Compare commits
587 Commits
889b0f0cb8
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 801f9d8a26 | |||
| 3a9ff5e740 | |||
| b0de86767a | |||
| a796bd933f | |||
| 8fc581ad6b | |||
| 1d86202abb | |||
| e02e566648 | |||
| 9a3f60d5d3 | |||
| a49f668b5a | |||
| e56bd2d834 | |||
| 7e2f122d36 | |||
| 4d24b5b661 | |||
| a7b1572693 | |||
| db526bbdb2 | |||
| 56244e8e35 | |||
| c0801c3894 | |||
| a956a98197 | |||
| 39013be535 | |||
| c50a04445c | |||
| 320be659c0 | |||
| 02ebf14828 | |||
| fc86774618 | |||
| 8a42839b37 | |||
| c84786364a | |||
| deffcdc326 | |||
| 8a7e1aa036 | |||
| cf35027b5a | |||
| 9bd3c10a09 | |||
| 7505f7fdeb | |||
| 7f8467b068 | |||
| 2655288412 | |||
| db65271587 | |||
| f3e4d5d072 | |||
| b9bb1ff804 | |||
| d1f58094f8 | |||
| 4324fa7511 | |||
| 59b626d3ba | |||
| b4854cf693 | |||
| 69930fb29f | |||
| 186cb38eeb | |||
| edeed3d1b6 | |||
| 19a2ffde96 | |||
| 11d111360d | |||
| be5db846cc | |||
| 1ae8e8ec9d | |||
| 9979ff2cf9 | |||
| d22a591fdc | |||
| 0403dc9871 | |||
| 4ed1fb5110 | |||
| 8802e1fe59 | |||
| dcd695ad0e | |||
| 549a9defc4 | |||
| 3ce34c34e9 | |||
| 8a98e2fe9b | |||
| 283bbc8658 | |||
| f3bb0a6f4b | |||
| 6f30815b64 | |||
| 89bf4ae0cf | |||
| 42b576d285 | |||
| 39bdbbd095 | |||
| a8d45dcdff | |||
| 3ac10b1e1c | |||
| ab01a62bd1 | |||
| 6092f7efbb | |||
| b698cee284 | |||
| dd35a8a530 | |||
| 97b9eaa39d | |||
| 2a77f73ba4 | |||
| 8f392f4fc7 | |||
| f5ab75ecaa | |||
| b060d8fc88 | |||
| 20e1210818 | |||
| 46b1e84629 | |||
| e4af2d5c08 | |||
| fa54451ba6 | |||
| 5d1e75f7e0 | |||
| efc15c48da | |||
| 691f34348e | |||
| 2f6a221f09 | |||
| 619bdd9c82 | |||
| 3ff361bfe8 | |||
| 30dd4b3a0a | |||
| a65cd86c8f | |||
| 1e40215c3e | |||
| d0b7db6765 | |||
| 32a3465fc4 | |||
| 776aad3877 | |||
| 309d330734 | |||
| bb779a0b0f | |||
| bf17fc14af | |||
| 5d6757dd65 | |||
| f63464852b | |||
| 1946709681 | |||
| f62012ee9c | |||
| c1a50eab8e | |||
| 7cd9706c0f | |||
| 3772c0d03c | |||
| cf470f5048 | |||
| 8f23d13ac8 | |||
| aed29b952c | |||
| 36ca8d5e3b | |||
| 1bd01eb9d4 | |||
| 6265fa534e | |||
| b7db6d6aae | |||
| e9ed58502a | |||
| e879d6f602 | |||
| 6c2bdde695 | |||
| d70719e23c | |||
| 05d057a40a | |||
| 01169332b3 | |||
| 7faacb6664 | |||
| 83f7e41932 | |||
| 0c2789b2c1 | |||
| fb5a21cfbb | |||
| d2d5ef8afa | |||
| 3d986a733b | |||
| 8a51dbd2ed | |||
| 38e828979c | |||
| 0d14fffe1c | |||
| de5b585157 | |||
| 70aaffc2ab | |||
| d1a2393b32 | |||
| 63ce7b9ec3 | |||
| 9b24c2e281 | |||
| bf1393fa60 | |||
| 7ee542dd1e | |||
| dffa05d703 | |||
| 571a057f52 | |||
| 225c4f2b46 | |||
| 03738f35f5 | |||
| 574df48ff3 | |||
| be7b7025d5 | |||
| c1bb5888a8 | |||
| 191883fe2a | |||
| 88f9e5dd54 | |||
| 1388658ae8 | |||
| 63d5a500de | |||
| 615e1c7f73 | |||
| 63a30a9319 | |||
| aa7b26a24a | |||
| ded8c6fd66 | |||
| 26f9f3f7fc | |||
| 4aadf4aa47 | |||
| 4b64bc614f | |||
| 80661fa622 | |||
| b008235d0d | |||
| 646dc490b8 | |||
| c6bc6f07f7 | |||
| 272a592a4d | |||
| d654f55981 | |||
| 101f616346 | |||
| 1ecb4dad55 | |||
| ed8646f0d9 | |||
| 875096b3ec | |||
| 77081926d1 | |||
| fce7e16811 | |||
| 2b28ccbf2c | |||
| 4a0f57478c | |||
| 39a9766d7d | |||
| 5884dac825 | |||
| 0b7f7dfdf7 | |||
| 756c790b9f | |||
| 6a582d73b6 | |||
| ea872fa01c | |||
| cbb0a50729 | |||
| 6c8043d866 | |||
| 9040d18f50 | |||
| 25603bb8cb | |||
| 5da29c3d91 | |||
| 65d2fb210c | |||
| ac85cfce5d | |||
| 144f07f412 | |||
| 75533225e4 | |||
| 56c979c950 | |||
| 7b305ba892 | |||
| 7408cc5b4b | |||
| fc71c22305 | |||
| 8e608feec1 | |||
| 404fd396f5 | |||
| 1f02de8cd0 | |||
| d07728f22b | |||
| adf936be07 | |||
| 34a399b838 | |||
| 928d613190 | |||
| a8ead9cd10 | |||
| 9fbbfcd585 | |||
| a1afe069fa | |||
| c600b94f4e | |||
| b340aa97b0 | |||
| 0e73a34791 | |||
| 06035f20ad | |||
| eca15b4ee7 | |||
| 40f1794d41 | |||
| 0d805313d6 | |||
| 0e09a1ed4b | |||
| db00a5d4b5 | |||
| a86448f6a6 | |||
| ca72f36c78 | |||
| 5aedf94512 | |||
| f1e42710b5 | |||
| ce94dd0af4 | |||
| 851324740c | |||
| 0dff2d5c47 | |||
| 8f91f55cd1 | |||
| 23e22ba49c | |||
| 8bdaabd06c | |||
| 795b172bba | |||
| 65a3767a7a | |||
| ff51a1a465 | |||
| 365b907ba4 | |||
| 148c88bd40 | |||
| 8673e563a9 | |||
| f88bb5f486 | |||
| d8f9be5b23 | |||
| dc7ae3a23c | |||
| b84ce1f6bb | |||
| c12a49487e | |||
| 7548486a53 | |||
| d826daaf41 | |||
| fd52c29302 | |||
| 853f53e8e6 | |||
| 14b158d0b2 | |||
| 2a3f88fdcf | |||
| 120745d102 | |||
| e4dd4bbe2c | |||
| 33cb2bed3e | |||
| 4b089c1ed8 | |||
| aeff0b55be | |||
| 9e3d2f6a69 | |||
| 61da29a904 | |||
| 2097787e1f | |||
| e20083a283 | |||
| a465d6fd23 | |||
| b70ee1aa4b | |||
| 23fd70c131 | |||
| e1bfbf4232 | |||
| c16d9e471d | |||
| 360bca45c8 | |||
| 271f8ea6a8 | |||
| eca0ef792c | |||
| 62bfaf20f4 | |||
| da6ae89667 | |||
| 60a9c87794 | |||
| 2dc2513fac | |||
| 65c896f07f | |||
| aba3120388 | |||
| 1910365321 | |||
| d9e883c21d | |||
| 4a80600e22 | |||
| 23890a1d33 | |||
| 2f07365745 | |||
| 3521649cbf | |||
| 4b765bbc39 | |||
| c9e8ed030e | |||
| b3da321a3b | |||
| f2d9926c4c | |||
| 135e9c4639 | |||
| 0181dbbb16 | |||
| 07ef7045ce | |||
| 09151e37ef | |||
| e7deb65e45 | |||
| 45f1096b96 | |||
| b77e139347 | |||
| 43ca0cbc59 | |||
| 982e65aec5 | |||
| 6c76b569c4 | |||
| fd7698f0e7 | |||
| 4b710b02f2 | |||
| e734e80da5 | |||
| 4ddf2a4367 | |||
| 2b95388efd | |||
| 9f0274417d | |||
| df2f20a5e5 | |||
| 61502f51d9 | |||
| 4553d7215a | |||
| 4a1c6b4cfa | |||
| 2663c5f91f | |||
| 79ee19ca5b | |||
| 871a18f821 | |||
| f4a97c1135 | |||
| 0969fb5d51 | |||
| 744cc9dca4 | |||
| ce37281333 | |||
| 149a383447 | |||
| d68614e26a | |||
| a4480fa067 | |||
| beb84ade9f | |||
| d235fd41ac | |||
| 2246278845 | |||
| d80fc143c2 | |||
| 1fe4ca2b7a | |||
| c28c86dbc6 | |||
| 70fecafd41 | |||
| c34b119526 | |||
| 0bf715d9bb | |||
| 7fa31c03a3 | |||
| 483489cc44 | |||
| ec40b4771b | |||
| 52b21c22b1 | |||
| 8936abd8cd | |||
| 8482df2f4e | |||
| 327163eb60 | |||
| 8f1dd0ad13 | |||
| 28adef9739 | |||
| badfabcf5e | |||
| d0d2b17484 | |||
| efe434ede3 | |||
| df5ba8ebab | |||
| ff1149750b | |||
| d824dc4b73 | |||
| 28777b0c77 | |||
| f412c7dee6 | |||
| 44fe52195e | |||
| 979cf39228 | |||
| 10d3517648 | |||
| 8a62b62819 | |||
| 2e412af4dd | |||
| 39b1964b68 | |||
| bd04c6acd7 | |||
| 7977b7c5f8 | |||
| d618bc3b32 | |||
| 845b85e7a7 | |||
| ed2526ce41 | |||
| 05655847d8 | |||
| 0cb68e1de9 | |||
| cd189cfe60 | |||
| 69dab063a8 | |||
| 5806156af3 | |||
| 12497eb4f1 | |||
| 8b5275a30b | |||
| 5536803ad6 | |||
| c4462e2918 | |||
| f6cd947173 | |||
| fa7c2fa0ed | |||
| f2fc33c86b | |||
| 05c3b11e57 | |||
| ae4cacefe8 | |||
| 8ae06cc8e2 | |||
| da5d604d01 | |||
| 9c3dbfb765 | |||
| b85d7b3b86 | |||
| 76765e15d2 | |||
| b7f077197d | |||
| a344cfadee | |||
| cec62dad1c | |||
| 6b1737d52d | |||
| b4dbfcbde6 | |||
| 2bdb0eb730 | |||
| 5f01631e6a | |||
| c80931c15c | |||
| f140238cc3 | |||
| 05bdc71ebc | |||
| ec6891b5ba | |||
| 06defd9596 | |||
| 0b58b0486c | |||
| b43e7cf752 | |||
| 8ae6ca3eb8 | |||
| bac07d28a7 | |||
| fc89be2f55 | |||
| 028bff5ef1 | |||
| 1f66183c8e | |||
| f958f57e56 | |||
| 8393a67c89 | |||
| e32300d1f8 | |||
| 32e36bbc4b | |||
| c0d1be675b | |||
| d06241c20c | |||
| 599fbdc71d | |||
| 6998275331 | |||
| a9a1852422 | |||
| 48ea612739 | |||
| 17d635b66b | |||
| 4ab723f40b | |||
| 5d193bb568 | |||
| dcf6cf8f82 | |||
| eea54ca616 | |||
| dd53870c59 | |||
| 5696d77922 | |||
| 44ef477a01 | |||
| de738b27ed | |||
| fc24da82ae | |||
| bae3619723 | |||
| ea36160667 | |||
| 2e0ed98d42 | |||
| 40893a8cb1 | |||
| bc2b1e244c | |||
| 6f7a0c7708 | |||
| 91be0ac47f | |||
| 808935b446 | |||
| 4c8fe910a7 | |||
| 8f34c521fb | |||
| a59f4fc1a5 | |||
| b88857c2e4 | |||
| 1ca9bc1bfd | |||
| 73890c98fa | |||
| bfede09fe6 | |||
| 11d19d8902 | |||
| 1dd675796b | |||
| 31388da609 | |||
| fe405e81c6 | |||
| 7e5b9839e8 | |||
| 2a24a4cc85 | |||
| 6310c8bf49 | |||
| 61ae30873f | |||
| f015fe5a1d | |||
| c6b6be872b | |||
| 5377eeae5b | |||
| 92b212e7fd | |||
| 9633ab35a6 | |||
| d1b845fd2e | |||
| 934bda5904 | |||
| 962e3d4e7d | |||
| 0de9200d48 | |||
| c324452b38 | |||
| d3ee850f37 | |||
| cbe016d7a2 | |||
| 6f6d37e955 | |||
| 84717b04bd | |||
| 1d9287389a | |||
| 13635b01bc | |||
| 1707277bb7 | |||
| 7c0015beb0 | |||
| f7d69cde50 | |||
| 995576358f | |||
| 5765fb57be | |||
| 41515e3b8f | |||
| 8b2e068d3e | |||
| 59fbb56252 | |||
| 278bc8f050 | |||
| f5634a7434 | |||
| 8d9600183f | |||
| bb865687d5 | |||
| 1ffdd75475 | |||
| 46a254f80c | |||
| 1baa83c1fd | |||
| 870f49509d | |||
| 8fd49d563e | |||
| f43d30bdae | |||
| 6a56fa5623 | |||
| eba933e21e | |||
| bc429edf49 | |||
| 5c2769dd7d | |||
| dbdcf334aa | |||
| 09a89fdb6b | |||
| 0fa0b60feb | |||
| e814f5dd3c | |||
| ce9acbdeab | |||
| ea8e12190b | |||
| dea410149a | |||
| f8bebd0fdf | |||
| 753f7f1c92 | |||
| c4e70db85f | |||
| c06a01facb | |||
| 0072e44e0f | |||
| 8372b77e07 | |||
| 8be4e73d10 | |||
| 2811c27a2a | |||
| 15a52d6d38 | |||
| c73153dd4e | |||
| c621bca7b1 | |||
| 5a9601dd3c | |||
| b05ddedb41 | |||
| 0e2d9fe1cd | |||
| a126929f00 | |||
| 7eecfeb56a | |||
| c7cf1e8335 | |||
| 61a8f0edca | |||
| fa5885154b | |||
| 0adc2a494e | |||
| 19768c23d5 | |||
| 1b8c391836 | |||
| 1acb8123ae | |||
| 132d61cb68 | |||
| 4476c57444 | |||
| c64577eff0 | |||
| a3a3942b0a | |||
| d158b05a1a | |||
| 9fc68e1379 | |||
| fcc9d35c33 | |||
| 8ae5dad649 | |||
| 9b36252d1d | |||
| 88c1d8b44f | |||
| 9a255086c4 | |||
| 4f6d4a1e2e | |||
| f1ef31d1ee | |||
| 0c9e120ba2 | |||
| afdb604255 | |||
| d5fcbb19f0 | |||
| 4c51258a17 | |||
| e0b51e8041 | |||
| 5e025c6c20 | |||
| 78f79e9081 | |||
| d2db973daa | |||
| 4e082009c2 | |||
| 05eb13eab3 | |||
| 85ebecb115 | |||
| 9e9ab374f0 | |||
| b07eb70c70 | |||
| 15d0209bcc | |||
| 018b185489 | |||
| 89aa705880 | |||
| e7f483f169 | |||
| 79d8f70c29 | |||
| 7a86e5c26e | |||
| 2f1c412fd9 | |||
| a72e83c703 | |||
| 93438dc672 | |||
| 5413a26406 | |||
| 26de009259 | |||
| 7a82a411ec | |||
| 893f5b4984 | |||
| 22611b9a77 | |||
| b64db3ba9e | |||
| bf2da4576d | |||
| 08260e2c6f | |||
| 470e7a5fd5 | |||
| f63ed664eb | |||
| 552836b29b | |||
| f598ed1ab9 | |||
| e62ddce674 | |||
| 9aa07cf7a1 | |||
| 69d9dc8bc1 | |||
| abd5c6381a | |||
| ed6747c487 | |||
| 5e17784f7f | |||
| 91d31d908f | |||
| 4e772b72db | |||
| 6ba0088128 | |||
| e6ee814801 | |||
| 3ab0410a82 | |||
| 5561b9c6c7 | |||
| c98f661b87 | |||
| 74d04d1157 | |||
| 935a04c042 | |||
| bcd642043a | |||
| 5168592cd3 | |||
| 2266e0d4dc | |||
| 339a8558af | |||
| 7b6865b099 | |||
| 3941abcca8 | |||
| 8164917f32 | |||
| 1d95ee17bb | |||
| 78b3f4c165 | |||
| 96fec31bb7 | |||
| 52a2ee8ac7 | |||
| 416adf9009 | |||
| 86186b9ab3 | |||
| eb8654dba0 | |||
| 0458de2b70 | |||
| 1c571fd8ce | |||
| 107d95eece | |||
| fa99f19198 | |||
| d4979ae492 | |||
| 030cef914c | |||
| df135e9373 | |||
| c4e2f600de | |||
| 6375863c77 | |||
| 18d2242815 | |||
| 0ae0d0fba7 | |||
| 97220f5321 | |||
| 3b7b0c82de | |||
| e45d57bfb9 | |||
| a7d48afe3a | |||
| c56e462340 | |||
| ee86e4a3d3 | |||
| 9b2c31688c | |||
| e74f5275ef | |||
| 40a04397b4 | |||
| 187e3d13f1 | |||
| 6af7e3d30b | |||
| 34ab43aa7e | |||
| 2a3415c536 | |||
| d5aca532da | |||
| 3c9a5238cd | |||
| f4b43f80c2 | |||
| d22786a200 | |||
| b602a2e4e5 | |||
| 4a30c0924d | |||
| 183d4c12bf | |||
| a4a09bd094 | |||
| 030fa04d34 | |||
| 6b7c3bb450 | |||
| 656a840607 | |||
| 2cf654aa4c | |||
| a11900a78f | |||
| e142e1a9c3 | |||
| a211fba874 |
@@ -0,0 +1,21 @@
|
||||
Show test coverage from the cached `.coverage_baseline` file, or rerun the full test suite with `$ARGUMENTS`.
|
||||
|
||||
## Usage
|
||||
|
||||
- `/coverage` — read cached coverage from `.coverage_baseline` (instant)
|
||||
- `/coverage run` — run `script/test_coverage` and report fresh results
|
||||
|
||||
## What it does
|
||||
|
||||
**Cached mode (default):** Reads `.coverage_baseline` and displays the stored coverage percentage(s). This is instant and does not run any tests.
|
||||
|
||||
**Run mode (`run`):** Executes `script/test_coverage` which runs:
|
||||
1. Rust tests with `cargo llvm-cov` (reports line coverage %)
|
||||
2. Frontend tests with `npm run test:coverage` (reports line coverage %)
|
||||
3. Computes the overall average and compares to the threshold
|
||||
|
||||
Reports Rust coverage, Frontend coverage, Overall coverage, and whether the run passed the threshold.
|
||||
|
||||
---
|
||||
|
||||
If the arguments (`$ARGUMENTS`) equal `run`, execute `bash script/test_coverage` from the project root and show the Coverage Summary section from the output. Otherwise, read `.coverage_baseline` and display the stored coverage value(s).
|
||||
+12
-60
@@ -1,76 +1,28 @@
|
||||
{
|
||||
"enabledMcpjsonServers": [
|
||||
"huskies"
|
||||
],
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(./server/target/debug/huskies:*)",
|
||||
"Bash(./target/debug/huskies:*)",
|
||||
"Bash(HUSKIES_PORT=*)",
|
||||
"Bash(cargo build:*)",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(cargo clippy:*)",
|
||||
"Bash(cargo doc:*)",
|
||||
"Bash(cargo llvm-cov:*)",
|
||||
"Bash(cargo nextest run:*)",
|
||||
"Bash(cargo run:*)",
|
||||
"Bash(cargo test:*)",
|
||||
"Bash(cargo watch:*)",
|
||||
"Bash(cd *)",
|
||||
"Bash(claude:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(env:*)",
|
||||
"Bash(git *)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(kill *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(lsof *)",
|
||||
"Bash(mkdir *)",
|
||||
"Bash(mv *)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npx @biomejs/biome check:*)",
|
||||
"Bash(npx @playwright/test test:*)",
|
||||
"Bash(npx biome check:*)",
|
||||
"Bash(npx playwright test:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npx vitest:*)",
|
||||
"Bash(pnpm add:*)",
|
||||
"Bash(pnpm build:*)",
|
||||
"Bash(pnpm dev:*)",
|
||||
"Bash(pnpm install:*)",
|
||||
"Bash(pnpm run build:*)",
|
||||
"Bash(pnpm run test:*)",
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(printf:*)",
|
||||
"Bash(ps *)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(pwd *)",
|
||||
"Bash(rm *)",
|
||||
"Bash(sleep *)",
|
||||
"Bash(touch *)",
|
||||
"Bash(xargs:*)",
|
||||
"WebFetch(domain:crates.io)",
|
||||
"WebFetch(domain:docs.rs)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:portkey.ai)",
|
||||
"WebFetch(domain:www.shuttle.dev)",
|
||||
"WebSearch",
|
||||
"mcp__huskies__*",
|
||||
"Edit",
|
||||
"Write",
|
||||
"Bash(echo:*)",
|
||||
"Bash(pwd *)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(find *)",
|
||||
"Bash(sqlite3 *)",
|
||||
"Bash(cat <<:*)",
|
||||
"Bash(cat <<'ENDJSON:*)",
|
||||
"Bash(make release:*)",
|
||||
"Bash(npm test:*)",
|
||||
"Bash(head *)",
|
||||
"Bash(tail *)",
|
||||
"Bash(wc *)",
|
||||
"Bash(npx vite:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(stat *)"
|
||||
"Bash(cat *)",
|
||||
"Edit",
|
||||
"Write",
|
||||
"mcp__huskies__*"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"huskies"
|
||||
]
|
||||
}
|
||||
|
||||
+10
@@ -5,10 +5,19 @@
|
||||
# Local environment (secrets)
|
||||
.env
|
||||
|
||||
# Local-only scripts
|
||||
script/local-release
|
||||
|
||||
# App specific (root-level; huskies subdirectory patterns live in .huskies/.gitignore)
|
||||
store.json
|
||||
_merge_parsed.json
|
||||
.huskies_port
|
||||
.huskies/bot.toml.bak
|
||||
.huskies/build_hash
|
||||
|
||||
# Coverage report (generated by script/test_coverage, not tracked in git)
|
||||
.coverage_report.json
|
||||
.coverage_baseline
|
||||
|
||||
# Rust stuff
|
||||
target
|
||||
@@ -49,3 +58,4 @@ server/target
|
||||
# Ignore old story files until we feel like deleting them
|
||||
.storkit
|
||||
.storkit_port
|
||||
/.huskies/node_identity.key
|
||||
|
||||
@@ -26,3 +26,11 @@ whatsapp_history.json
|
||||
|
||||
# Timers
|
||||
timers.json
|
||||
|
||||
# Misc
|
||||
wishlist.md
|
||||
|
||||
# Database
|
||||
pipeline.db
|
||||
pipeline.db.bak*
|
||||
session_store.json
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Huskies project-local agent guidance
|
||||
|
||||
## Documentation
|
||||
Docs live in `website/docs/*.html` (static HTML), **not** Markdown files. When a story asks you to document something, edit the relevant `.html` file in `website/docs/`.
|
||||
|
||||
## Configuration files
|
||||
- Agent config: `.huskies/agents.toml` (preferred) or `[[agent]]` blocks in `.huskies/project.toml`
|
||||
- Project settings: `.huskies/project.toml`
|
||||
- Bot credentials: `.huskies/bot.toml` (gitignored — never commit)
|
||||
|
||||
## Frontend build
|
||||
The frontend is embedded into the Rust binary via `rust-embed`. Run `npm run build` in `frontend/` before testing frontend changes, or the embedded assets will be stale.
|
||||
|
||||
## Quality gates (all enforced by `script/test`)
|
||||
1. `npm run build` (frontend)
|
||||
2. `cargo fmt --all --check`
|
||||
3. `cargo clippy -- -D warnings`
|
||||
4. `cargo test`
|
||||
5. `npm test` (frontend Vitest)
|
||||
|
||||
Clippy is zero-tolerance: no warnings allowed. Fix every warning before committing.
|
||||
|
||||
## File size
|
||||
Target a maximum of 800 lines per source file as a soft guide. If a file grows beyond 800 lines, decompose it by concern into smaller modules. Split at natural seams: group related types, functions, or handlers together and move each cohesive group to its own file. This keeps files readable and diffs focused.
|
||||
|
||||
## Runtime validation
|
||||
The `validate_agents` function in `server/src/config.rs` rejects unknown runtimes. Supported values: `"claude-code"` and `"gemini"`. Adding a new runtime requires updating that function.
|
||||
+134
-236
@@ -1,267 +1,165 @@
|
||||
# Story Kit: The Story-Driven Test Workflow (SDTW)
|
||||
# Huskies: Story-Driven Development
|
||||
|
||||
**Target Audience:** Large Language Models (LLMs) acting as Senior Engineers.
|
||||
**Goal:** To maintain long-term project coherence, prevent context window exhaustion, and ensure high-quality, testable code generation in large software projects.
|
||||
**Target Audience:** LLM agents working as engineers.
|
||||
**Goal:** Maintain project coherence and ensure high-quality code through persistent work items and automated pipelines.
|
||||
|
||||
---
|
||||
|
||||
## 0. First Steps (For New LLM Sessions)
|
||||
|
||||
When you start a new session with this project:
|
||||
|
||||
1. **Check Setup Wizard:** Call `wizard_status` to check if project setup is complete. If the wizard is not complete, guide the user through the remaining steps. Important rules for the wizard flow:
|
||||
- **Be conversational.** Don't show tool names, step numbers, or raw wizard output to the user.
|
||||
- **On projects with existing code:** Read the codebase and generate each file, then show the user what you wrote and ask if it looks right.
|
||||
- **On bare projects with no code:** Ask the user what they want to build, what language/framework they plan to use, and generate files from their answers.
|
||||
- **You must actually generate the files.** The workflow for each step is: (1) call `wizard_generate` with no args to get a hint, (2) write the file content yourself based on the conversation, (3) call `wizard_generate` again with the `content` argument containing the full file body, (4) show the user what you wrote, (5) call `wizard_confirm` (they approve), `wizard_retry` (they want changes), or `wizard_skip` (they want to skip). Do not stop after discussing — follow through and write the files.
|
||||
- **Keep moving.** After each step is confirmed, immediately proceed to the next wizard step without waiting for the user to ask.
|
||||
2. **Check for MCP Tools:** Read `.mcp.json` to discover the MCP server endpoint. Then list available tools by calling:
|
||||
```bash
|
||||
curl -s "$(jq -r '.mcpServers["huskies"].url' .mcp.json)" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
|
||||
```
|
||||
This returns the full tool catalog (create stories, spawn agents, record tests, manage worktrees, etc.). Familiarize yourself with the available tools before proceeding. These tools allow you to directly manipulate the workflow and spawn subsidiary agents without manual file manipulation.
|
||||
3. **Read Context:** Check `.huskies/specs/00_CONTEXT.md` for high-level project goals.
|
||||
4. **Read Stack:** Check `.huskies/specs/tech/STACK.md` for technical constraints and patterns.
|
||||
5. **Check Work Items:** Look at `.huskies/work/1_backlog/` and `.huskies/work/2_current/` to see what work is pending.
|
||||
## 0. First Steps (For New Agent Sessions)
|
||||
|
||||
1. **Read CLAUDE.md** in the worktree root for project-specific rules.
|
||||
2. **Check MCP Tools:** Your `.mcp.json` connects you to the huskies server. Use MCP tools for all pipeline operations — never manipulate files directly.
|
||||
3. **Check your story:** Call `status(story_id)` or `get_story_todos(story_id)` to see what needs doing.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Philosophy
|
||||
## 1. Pipeline Overview
|
||||
|
||||
We treat the codebase as the implementation of a **"Living Specification."** driven by **User Stories**
|
||||
Instead of ephemeral chat prompts ("Fix this", "Add that"), we work through persistent artifacts.
|
||||
* **Stories** define the *Change*.
|
||||
* **Tests** define the *Truth*.
|
||||
* **Code** defines the *Reality*.
|
||||
Work items (stories, bugs, spikes, refactors) move through stages managed by a CRDT state machine:
|
||||
|
||||
**The Golden Rule:** You are not allowed to write code until the Acceptance Criteria are captured in the story.
|
||||
`Backlog → Current → QA → Merge → Done → Archived`
|
||||
|
||||
**All state lives in the CRDT.** There are no filesystem pipeline directories to read or write. Use MCP tools to query and manipulate pipeline state.
|
||||
|
||||
---
|
||||
|
||||
## 1.5 MCP Tools
|
||||
## 2. Your Workflow as a Coder Agent
|
||||
|
||||
Agents have programmatic access to the workflow via MCP tools served at `POST /mcp`. The project `.mcp.json` registers this endpoint automatically so Claude Code sessions and spawned agents can call tools like `create_story`, `validate_stories`, `list_upcoming`, `get_story_todos`, `record_tests`, `ensure_acceptance`, `start_agent`, `stop_agent`, `list_agents`, and `get_agent_output` without parsing English instructions.
|
||||
1. **Read the story** via `status(story_id)` — understand the acceptance criteria.
|
||||
2. **Implement** the feature/fix in your worktree. Commit as you go using `git_add` and `git_commit` MCP tools.
|
||||
3. **Run tests** via the `run_tests` MCP tool (starts tests in the background). Poll `get_test_result` to check completion. Never run `cargo test` or `script/test` directly via Bash.
|
||||
4. **Check off acceptance criteria** as you complete them using `check_criterion(story_id, criterion_index)`.
|
||||
5. **Commit and exit.** The server runs acceptance gates automatically when your process exits and advances the pipeline based on the results.
|
||||
|
||||
**To discover what tools are available:** Check `.mcp.json` for the server endpoint, then use the MCP protocol to list available tools.
|
||||
**Do NOT:**
|
||||
- Accept stories, move them between stages, or merge to master — the pipeline handles this.
|
||||
- Run tests via Bash — use the MCP tools.
|
||||
- Create summary documents or write terminal output to files.
|
||||
|
||||
---
|
||||
|
||||
## 2. Directory Structure
|
||||
## 3. Work Item Types
|
||||
|
||||
```text
|
||||
project_root/
|
||||
.mcp.json # MCP server configuration (if MCP tools are available)
|
||||
.story_kit/
|
||||
├── README.md # This document
|
||||
├── project.toml # Agent configuration (roles, models, prompts)
|
||||
├── work/ # Unified work item pipeline (stories, bugs, spikes)
|
||||
│ ├── 1_backlog/ # New work items awaiting implementation
|
||||
│ ├── 2_current/ # Work in progress
|
||||
│ ├── 3_qa/ # QA review
|
||||
│ ├── 4_merge/ # Ready to merge to master
|
||||
│ ├── 5_done/ # Merged and completed (auto-swept to 6_archived after 4 hours)
|
||||
│ └── 6_archived/ # Long-term archive
|
||||
├── worktrees/ # Agent worktrees (managed by the server)
|
||||
├── specs/ # Minimal guardrails (context + stack)
|
||||
│ ├── 00_CONTEXT.md # High-level goals, domain definition, and glossary
|
||||
│ ├── tech/ # Implementation details (Stack, Architecture, Constraints)
|
||||
│ │ └── STACK.md # The "Constitution" (Languages, Libs, Patterns)
|
||||
│ └── functional/ # Domain logic (Platform-agnostic behavior)
|
||||
│ └── ...
|
||||
└── src/ # The Code
|
||||
- **Story:** New functionality → implement and test
|
||||
- **Bug:** Broken functionality → fix with minimal surgical change
|
||||
- **Spike:** Research/uncertainty → investigate, document findings, no production code
|
||||
- **Refactor:** Code improvement → restructure without changing behaviour
|
||||
|
||||
---
|
||||
|
||||
## 4. Bug Workflow
|
||||
|
||||
When working on bugs:
|
||||
1. Read the story description first. If it specifies exact files and locations, go directly there.
|
||||
2. If not specified, investigate with targeted grep.
|
||||
3. Fix with a surgical, minimal change.
|
||||
4. Commit early. Don't spend turns on unnecessary verification.
|
||||
|
||||
---
|
||||
|
||||
## 5. Code Quality
|
||||
|
||||
Before exiting, ensure your code compiles and tests pass. Use `run_tests` MCP tool to verify. Fix all errors and warnings — zero tolerance.
|
||||
|
||||
Consult `specs/tech/STACK.md` for project-specific quality gates.
|
||||
|
||||
---
|
||||
|
||||
## 6. Key MCP Tools
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `status` | Get story details, ACs, git state |
|
||||
| `get_story_todos` | List unchecked acceptance criteria |
|
||||
| `check_criterion` | Mark an AC as done |
|
||||
| `run_tests` | Start test suite (blocks until complete) |
|
||||
| `git_status` | Worktree git status |
|
||||
| `git_add` | Stage files |
|
||||
| `git_commit` | Commit staged changes |
|
||||
| `git_diff` | View changes |
|
||||
| `git_log` | View commit history |
|
||||
|
||||
---
|
||||
|
||||
## 7. Project Architecture
|
||||
|
||||
Huskies is a single Rust binary with an embedded React frontend. Key things to know:
|
||||
|
||||
- **Backend:** `server/src/` — Rust, built with Poem (HTTP framework)
|
||||
- **Frontend:** `frontend/src/` — React + TypeScript, built with Vite
|
||||
- **Gateway mode:** `huskies --gateway` is a deployment mode of the same binary, NOT a separate application. The gateway backend code lives in `server/src/gateway.rs`. Gateway frontend components live in `frontend/src/` alongside everything else.
|
||||
- **Stories that say "UI":** These are primarily frontend (TypeScript/React) work. Check what backend endpoints already exist before adding new ones. Keep Rust changes minimal.
|
||||
- **Stories that say "gateway":** The gateway is just a mode. Don't restructure `gateway.rs` unless the story specifically asks for backend changes.
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment Modes
|
||||
|
||||
Huskies has three modes, all from the same binary:
|
||||
|
||||
### Standard (single project)
|
||||
|
||||
```
|
||||
huskies [--port 3001] /path/to/project
|
||||
```
|
||||
|
||||
### Work Items
|
||||
Full server: web UI, MCP endpoint, chat bot, agent pool, pipeline. One project per instance.
|
||||
|
||||
All work items (stories, bugs, spikes) live in the same `work/` pipeline. Items are named: `{id}_{type}_{slug}.md`
|
||||
### Headless Build Agent
|
||||
|
||||
* Stories: `57_story_live_test_gate_updates.md`
|
||||
* Bugs: `4_bug_run_button_does_not_start_agent.md`
|
||||
* Spikes: `61_spike_filesystem_watcher_architecture.md`
|
||||
|
||||
Items move through stages by moving the file between directories:
|
||||
|
||||
`1_backlog` → `2_current` → `3_qa` → `4_merge` → `5_done` → `6_archived`
|
||||
|
||||
Items in `5_done` are auto-swept to `6_archived` after 4 hours by the server.
|
||||
|
||||
### Filesystem Watcher
|
||||
|
||||
The server watches `.story_kit/work/` for changes. When a file is created, moved, or modified, the watcher auto-commits with a deterministic message and broadcasts a WebSocket notification to the frontend. This means:
|
||||
|
||||
* MCP tools only need to write/move files — the watcher handles git commits
|
||||
* IDE drag-and-drop works (drag a story from `1_backlog/` to `2_current/`)
|
||||
* The frontend updates automatically without manual refresh
|
||||
|
||||
---
|
||||
|
||||
## 3. The Cycle (The "Loop")
|
||||
|
||||
When the user asks for a feature, follow this 4-step loop strictly:
|
||||
|
||||
### Step 1: The Story (Ingest)
|
||||
* **User Input:** "I want the robot to dance."
|
||||
* **Action:** Create a story via MCP tool `create_story` (guarantees correct front matter and auto-assigns the story number).
|
||||
* **Front Matter (Required):** Every work item file MUST begin with YAML front matter containing a `name` field:
|
||||
```yaml
|
||||
---
|
||||
name: Short Human-Readable Story Name
|
||||
---
|
||||
```
|
||||
* **Move to Current:** Once the story is validated and ready for coding, move it to `work/2_current/`.
|
||||
* **Tracking:** Mark Acceptance Criteria as tested directly in the story file as tests are completed.
|
||||
* **Content:**
|
||||
* **User Story:** "As a user, I want..."
|
||||
* **Acceptance Criteria:** Bullet points of observable success.
|
||||
* **Out of scope:** Things that are out of scope so that the LLM doesn't go crazy
|
||||
* **Story Quality (INVEST):** Stories should be Independent, Negotiable, Valuable, Estimable, Small, and Testable.
|
||||
* **Git:** The `start_agent` MCP tool automatically creates a worktree under `.story_kit/worktrees/`, checks out a feature branch, moves the story to `work/2_current/`, and spawns the agent. No manual branch or worktree creation is needed.
|
||||
|
||||
### Step 2: The Implementation (Code)
|
||||
* **Action:** Write the code to satisfy the approved tests and Acceptance Criteria.
|
||||
* **Constraint:** adhere strictly to `specs/tech/STACK.md` (e.g., if it forbids certain patterns, you must not use them).
|
||||
* **Full-Stack Completion:** Every story must be completed across all components of the stack. If a feature touches the backend, frontend, and API layer, all three must be fully implemented and working end-to-end before the story can be accepted. Partial implementations (e.g., backend logic with no frontend wiring, or UI scaffolding with no real data) do not satisfy acceptance criteria.
|
||||
|
||||
### Step 3: Verification (Close)
|
||||
* **Action:** For each Acceptance Criterion in the story, write a failing test (red), mark the criterion as tested, make the test pass (green), and refactor if needed. Keep only one failing test at a time.
|
||||
* **Action:** Run compilation and make sure it succeeds without errors. Consult `specs/tech/STACK.md` and run all required linters listed there (treat warnings as errors). Run tests and make sure they all pass before proceeding. Ask questions here if needed.
|
||||
* **Action:** Do not accept stories yourself. Ask the user if they accept the story. If they agree, move the story file to `work/5_done/`.
|
||||
* **Move to Done:** After acceptance, move the story from `work/2_current/` (or `work/4_merge/`) to `work/5_done/`.
|
||||
* **Action:** When the user accepts:
|
||||
1. Move the story file to `work/5_done/`
|
||||
2. Commit both changes to the feature branch
|
||||
3. Perform the squash merge: `git merge --squash feature/story-name`
|
||||
4. Commit to master with a comprehensive commit message
|
||||
5. Delete the feature branch: `git branch -D feature/story-name`
|
||||
* **Important:** Do NOT mark acceptance criteria as complete before user acceptance. Only mark them complete when the user explicitly accepts the story.
|
||||
|
||||
**CRITICAL - NO SUMMARY DOCUMENTS:**
|
||||
* **NEVER** create a separate summary document (e.g., `STORY_XX_SUMMARY.md`, `IMPLEMENTATION_NOTES.md`, etc.)
|
||||
* **NEVER** write terminal output to a markdown file for "documentation purposes"
|
||||
* Tests are the primary source of truth. Keep test coverage and Acceptance Criteria aligned after each story.
|
||||
* If you find yourself typing `cat << 'EOF' > SUMMARY.md` or similar, **STOP IMMEDIATELY**.
|
||||
* The only files that should exist after story completion:
|
||||
* Updated code in `src/`
|
||||
* Updated guardrails in `specs/` (if needed)
|
||||
* Archived work item in `work/5_done/` (server auto-sweeps to `work/6_archived/` after 4 hours)
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 3.5. Bug Workflow (Simplified Path)
|
||||
|
||||
Not everything needs to be a full story. Simple bugs can skip the story process:
|
||||
|
||||
### When to Use Bug Workflow
|
||||
* Defects in existing functionality (not new features)
|
||||
* State inconsistencies or data corruption
|
||||
* UI glitches that don't require spec changes
|
||||
* Performance issues with known fixes
|
||||
|
||||
### Bug Process
|
||||
1. **Document Bug:** Create a bug file in `work/1_backlog/` named `{id}_bug_{slug}.md` with:
|
||||
* **Symptom:** What the user observes
|
||||
* **Root Cause:** Technical explanation (if known)
|
||||
* **Reproduction Steps:** How to trigger the bug
|
||||
* **Proposed Fix:** Brief technical approach
|
||||
* **Workaround:** Temporary solution if available
|
||||
2. **Start an Agent:** Use the `start_agent` MCP tool to create a worktree and spawn an agent for the bug fix.
|
||||
3. **Write a Failing Test:** Before fixing the bug, write a test that reproduces it (red). This proves the bug exists and prevents regression.
|
||||
4. **Fix the Bug:** Make minimal code changes to make the test pass (green).
|
||||
5. **User Testing:** Let the user verify the fix in the worktree before merging. Do not proceed until they confirm.
|
||||
6. **Archive & Merge:** Move the bug file to `work/5_done/`, squash merge to master, delete the worktree and branch.
|
||||
7. **No Guardrail Update Needed:** Unless the bug reveals a missing constraint
|
||||
|
||||
### Bug vs Story vs Spike
|
||||
* **Bug:** Existing functionality is broken → Fix it
|
||||
* **Story:** New functionality is needed → Test it, then build it
|
||||
* **Spike:** Uncertainty/feasibility discovery → Run spike workflow
|
||||
|
||||
---
|
||||
|
||||
## 3.6. Spike Workflow (Research Path)
|
||||
|
||||
Not everything needs a story or bug fix. Spikes are time-boxed investigations to reduce uncertainty.
|
||||
|
||||
### When to Use a Spike
|
||||
* Unclear root cause or feasibility
|
||||
* Need to compare libraries/encoders/formats
|
||||
* Need to validate performance constraints
|
||||
|
||||
### Spike Process
|
||||
1. **Document Spike:** Create a spike file in `work/1_backlog/` named `{id}_spike_{slug}.md` with:
|
||||
* **Question:** What you need to answer
|
||||
* **Hypothesis:** What you expect to be true
|
||||
* **Timebox:** Strict limit for the research
|
||||
* **Investigation Plan:** Steps/tools to use
|
||||
* **Findings:** Evidence and observations
|
||||
* **Recommendation:** Next step (Story, Bug, or No Action)
|
||||
2. **Execute Research:** Stay within the timebox. No production code changes.
|
||||
3. **Escalate if Needed:** If implementation is required, open a Story or Bug and follow that workflow.
|
||||
4. **Archive:** Move the spike file to `work/5_done/`.
|
||||
|
||||
### Spike Output
|
||||
* Decision and evidence, not production code
|
||||
* Specs updated only if the spike changes system truth
|
||||
|
||||
---
|
||||
|
||||
## 4. Context Reset Protocol
|
||||
|
||||
When the LLM context window fills up (or the chat gets slow/confused):
|
||||
1. **Stop Coding.**
|
||||
2. **Instruction:** Tell the user to open a new chat.
|
||||
3. **Handoff:** The only context the new LLM needs is in the `specs/` folder and `.mcp.json`.
|
||||
* *Prompt for New Session:* "I am working on Project X. Read `.mcp.json` to discover available tools, then read `specs/00_CONTEXT.md` and `specs/tech/STACK.md`. Then look at `work/1_backlog/` and `work/2_current/` to see what is pending."
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 5. Setup Instructions (For the LLM)
|
||||
|
||||
If a user hands you this document and says "Apply this process to my project":
|
||||
|
||||
1. **Check for MCP Tools:** Look for `.mcp.json` in the project root. If it exists, you have programmatic access to workflow tools and agent spawning capabilities.
|
||||
2. **Analyze the Request:** Ask for the high-level goal ("What are we building?") and the tech preferences ("Rust or Python?").
|
||||
3. **Git Check:** Check if the directory is a git repository (`git status`). If not, run `git init`.
|
||||
4. **Scaffold:** Run commands to create the `work/` and `specs/` folders with the 6-stage pipeline (`work/1_backlog/` through `work/6_archived/`).
|
||||
5. **Draft Context:** Write `specs/00_CONTEXT.md` based on the user's answer.
|
||||
6. **Draft Stack:** Write `specs/tech/STACK.md` based on best practices for that language.
|
||||
7. **Wait:** Ask the user for "Story #1".
|
||||
|
||||
---
|
||||
|
||||
## 6. Chat Bot Configuration
|
||||
|
||||
Story Kit includes a chat bot that can be connected to one messaging platform at a time. The bot handles commands, LLM conversations, and pipeline notifications.
|
||||
|
||||
**Only one transport can be active at a time.** To configure the bot, copy the appropriate example file to `.huskies/bot.toml`:
|
||||
|
||||
| Transport | Example file | Webhook endpoint |
|
||||
|-----------|-------------|-----------------|
|
||||
| Matrix | `bot.toml.matrix.example` | *(uses Matrix sync, no webhook)* |
|
||||
| WhatsApp (Meta Cloud API) | `bot.toml.whatsapp-meta.example` | `/webhook/whatsapp` |
|
||||
| WhatsApp (Twilio) | `bot.toml.whatsapp-twilio.example` | `/webhook/whatsapp` |
|
||||
| Slack | `bot.toml.slack.example` | `/webhook/slack` |
|
||||
|
||||
```bash
|
||||
cp .huskies/bot.toml.matrix.example .huskies/bot.toml
|
||||
# Edit bot.toml with your credentials
|
||||
```
|
||||
huskies --rendezvous ws://host:port/crdt-sync
|
||||
```
|
||||
|
||||
The `bot.toml` file is gitignored (it contains secrets). The example files are checked in for reference.
|
||||
Connects to an existing huskies instance as a worker node. Syncs the CRDT, claims work from the pipeline, runs agents. No web UI, no chat — just a build worker. Use this to add more compute to a project by running extra containers.
|
||||
|
||||
---
|
||||
### Gateway (multi-project)
|
||||
|
||||
## 7. Code Quality
|
||||
```
|
||||
huskies --gateway [--port 3000] /path/to/config
|
||||
```
|
||||
|
||||
**MANDATORY:** Before completing Step 3 (Verification) of any story, you MUST run all applicable linters, formatters, and test suites and fix ALL errors and warnings. Zero tolerance for warnings or errors.
|
||||
Lightweight proxy that sits in front of multiple project containers. Reads a `projects.toml` that maps project names to container URLs:
|
||||
|
||||
**AUTO-RUN CHECKS:** Always run the required lint/test/build checks as soon as relevant changes are made. Do not ask for permission to run them—run them automatically and fix any failures.
|
||||
```toml
|
||||
[projects.huskies]
|
||||
url = "http://huskies:3001"
|
||||
|
||||
**ALWAYS FIX DIAGNOSTICS:** At every stage, you must proactively fix all errors and warnings without waiting for user confirmation. Do not pause to ask whether to fix diagnostics—fix them immediately as part of the workflow.
|
||||
[projects.robot-studio]
|
||||
url = "http://robot-studio:3002"
|
||||
```
|
||||
|
||||
**Consult `specs/tech/STACK.md`** for the specific tools, commands, linter configurations, and quality gates for this project. The STACK file is the single source of truth for what must pass before a story can be accepted.
|
||||
The gateway presents a unified MCP surface to the chat agent. All tool calls are proxied to the active project's container. Gateway-specific tools:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `switch_project` | Change the active project |
|
||||
| `gateway_status` | Show active project and list all registered projects |
|
||||
| `gateway_health` | Health check all containers |
|
||||
| `init_project` | Scaffold a new `.huskies/` project at a given path — prefer this over asking the user to run `huskies init` on the CLI |
|
||||
|
||||
**Initialising a new project via MCP (preferred):** Instead of asking the user to run `huskies init <path>` in a terminal, call `init_project` with the `path` argument. Optionally pass `name` and `url` to register the project in `projects.toml` immediately. After that, start a huskies server at the path and use `switch_project` to make it active before calling `wizard_status`.
|
||||
|
||||
### Example: multi-project Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gateway:
|
||||
image: huskies
|
||||
command: ["huskies", "--gateway", "--port", "3000", "/workspace"]
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
depends_on: [huskies, robot-studio]
|
||||
|
||||
huskies:
|
||||
image: huskies
|
||||
volumes:
|
||||
- /path/to/huskies:/workspace
|
||||
|
||||
robot-studio:
|
||||
image: huskies
|
||||
environment:
|
||||
- HUSKIES_PORT=3002
|
||||
volumes:
|
||||
- /path/to/robot-studio:/workspace
|
||||
```
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 80
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-2"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 80
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-3"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 80
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
|
||||
[[agent]]
|
||||
name = "qa-2"
|
||||
stage = "qa"
|
||||
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||
model = "sonnet"
|
||||
max_turns = 40
|
||||
max_budget_usd = 4.00
|
||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||
|
||||
Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
|
||||
|
||||
## Your Workflow
|
||||
|
||||
### 0. Read the Story
|
||||
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
|
||||
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
|
||||
- Keep this list in mind for Step 3
|
||||
|
||||
### 1. Deterministic Gates (Prerequisites)
|
||||
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||
- Call the `run_tests` MCP tool — it blocks until tests finish and returns the full result directly. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable). All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
|
||||
|
||||
### 2. Code Change Review
|
||||
- Run `git diff master...HEAD --stat` to see what files changed
|
||||
- Run `git diff master...HEAD` to review the actual changes
|
||||
- Flag any incomplete implementations:
|
||||
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
|
||||
- Placeholder strings like "TODO", "FIXME", "not implemented"
|
||||
- Empty match arms or arms that just return `Default::default()`
|
||||
- Hardcoded values where real logic is expected
|
||||
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
|
||||
|
||||
### 3. Acceptance Criteria Review
|
||||
For each AC extracted in Step 0:
|
||||
- Review the diff and test files to determine if the code addresses this AC
|
||||
- PASS: describe specifically how the code addresses it (which file/function/test)
|
||||
- FAIL: explain exactly what is missing or incorrect
|
||||
|
||||
An AC fails if:
|
||||
- No code change or test relates to it
|
||||
- The implementation is stubbed out (todo!/unimplemented!)
|
||||
- A test exists but doesn't actually assert the behaviour described
|
||||
|
||||
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
|
||||
- Build: run `run_build` MCP tool and note success/failure
|
||||
- If build succeeds: find a free port (try 3010-3020), set `HUSKIES_PORT=<port>` and start the server with `script/server`
|
||||
- Generate a testing plan including:
|
||||
- URL to visit in the browser
|
||||
- Things to check in the UI
|
||||
- curl commands to exercise relevant API endpoints
|
||||
- Stop the test server when done: send SIGTERM to the `script/server` process (e.g. `kill <pid>`)
|
||||
|
||||
### 5. Produce Structured Report and Verdict
|
||||
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
|
||||
|
||||
```
|
||||
## QA Report for {{story_id}}
|
||||
|
||||
### Code Quality
|
||||
- run_tests MCP tool: PASS/FAIL (details)
|
||||
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
|
||||
- Other code review findings: (list any issues found, or "None")
|
||||
|
||||
### Acceptance Criteria Review
|
||||
- AC: <criterion text>
|
||||
Result: PASS/FAIL
|
||||
Evidence: <how the code addresses it, or what is missing>
|
||||
|
||||
(repeat for each AC)
|
||||
|
||||
### Manual Testing Plan
|
||||
- Server URL: http://localhost:PORT (or "Skipped — gate/AC failure" or "Build failed")
|
||||
- Pages to visit: (list, or "N/A")
|
||||
- Things to check: (list, or "N/A")
|
||||
- curl commands: (list, or "N/A")
|
||||
|
||||
### Overall: PASS/FAIL
|
||||
Reason: (summary of why it passed or the primary reason it failed)
|
||||
```
|
||||
|
||||
After printing the report:
|
||||
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
|
||||
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
|
||||
|
||||
## Rules
|
||||
- Do NOT modify any code — read-only review only
|
||||
- Gates must pass before AC review — a gate failure is an automatic reject
|
||||
- If any AC is not met, the overall result is FAIL
|
||||
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
|
||||
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-opus"
|
||||
stage = "coder"
|
||||
role = "Senior full-stack engineer for complex tasks. Implements features across all components."
|
||||
model = "opus"
|
||||
max_turns = 80
|
||||
max_budget_usd = 20.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map. The story details are in your prompt above. The worktree and feature branch already exist - do not create them.\n\n## Your workflow\n1. Read the story and understand the acceptance criteria.\n2. Implement the changes.\n3. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done.\n4. Run the run_tests MCP tool. It blocks server-side until tests finish (up to 20 minutes) and returns the full result. Do NOT call get_test_result — run_tests already gives you the pass/fail outcome.\n5. If tests fail, fix the failures and run run_tests again. Do not commit until tests pass.\n6. Once tests pass, commit your work with a descriptive message and exit.\n\nDo NOT accept stories, move them between stages, or merge to master. The server handles all of that after you exit.\n\n## Bug Workflow: Trust the Story, Act Fast\nWhen working on bugs:\n1. READ THE STORY DESCRIPTION FIRST. If it specifies exact files, functions, and line numbers — go directly there and make the fix.\n2. If the story does NOT specify the exact location, investigate with targeted grep.\n3. Fix with a surgical, minimal change.\n4. Run tests, fix failures, commit and exit.\n5. Write commit messages that explain what broke and why."
|
||||
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. Step 0: Before anything else, call `git_status` and `git_log` + `git_diff` against `master..HEAD` to discover any prior-session work in this worktree — uncommitted changes AND commits already on the feature branch. If either shows progress, RESUME from there; do not re-explore the codebase from scratch. You handle complex tasks requiring deep architectural understanding. Always run the run_tests MCP tool before committing — do not commit until tests pass. run_tests blocks server-side and returns the full result; do not poll get_test_result. As you complete each acceptance criterion, call check_criterion MCP tool to mark it done. Add //! module-level doc comments to any new modules and /// doc comments to any new public functions, structs, or enums. Before committing, run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` to check doc coverage on your changed files and address every missing-docs direction it prints. Do not accept stories, move them between stages, or merge to master — the server handles that. For bugs, trust the story description and make surgical fixes. For refactors that delete code or change function signatures, delete first and let the compiler error list be your guide to call sites — do not pre-read files trying to predict what will break. Each compile error is one mechanical fix; resist the urge to explore. When splitting `path/X.rs` into `path/X/mod.rs` + submodules, you MUST `git rm path/X.rs` in the SAME commit — leaving both files produces a `duplicate module file` cargo error (E0761) that breaks the build. Each new file you create as part of a decompose (e.g. the new `mod.rs`, `tests.rs`, and any submodule .rs files) MUST start with a `//!` doc comment describing what that module is for. The doc-coverage gate WILL block your merge if you skip this on any new file. Run `cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master` BEFORE you commit and address every direction it prints. For cross-stack stories (any story that touches more than 5 files OR more than 2 modules), commit progressively after each completed acceptance criterion or natural unit of work — do not save everything for a single end-of-story commit. Use `wip(story-{id}): {AC summary}` for intermediate commits and `{type}({id}): {summary}` for the final commit. This rule does NOT apply to small bug fixes or single-AC stories — for those, a single commit at the end is correct. For fast compile-error feedback while iterating, call `run_check` (runs `script/check`). Use `run_tests` only to validate the full pipeline before committing."
|
||||
|
||||
[[agent]]
|
||||
name = "qa"
|
||||
stage = "qa"
|
||||
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||
model = "sonnet"
|
||||
max_turns = 40
|
||||
max_budget_usd = 4.00
|
||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||
|
||||
Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
|
||||
|
||||
## Your Workflow
|
||||
|
||||
### 0. Read the Story
|
||||
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
|
||||
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
|
||||
- Keep this list in mind for Step 3
|
||||
|
||||
### 1. Deterministic Gates (Prerequisites)
|
||||
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||
- Call the `run_tests` MCP tool — it blocks until tests finish and returns the full result directly. All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable). All gates must pass (0 lint errors/warnings, all tests green, frontend build clean if applicable).
|
||||
|
||||
### 2. Code Change Review
|
||||
- Run `git diff master...HEAD --stat` to see what files changed
|
||||
- Run `git diff master...HEAD` to review the actual changes
|
||||
- Flag any incomplete implementations:
|
||||
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
|
||||
- Placeholder strings like "TODO", "FIXME", "not implemented"
|
||||
- Empty match arms or arms that just return `Default::default()`
|
||||
- Hardcoded values where real logic is expected
|
||||
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
|
||||
|
||||
### 3. Acceptance Criteria Review
|
||||
For each AC extracted in Step 0:
|
||||
- Review the diff and test files to determine if the code addresses this AC
|
||||
- PASS: describe specifically how the code addresses it (which file/function/test)
|
||||
- FAIL: explain exactly what is missing or incorrect
|
||||
|
||||
An AC fails if:
|
||||
- No code change or test relates to it
|
||||
- The implementation is stubbed out (todo!/unimplemented!)
|
||||
- A test exists but doesn't actually assert the behaviour described
|
||||
|
||||
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
|
||||
- Build: run `run_build` MCP tool and note success/failure
|
||||
- If build succeeds: find a free port (try 3010-3020), set `HUSKIES_PORT=<port>` and start the server with `script/server`
|
||||
- Generate a testing plan including:
|
||||
- URL to visit in the browser
|
||||
- Things to check in the UI
|
||||
- curl commands to exercise relevant API endpoints
|
||||
- Stop the test server when done: send SIGTERM to the `script/server` process (e.g. `kill <pid>`)
|
||||
|
||||
### 5. Produce Structured Report and Verdict
|
||||
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
|
||||
|
||||
```
|
||||
## QA Report for {{story_id}}
|
||||
|
||||
### Code Quality
|
||||
- run_tests MCP tool: PASS/FAIL (details)
|
||||
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
|
||||
- Other code review findings: (list any issues found, or "None")
|
||||
|
||||
### Acceptance Criteria Review
|
||||
- AC: <criterion text>
|
||||
Result: PASS/FAIL
|
||||
Evidence: <how the code addresses it, or what is missing>
|
||||
|
||||
(repeat for each AC)
|
||||
|
||||
### Manual Testing Plan
|
||||
- Server URL: http://localhost:PORT (or "Skipped — gate/AC failure" or "Build failed")
|
||||
- Pages to visit: (list, or "N/A")
|
||||
- Things to check: (list, or "N/A")
|
||||
- curl commands: (list, or "N/A")
|
||||
|
||||
### Overall: PASS/FAIL
|
||||
Reason: (summary of why it passed or the primary reason it failed)
|
||||
```
|
||||
|
||||
After printing the report:
|
||||
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
|
||||
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
|
||||
|
||||
## Rules
|
||||
- Do NOT modify any code — read-only review only
|
||||
- Gates must pass before AC review — a gate failure is an automatic reject
|
||||
- If any AC is not met, the overall result is FAIL
|
||||
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
|
||||
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
|
||||
|
||||
[[agent]]
|
||||
name = "mergemaster"
|
||||
stage = "mergemaster"
|
||||
role = "Merges completed coder work into master, runs quality gates, archives stories, and cleans up worktrees."
|
||||
model = "opus"
|
||||
max_turns = 100
|
||||
max_budget_usd = 25.00
|
||||
inactivity_timeout_secs = 900
|
||||
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
|
||||
|
||||
Read CLAUDE.md first, then .huskies/README.md for the dev process, .huskies/specs/00_CONTEXT.md for what this project does, and .huskies/specs/tech/STACK.md for the tech stack and source map.
|
||||
|
||||
## Your Workflow
|
||||
1. Call merge_agent_work(story_id='{{story_id}}'). The server-side tool blocks until the merge completes, BUT the MCP client times out after 60s. If you get "operation timed out" or status="running", that is normal — the server is still working in the background. Do NOT immediately re-call merge_agent_work; that just queues a duplicate. Instead, follow Step 2.
|
||||
2. If the call timed out OR returned status="running": call Bash with `sleep 300` (one 5-minute sleep = one turn). Then call get_merge_status once. Repeat up to 3 times (15 minutes total). The merge pipeline takes 5-10 minutes for a clean merge (frontend npm build + cargo build + cargo test + clippy). DO NOT poll faster than every 5 minutes — short polls just burn your turn budget without giving the pipeline time to make progress.
|
||||
3. If get_merge_status eventually returns success: you're done. Exit.
|
||||
4. If gates failed: read the gate_output carefully, fix the issues in the merge workspace at `.huskies/merge_workspace/`, run run_tests MCP tool to verify, recommit, and call merge_agent_work again.
|
||||
5. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and exit.
|
||||
6. After 3 failed fix attempts, call report_merge_failure and exit.
|
||||
|
||||
## Fixing Gate Failures
|
||||
|
||||
The auto-resolver often produces broken code. Common problems:
|
||||
- Duplicate imports or definitions (kept both sides)
|
||||
- Formatting issues (import ordering, line breaks)
|
||||
- Unclosed delimiters from bad conflict resolution
|
||||
- Type mismatches from incompatible merge of both sides
|
||||
|
||||
To fix:
|
||||
1. Read the broken files in `.huskies/merge_workspace/`
|
||||
2. Fix the issues — prefer master's structure, integrate only the feature's new code
|
||||
3. Run run_lint MCP tool to check formatting
|
||||
4. Run run_tests MCP tool to verify everything passes
|
||||
5. Commit the fix and call merge_agent_work again
|
||||
|
||||
## Rules
|
||||
- NEVER manually move story files between pipeline stages
|
||||
- NEVER call accept_story — merge_agent_work handles that
|
||||
- ALWAYS call report_merge_failure if you can't fix the merge"""
|
||||
system_prompt = "You are the mergemaster agent. Call merge_agent_work to merge. If gates fail, fix the issues in the merge workspace, verify with run_lint and run_tests MCP tools, recommit, and retrigger. After 3 failed attempts, call report_merge_failure and exit. Never move story files or call accept_story. CRITICAL: When fixing gate failures, commit the fix on feature/story-{id} (the feature branch), NOT in the merge_workspace — commits made in the merge_workspace are discarded when the next squash-merge re-runs from the feature branch. Example: cd /workspace/.huskies/worktrees/{id} && git add ... && git commit && retrigger merge. When resolving merge conflicts: before editing any conflicted file, use git blame and git log on the merge commit to identify the originating story IDs for each side of the conflict. Read those stories' spec files (.huskies/work/ or .huskies/specs/) to understand the intent of each change. Resolve conflicts in a way that satisfies both stories' intent, and explain the resolution in the merge commit message (cite the story IDs and why you chose the resolution you did)."
|
||||
@@ -0,0 +1,28 @@
|
||||
# Discord Transport
|
||||
# Copy this file to bot.toml and fill in your values.
|
||||
# Only one transport can be active at a time.
|
||||
#
|
||||
# Setup:
|
||||
# 1. Create a Discord Application at discord.com/developers/applications
|
||||
# 2. Go to Bot → create a bot and copy the token
|
||||
# 3. Enable "Message Content Intent" under Privileged Gateway Intents
|
||||
# 4. Go to OAuth2 → URL Generator, select "bot" scope with permissions:
|
||||
# Send Messages, Read Message History, Manage Messages
|
||||
# 5. Use the generated URL to invite the bot to your server
|
||||
# 6. Right-click the channel(s) → Copy Channel ID (enable Developer Mode in settings)
|
||||
|
||||
enabled = true
|
||||
transport = "discord"
|
||||
|
||||
discord_bot_token = "your-bot-token-here"
|
||||
discord_channel_ids = ["123456789012345678"]
|
||||
|
||||
# Discord user IDs allowed to interact with the bot.
|
||||
# When empty, all users in configured channels can interact.
|
||||
# discord_allowed_users = ["111222333444555666"]
|
||||
|
||||
# Bot display name (used in formatted messages).
|
||||
# display_name = "Assistant"
|
||||
|
||||
# Maximum conversation turns to remember per channel (default: 20).
|
||||
# history_size = 20
|
||||
+8
-312
@@ -18,6 +18,14 @@ max_retries = 3
|
||||
# (reads current HEAD branch).
|
||||
base_branch = "master"
|
||||
|
||||
# Suppress soft rate-limit warning notifications in chat.
|
||||
# Hard blocks and story-blocked notifications are always sent.
|
||||
rate_limit_notifications = false
|
||||
|
||||
# IANA timezone for timer scheduling (e.g. "Europe/London", "America/New_York").
|
||||
# Timer HH:MM inputs are interpreted in this timezone.
|
||||
timezone = "Europe/London"
|
||||
|
||||
[[component]]
|
||||
name = "frontend"
|
||||
path = "frontend"
|
||||
@@ -29,315 +37,3 @@ name = "server"
|
||||
path = "."
|
||||
setup = ["mkdir -p frontend/dist", "cargo check"]
|
||||
teardown = []
|
||||
|
||||
[[agent]]
|
||||
name = "coder-1"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-2"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-3"
|
||||
stage = "coder"
|
||||
role = "Full-stack engineer. Implements features across all components."
|
||||
model = "sonnet"
|
||||
max_turns = 50
|
||||
max_budget_usd = 5.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||
|
||||
[[agent]]
|
||||
name = "qa-2"
|
||||
stage = "qa"
|
||||
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||
model = "sonnet"
|
||||
max_turns = 40
|
||||
max_budget_usd = 4.00
|
||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||
|
||||
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
|
||||
## Your Workflow
|
||||
|
||||
### 0. Read the Story
|
||||
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
|
||||
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
|
||||
- Keep this list in mind for Step 3
|
||||
|
||||
### 1. Deterministic Gates (Prerequisites)
|
||||
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||
- Run `cargo clippy --all-targets --all-features` — must show 0 errors, 0 warnings
|
||||
- Run `cargo test` and verify all tests pass
|
||||
- If a `frontend/` directory exists:
|
||||
- Run `npm run build` and note any TypeScript errors
|
||||
- Run `npx @biomejs/biome check src/` and note any linting issues
|
||||
- Run `npm test` and verify all frontend tests pass
|
||||
|
||||
### 2. Code Change Review
|
||||
- Run `git diff master...HEAD --stat` to see what files changed
|
||||
- Run `git diff master...HEAD` to review the actual changes
|
||||
- Flag any incomplete implementations:
|
||||
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
|
||||
- Placeholder strings like "TODO", "FIXME", "not implemented"
|
||||
- Empty match arms or arms that just return `Default::default()`
|
||||
- Hardcoded values where real logic is expected
|
||||
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
|
||||
|
||||
### 3. Acceptance Criteria Review
|
||||
For each AC extracted in Step 0:
|
||||
- Review the diff and test files to determine if the code addresses this AC
|
||||
- PASS: describe specifically how the code addresses it (which file/function/test)
|
||||
- FAIL: explain exactly what is missing or incorrect
|
||||
|
||||
An AC fails if:
|
||||
- No code change or test relates to it
|
||||
- The implementation is stubbed out (todo!/unimplemented!)
|
||||
- A test exists but doesn't actually assert the behaviour described
|
||||
|
||||
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
|
||||
- Build the server: run `cargo build` and note success/failure
|
||||
- If build succeeds: find a free port (try 3010-3020) and attempt to start the server
|
||||
- Generate a testing plan including:
|
||||
- URL to visit in the browser
|
||||
- Things to check in the UI
|
||||
- curl commands to exercise relevant API endpoints
|
||||
- Kill the test server when done: `pkill -f 'target.*huskies' || true` (NEVER use `pkill -f huskies` — it kills the vite dev server)
|
||||
|
||||
### 5. Produce Structured Report and Verdict
|
||||
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
|
||||
|
||||
```
|
||||
## QA Report for {{story_id}}
|
||||
|
||||
### Code Quality
|
||||
- clippy: PASS/FAIL (details)
|
||||
- TypeScript build: PASS/FAIL/SKIP (details)
|
||||
- Biome lint: PASS/FAIL/SKIP (details)
|
||||
- cargo test: PASS/FAIL (N tests)
|
||||
- npm test: PASS/FAIL/SKIP (N tests)
|
||||
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
|
||||
- Other code review findings: (list any issues found, or "None")
|
||||
|
||||
### Acceptance Criteria Review
|
||||
- AC: <criterion text>
|
||||
Result: PASS/FAIL
|
||||
Evidence: <how the code addresses it, or what is missing>
|
||||
|
||||
(repeat for each AC)
|
||||
|
||||
### Manual Testing Plan
|
||||
- Server URL: http://localhost:PORT (or "Skipped — gate/AC failure" or "Build failed")
|
||||
- Pages to visit: (list, or "N/A")
|
||||
- Things to check: (list, or "N/A")
|
||||
- curl commands: (list, or "N/A")
|
||||
|
||||
### Overall: PASS/FAIL
|
||||
Reason: (summary of why it passed or the primary reason it failed)
|
||||
```
|
||||
|
||||
After printing the report:
|
||||
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
|
||||
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
|
||||
|
||||
## Rules
|
||||
- Do NOT modify any code — read-only review only
|
||||
- Gates must pass before AC review — a gate failure is an automatic reject
|
||||
- If any AC is not met, the overall result is FAIL
|
||||
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
|
||||
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
|
||||
|
||||
[[agent]]
|
||||
name = "coder-opus"
|
||||
stage = "coder"
|
||||
role = "Senior full-stack engineer for complex tasks. Implements features across all components."
|
||||
model = "opus"
|
||||
max_turns = 80
|
||||
max_budget_usd = 20.00
|
||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. The story details are in your prompt above. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates (cargo clippy + tests) when your process exits and advance the pipeline based on the results.\n\n## Bug Workflow: Root Cause First\nWhen working on bugs:\n1. Investigate the root cause before writing any fix. Use `git bisect` to find the breaking commit or `git log` to trace history. Read the relevant code before touching anything.\n2. Fix the root cause with a surgical, minimal change. Do NOT add new abstractions, wrappers, or workarounds when a targeted fix to the original code is possible.\n3. Write commit messages that explain what broke and why, not just what was changed.\n4. If you cannot determine the root cause after thorough investigation, document what you tried and why it was inconclusive — do not guess and ship a speculative fix."
|
||||
system_prompt = "You are a senior full-stack engineer working autonomously in a git worktree. You handle complex tasks requiring deep architectural understanding. Follow the Story-Driven Test Workflow strictly. Run cargo clippy --all-targets --all-features and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. The server automatically runs acceptance gates when your process exits. For bugs, always find and fix the root cause. Use git bisect to find breaking commits. Do not layer new code on top of existing code when a surgical fix is possible. If root cause is unclear after investigation, document what you tried rather than guessing."
|
||||
|
||||
[[agent]]
|
||||
name = "qa"
|
||||
stage = "qa"
|
||||
role = "Reviews coder work in worktrees: runs quality gates, verifies acceptance criteria, and reports findings."
|
||||
model = "sonnet"
|
||||
max_turns = 40
|
||||
max_budget_usd = 4.00
|
||||
prompt = """You are the QA agent for story {{story_id}}. Your job is to verify the coder's work satisfies the story's acceptance criteria and produce a structured QA report.
|
||||
|
||||
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
|
||||
## Your Workflow
|
||||
|
||||
### 0. Read the Story
|
||||
- Read the story file at `.huskies/work/3_qa/{{story_id}}.md`
|
||||
- Extract every acceptance criterion (the `- [ ]` checkbox lines)
|
||||
- Keep this list in mind for Step 3
|
||||
|
||||
### 1. Deterministic Gates (Prerequisites)
|
||||
Run these first — if any fail, reject immediately without proceeding to AC review:
|
||||
- Run `cargo clippy --all-targets --all-features` — must show 0 errors, 0 warnings
|
||||
- Run `cargo test` and verify all tests pass
|
||||
- If a `frontend/` directory exists:
|
||||
- Run `npm run build` and note any TypeScript errors
|
||||
- Run `npx @biomejs/biome check src/` and note any linting issues
|
||||
- Run `npm test` and verify all frontend tests pass
|
||||
|
||||
### 2. Code Change Review
|
||||
- Run `git diff master...HEAD --stat` to see what files changed
|
||||
- Run `git diff master...HEAD` to review the actual changes
|
||||
- Flag any incomplete implementations:
|
||||
- `todo!()`, `unimplemented!()`, `panic!()` used as stubs
|
||||
- Placeholder strings like "TODO", "FIXME", "not implemented"
|
||||
- Empty match arms or arms that just return `Default::default()`
|
||||
- Hardcoded values where real logic is expected
|
||||
- Note any obvious coding mistakes (unused imports, dead code, unhandled errors)
|
||||
|
||||
### 3. Acceptance Criteria Review
|
||||
For each AC extracted in Step 0:
|
||||
- Review the diff and test files to determine if the code addresses this AC
|
||||
- PASS: describe specifically how the code addresses it (which file/function/test)
|
||||
- FAIL: explain exactly what is missing or incorrect
|
||||
|
||||
An AC fails if:
|
||||
- No code change or test relates to it
|
||||
- The implementation is stubbed out (todo!/unimplemented!)
|
||||
- A test exists but doesn't actually assert the behaviour described
|
||||
|
||||
### 4. Manual Testing Support (only if all gates PASS and all ACs PASS)
|
||||
- Build the server: run `cargo build` and note success/failure
|
||||
- If build succeeds: find a free port (try 3010-3020) and attempt to start the server
|
||||
- Generate a testing plan including:
|
||||
- URL to visit in the browser
|
||||
- Things to check in the UI
|
||||
- curl commands to exercise relevant API endpoints
|
||||
- Kill the test server when done: `pkill -f 'target.*huskies' || true` (NEVER use `pkill -f huskies` — it kills the vite dev server)
|
||||
|
||||
### 5. Produce Structured Report and Verdict
|
||||
Print your QA report to stdout. Then call `approve_qa` or `reject_qa` via the MCP tool based on the overall result. Use this format:
|
||||
|
||||
```
|
||||
## QA Report for {{story_id}}
|
||||
|
||||
### Code Quality
|
||||
- clippy: PASS/FAIL (details)
|
||||
- TypeScript build: PASS/FAIL/SKIP (details)
|
||||
- Biome lint: PASS/FAIL/SKIP (details)
|
||||
- cargo test: PASS/FAIL (N tests)
|
||||
- npm test: PASS/FAIL/SKIP (N tests)
|
||||
- Incomplete implementations: (list any todo!/unimplemented!/stubs found, or "None")
|
||||
- Other code review findings: (list any issues found, or "None")
|
||||
|
||||
### Acceptance Criteria Review
|
||||
- AC: <criterion text>
|
||||
Result: PASS/FAIL
|
||||
Evidence: <how the code addresses it, or what is missing>
|
||||
|
||||
(repeat for each AC)
|
||||
|
||||
### Manual Testing Plan
|
||||
- Server URL: http://localhost:PORT (or "Skipped — gate/AC failure" or "Build failed")
|
||||
- Pages to visit: (list, or "N/A")
|
||||
- Things to check: (list, or "N/A")
|
||||
- curl commands: (list, or "N/A")
|
||||
|
||||
### Overall: PASS/FAIL
|
||||
Reason: (summary of why it passed or the primary reason it failed)
|
||||
```
|
||||
|
||||
After printing the report:
|
||||
- If Overall is PASS: call `approve_qa(story_id='{{story_id}}')` via MCP
|
||||
- If Overall is FAIL: call `reject_qa(story_id='{{story_id}}', notes='<concise reason>')` via MCP so the coder knows exactly what to fix
|
||||
|
||||
## Rules
|
||||
- Do NOT modify any code — read-only review only
|
||||
- Gates must pass before AC review — a gate failure is an automatic reject
|
||||
- If any AC is not met, the overall result is FAIL
|
||||
- Always call approve_qa or reject_qa — never leave the story without a verdict"""
|
||||
system_prompt = "You are a QA agent. Your job is read-only: run quality gates, verify each acceptance criterion against the diff, and produce a structured QA report. Always call approve_qa or reject_qa via MCP to record your verdict. Do not modify code."
|
||||
|
||||
[[agent]]
|
||||
name = "mergemaster"
|
||||
stage = "mergemaster"
|
||||
role = "Merges completed coder work into master, runs quality gates, archives stories, and cleans up worktrees."
|
||||
model = "opus"
|
||||
max_turns = 30
|
||||
max_budget_usd = 5.00
|
||||
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
|
||||
|
||||
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||
|
||||
## Your Workflow
|
||||
1. Call merge_agent_work(story_id='{{story_id}}') via the MCP tool to trigger the full merge pipeline
|
||||
2. Review the result: check success, had_conflicts, conflicts_resolved, gates_passed, and gate_output
|
||||
3. If merge succeeded and gates passed: report success to the human
|
||||
4. If conflicts were auto-resolved (conflicts_resolved=true) and gates passed: report success, noting which conflicts were resolved
|
||||
5. If conflicts could not be auto-resolved: **resolve them yourself** in the merge worktree (see below)
|
||||
6. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and report to the human
|
||||
7. If gates failed after merge: attempt to fix the issues yourself in the merge worktree, then re-trigger merge_agent_work. After 3 fix attempts, call report_merge_failure and stop.
|
||||
|
||||
## Resolving Complex Conflicts Yourself
|
||||
|
||||
When the auto-resolver fails, you have access to the merge worktree at `.story_kit/merge_workspace/`. Go in there and resolve the conflicts manually:
|
||||
|
||||
1. Run `git diff --name-only --diff-filter=U` in the merge worktree to list conflicted files
|
||||
2. **Build context before touching code.** Run `git log --oneline master...HEAD` on the feature branch to see its commits. Then run `git log --oneline --since="$(git log -1 --format=%ci <feature-branch-base-commit>)" master` to see what landed on master since the branch was created. Read the story files in `.story_kit/work/` for any recently merged stories that touch the same files — this tells you WHY master changed and what must be preserved.
|
||||
3. Read each conflicted file and understand both sides of the conflict
|
||||
4. **Understand intent, not just syntax.** The feature branch may be behind master — master's version of shared infrastructure is almost always correct. The feature branch's contribution is the NEW functionality it adds. Your job is to integrate the new into master's structure, not pick one side.
|
||||
5. Resolve by integrating the feature's new functionality into master's code structure
|
||||
5. Stage resolved files with `git add`
|
||||
6. Run `cargo check` (and `npm run build` if frontend changed) to verify compilation
|
||||
7. If it compiles, commit and re-trigger merge_agent_work
|
||||
|
||||
### Common conflict patterns in this project:
|
||||
|
||||
**Story file rename/rename conflicts:** Both branches moved the story .md file to different pipeline directories. Resolution: `git rm` both sides — story files in `work/2_current/`, `work/3_qa/`, `work/4_merge/` are gitignored and don't need to be committed.
|
||||
|
||||
**bot.rs tokio::select! conflicts:** Master has a `tokio::select!` loop in `handle_message()` that handles permission forwarding (story 275). Feature branches created before story 275 have a simpler direct `provider.chat_stream().await` call. Resolution: KEEP master's tokio::select! loop. Integrate only the feature's new logic (e.g. typing indicators, new callbacks) into the existing loop structure. Do NOT replace the loop with the old direct call.
|
||||
|
||||
**Duplicate functions/imports:** The auto-resolver keeps both sides, producing duplicates. Resolution: keep one copy (prefer master's version), delete the duplicate.
|
||||
|
||||
**Formatting-only conflicts:** Both sides reformatted the same code differently. Resolution: pick either side (prefer master).
|
||||
|
||||
## Fixing Gate Failures
|
||||
|
||||
If quality gates fail (cargo clippy, cargo test, npm run build, npm test), attempt to fix issues yourself in the merge worktree.
|
||||
|
||||
**Fix yourself (up to 3 attempts total):**
|
||||
- Syntax errors (missing semicolons, brackets, commas)
|
||||
- Duplicate definitions from merge artifacts
|
||||
- Simple type annotation errors
|
||||
- Unused import warnings flagged by clippy
|
||||
- Mismatched braces from bad conflict resolution
|
||||
- Trivial formatting issues that block compilation or linting
|
||||
|
||||
**Report to human without attempting a fix:**
|
||||
- Logic errors or incorrect business logic
|
||||
- Missing function implementations
|
||||
- Architectural changes required
|
||||
- Non-trivial refactoring needed
|
||||
|
||||
**Max retry limit:** If gates still fail after 3 fix attempts, call report_merge_failure to record the failure, then stop immediately and report the full gate output to the human.
|
||||
|
||||
## CRITICAL Rules
|
||||
- NEVER manually move story files between pipeline stages (e.g. from 4_merge/ to 5_done/)
|
||||
- NEVER call accept_story — only merge_agent_work can move stories to done after a successful merge
|
||||
- When merge fails after exhausting your fix attempts, ALWAYS call report_merge_failure
|
||||
- Report conflict resolution outcomes clearly
|
||||
- Report gate failures with full output so the human can act if needed
|
||||
- The server automatically runs acceptance gates when your process exits"""
|
||||
system_prompt = "You are the mergemaster agent. Your primary job is to merge feature branches to master. First try the merge_agent_work MCP tool. If the auto-resolver fails on complex conflicts, resolve them yourself in the merge worktree — you are an opus-class agent capable of understanding both sides of a conflict and producing correct merged code. Common patterns: keep master's tokio::select! permission loop in bot.rs, discard story file rename conflicts (gitignored), remove duplicate definitions. After resolving, verify compilation before re-triggering merge. CRITICAL: Never manually move story files or call accept_story. After 3 failed fix attempts, call report_merge_failure and stop."
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
# Spike 679: Migrate Inter-Component HTTP to Signed CRDT WebSocket Bus
|
||||
|
||||
## 1. Endpoint Inventory
|
||||
|
||||
Every HTTP/WS endpoint currently exposed by the gateway and project servers, with caller, purpose, and requirements.
|
||||
|
||||
### Standard-Mode Server Endpoints
|
||||
|
||||
#### WebSocket
|
||||
|
||||
| Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|------|--------|---------|---------|-----------|------------|
|
||||
| `/ws` | Browser frontend | Chat messages, command output streaming | Real-time | N/A (stream) | Ephemeral |
|
||||
| `/crdt-sync` | Peer nodes, headless agents | CRDT op replication, snapshot exchange | Sub-second | Must converge | Durable (SQLite) |
|
||||
|
||||
#### MCP
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET/POST | `/mcp` | Claude Code agent (stdio), gateway proxy | Agent tool calls (story create/update, git, shell, etc.) | <500 ms | Strong (mutations) | Durable via CRDT |
|
||||
|
||||
#### Agents API
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| POST | `/api/agents/start` | Frontend, MCP | Start a coding agent for a story | <1 s | N/A | Durable (process started) |
|
||||
| POST | `/api/agents/stop` | Frontend, MCP | Stop a running agent | <1 s | N/A | Durable (process killed) |
|
||||
| GET | `/api/agents` | Frontend | List active agents and status | <100 ms | Near-real-time | None (in-memory) |
|
||||
| GET | `/api/agents/config` | Frontend | Read agent config from project.toml | <100 ms | Seconds OK | None |
|
||||
| POST | `/api/agents/config/reload` | Frontend | Reload config from disk | <500 ms | N/A | None |
|
||||
| POST | `/api/agents/worktrees` | MCP | Create worktree for a story | <1 s | N/A | Durable (git) |
|
||||
| GET | `/api/agents/worktrees` | Frontend, MCP | List worktrees | <100 ms | Seconds OK | None |
|
||||
| DELETE | `/api/agents/worktrees/:story_id` | MCP | Remove a worktree | <1 s | N/A | Durable (git) |
|
||||
| GET | `/api/agents/:story_id/:name/output` | Frontend, MCP | Read agent log file | <200 ms | Seconds OK | Durable (JSONL file) |
|
||||
| GET | `/api/work-items/:story_id` | MCP | Get story test results | <100 ms | Seconds OK | Durable (file) |
|
||||
| GET | `/api/work-items/:story_id/test-results` | MCP | Fetch cached test run output | <100 ms | Seconds OK | Durable (file) |
|
||||
| GET | `/api/work-items/:story_id/token-cost` | MCP | Get token usage for story | <100 ms | Seconds OK | Durable (file) |
|
||||
| GET | `/api/token-usage` | Frontend | Aggregate token usage | <100 ms | Minutes OK | Durable (file) |
|
||||
|
||||
#### Project Management
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET | `/api/project` | Frontend | Get current project config | <100 ms | Seconds OK | Durable (file) |
|
||||
| POST | `/api/project` | Frontend | Update project config | <500 ms | N/A | Durable (file) |
|
||||
| DELETE | `/api/project` | Frontend | Reset project config | <500 ms | N/A | Durable (file) |
|
||||
| GET | `/api/projects` | Frontend | List all known projects | <100 ms | Seconds OK | Durable (file) |
|
||||
| POST | `/api/projects/forget` | Frontend | Remove project from registry | <500 ms | N/A | Durable (file) |
|
||||
|
||||
#### Chat
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| POST | `/api/chat/cancel` | Frontend | Cancel an in-progress chat | <100 ms | N/A | None |
|
||||
|
||||
#### Settings
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET/PUT | `/api/settings` | Frontend | Read/write general settings | <100 ms | Seconds OK | Durable (JSON store) |
|
||||
| GET/PUT | `/api/settings/editor` | Frontend | Read/write editor setting | <100 ms | Seconds OK | Durable (JSON store) |
|
||||
| POST | `/api/settings/open-file` | Frontend | Open file in editor | <500 ms | N/A | None |
|
||||
|
||||
#### IO (Filesystem/Shell)
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| POST | `/api/io/fs/read` | Agent (MCP alt), Frontend | Read file contents | <200 ms | Real-time | N/A |
|
||||
| POST | `/api/io/fs/write` | Agent (MCP alt), Frontend | Write file contents | <500 ms | N/A | Durable (fs) |
|
||||
| POST | `/api/io/fs/list` | Frontend | List directory relative to project | <100 ms | Real-time | N/A |
|
||||
| POST | `/api/io/fs/list/absolute` | Frontend | List absolute path directory | <100 ms | Real-time | N/A |
|
||||
| POST | `/api/io/fs/create/absolute` | Frontend | Create file at absolute path | <500 ms | N/A | Durable (fs) |
|
||||
| GET | `/api/io/fs/home` | Frontend | Get home directory | <50 ms | Stable | N/A |
|
||||
| GET | `/api/io/fs/files` | Frontend | File tree of project | <500 ms | Seconds OK | N/A |
|
||||
| POST | `/api/io/search` | Frontend | Ripgrep search | <1 s | Real-time | N/A |
|
||||
| POST | `/api/io/shell/exec` | Frontend | Execute shell command | Variable | N/A | None |
|
||||
|
||||
#### Model / LLM Config
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET/POST | `/api/model` | Frontend | Read/write active model selection | <100 ms | Seconds OK | Durable (JSON store) |
|
||||
| GET | `/api/ollama/models` | Frontend | List available Ollama models | <1 s | Minutes OK | None |
|
||||
| GET | `/api/anthropic/key/exists` | Frontend | Check if API key is set | <50 ms | Seconds OK | None |
|
||||
| POST | `/api/anthropic/key` | Frontend | Store Anthropic API key | <100 ms | N/A | Durable (store) |
|
||||
| GET | `/api/anthropic/models` | Frontend | List Claude models | <1 s | Minutes OK | None |
|
||||
|
||||
#### Wizard
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET | `/api/wizard` | Frontend | Get wizard state | <100 ms | Real-time | Durable (store) |
|
||||
| PUT | `/api/wizard/step/:step/content` | Frontend | Update step content | <200 ms | N/A | Durable (store) |
|
||||
| POST | `/api/wizard/step/:step/confirm` | Frontend | Confirm a wizard step | <200 ms | N/A | Durable |
|
||||
| POST | `/api/wizard/step/:step/skip` | Frontend | Skip a wizard step | <100 ms | N/A | Durable |
|
||||
| POST | `/api/wizard/step/:step/generating` | Frontend | Mark step as generating | <100 ms | N/A | Durable |
|
||||
|
||||
#### Bot / Transports
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| POST | `/api/bot/command` | Frontend | Send a bot command | <500 ms | N/A | None |
|
||||
| GET/PUT | `/api/bot/config` | Frontend | Read/write bot config | <100 ms | Seconds OK | Durable (file) |
|
||||
|
||||
#### Auth / OAuth
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET | `/oauth/authorize` | Browser redirect | Start OAuth flow | <200 ms | N/A | None |
|
||||
| GET | `/callback` | OAuth provider redirect | Handle OAuth callback | <500 ms | N/A | Durable (token) |
|
||||
| GET | `/oauth/status` | Frontend | Check OAuth connection status | <100 ms | Seconds OK | None |
|
||||
|
||||
#### Webhooks (External Inbound)
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET/POST | `/webhook/whatsapp` | WhatsApp platform | Receive WhatsApp messages | <200 ms | Real-time | None (forwarded) |
|
||||
| POST | `/webhook/slack` | Slack platform | Receive Slack events | <200 ms | Real-time | None (forwarded) |
|
||||
| POST | `/webhook/slack/command` | Slack platform | Receive Slack slash commands | <200 ms | Real-time | None (forwarded) |
|
||||
|
||||
#### Debug / Health
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET | `/health` | Gateway, load balancer | Health check | <50 ms | Real-time | None |
|
||||
| GET | `/debug/crdt` | Developer/ops | Dump raw CRDT state | <500 ms | Real-time | None |
|
||||
| GET (SSE) | `/api/agents/:story_id/:name/stream` | Frontend | Stream live agent output | Real-time | N/A | None |
|
||||
| GET | `/api/events` | Gateway polling task | Poll project events | <200 ms | Seconds OK | None |
|
||||
|
||||
#### Frontend Assets
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `/` | SPA entry point |
|
||||
| `/assets/*` | JS/CSS/fonts (rust-embed) |
|
||||
| `/*path` | SPA fallback |
|
||||
|
||||
---
|
||||
|
||||
### Gateway-Mode Server Endpoints
|
||||
|
||||
| Method | Path | Caller | Purpose | Latency | Freshness | Durability |
|
||||
|--------|------|--------|---------|---------|-----------|------------|
|
||||
| GET | `/health` | Load balancer, project containers | Health check | <50 ms | Real-time | None |
|
||||
| GET | `/bot-config` | Browser | Serve bot config HTML page | <100 ms | N/A | N/A |
|
||||
| GET | `/api/gateway` | Frontend | Get gateway state (active project, project list) | <100 ms | Seconds OK | Durable (toml) |
|
||||
| POST | `/api/gateway/switch` | Frontend, MCP | Switch active project | <200 ms | N/A | Durable (in-memory + file) |
|
||||
| GET | `/api/gateway/pipeline` | Frontend | Aggregate pipeline status across all projects | <1 s | Seconds OK | None (aggregated) |
|
||||
| POST | `/api/gateway/projects` | Frontend, init_project MCP | Register a new project in projects.toml | <500 ms | N/A | Durable (file) |
|
||||
| DELETE | `/api/gateway/projects/:name` | Frontend | Remove a registered project | <500 ms | N/A | Durable (file) |
|
||||
| GET/PUT | `/api/gateway/bot-config` | Frontend | Read/write bot config file | <100 ms | Seconds OK | Durable (file) |
|
||||
| GET/POST | `/mcp` | Claude Code agent | MCP proxy to active project | <500 ms | Strong | Durable via upstream |
|
||||
| GET | `/gateway/mode` | Frontend | Check whether gateway mode is active | <50 ms | Stable | None |
|
||||
| POST | `/gateway/tokens` | Ops/admin | Generate a headless-agent join token | <100 ms | N/A | Durable (in-memory HashMap) |
|
||||
| POST | `/gateway/register` | Headless build agent at startup | Register agent with token, supply address | <200 ms | N/A | In-memory Vec |
|
||||
| GET | `/gateway/agents` | Frontend, ops | List all registered headless agents | <100 ms | Seconds OK | In-memory Vec |
|
||||
| DELETE | `/gateway/agents/:id` | Frontend, ops | Deregister an agent | <200 ms | N/A | In-memory Vec |
|
||||
| POST | `/gateway/agents/:id/assign` | Frontend, ops | Assign agent to a project | <200 ms | N/A | In-memory Vec |
|
||||
| POST | `/gateway/agents/:id/heartbeat` | Headless agent (periodic) | Signal agent is alive | <100 ms | Real-time | In-memory Vec |
|
||||
|
||||
---
|
||||
|
||||
## 2. Classification
|
||||
|
||||
| Endpoint Group | Classification |
|
||||
|---------------|----------------|
|
||||
| `/webhook/whatsapp`, `/webhook/slack`, `/webhook/slack/command` | **external-webhook** |
|
||||
| `/`, `/assets/*`, `/*path`, `/bot-config` (HTML) | **frontend-asset** |
|
||||
| `POST /api/agents/start`, `POST /api/agents/stop`, `POST /api/agents/worktrees`, `DELETE /api/agents/worktrees/:id` | **write** |
|
||||
| `POST /api/project`, `DELETE /api/project`, `POST /api/projects/forget` | **write** |
|
||||
| `PUT /api/settings`, `PUT /api/settings/editor`, `POST /api/settings/open-file` | **write** |
|
||||
| `POST /api/model`, `POST /api/anthropic/key` | **write** |
|
||||
| `POST /api/wizard/step/*`, `PUT /api/wizard/step/*` | **write** |
|
||||
| `POST /api/bot/command`, `PUT /api/bot/config` | **write** |
|
||||
| `POST /api/io/fs/write`, `POST /api/io/fs/create/absolute`, `POST /api/io/shell/exec` | **write** |
|
||||
| `POST /api/gateway/switch`, `POST /api/gateway/projects`, `DELETE /api/gateway/projects/:name` | **write** |
|
||||
| `POST /gateway/tokens`, `POST /gateway/register`, `DELETE /gateway/agents/:id`, `POST /gateway/agents/:id/assign` | **write** |
|
||||
| `POST /gateway/agents/:id/heartbeat` | **write** |
|
||||
| `POST /mcp`, `GET /mcp` | **write** (mutations dominate; reads via CRDT subscription eventually) |
|
||||
| All remaining `GET` endpoints | **read** |
|
||||
| `POST /api/chat/cancel`, `POST /api/agents/config/reload` | **write** (side-effect only, stateless result) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Write Endpoints → Target CRDT Collections
|
||||
|
||||
| Endpoint | Current Storage | Target CRDT Collection | Notes |
|
||||
|----------|----------------|----------------------|-------|
|
||||
| `POST /gateway/tokens` | `GatewayState.pending_tokens: HashMap<String, PendingToken>` | `tokens` — LWW map keyed by token UUID | TTL field; garbage-collect expired entries |
|
||||
| `POST /gateway/register` | `GatewayState.joined_agents: Vec<JoinedAgent>` | `nodes` — existing CRDT node collection (extend with agent metadata) | Already partially exists for CRDT mesh peers |
|
||||
| `POST /gateway/agents/:id/assign` | `joined_agents` Vec mutation | `nodes` — LWW field `assigned_project` per node entry | |
|
||||
| `DELETE /gateway/agents/:id` | `joined_agents` Vec mutation | `nodes` — tombstone / remove entry | Add-wins or explicit remove flag |
|
||||
| `POST /gateway/agents/:id/heartbeat` | `joined_agents` Vec `last_seen` field | `nodes` — LWW `last_seen_ms` field per node | Low-cost: just a timestamp LWW |
|
||||
| `POST /api/agents/start` | `AgentPool.agents: HashMap` | No new CRDT; agent process is local. Side-effect only. Assign record if cross-node visibility needed → `active_agents` LWW map | |
|
||||
| `POST /api/agents/stop` | `AgentPool.agents` mutation | Same as above | |
|
||||
| `POST /api/agents/worktrees` | git filesystem | No CRDT needed; git worktrees are local | |
|
||||
| `POST /api/gateway/switch` | `GatewayState.active_project` in-memory | `gateway_config` — LWW field `active_project` | |
|
||||
| `POST /api/gateway/projects` | `projects.toml` file | `gateway_config.projects` — LWW map by project name | |
|
||||
| `DELETE /api/gateway/projects/:name` | `projects.toml` file | `gateway_config.projects` — tombstone entry | |
|
||||
| `PUT /api/settings`, `PUT /api/settings/editor` | `JsonFileStore` | `settings` — LWW map per key | Low priority; settings are single-node today |
|
||||
| `POST /api/model` | `JsonFileStore` | `settings` — same LWW map | |
|
||||
| `POST /api/anthropic/key` | Encrypted file/env | Stay out of CRDT (secrets) | |
|
||||
| `PUT /api/bot/config` | `.huskies/bot.toml` file | Stay out of CRDT (credentials) | |
|
||||
| `POST /mcp` | CRDT (already) | Already replicated via CRDT WebSocket bus | Story/pipeline mutations are CRDT-native |
|
||||
| Merge job tracking | `AgentPool.merge_jobs: HashMap<String, MergeJob>` | `merge_jobs` — LWW map by story_id, or append-only log | Needed for cross-node merge visibility |
|
||||
| Test job tracking | `AppContext.test_job_registry: HashMap<WorkPath, TestJob>` | `test_jobs` — LWW map by story_id | Needed so any node can query test status |
|
||||
|
||||
---
|
||||
|
||||
## 4. Read Endpoints → Proposed RPC Frame Shapes
|
||||
|
||||
| Endpoint | Request Fields | Response Fields |
|
||||
|----------|---------------|-----------------|
|
||||
| `GET /health` | _(none)_ | `{status: "ok", version: string, node_id: string}` |
|
||||
| `GET /api/gateway` | _(none)_ | `{active_project: string, projects: {name, url, healthy}[]}` |
|
||||
| `GET /api/gateway/pipeline` | _(none)_ | `{projects: {name: string, pipeline: PipelineStages}[]}` |
|
||||
| `GET /gateway/agents` | _(none)_ | `{agents: {id, label, address, assigned_project, last_seen_ms, alive: bool}[]}` |
|
||||
| `GET /api/agents` | _(none)_ | `{agents: {story_id, agent_name, pid, status, started_at}[]}` |
|
||||
| `GET /api/agents/worktrees` | _(none)_ | `{worktrees: {story_id, path, branch}[]}` |
|
||||
| `GET /api/agents/:id/:name/output` | _(path params)_ | `{lines: AgentLogLine[]}` |
|
||||
| `GET /api/work-items/:story_id/test-results` | _(path param)_ | `{passed: bool, output: string, ran_at: timestamp}` |
|
||||
| `GET /api/work-items/:story_id/token-cost` | _(path param)_ | `{input_tokens: u64, output_tokens: u64, cost_usd: f64}` |
|
||||
| `GET /api/token-usage` | _(none)_ | `{total_input: u64, total_output: u64, per_agent: {...}[]}` |
|
||||
| `GET /api/settings` | _(none)_ | `{settings: Record<string, JsonValue>}` |
|
||||
| `GET /api/model` | _(none)_ | `{provider: string, model: string}` |
|
||||
| `GET /api/events` | `{since: unix_ms}` | `{events: {type, payload, ts}[], next_since: unix_ms}` |
|
||||
| `GET /debug/crdt` | _(none)_ | `{crdt_doc: json}` |
|
||||
| `GET /api/wizard` | _(none)_ | `{steps: WizardStep[], current_step: string}` |
|
||||
| `GET /api/anthropic/models` | _(none)_ | `{models: {id, name}[]}` |
|
||||
| `GET /api/ollama/models` | _(none)_ | `{models: {name, size}[]}` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Draft: Unsigned Read-RPC Protocol
|
||||
|
||||
### Rationale
|
||||
|
||||
Write mutations already flow through the CRDT bus (signed ops). Read endpoints are the remaining HTTP surface that could be migrated to the same WebSocket channel. This section drafts the envelope format so read RPCs can share the bus without requiring Ed25519 auth (unsigned reads are fine; only writes need authenticity guarantees).
|
||||
|
||||
### Frame Envelope (JSON over WebSocket)
|
||||
|
||||
```json
|
||||
// Request (caller → peer)
|
||||
{
|
||||
"version": 1,
|
||||
"kind": "rpc_request",
|
||||
"correlation_id": "uuid-v4",
|
||||
"ttl_ms": 5000,
|
||||
"method": "get_pipeline_status",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
// Success response (peer → caller)
|
||||
{
|
||||
"version": 1,
|
||||
"kind": "rpc_response",
|
||||
"correlation_id": "uuid-v4",
|
||||
"ok": true,
|
||||
"result": { ... }
|
||||
}
|
||||
|
||||
// Error response
|
||||
{
|
||||
"version": 1,
|
||||
"kind": "rpc_response",
|
||||
"correlation_id": "uuid-v4",
|
||||
"ok": false,
|
||||
"error": "human-readable message",
|
||||
"code": "NOT_FOUND | TIMEOUT | PEER_OFFLINE | INTERNAL"
|
||||
}
|
||||
```
|
||||
|
||||
### Correlation IDs
|
||||
|
||||
Each request carries a UUID v4 `correlation_id`. The responder echoes it verbatim. Callers maintain a `HashMap<String, oneshot::Sender>` to route responses back to waiting futures. On TTL expiry the entry is removed and the caller receives `Err(Timeout)`.
|
||||
|
||||
### TTL Semantics
|
||||
|
||||
- Caller specifies `ttl_ms` (default 5000, max 30000).
|
||||
- If the responding peer does not answer within the TTL, the caller synthesises a `TIMEOUT` error response locally.
|
||||
- Responders do not need to track TTLs; they answer as fast as they can.
|
||||
- Callers may use stale cached results if `ttl_ms == 0` is supplied and a cache entry exists (opt-in freshness trade-off).
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `NOT_FOUND` | Resource does not exist |
|
||||
| `TIMEOUT` | Peer did not respond within TTL |
|
||||
| `PEER_OFFLINE` | No live peer with the requested capability is connected |
|
||||
| `UNAUTHORIZED` | Caller lacks permission (future, when auth lands) |
|
||||
| `INTERNAL` | Unexpected server-side error |
|
||||
|
||||
### Peer-Offline Handling
|
||||
|
||||
- Before sending a request the caller checks whether any peer that can serve the method is currently connected.
|
||||
- If no peer is online, the caller immediately returns `PEER_OFFLINE` without queuing (fail-fast).
|
||||
- For idempotent reads, callers may fall back to a local CRDT-materialized view if `PEER_OFFLINE` or `TIMEOUT` is received.
|
||||
- Non-idempotent reads (e.g., `exec_shell`) must not be retried automatically.
|
||||
|
||||
### Method Naming Convention
|
||||
|
||||
`<noun>.<verb>` — e.g. `pipeline.get`, `agents.list`, `health.check`, `events.poll`.
|
||||
|
||||
---
|
||||
|
||||
## 6. In-Memory State → CRDT Collection Migration
|
||||
|
||||
| Location | Field | Current Type | Proposed CRDT Type | Rationale |
|
||||
|----------|-------|-------------|-------------------|-----------|
|
||||
| `gateway.rs::GatewayState` | `pending_tokens` | `HashMap<String, PendingToken>` | **LWW-map** keyed by token UUID, with `expires_at` TTL field | Tokens are short-lived; LWW is fine; GC by TTL |
|
||||
| `gateway.rs::GatewayState` | `joined_agents` | `Vec<JoinedAgent>` | Extend existing **`nodes` CRDT collection** with agent metadata fields (label, address, assigned_project, last_seen_ms) | Nodes collection already exists for CRDT mesh peers |
|
||||
| `agents/pool/mod.rs::AgentPool` | `merge_jobs` | `HashMap<String, MergeJob>` | **LWW-map** keyed by story_id; fields: node_id, status, started_at, error | Required for cross-node merge visibility |
|
||||
| `agents/pool/mod.rs::AgentPool` | `agents` (running agent handles) | `HashMap<String, StoryAgent>` | **LWW-map** `active_agents` keyed by story_id; fields: node_id, agent_name, pid(optional), started_at, status | Process handles stay local; only metadata replicated |
|
||||
| `http/context.rs::AppContext` | `test_job_registry` | `HashMap<WorkPath, TestJob>` (TestJobRegistry) | **LWW-map** `test_jobs` keyed by story_id; fields: node_id, status, started_at, finished_at | Needed so any node can query test run status |
|
||||
| `agents/pool/auto_assign` | agent throttle / last-seen timestamps | Local variables / in-memory | **LWW-map** `agent_throttle` keyed by agent_name; field: last_dispatched_at | Prevents double-dispatch on multi-node |
|
||||
| `gateway.rs::GatewayState` | `active_project` | `Arc<RwLock<String>>` | **LWW register** in `gateway_config` collection, field `active_project` | Single-value; LWW is correct |
|
||||
| `gateway.rs::GatewayState` | `projects` (BTreeMap) | `Arc<RwLock<BTreeMap<String, ProjectEntry>>>` | **LWW-map** in `gateway_config.projects` keyed by project name | Infrequently mutated; LWW correct |
|
||||
|
||||
### Summary of Proposed New CRDT Collections
|
||||
|
||||
| Collection | Type | Notes |
|
||||
|-----------|------|-------|
|
||||
| `tokens` | LWW-map | Join tokens with TTL; garbage-collect on expiry |
|
||||
| `nodes` | LWW-map (extend existing) | Already exists; add agent metadata fields |
|
||||
| `merge_jobs` | LWW-map | One entry per story; overwritten on each merge attempt |
|
||||
| `active_agents` | LWW-map | One entry per story; metadata only (not process handles) |
|
||||
| `test_jobs` | LWW-map | One entry per story; test run status |
|
||||
| `agent_throttle` | LWW-map | One entry per agent name; last-dispatched timestamp |
|
||||
| `gateway_config` | LWW-map (or flat LWW fields) | `active_project`, `projects` map |
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration Order and Dependencies
|
||||
|
||||
### Blocking Dependency
|
||||
|
||||
**Story 665 (Ed25519 auth)** must land before any write operation is migrated to the CRDT bus. Unsigned writes on a shared bus would allow any connected peer to forge mutations. Read RPCs do not require auth.
|
||||
|
||||
### Wave 0 — Foundation (no story 665 needed)
|
||||
|
||||
These can land in parallel with or before story 665:
|
||||
|
||||
1. **Extend `nodes` CRDT collection** with `label`, `address`, `assigned_project`, `last_seen_ms` fields. This is a pure schema addition.
|
||||
2. **Add `merge_jobs` and `active_agents` LWW-maps** to the CRDT document schema (additive; existing nodes ignore unknown fields via `serde(default)`).
|
||||
3. **Implement unsigned read-RPC multiplexer** on the existing `/crdt-sync` WebSocket channel (new `kind: "rpc_request"/"rpc_response"` frame types, ignored by old peers).
|
||||
|
||||
### Wave 1 — Migrate Heartbeat + Agent Registration (after `nodes` schema extended)
|
||||
|
||||
- Replace `POST /gateway/agents/:id/heartbeat` HTTP call with a CRDT LWW write to `nodes[id].last_seen_ms`.
|
||||
- Replace `POST /gateway/register` with a CRDT insert into `nodes` collection.
|
||||
- Replace `POST /gateway/tokens` / token validation with CRDT `tokens` map read/write.
|
||||
- **Blocks on story 665** for the write side; read queries (list agents, check token) can migrate via read-RPC first.
|
||||
|
||||
### Wave 2 — Migrate Read Endpoints to Read-RPC (no auth required)
|
||||
|
||||
Can land in parallel with Wave 1 write migration:
|
||||
|
||||
- `GET /health` → `health.check` RPC (gateway reads from CRDT `nodes` liveness)
|
||||
- `GET /gateway/agents` → `agents.list` RPC reading from CRDT `nodes`
|
||||
- `GET /api/events` polling loop → subscribe to CRDT op stream directly (eliminate polling)
|
||||
- `GET /api/gateway/pipeline` → `pipeline.get` RPC or direct CRDT materialisation (already replicated)
|
||||
- `GET /api/agents` → `active_agents.list` RPC reading from CRDT `active_agents`
|
||||
|
||||
### Wave 3 — Migrate Merge and Test Job Tracking (after waves 0–1)
|
||||
|
||||
- Replace `merge_jobs` HashMap with CRDT `merge_jobs` map writes on merge start/completion.
|
||||
- Replace `test_job_registry` HashMap with CRDT `test_jobs` map writes on test start/completion.
|
||||
- Enables: any node can query merge or test status without HTTP call to the node that started the job.
|
||||
|
||||
### Wave 4 — Migrate Gateway Config Writes (after story 665)
|
||||
|
||||
- `POST /api/gateway/switch`, `POST /api/gateway/projects`, `DELETE /api/gateway/projects/:name` → CRDT `gateway_config` LWW writes.
|
||||
- Low urgency; these are infrequent admin operations. Can keep HTTP as a thin wrapper that writes to CRDT.
|
||||
|
||||
### Endpoints That Stay HTTP
|
||||
|
||||
| Endpoint | Reason |
|
||||
|----------|--------|
|
||||
| `/webhook/whatsapp`, `/webhook/slack` | External platform callbacks; must remain HTTP |
|
||||
| `/oauth/authorize`, `/callback` | OAuth redirect flow; must remain HTTP |
|
||||
| `/api/io/*`, `/api/io/shell/exec` | Local filesystem/shell; process-local, not cross-node |
|
||||
| `/api/io/fs/*` | Same — local I/O only |
|
||||
| `/mcp` | External MCP clients (Claude Code CLI) speak HTTP/SSE; gateway proxy stays HTTP |
|
||||
| `/assets/*`, `/`, `/*path` | Static frontend assets |
|
||||
| `/api/anthropic/key`, `PUT /api/bot/config` | Credentials — must stay local, never in CRDT |
|
||||
| `GET /debug/crdt` | Debug only; HTTP fine |
|
||||
|
||||
### Dependency Graph Summary
|
||||
|
||||
```
|
||||
story 665 (Ed25519 auth)
|
||||
└── Wave 1 write migrations (heartbeat, register, assign, tokens)
|
||||
└── Wave 4 gateway config writes
|
||||
|
||||
Wave 0 (schema extensions + read-RPC multiplexer) [can start now, parallel]
|
||||
└── Wave 2 read endpoint migrations [can start now, parallel]
|
||||
└── Wave 3 merge/test job tracking [after Wave 0 schema]
|
||||
```
|
||||
|
||||
**Critical path:** Story 665 → Wave 1 → Wave 4. Everything else is parallel.
|
||||
@@ -0,0 +1,241 @@
|
||||
# Spike 814: Chat-Driven Update Command for Multi-Project Gateway
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
In a multi-project gateway deployment (Docker Compose or similar), each project runs as its own container.
|
||||
Today, updating a project container requires direct operator access to the host — `docker pull`, `docker compose up -d <project>`, or equivalent.
|
||||
There is no way to trigger an update from chat.
|
||||
|
||||
This spike designs a `update` bot command that:
|
||||
- Can be typed in the Matrix/Slack/Discord chat room.
|
||||
- Pulls the latest image (or rebuilds from source) for one or all project containers managed by the gateway.
|
||||
- Reports progress and outcome back to the room.
|
||||
- Supports rollback when a container fails to start cleanly.
|
||||
|
||||
---
|
||||
|
||||
## 2. Command Surface
|
||||
|
||||
### Basic syntax
|
||||
|
||||
```
|
||||
update [<project>|all] [--rollback]
|
||||
```
|
||||
|
||||
| Invocation | Effect |
|
||||
|-----------|--------|
|
||||
| `update huskies` | Update and restart the `huskies` container. |
|
||||
| `update all` | Update every registered project container, one at a time. |
|
||||
| `update` (no args) | Same as `update all`. |
|
||||
| `update huskies --rollback` | Roll back `huskies` to its previous image tag. |
|
||||
|
||||
### Progress feedback
|
||||
|
||||
The bot posts incremental updates to the room (editing the same message where the platform supports it):
|
||||
|
||||
```
|
||||
[huskies] Pulling image… ⏳
|
||||
[huskies] Image pulled (sha256:abc123). Stopping container…
|
||||
[huskies] Container stopped. Starting new container…
|
||||
[huskies] Health check passed ✅ (2 s)
|
||||
```
|
||||
|
||||
On failure:
|
||||
```
|
||||
[huskies] Health check failed after 30 s ❌
|
||||
[huskies] Rolling back to previous image (sha256:def456)…
|
||||
[huskies] Rollback complete ✅
|
||||
```
|
||||
|
||||
### Error cases
|
||||
|
||||
| Condition | Response |
|
||||
|-----------|----------|
|
||||
| Unknown project name | `Unknown project 'foo'. Known projects: huskies, robot-studio` |
|
||||
| No Docker socket access | `Update not available: Docker socket not mounted` |
|
||||
| Rollback with no previous image | `No previous image recorded for 'huskies'; cannot roll back` |
|
||||
| Project container not managed by Docker | `'huskies' is not a container-managed project; rebuild it manually` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Auth
|
||||
|
||||
### 3.1 Threat model
|
||||
|
||||
The update command triggers container replacement — a privileged operation equivalent to `docker compose up -d`.
|
||||
An unauthenticated attacker who can send a message to the bot room could force a rolling restart or roll back a working container.
|
||||
|
||||
### 3.2 Proposed approach: room + role guard
|
||||
|
||||
**Layer 1 — Room restriction.**
|
||||
The update command is only accepted in a designated *ops room*, configured in `bot.toml` (or `projects.toml`):
|
||||
|
||||
```toml
|
||||
[gateway.ops_room]
|
||||
room_id = "!abc123:homeserver.example.com"
|
||||
```
|
||||
|
||||
Messages from other rooms are rejected with: `The update command is only available in the ops room.`
|
||||
|
||||
**Layer 2 — Sender role check (Matrix/Slack).**
|
||||
The bot checks the sender's power level (Matrix) or admin status (Slack/Discord).
|
||||
Only users with power level ≥ 50 (moderator) on Matrix, or workspace admin on Slack, may issue `update`.
|
||||
Unapproved senders receive: `You do not have permission to issue update commands.`
|
||||
|
||||
**Layer 3 — Confirmation prompt for destructive operations.**
|
||||
`update all` affects every project.
|
||||
The bot responds with a confirmation challenge:
|
||||
|
||||
```
|
||||
This will restart all 3 project containers. Reply `yes` within 60 s to confirm, or `no` to cancel.
|
||||
```
|
||||
|
||||
Single-project updates (`update huskies`) do **not** require confirmation — they are already scoped.
|
||||
|
||||
### 3.3 Future: Ed25519 operator token
|
||||
|
||||
When story 665 (Ed25519 auth) lands, the gateway's node identity keypair can sign an operator token.
|
||||
The bot verifies the token against the node's public key before acting.
|
||||
This removes the room/role dependency and allows the command to be issued programmatically
|
||||
(e.g. from a CI pipeline via MCP).
|
||||
|
||||
For now the room + role guard is sufficient.
|
||||
|
||||
---
|
||||
|
||||
## 4. Rollout Approach
|
||||
|
||||
### 4.1 Docker-managed containers (primary path)
|
||||
|
||||
The gateway process has access to the Docker socket (mounted as a volume at `/var/run/docker.sock`).
|
||||
The update sequence for a single project:
|
||||
|
||||
1. **Record current image** — read the running container's image digest (store in gateway's `update_history` LWW-map in CRDT, keyed by project name).
|
||||
2. **Pull new image** — `docker pull <image>` (or the compose-file equivalent tag).
|
||||
3. **Drain connections** — gateway marks the project as `updating`; new proxy requests return 503 with a `Retry-After: 5` header; in-flight requests are allowed to complete (30 s grace window).
|
||||
4. **Stop old container** — `docker stop --time=30 <container_name>`.
|
||||
5. **Start new container** — `docker start <container_name>` (or `docker compose up -d <service>`).
|
||||
6. **Health check** — poll the project's `/health` endpoint until 200 OK or 30 s timeout.
|
||||
7. **Restore routing** — remove the `updating` flag; proxy resumes normal operation.
|
||||
|
||||
Steps 1–7 are serialised per project. When `update all` is used, projects are updated **one at a time** (not in parallel) to limit blast radius.
|
||||
|
||||
### 4.2 Source-rebuild path (non-Docker / dev mode)
|
||||
|
||||
When Docker is not available (the gateway binary is running directly on the host, not in a container),
|
||||
the update command falls back to the existing `rebuild_and_restart` flow (`server/src/rebuild.rs`):
|
||||
`cargo build` → re-exec.
|
||||
This path cannot update individual projects independently — it rebuilds the gateway itself.
|
||||
|
||||
### 4.3 Gateway state during update
|
||||
|
||||
```
|
||||
normal → updating → (success) normal
|
||||
→ (failure) rolling_back → normal
|
||||
```
|
||||
|
||||
The CRDT `gateway_config` collection gains two new LWW fields per project:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `update_state` | `"idle" \| "updating" \| "rolling_back"` | Current update lifecycle stage |
|
||||
| `update_started_at` | `u64` (unix ms) | When the update was triggered |
|
||||
| `previous_image` | `string` | Image digest before the most recent update |
|
||||
| `current_image` | `string` | Image digest currently running |
|
||||
|
||||
These fields are replicated to all nodes so that other gateway instances and headless agents
|
||||
can observe update progress without polling HTTP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rollback Approach
|
||||
|
||||
### 5.1 Automatic rollback
|
||||
|
||||
If the health check in step 6 (§4.1) times out or returns a non-200 status, the gateway automatically:
|
||||
|
||||
1. Logs the failure: `[update] health check failed for huskies after 30 s`.
|
||||
2. Posts to the ops room: `Health check failed. Rolling back…`.
|
||||
3. Runs `docker stop` on the new container.
|
||||
4. Pulls and starts the previous image digest (stored in `previous_image`).
|
||||
5. Re-runs the health check on the rolled-back container.
|
||||
6. Reports outcome to the room.
|
||||
|
||||
If the rollback health check also fails, the bot reports:
|
||||
```
|
||||
Rollback failed. Manual intervention required. Previous image: sha256:def456
|
||||
```
|
||||
and sets `update_state = "error"` in the CRDT. The ops room is notified; no further automatic action is taken.
|
||||
|
||||
### 5.2 Manual rollback
|
||||
|
||||
An operator can issue `update huskies --rollback` at any time when the project is in `idle` state.
|
||||
The command replays steps 3–7 of §4.1 with `previous_image` substituted for the target image.
|
||||
`previous_image` is overwritten with the image that was displaced, so repeated rollbacks alternate between two images.
|
||||
|
||||
### 5.3 Rollback unavailability
|
||||
|
||||
Rollback is unavailable when:
|
||||
- No `previous_image` is recorded (first-ever update on this installation).
|
||||
- `update_state` is already `"updating"` or `"rolling_back"` (only one concurrent update per project).
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Sketch
|
||||
|
||||
### 6.1 New files
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/src/chat/commands/update.rs` | Synchronous `handle_update` stub (returns `None` — async, like `rebuild`) |
|
||||
| `server/src/service/gateway/update.rs` | Core update/rollback logic; calls Docker API or falls back to `rebuild.rs` |
|
||||
| `server/src/service/gateway/docker.rs` | Thin wrapper around Docker socket HTTP API (`/containers/:id/start` etc.) |
|
||||
|
||||
### 6.2 New CRDT fields
|
||||
|
||||
Extend the `gateway_config` CRDT document (already exists per Spike 679 §6) with:
|
||||
- `projects.<name>.update_state` (LWW string)
|
||||
- `projects.<name>.update_started_at` (LWW u64)
|
||||
- `projects.<name>.previous_image` (LWW string)
|
||||
- `projects.<name>.current_image` (LWW string)
|
||||
|
||||
### 6.3 Gateway HTTP changes
|
||||
|
||||
Add one endpoint for the Docker-fallback check:
|
||||
|
||||
```
|
||||
GET /gateway/update/available
|
||||
→ {"available": true, "mode": "docker"} | {"available": true, "mode": "rebuild"} | {"available": false}
|
||||
```
|
||||
|
||||
The frontend can use this to show/hide an "Update" button in the gateway project list.
|
||||
|
||||
### 6.4 Async dispatch
|
||||
|
||||
`update` is an async command (like `rebuild`, `htop`, `start`).
|
||||
The command keyword is detected in `on_room_message` before `try_handle_command` is invoked.
|
||||
The handler spawns a `tokio::spawn` task, posts incremental updates via the existing transport's `send_message` / `edit_message` API, and returns.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open Questions
|
||||
|
||||
| # | Question | Notes |
|
||||
|---|----------|-------|
|
||||
| 1 | Should the Docker socket be mounted in the gateway container by default? | Security trade-off: socket access = container escape risk. Alternative: `docker exec` via a sidecar. |
|
||||
| 2 | Should `update all` use a sequential or rolling strategy? | Sequential is safer; rolling is faster. Sequential chosen for v1. |
|
||||
| 3 | How do we handle projects not managed by Docker (e.g. running on bare metal)? | Fallback to `rebuild` covers the gateway itself; project-specific fallback is out of scope for v1. |
|
||||
| 4 | Should the confirmation challenge expire? | Yes — 60 s timeout, configurable in `bot.toml`. |
|
||||
| 5 | Should update history be persisted beyond CRDT (i.e. across full gateway restarts)? | CRDT persists to SQLite, so yes, as long as the CRDT DB survives the restart. |
|
||||
| 6 | Multi-gateway HA: which node triggers the actual Docker call? | The node that owns the Docker socket. CRDT `update_state` prevents double-triggering. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Dependencies
|
||||
|
||||
| Story / Spike | Dependency type |
|
||||
|--------------|----------------|
|
||||
| Spike 679 (HTTP → CRDT bus) | Soft — `gateway_config` LWW collection needed for update state; can stub without it |
|
||||
| Story 665 (Ed25519 auth) | Soft — operator token auth is a future hardening step; room+role guard suffices for v1 |
|
||||
| `server/src/rebuild.rs` | Direct — reuse `rebuild_and_restart` for the non-Docker path |
|
||||
| `server/src/gateway_relay.rs` | Indirect — update state changes should trigger relay events to connected frontends |
|
||||
+160
-113
@@ -1,130 +1,177 @@
|
||||
# Tech Stack & Constraints
|
||||
# Tech Stack
|
||||
|
||||
## Overview
|
||||
This project is a standalone Rust **web server binary** that serves a Vite/React frontend and exposes a **WebSocket API**. The built frontend assets are packaged with the binary (in a `frontend` directory) and served as static files. It functions as an **Agentic Code Assistant** capable of safely executing tools on the host system.
|
||||
## Backend
|
||||
- **Language:** Rust
|
||||
- **Framework:** Poem (HTTP + WebSocket + OpenAPI)
|
||||
- **Database:** SQLite via sqlx + rusqlite
|
||||
- **State:** BFT CRDT replicated document backed by SQLite
|
||||
- **Agents:** Claude Code CLI spawned in PTY pseudo-terminals
|
||||
- **Package manager:** cargo
|
||||
|
||||
## Core Stack
|
||||
* **Backend:** Rust (Web Server)
|
||||
* **MSRV:** Stable (latest)
|
||||
* **Framework:** Poem HTTP server with WebSocket support for streaming; HTTP APIs should use Poem OpenAPI (Swagger) for non-streaming endpoints.
|
||||
* **Frontend:** TypeScript + React
|
||||
* **Build Tool:** Vite
|
||||
* **Package Manager:** npm
|
||||
* **Styling:** CSS Modules or Tailwind (TBD - Defaulting to CSS Modules)
|
||||
* **State Management:** React Context / Hooks
|
||||
* **Chat UI:** Rendered Markdown with syntax highlighting.
|
||||
## Frontend
|
||||
- **Language:** TypeScript + React
|
||||
- **Build:** Vite
|
||||
- **Package manager:** npm
|
||||
- **Testing:** Vitest (unit), Playwright (e2e)
|
||||
|
||||
## Agent Architecture
|
||||
The application follows a **Tool-Use (Function Calling)** architecture:
|
||||
1. **Frontend:** Collects user input and sends it to the LLM.
|
||||
2. **LLM:** Decides to generate text OR request a **Tool Call** (e.g., `execute_shell`, `read_file`).
|
||||
3. **Web Server Backend (The "Hand"):**
|
||||
* Intercepts Tool Calls.
|
||||
* Validates the request against the **Safety Policy**.
|
||||
* Executes the native code (File I/O, Shell Process, Search).
|
||||
* Returns the output (stdout/stderr/file content) to the LLM.
|
||||
* **Streaming:** The backend sends real-time updates over WebSocket to keep the UI responsive during long-running Agent tasks.
|
||||
## Deployment
|
||||
- Single Rust binary with embedded React frontend (rust-embed)
|
||||
- Three modes: standard server, headless build agent (`--rendezvous`), multi-project gateway (`--gateway`)
|
||||
- Docker container with OrbStack recommended on macOS
|
||||
|
||||
## LLM Provider Abstraction
|
||||
To support both Remote and Local models, the system implements a `ModelProvider` abstraction layer.
|
||||
## Project Layout
|
||||
```
|
||||
server/src/ — Rust backend
|
||||
frontend/src/ — React frontend
|
||||
crates/bft-json-crdt/ — CRDT library
|
||||
.huskies/ — Pipeline config, agent config, specs
|
||||
script/ — test, build, lint scripts
|
||||
docker/ — Dockerfile and docker-compose
|
||||
website/ — Static marketing/docs site
|
||||
```
|
||||
|
||||
* **Strategy:**
|
||||
* Abstract the differences between API formats (OpenAI-compatible vs Anthropic vs Gemini).
|
||||
* Normalize "Tool Use" definitions, as each provider handles function calling schemas differently.
|
||||
* **Supported Providers:**
|
||||
* **Ollama:** Local inference (e.g., Llama 3, DeepSeek Coder) for privacy and offline usage.
|
||||
* **Anthropic:** Claude 3.5 models (Sonnet, Haiku) via API for coding tasks (Story 12).
|
||||
* **Provider Selection:**
|
||||
* Automatic detection based on model name prefix:
|
||||
* `claude-` → Anthropic API
|
||||
* Otherwise → Ollama
|
||||
* Single unified model dropdown with section headers ("Anthropic", "Ollama")
|
||||
* **API Key Management:**
|
||||
* Anthropic API key stored server-side and persisted securely
|
||||
* On first use of Claude model, user prompted to enter API key
|
||||
* Key persists across sessions (no re-entry needed)
|
||||
## Source Map
|
||||
|
||||
## Tooling Capabilities
|
||||
One row per directory or top-level file. Descriptions are pulled from the module's `//!` doc-comment where present. **Use this to know where to look — do not re-discover the codebase via grep.**
|
||||
|
||||
### 1. Filesystem (Native)
|
||||
* **Scope:** Strictly limited to the user-selected `project_root`.
|
||||
* **Operations:** Read, Write, List, Delete.
|
||||
* **Constraint:** Modifications to `.git/` are strictly forbidden via file APIs (use Git tools instead).
|
||||
### Top-level backend files (`server/src/`)
|
||||
|
||||
### 2. Shell Execution
|
||||
* **Library:** `tokio::process` for async execution.
|
||||
* **Constraint:** We do **not** run an interactive shell (repl). We run discrete, stateless commands.
|
||||
* **Allowlist:** The agent may only execute specific binaries:
|
||||
* `git`
|
||||
* `cargo`, `rustc`, `rustfmt`, `clippy`
|
||||
* `npm`, `node`, `yarn`, `pnpm`, `bun`
|
||||
* `ls`, `find`, `grep` (if not using internal search)
|
||||
* `mkdir`, `rm`, `touch`, `mv`, `cp`
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `server/src/agent_log.rs` | Agent log persistence — reads and writes JSONL agent event logs to disk. |
|
||||
| `server/src/agent_mode.rs` | Headless build-agent mode for distributed, rendezvous-based story processing. |
|
||||
| `server/src/cli.rs` | Command-line argument parsing for the huskies binary. |
|
||||
| `server/src/crdt_wire.rs` | CRDT wire codec — serialization format for `SignedOp` sync messages between nodes. |
|
||||
| `server/src/gateway.rs` | Multi-project gateway — entrypoint wiring and route tree. When `huskies --gateway` is used, the server starts in gateway mode. B… |
|
||||
| `server/src/gateway_relay.rs` | Gateway relay task — pushes project status events to the gateway via WebSocket. When `gateway_url` is configured in `project.tom… |
|
||||
| `server/src/log_buffer.rs` | Bounded in-memory ring buffer for server log output. Use the [`slog!`] macro (INFO), [`slog_warn!`] (WARN), or [`slog_error!`] (… |
|
||||
| `server/src/main.rs` | Huskies server — entry point, CLI argument parsing, and server startup. |
|
||||
| `server/src/mesh.rs` | Peer mesh discovery — supplementary CRDT sync connections between build agents. When mesh discovery is enabled, a build agent pe… |
|
||||
| `server/src/node_identity.rs` | Node identity — Ed25519 keypair foundation for distributed huskies. Each huskies node has a stable identity derived from an Ed25… |
|
||||
| `server/src/rebuild.rs` | Server rebuild and restart logic shared between the MCP tool and Matrix bot command. |
|
||||
| `server/src/services.rs` | Shared services bundle — common state threaded through HTTP handlers and chat transports. `Services` bundles the fields that eve… |
|
||||
| `server/src/state.rs` | Session state — global mutable state shared across the server (project root, cancellation). |
|
||||
| `server/src/store.rs` | Key-value store — JSON-backed persistent storage for user settings and preferences. |
|
||||
| `server/src/workflow.rs` | Workflow module: test result tracking and acceptance evaluation. |
|
||||
|
||||
### 3. Search & Navigation
|
||||
* **Library:** `ignore` (by BurntSushi) + `grep` logic.
|
||||
* **Behavior:**
|
||||
* Must respect `.gitignore` files automatically.
|
||||
* Must be performant (parallel traversal).
|
||||
### Backend modules (`server/src/`)
|
||||
|
||||
## Coding Standards
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `server/src/` | |
|
||||
| `server/src/agents/` | Agent subsystem — types, configuration, and orchestration for coding agents. |
|
||||
| `server/src/agents/merge/` | Merge operations — rebases agent work onto master and runs post-merge validation. |
|
||||
| `server/src/agents/merge/squash/` | Squash-merge orchestration: rebase agent work onto master and run post-merge gates. |
|
||||
| `server/src/agents/pool/` | Agent pool — manages the set of active agents across all pipeline stages. |
|
||||
| `server/src/agents/pool/auto_assign/` | Auto-assign submodules: wires focused sub-files and re-exports public items. |
|
||||
| `server/src/agents/pool/auto_assign/watchdog/` | Watchdog task: detects orphaned agents, enforces turn/budget limits, and triggers auto-assign. |
|
||||
| `server/src/agents/pool/auto_assign/watchdog/tests/` | Shared test helpers for the watchdog module. |
|
||||
| `server/src/agents/pool/pipeline/` | Pipeline operations — stage advancement, completion handling, and merge orchestration. |
|
||||
| `server/src/agents/pool/pipeline/advance/` | Pipeline advance — moves stories forward through pipeline stages after agent completion. |
|
||||
| `server/src/agents/pool/pipeline/completion/` | Agent completion handling — processes exit results and triggers pipeline advancement. |
|
||||
| `server/src/agents/pool/start/` | Agent start — spawns a new agent process in a worktree for a given story. |
|
||||
| `server/src/agents/runtime/` | Agent runtimes — pluggable backends (Claude Code, Gemini, OpenAI) for running agents. |
|
||||
| `server/src/chat/` | Transport abstraction for chat platforms. The [`ChatTransport`] trait defines a platform-agnostic interface for sending and edit… |
|
||||
| `server/src/chat/commands/` | Bot-level command registry shared by all chat transports. Commands registered here are handled directly by the bot without invok… |
|
||||
| `server/src/chat/transport/` | Chat transports — pluggable backends (Matrix, Slack, WhatsApp, Discord) for bot messaging. |
|
||||
| `server/src/chat/transport/discord/` | Discord Bot integration. Provides: - [`DiscordTransport`] — a [`ChatTransport`] that sends messages via the Discord REST API (`/… |
|
||||
| `server/src/chat/transport/matrix/` | Matrix bot integration for Story Kit. When a `.huskies/bot.toml` file is present with `enabled = true`, the server spawns a Matr… |
|
||||
| `server/src/chat/transport/matrix/bot/` | Matrix bot — sub-modules for the Matrix chat bot implementation. |
|
||||
| `server/src/chat/transport/matrix/bot/messages/` | Matrix message handler — processes incoming room messages and dispatches commands. |
|
||||
| `server/src/chat/transport/matrix/config/` | Matrix transport configuration — deserialization of `bot.toml` Matrix settings. |
|
||||
| `server/src/chat/transport/slack/` | Slack Bot API integration. Provides: - [`SlackTransport`] — a [`ChatTransport`] that sends messages via the Slack Web API (`api.… |
|
||||
| `server/src/chat/transport/slack/commands/` | Slack incoming message dispatch and slash command handling. |
|
||||
| `server/src/chat/transport/whatsapp/` | WhatsApp Business API integration. Provides: - [`WhatsAppTransport`] — a [`ChatTransport`] that sends messages via the Meta Grap… |
|
||||
| `server/src/chat/transport/whatsapp/commands/` | WhatsApp command handling — processes incoming WhatsApp messages as bot commands. |
|
||||
| `server/src/config/` | Project configuration — parses `project.toml` for agents, components, and server settings. |
|
||||
| `server/src/crdt_snapshot/` | CRDT snapshot compaction with cross-node coordination. This module implements full CRDT state snapshots for compacting the op jo… |
|
||||
| `server/src/crdt_state/` | CRDT state layer — manages pipeline state as a conflict-free replicated document backed by SQLite. The CRDT document is the prim… |
|
||||
| `server/src/crdt_sync/` | CRDT sync — WebSocket-based replication of pipeline state between huskies nodes. WebSocket-based CRDT sync layer for replicating… |
|
||||
| `server/src/crdt_sync/server/` | Server-side `/crdt-sync` WebSocket handler. |
|
||||
| `server/src/db/` | SQLite storage layer — content store, shadow writes, and CRDT op persistence. |
|
||||
| `server/src/http/` | HTTP server — module declarations for all REST, MCP, WebSocket, and SSE endpoints. |
|
||||
| `server/src/http/agents/` | HTTP agent endpoints — thin adapters over `service::agents`. Each handler: extracts payload → calls `service::agents::X` → shape… |
|
||||
| `server/src/http/gateway/` | Gateway HTTP handlers — thin transport shells for the gateway service. Each handler calls `service::gateway::*` for business log… |
|
||||
| `server/src/http/mcp/` | HTTP MCP server module. |
|
||||
| `server/src/http/mcp/agent_tools/` | MCP agent tools — start, stop, wait, list, and inspect agents via MCP. |
|
||||
| `server/src/http/mcp/diagnostics/` | MCP diagnostic tools — server logs, CRDT dump, version, line counting, story movement. |
|
||||
| `server/src/http/mcp/shell_tools/` | MCP shell tools — run commands, execute tests, and stream output via MCP. This file is a thin adapter: it deserialises MCP paylo… |
|
||||
| `server/src/http/mcp/story_tools/` | MCP story tools — create, update, move, and manage stories, bugs, refactors, and spikes via MCP. This module is a thin adapter:… |
|
||||
| `server/src/http/mcp/story_tools/story/` | Story creation, listing, update, and lifecycle MCP tools. |
|
||||
| `server/src/http/mcp/tools_list/` | `tools/list` MCP method — returns the static schema for every tool the server exposes. |
|
||||
| `server/src/http/workflow/` | Workflow helpers — shared story/bug file operations used by HTTP and MCP handlers. |
|
||||
| `server/src/http/workflow/story_ops/` | Story operations — creates, updates, and manages acceptance criteria in story files. |
|
||||
| `server/src/io/` | I/O subsystem — filesystem, shell, search, onboarding, and story metadata operations. |
|
||||
| `server/src/io/fs/` | Filesystem I/O — module declarations and re-exports for file operations. |
|
||||
| `server/src/io/fs/scaffold/` | Project scaffolding — creates the `.huskies/` directory structure and default files. |
|
||||
| `server/src/io/fs/scaffold/detect/` | Stack detection — inspect the project root for marker files and emit TOML `[[component]]` entries plus `script/build\|lint\|test`… |
|
||||
| `server/src/io/watcher/` | Filesystem watcher for `.huskies/project.toml` and `.huskies/agents.toml`. Watches config files for changes and broadcasts a [`W… |
|
||||
| `server/src/llm/` | LLM subsystem — chat orchestration, prompts, OAuth, and provider integrations. |
|
||||
| `server/src/llm/chat/` | LLM chat — orchestrates multi-turn conversations with tool-calling LLM providers. |
|
||||
| `server/src/llm/providers/` | LLM providers — module declarations for Anthropic, Claude Code, and Ollama backends. |
|
||||
| `server/src/llm/providers/claude_code/` | Claude Code provider — runs Claude Code CLI in a PTY and parses structured output. |
|
||||
| `server/src/pipeline_state/` | Typed pipeline state machine (story 520). Replaces the stringly-typed CRDT views with strict Rust enums so that impossible state… |
|
||||
| `server/src/service/` | Service layer — domain logic extracted from HTTP handlers. Each sub-module follows the conventions documented in `docs/architect… |
|
||||
| `server/src/service/agents/` | Agent service — public API for the agent domain. This module orchestrates calls to `io.rs` (side effects) and the pure topic mod… |
|
||||
| `server/src/service/anthropic/` | Anthropic service — public API for Anthropic API-key management and model listing. Exposes functions to check, store, and use th… |
|
||||
| `server/src/service/bot_command/` | Bot command service — domain logic for dispatching slash commands. Extracted from `http/bot_command.rs` so that argument parsing… |
|
||||
| `server/src/service/common/` | Shared pure helpers used by multiple service modules. All sub-modules here are pure (no I/O, no side effects). Any helper that d… |
|
||||
| `server/src/service/diagnostics/` | Diagnostics service — server logs, CRDT dump, permission management, and story movement. Extracted from `http/mcp/diagnostics.rs… |
|
||||
| `server/src/service/events/` | Events service — public API for the events domain. This module re-exports the pure buffer types from `buffer.rs` and the side-ef… |
|
||||
| `server/src/service/file_io/` | File I/O service — public API for filesystem and shell operations. Exposes functions for reading, writing, and listing files sco… |
|
||||
| `server/src/service/gateway/` | Gateway service — domain logic for the multi-project gateway. Follows the conventions in `docs/architecture/service-modules.md`:… |
|
||||
| `server/src/service/git_ops/` | Git operations service — worktree path validation and git command execution. Extracted from `http/mcp/git_tools.rs` following th… |
|
||||
| `server/src/service/merge/` | Merge service — domain logic for merging agent work to master. Extracted from `http/mcp/merge_tools.rs` following the convention… |
|
||||
| `server/src/service/notifications/` | Notifications service — pipeline-event fan-out to chat transports. Subscribes to [`WatcherEvent`] broadcasts and posts human-rea… |
|
||||
| `server/src/service/notifications/io/` | I/O side of the notifications service. This is the **only** file inside `service/notifications/` that may perform side effects:… |
|
||||
| `server/src/service/oauth/` | OAuth service — domain logic for the Anthropic OAuth 2.0 PKCE flow. Extracts business logic from `http/oauth.rs` following the c… |
|
||||
| `server/src/service/pipeline/` | Pipeline service — shared pipeline-domain logic. Contains pure functions for parsing and aggregating pipeline status data. Used… |
|
||||
| `server/src/service/project/` | Project service — public API for the project domain. Exposes functions to open, close, query, and manage known projects. HTTP ha… |
|
||||
| `server/src/service/qa/` | QA service — domain logic for requesting, approving, and rejecting QA reviews. Extracted from `http/mcp/qa_tools.rs` following t… |
|
||||
| `server/src/service/settings/` | Settings service — domain logic for project settings and editor configuration. Extracts business logic from `http/settings.rs` f… |
|
||||
| `server/src/service/shell/` | Shell service — command safety, path sandboxing, and output helpers. Extracted from `http/mcp/shell_tools.rs` following the conv… |
|
||||
| `server/src/service/status/` | Status broadcaster — unified pipeline-event fan-out for all consumers. [`StatusBroadcaster`] lives on the [`crate::services::Ser… |
|
||||
| `server/src/service/story/` | Story service — domain logic for creating, updating, and managing pipeline work items. Extracted from `http/mcp/story_tools.rs`… |
|
||||
| `server/src/service/timer/` | Timer service — deferred agent start via one-shot timers. Provides [`TimerStore`] for persisting timers to `.huskies/timers.json… |
|
||||
| `server/src/service/wizard/` | Wizard service — domain logic for the multi-step project setup wizard. Follows the conventions from `docs/architecture/service-m… |
|
||||
| `server/src/service/ws/` | WebSocket service — domain logic for real-time pipeline updates, chat, and permission prompts. This module extracts the business… |
|
||||
| `server/src/worktree/` | Git worktree management — creates, lists, and removes worktrees for agent isolation. |
|
||||
|
||||
### Rust
|
||||
* **Style:** `rustfmt` standard.
|
||||
* **Linter:** `clippy` - Must pass with 0 warnings before merging.
|
||||
* **Error Handling:** Custom `AppError` type deriving `thiserror`. All Commands return `Result<T, AppError>`.
|
||||
* **Concurrency:** Heavy tools (Search, Shell) must run on `tokio` threads to avoid blocking the UI.
|
||||
* **Quality Gates:**
|
||||
* `cargo clippy --all-targets --all-features` must show 0 errors, 0 warnings
|
||||
* `cargo check` must succeed
|
||||
* `cargo nextest run` must pass all tests
|
||||
* **Test Coverage:**
|
||||
* Generate JSON report: `cargo llvm-cov nextest --no-clean --json --output-path .story_kit/coverage/server.json`
|
||||
* Generate lcov report: `cargo llvm-cov report --lcov --output-path .story_kit/coverage/server.lcov`
|
||||
* Reports are written to `.story_kit/coverage/` (excluded from git)
|
||||
### Crates
|
||||
|
||||
### TypeScript / React
|
||||
* **Style:** Biome formatter (replaces Prettier/ESLint).
|
||||
* **Linter:** Biome - Must pass with 0 errors, 0 warnings before merging.
|
||||
* **Types:** Shared types with Rust (via `tauri-specta` or manual interface matching) are preferred to ensure type safety across the bridge.
|
||||
* **Testing:** Vitest for unit/component tests; Playwright for end-to-end tests.
|
||||
* **Quality Gates:**
|
||||
* `npx @biomejs/biome check src/` must show 0 errors, 0 warnings
|
||||
* `npm run build` must succeed
|
||||
* `npm test` must pass
|
||||
* `npm run test:e2e` must pass
|
||||
* No `any` types allowed (use proper types or `unknown`)
|
||||
* React keys must use stable IDs, not array indices
|
||||
* All buttons must have explicit `type` attribute
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `crates/bft-json-crdt/benches/` | |
|
||||
| `crates/bft-json-crdt/bft-crdt-derive/src/` | |
|
||||
| `crates/bft-json-crdt/src/` | |
|
||||
| `crates/bft-json-crdt/tests/` | |
|
||||
|
||||
## Libraries (Approved)
|
||||
* **Rust:**
|
||||
* `serde`, `serde_json`: Serialization.
|
||||
* `ignore`: Fast recursive directory iteration respecting gitignore.
|
||||
* `walkdir`: Simple directory traversal.
|
||||
* `tokio`: Async runtime.
|
||||
* `reqwest`: For LLM API calls (Anthropic, Ollama).
|
||||
* `eventsource-stream`: For Server-Sent Events (Anthropic streaming).
|
||||
* `uuid`: For unique message IDs.
|
||||
* `chrono`: For timestamps.
|
||||
* `poem`: HTTP server framework.
|
||||
* `poem-openapi`: OpenAPI (Swagger) for non-streaming HTTP APIs.
|
||||
* **JavaScript:**
|
||||
* `react-markdown`: For rendering chat responses.
|
||||
* `vitest`: Unit/component testing.
|
||||
* `playwright`: End-to-end testing.
|
||||
### Frontend (`frontend/src/`)
|
||||
|
||||
## Running the App (Worktrees & Ports)
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `frontend/src/` | |
|
||||
| `frontend/src/api/` | |
|
||||
| `frontend/src/components/` | |
|
||||
| `frontend/src/components/selection/` | |
|
||||
| `frontend/src/hooks/` | |
|
||||
| `frontend/src/utils/` | |
|
||||
|
||||
Multiple instances can run simultaneously in different worktrees. To avoid port conflicts:
|
||||
### Canonical patterns (copy these when adding new things)
|
||||
- **New CRDT LWW-map collection:** see `server/src/crdt_state/lww_maps.rs`
|
||||
- **New read-RPC handler:** register in `server/src/crdt_sync/rpc.rs`; call from frontend via `rpcCall<T>("method.name")` from `frontend/src/api/rpc.ts`
|
||||
- **Migrate HTTP route → CRDT:** delete from `gateway.rs` / `http/*`, add op to `service/<area>/`, write through `crdt_state/`
|
||||
- **New front-matter field:** add to `StoryMetadata` and `FrontMatter` in `io/story_metadata.rs` plus a `write_<name>_in_content` helper
|
||||
- **New service module:** copy `service/agents/` structure (`mod.rs` + `io.rs` + `selection.rs`)
|
||||
- **New chat command:** add a file under `chat/commands/` and register in `chat/commands/mod.rs::dispatch_command`
|
||||
- **New auto-assigner predicate:** add to `agents/pool/auto_assign/story_checks.rs`, wire in `auto_assign/auto_assign.rs`
|
||||
- **CRDT-seeding test helper:** `crate::db::write_item_with_content(story_id, stage, content)` — do not `fs::write` to `.huskies/work/{stage}/`
|
||||
|
||||
- **Backend:** Set `HUSKIES_PORT` to a unique port (default is 3001). Example: `HUSKIES_PORT=3002 cargo run`
|
||||
- **Frontend:** Run `npm run dev` from `frontend/`. It auto-selects the next unused port. It reads `HUSKIES_PORT` to know which backend to talk to, so export it before running: `export HUSKIES_PORT=3002 && cd frontend && npm run dev`
|
||||
|
||||
When running in a worktree, use a port that won't conflict with the main instance (3001). Ports 3002+ are good choices.
|
||||
|
||||
## Safety & Sandbox
|
||||
1. **Project Scope:** The application must strictly enforce that it does not read/write outside the `project_root` selected by the user.
|
||||
2. **Human in the Loop:**
|
||||
* Shell commands that modify state (non-readonly) should ideally require a UI confirmation (configurable).
|
||||
* File writes must be confirmed or revertible.
|
||||
## Quality Gates
|
||||
All enforced by `script/test`:
|
||||
1. Frontend build (`npm run build`)
|
||||
2. Rust formatting (`cargo fmt --all --check`)
|
||||
3. Rust linting (`cargo clippy -- -D warnings`)
|
||||
4. Rust tests (`cargo test`)
|
||||
5. Frontend tests (`npm test`)
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
# Pipeline State Machine
|
||||
|
||||
This document describes the huskies pipeline state machine in two halves:
|
||||
**(a)** the model that runs in production today, and **(b)** transitions, refinements,
|
||||
and corrections we have identified as needed but not yet implemented.
|
||||
|
||||
The codebase is in a deliberate transitional state: a typed CRDT state machine
|
||||
exists at `server/src/pipeline_state.rs` (introduced by story 520) with strict Rust
|
||||
enums for every stage, archive reason, execution state, and event. It is fully
|
||||
defined and tested but **not yet called from non-test code** (`#![allow(dead_code)]`
|
||||
at the top of the module). Consumers will migrate incrementally.
|
||||
|
||||
The model that is actually doing work is the older **filesystem-stage-string +
|
||||
front-matter-flag** model. Section (a) below documents both representations and
|
||||
the migration intent.
|
||||
|
||||
---
|
||||
|
||||
## (a) The current state machine
|
||||
|
||||
### Stages (production: filesystem string; future: typed enum)
|
||||
|
||||
| Filesystem (production) | Typed (future) | Meaning |
|
||||
|---|---|---|
|
||||
| `work/1_backlog/` | `Stage::Backlog` | Story exists, waiting for dependencies or auto-assign promotion |
|
||||
| `work/2_current/` | `Stage::Coding` | Coder agent is running (or about to) |
|
||||
| `work/3_qa/` | `Stage::Qa` | Coder finished; gates / human review running |
|
||||
| `work/4_merge/` | `Stage::Merge { feature_branch, commits_ahead: NonZeroU32 }` | Gates passed, mergemaster ready to squash |
|
||||
| `work/5_done/` | `Stage::Done { merged_at, merge_commit }` | Mergemaster squashed to master |
|
||||
| `work/6_archived/` | `Stage::Archived { archived_at, reason: ArchiveReason }` | Out of the active flow |
|
||||
|
||||
`5_done` auto-sweeps to `6_archived` after four hours. The typed `Stage::Done`
|
||||
variant always carries the merge SHA and timestamp; `Stage::Merge`'s
|
||||
`commits_ahead: NonZeroU32` makes "Merge with nothing to merge" structurally
|
||||
impossible (eliminates bug 519).
|
||||
|
||||
### Archive reasons (`pipeline_state.rs::ArchiveReason`)
|
||||
|
||||
The typed model already enumerates the reasons a story can leave the active flow
|
||||
(subsumes the legacy `blocked`, `merge_failure`, and `review_hold` front-matter
|
||||
fields per story 436):
|
||||
|
||||
- `Completed` — happy-path
|
||||
- `Abandoned` — user explicitly abandoned
|
||||
- `Superseded { by: StoryId }` — replaced by another story
|
||||
- `Blocked { reason: String }` — manually blocked, awaiting human resolution
|
||||
- `MergeFailed { reason: String }` — mergemaster gave up after retry budget
|
||||
- `ReviewHeld { reason: String }` — held for human review at user request
|
||||
|
||||
### Per-node execution state (`pipeline_state.rs::ExecutionState`)
|
||||
|
||||
Stage is shared/CRDT-replicated. Execution state is per-node and lives under
|
||||
each node's pubkey in the CRDT, so there are no inter-author merge conflicts:
|
||||
|
||||
- `Idle`
|
||||
- `Pending { agent, since }` — worktree being created, agent about to start
|
||||
- `Running { agent, started_at, last_heartbeat }`
|
||||
- `RateLimited { agent, resume_at }`
|
||||
- `Completed { agent, exit_code, completed_at }`
|
||||
|
||||
### Pipeline events (`pipeline_state.rs::PipelineEvent`)
|
||||
|
||||
The typed model defines every event that drives a Stage transition. Each variant
|
||||
carries the data needed to construct the destination state, so a transition
|
||||
function can never accidentally land in an underspecified state:
|
||||
|
||||
- `DepsMet` — dependencies met; promote from backlog
|
||||
- `GatesStarted` — coder starting gates
|
||||
- `GatesPassed { feature_branch, commits_ahead }`
|
||||
- `GatesFailed { reason }`
|
||||
- `QaSkipped { feature_branch, commits_ahead }` — qa-mode = "server"; skip QA, go to merge
|
||||
- `MergeSucceeded { merge_commit }`
|
||||
- `MergeFailedFinal { reason }`
|
||||
- `Accepted` — Done → Archived(Completed)
|
||||
|
||||
### Transitions (current production = MCP verb shape)
|
||||
|
||||
#### Backlog → Coding (a.k.a. backlog → 2_current)
|
||||
|
||||
- **Auto path**: `AgentPool::auto_assign_available_work` calls
|
||||
`promote_ready_backlog_stories`. A backlog story is promoted iff (a) it has
|
||||
an explicit non-empty `depends_on` AND (b) every dep is in `5_done` or
|
||||
`6_archived`. Stories with no `depends_on` are NOT auto-promoted — they wait
|
||||
for human scheduling.
|
||||
- Implemented in `server/src/agents/pool/auto_assign/auto_assign.rs::promote_ready_backlog_stories`.
|
||||
- **Manual path**: `mcp__huskies__move_story story_id=X target_stage=current`,
|
||||
or `mcp__huskies__start_agent` (which moves the story to current as a
|
||||
side-effect of starting an agent).
|
||||
- **Archived-dep warning**: if a dep was satisfied via `6_archived` rather than
|
||||
`5_done` (e.g. abandoned/superseded), the auto-assigner logs a prominent
|
||||
warning so the user can see the promotion was triggered by an archived dep.
|
||||
|
||||
#### Coding → Qa (current → 3_qa)
|
||||
|
||||
- Triggered when the coder agent finishes (gates start running).
|
||||
- `mcp__huskies__request_qa` is the manual verb.
|
||||
|
||||
#### Qa → Coding (qa → current — rejection path)
|
||||
|
||||
- `mcp__huskies__reject_qa story_id=X notes="..."` moves qa → current,
|
||||
**clears `review_hold`**, and writes the rejection notes
|
||||
(`agents/lifecycle.rs:210`).
|
||||
- Used when a qa agent fails or a human reviewer rejects the work.
|
||||
|
||||
#### Qa → Merge (qa → 4_merge)
|
||||
|
||||
- Triggered when QA gates pass. `mcp__huskies__move_story_to_merge` is the
|
||||
dedicated verb.
|
||||
- For server-mode QA: typed-side `PipelineEvent::QaSkipped` allows going from
|
||||
Coding → Merge directly without entering Qa.
|
||||
|
||||
#### Merge → Done (merge → 5_done)
|
||||
|
||||
- Mergemaster picks up a story in `4_merge/`, squashes the feature branch onto
|
||||
master, then transitions to `5_done`.
|
||||
- `mcp__huskies__move_story_to_merge` queues; mergemaster does the actual work.
|
||||
|
||||
#### Done → Archived(Completed) (5_done → 6_archived)
|
||||
|
||||
- Auto-sweep after four hours, OR
|
||||
- `mcp__huskies__accept_story` (immediate manual archive).
|
||||
|
||||
#### Any-stage → Archived(other reasons)
|
||||
|
||||
- **Abandoned / Superseded**: today done by `mcp__huskies__move_story
|
||||
target_stage=done` (no first-class verbs for these reasons; see (b) below).
|
||||
- **Blocked**: `blocked: true` flag in front matter is set on retry-limit
|
||||
exceedance. `mcp__huskies__unblock_story` clears the flag and resets
|
||||
retry_count.
|
||||
- **MergeFailed**: written to front matter when mergemaster fails; auto-assign
|
||||
skips these stories (`has_merge_failure` check).
|
||||
- **ReviewHeld**: `review_hold: true` flag is set automatically on spike
|
||||
completion; auto-assign skips these stories until the flag is cleared.
|
||||
|
||||
#### Tombstone / purge
|
||||
|
||||
- `mcp__huskies__delete_story` and `mcp__huskies__purge_story` permanently
|
||||
remove. Purge writes a CRDT tombstone.
|
||||
|
||||
### Auto-assign skip conditions (current production)
|
||||
|
||||
`auto_assign_available_work` walks `2_current/`, `3_qa/`, `4_merge/` in order
|
||||
and attempts to dispatch a free agent to each unassigned story. It **skips**
|
||||
any story that:
|
||||
|
||||
1. Has `review_hold: true` in front matter (spikes after QA, manual hold).
|
||||
2. Is `frozen` (`is_story_frozen` — pipeline advancement suspended for this story).
|
||||
3. Has `blocked: true` (retry limit exceeded; cleared via `unblock_story`).
|
||||
4. Has unmet `depends_on` dependencies.
|
||||
5. (Merge stage only) Has a recorded merge failure (`has_merge_failure`).
|
||||
6. (Merge stage only) Has an empty diff on the feature branch — auto-writes
|
||||
`merge_failure` and blocks immediately rather than wasting a mergemaster turn.
|
||||
|
||||
### Front-matter fields that gate transitions
|
||||
|
||||
| Field | Type | Effect |
|
||||
|---|---|---|
|
||||
| `depends_on` | list of story IDs | Blocks backlog → current promotion until all deps are in 5_done or 6_archived |
|
||||
| `agent` | string (e.g. `coder-opus`) | Pins the preferred agent for next assignment |
|
||||
| `review_hold` | bool | Auto-assign skips this story; cleared by `reject_qa` or manual unblock |
|
||||
| `blocked` | bool | Auto-assign skips this story; cleared by `unblock_story` |
|
||||
| `frozen` | bool | Auto-assign skips this story; manual unfreeze required |
|
||||
| `merge_failure` | string | Auto-assign skips merge-stage agents on this story |
|
||||
| `retry_count` | int | Local-only (not in CRDT); incremented by orchestrator |
|
||||
|
||||
### Spike-specific behavior
|
||||
|
||||
Per the typical lifecycle, a spike runs through `current → qa` like any work
|
||||
item, then **stops** in qa awaiting human review (`spikes skip merge`). This
|
||||
is implemented via `review_hold: true` being written automatically when a
|
||||
spike's qa gates pass. The user accepts (move qa → done) or rejects (move
|
||||
qa → current). Spikes do NOT auto-promote to merge.
|
||||
|
||||
### Mergemaster lifecycle
|
||||
|
||||
The mergemaster agent only runs against stories in `4_merge/`. It:
|
||||
|
||||
1. Verifies the feature branch has commits (or the story is auto-blocked).
|
||||
2. Squashes the feature branch onto master with a deterministic commit message.
|
||||
3. Transitions the story to `5_done` with `merged_at` and `merge_commit`.
|
||||
4. On failure beyond the retry budget, writes `merge_failure` and blocks the
|
||||
story (auto-assign then skips it).
|
||||
|
||||
### Agent terminated with committed work (bug 645 recovery path)
|
||||
|
||||
When a coder agent terminates abnormally (e.g. the Claude Code CLI's
|
||||
`output.write(&bytes).is_ok()` PTY write assertion fires mid-session), the
|
||||
server-owned completion path detects the crash and checks for surviving work:
|
||||
|
||||
1. If the worktree is dirty but has commits ahead of master, reset the
|
||||
uncommitted files (`git checkout . && git clean -fd`) and run gates
|
||||
against the committed code.
|
||||
2. If gates still fail but `git log master..HEAD` shows commits and
|
||||
`cargo check` passes, **advance to QA** instead of entering the
|
||||
retry/block path. This is the "work survived" check, implemented in
|
||||
`server/src/agents/pool/pipeline/advance.rs`.
|
||||
3. Agents that die WITHOUT committed work (no commits ahead of master)
|
||||
still follow the existing retry → block path unchanged.
|
||||
|
||||
This prevents false-positive blocking of stories where the agent completed
|
||||
meaningful work before crashing.
|
||||
|
||||
### Watchdog (current production)
|
||||
|
||||
The "watchdog" at `server/src/agents/pool/auto_assign/watchdog.rs` runs every
|
||||
30 ticks of the unified background loop. Today it does **one** thing: detect
|
||||
orphaned agents whose tokio task is `is_finished()` but whose status is still
|
||||
`Running` or `Pending`, and mark them `Failed` with an `AgentEvent::Error`
|
||||
emission. Bug 624 (now merged) extends it to also enforce `max_turns` and
|
||||
`max_budget_usd` limits — an agent over either limit is killed via the
|
||||
existing `kill_child_for_key` path and recorded with a typed termination
|
||||
reason.
|
||||
|
||||
---
|
||||
|
||||
## (b) Transitions and behaviors that don't yet exist (or are only partially wired)
|
||||
|
||||
### Migration of consumers off legacy strings to typed `Stage` enum
|
||||
|
||||
The biggest outstanding piece. `pipeline_state.rs` is `#![allow(dead_code)]`.
|
||||
Every consumer (auto-assign, mergemaster, MCP tools, chat commands) still
|
||||
works with stage strings (`"2_current"`, `"4_merge"`) and front-matter flags.
|
||||
The projection layer (`TryFrom<PipelineItemView> for PipelineItem` and
|
||||
friends) exists but isn't called outside tests. Migration is intentionally
|
||||
incremental.
|
||||
|
||||
**Opportunity**: pick a leaf consumer (e.g. one MCP tool that reads the stage
|
||||
string) and migrate it to read `Stage` instead. Pattern repeats outward until
|
||||
all consumers go through the typed projection and the legacy stage-string
|
||||
code can be deleted.
|
||||
|
||||
### First-class verbs for archive reasons
|
||||
|
||||
`ArchiveReason` already has six variants but only `Completed` (via
|
||||
`accept_story`) and `Blocked` (via the `blocked: true` flag) have dedicated
|
||||
MCP verbs. Today, `Abandoned`, `Superseded`, `MergeFailed`, and `ReviewHeld`
|
||||
are reached either via `move_story target_stage=done` (which doesn't carry
|
||||
the reason) or via setting front-matter flags on the live story.
|
||||
|
||||
**Missing transitions**:
|
||||
|
||||
- `mcp__huskies__supersede_story story_id=X by=Y` — sets stage to
|
||||
`Archived { reason: Superseded { by: Y } }`. Today we use
|
||||
`move_story → done`, losing the `by` reference. (Came up 2026-04-25 with
|
||||
spike 621 → refactor 623.)
|
||||
- `mcp__huskies__abandon_story story_id=X reason="..."` — sets
|
||||
`Archived { reason: Abandoned }`. Today done via `move_story → done` or
|
||||
`purge_story`.
|
||||
- `mcp__huskies__hold_for_review story_id=X reason="..."` — explicitly puts
|
||||
a story in `Archived { reason: ReviewHeld }` rather than relying on the
|
||||
auto-set `review_hold` flag.
|
||||
|
||||
### Type-conversion transitions
|
||||
|
||||
Spike → story conversion is a real workflow (we do it when a spike's scope
|
||||
grows into an implementation story). Today, converting type via `update_story
|
||||
front_matter={"type": "story"}` does not bootstrap the
|
||||
`## Acceptance Criteria` section, and `add_criterion` then permanently fails
|
||||
on that story (see **bug 625** filed 2026-04-25). The `type` field passed via
|
||||
front_matter is also silently dropped — same silent-drop bug class as
|
||||
`acceptance_criteria`. The state machine should treat type conversion as a
|
||||
transition with side effects — at minimum, ensuring the AC section exists
|
||||
when transitioning to a type that requires it, and the displayed type
|
||||
reflects the new value (today the display chip is parsed from the immutable
|
||||
story_id prefix; story 578 in backlog will fix this by switching to
|
||||
numeric-only IDs).
|
||||
|
||||
### Limit-based agent termination (turn / budget)
|
||||
|
||||
Pre-624 master: `max_turns` and `max_budget_usd` per-agent config were read
|
||||
by the metric tool (`tool_get_agent_remaining_turns_and_budget`) but **not
|
||||
enforced** anywhere. Observed `coder-1` running 282/50 turns and $10.05/$5.00
|
||||
USD on story 623 before a human stopped it (bug 624, now merged).
|
||||
|
||||
The bug 624 fix adds enforcement to the watchdog. The state-machine impact:
|
||||
introduces a new agent-termination path distinct from "Failed (orphan)" —
|
||||
something like `Failed(LimitExceeded { kind: Turns | Budget })`. The
|
||||
`ExecutionState` enum may want a corresponding terminal variant so it can be
|
||||
distinguished from generic `Failed`.
|
||||
|
||||
### Pinned-agent honoring under contention
|
||||
|
||||
When a story has `agent: coder-opus` pinned but `coder-opus` is busy, today's
|
||||
auto-assign behavior is to leave the story unassigned — but if a human stops
|
||||
the running attempt and the story sits in `current/`, auto-assign **re-grabs
|
||||
it with the default coder** rather than waiting for the pinned agent.
|
||||
Observed multiple times on 2026-04-25 with story 623: pinning `coder-opus`
|
||||
did not prevent `coder-1` (sonnet) from being auto-assigned during opus's
|
||||
busy window.
|
||||
|
||||
**Missing behavior**: auto-assign should treat a pinned agent as a hard
|
||||
filter ("only this agent can take this story"), not a preference. Today the
|
||||
workaround is to also set `depends_on` on a phantom story, or move the story
|
||||
back to backlog and let the dependency system gate it.
|
||||
|
||||
### Honoring the `blocked` flag (bug 559)
|
||||
|
||||
`559_bug_mergemaster_ignores_blocked_flag_and_keeps_respawning_on_blocked_stories`
|
||||
is in backlog. Even though `blocked: true` is documented as a skip condition
|
||||
in `auto_assign_available_work`, mergemaster's spawn path apparently checks
|
||||
something different (or earlier) and respawns on blocked merge-stage stories.
|
||||
The state machine should make `Stage::Archived { reason: Blocked }` a single
|
||||
authoritative source so no consumer can incidentally bypass it.
|
||||
|
||||
### Formal "ghost story recovery" transition
|
||||
|
||||
The `move_story` MCP tool description mentions "recovering a ghost story by
|
||||
moving it back to current" as a valid use. Ghost stories are CRDT entries
|
||||
with no corresponding filesystem stage directory (or the inverse). Today this
|
||||
is an `update_story + move_story` ad-hoc dance. A first-class
|
||||
`recover_ghost_story` verb that reconciles the CRDT and filesystem would
|
||||
formalize the recovery path.
|
||||
|
||||
### Operator-level visibility / observability
|
||||
|
||||
There is no UI, CLI, or doc that shows "the state machine as a diagram." The
|
||||
typed enums are the closest thing to a canonical specification, but they
|
||||
aren't rendered anywhere a human can see at a glance: which stages exist,
|
||||
which transitions are valid, which events trigger them. A generated state
|
||||
diagram (graphviz or mermaid, dumped into this doc on each release) would
|
||||
help both new contributors and operators triaging stuck pipelines.
|
||||
|
||||
### Review-hold cleanup verb
|
||||
|
||||
`review_hold: true` is set automatically on spike completion. Clearing it is
|
||||
done as a side effect of `reject_qa` (which also moves the story qa →
|
||||
current) or by manually editing front matter. There is no clean "I have
|
||||
reviewed this, release the hold" verb that doesn't also move the story.
|
||||
|
||||
### Cross-node concurrency for execution state
|
||||
|
||||
`ExecutionState` is per-node (keyed by pubkey) so two nodes can't fight over
|
||||
who's running an agent. But there is no formal transition that says "node A
|
||||
hands the story to node B" if node A goes offline. The state machine's
|
||||
distributed semantics for this case are not yet specified.
|
||||
|
||||
---
|
||||
|
||||
## How to update this document
|
||||
|
||||
Whenever you discover a transition that doesn't yet exist, or a flag that
|
||||
behaves surprisingly, add it to **section (b)** with:
|
||||
|
||||
- A short description of the desired behavior
|
||||
- Citation of the work item or incident that surfaced it
|
||||
- Pointer to the place in `pipeline_state.rs` where it should be modeled (or
|
||||
note "needs a new variant" if it doesn't fit any existing enum yet)
|
||||
|
||||
When a transition from (b) ships, move it to (a) with the relevant file:line
|
||||
citations.
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
---
|
||||
name: "Unify story stuck states into a single status field"
|
||||
status: "superseded"
|
||||
superseded_by: 520
|
||||
---
|
||||
|
||||
# Refactor 436: Unify story stuck states into a single status field
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: "Build agent mode with CRDT-based work claiming"
|
||||
agent: coder-opus
|
||||
depends_on: [478]
|
||||
---
|
||||
|
||||
# Story 479: Build agent mode with CRDT-based work claiming
|
||||
|
||||
## User Story
|
||||
|
||||
As a user with multiple laptops, I want to run huskies in build agent mode so it connects to the mesh, syncs state, and autonomously picks up and runs coding work.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] New CLI mode: huskies agent --rendezvous ws://host:3001
|
||||
- [ ] Agent mode: syncs CRDT state, runs coders, no web UI or chat interface
|
||||
- [ ] Work claiming via CRDT: node writes claim (node ID) to CRDT doc, merge resolves conflicts deterministically, losing node stops work
|
||||
- [ ] Agent picks up stories in current stage and runs Claude Code locally
|
||||
- [ ] Agent pushes feature branch to Gitea when done, reports completion via CRDT
|
||||
- [ ] Handles offline/reconnect: CRDT merges on reconnect, interrupted work is reclaimed after timeout
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: "Cryptographic node auth for distributed mesh"
|
||||
agent: coder-opus
|
||||
depends_on: [479]
|
||||
---
|
||||
|
||||
# Story 480: Cryptographic node auth for distributed mesh
|
||||
|
||||
## User Story
|
||||
|
||||
As a user running a distributed huskies mesh, I want nodes authenticated by Ed25519 keypairs so only trusted machines can join and see pipeline state.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Each node has an Ed25519 keypair (generated on first run or via CLI command)
|
||||
- [ ] Trusted nodes defined by a list of known public keys in config
|
||||
- [ ] Nodes authenticate on WebSocket connect by signing a challenge
|
||||
- [ ] CRDT node ID derived from public key (already built into bft-json-crdt crate)
|
||||
- [ ] Unauthorised nodes rejected on connect
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "create_worktree deletes all files from main branch git index"
|
||||
---
|
||||
|
||||
# Bug 486: create_worktree deletes all files from main branch git index
|
||||
|
||||
## Description
|
||||
|
||||
On the reclaimer project, the create_worktree operation for story 34 produced a commit (cea0c48) with message "huskies: create 34_story_drawer_open_pushes_main_view_aside_with_animation" that removed 76 files (9853 deletions) from the main branch git index. All files remained on disk — nothing was lost — but every tracked file became untracked. Static analysis of the watcher code (watcher.rs:152-185) shows git add -A .huskies/work/ with correct current_dir, which should only affect files under .huskies/work/. No other code path in the server runs git add without a pathspec on the main branch. Root cause is unknown — may be related to the storkit→huskies migration leaving the git index in an inconsistent state, or a race condition during first-time scaffold on a project that previously used .storkit/.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Uncertain — may require fresh huskies setup on a project that previously used storkit. 2. Create a story via the bot or MCP tool. 3. Observe that the auto-commit for story creation removes all tracked files from the git index.
|
||||
|
||||
## Actual Result
|
||||
|
||||
The commit for story creation deleted 76 files (9853 deletions) from the git index while leaving them on disk.
|
||||
|
||||
## Expected Result
|
||||
|
||||
The commit for story creation should only add the new story markdown file under .huskies/work/1_backlog/. No other files should be affected.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: "Stale merge job lock prevents new merges after agent dies"
|
||||
---
|
||||
|
||||
# Bug 498: Stale merge job lock prevents new merges after agent dies
|
||||
|
||||
## Description
|
||||
|
||||
When the mergemaster agent is killed or stops while a merge is in progress, the in-memory `merge_jobs` map retains a `Running` status entry for that story. Subsequent attempts to call `merge_agent_work` get "Merge already in progress" and fail. The lock is never cleaned up.
|
||||
|
||||
This causes the mergemaster to loop: spawn, try merge, get "already in progress", waste turns, exit, respawn. The merge never completes.
|
||||
|
||||
The fix: clear the merge job entry when the mergemaster agent exits (whether cleanly or via kill/stop).
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Start mergemaster on a story in merge
|
||||
2. Kill/stop the mergemaster agent before merge completes
|
||||
3. Try to merge_agent_work again for the same story
|
||||
4. Get "Merge already in progress" error
|
||||
|
||||
## Actual Result
|
||||
|
||||
Stale Running entry in merge_jobs map blocks all future merge attempts until server restart.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Merge job lock is cleaned up when the agent exits, allowing retry.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: "CRDT lamport clock (inner.seq) resets to 1 on server restart instead of resuming from max(own_author_seq) + 1"
|
||||
---
|
||||
|
||||
# Bug 511: CRDT lamport clock (inner.seq) resets to 1 on server restart instead of resuming from max(own_author_seq) + 1
|
||||
|
||||
## Description
|
||||
|
||||
When the huskies server restarts (e.g. via `rebuild_and_restart`), the local node's CRDT lamport clock — `inner.seq` on each `SignedOp` — appears to reset to 1 instead of resuming from `MAX(seq) + 1` for the local author's own previously-persisted ops.
|
||||
|
||||
**Discovered live on 2026-04-09** while inspecting the `crdt_ops` table after a `rebuild_and_restart`. Pre-restart ops were at seqs 485-492 (creation ops for stories 503-510). Post-restart ops were being persisted at seqs 1, 2, 3, 4, 5, 6, 7 — visible by sorting `crdt_ops` by `created_at DESC`:
|
||||
|
||||
```
|
||||
created_at | seq
|
||||
2026-04-09T18:49:56 → seq 7 ← post-restart
|
||||
2026-04-09T18:38:22 → seq 6 ← post-restart
|
||||
2026-04-09T18:37:45 → seq 492 ← pre-restart, last write before restart
|
||||
2026-04-09T18:31:32 → seq 4 ← post-restart
|
||||
2026-04-09T18:27:04 → seq 3 ← post-restart
|
||||
```
|
||||
|
||||
So the local node, which had reached seq=492 before the restart, started writing new ops at seq=1 after the restart and is now climbing from there. This means **new ops have lower seqs than existing ops from the same author**.
|
||||
|
||||
## Why this matters
|
||||
|
||||
In a BFT JSON CRDT, `inner.seq` is the local lamport clock used for causality tracking. The library assumes per-author seqs are monotonically increasing — newer ops from the same author have higher seqs than older ops. Several things break when this invariant is violated:
|
||||
|
||||
1. **Causality / ordering on remote replay.** When a peer (or this same node after another restart) replays the persisted ops in `seq` order, the post-restart ops will be applied *before* the pre-restart ops, even though they happened later. This can produce a non-deterministic state and can cause field updates to "go backwards" — e.g. a story that was moved current → done pre-restart, then nothing post-restart, would correctly end at "done"; but if you also did a post-restart action, the seq ordering would re-play it in the wrong order.
|
||||
|
||||
2. **Op ID collisions.** The op id is a hash of the op contents (including author, seq, content). If a post-restart op happens to be structurally identical to a pre-restart op (e.g. "set stage to 1_backlog" with the same author and same seq=3), the op ids could collide. The persistence path uses `INSERT INTO crdt_ops ... ON CONFLICT(op_id) DO NOTHING`, which would *silently drop* the new op. (We have not yet observed this happen, but it's a latent risk.)
|
||||
|
||||
3. **Sync between nodes will desync.** Once the WebSocket sync layer (story 478, just merged) is exchanging ops between nodes, a restart on one node will produce ops with seqs that look "old" to the other node, and the receiving node may de-dupe or mis-order them. This will manifest as silent state divergence in multi-node deployments, which is exactly what the sync layer is supposed to prevent.
|
||||
|
||||
4. **Today's pipeline state confusion.** The 8 stories I created in this session (503-510) are at seqs 485-492 in the persisted CRDT. Their post-restart lifecycle moves are at seqs 1-15. If we replay the CRDT from disk in seq order, the lifecycle moves will be applied to *empty* state before the creation ops have run, and will silently no-op (because they reference content indexes that don't exist yet). On the next restart after this state, the in-memory view will show the stories in their *creation* state, not their post-restart-lifecycle state — i.e. all 8 stories will appear "stuck at 1_backlog" again. **This may well be the cause of bug 510's split-brain symptom**.
|
||||
|
||||
## Where the bug lives
|
||||
|
||||
`server/src/crdt_state.rs::init()` (around lines 80-115) replays persisted ops to reconstruct state, then constructs a fresh `CrdtState { crdt, keypair, index, persist_tx }`. The `BaseCrdt::new(&keypair)` call constructs a fresh CRDT with a fresh internal seq counter. The replay re-applies ops via `crdt.apply(signed_op)` which presumably updates the doc but does NOT advance the local seq counter (because `apply()` is for *remote* ops).
|
||||
|
||||
After replay, the local seq counter is at 0 (or wherever the BFT CRDT library defaults). The next call to `apply_and_persist` produces an op with `inner.seq = 1` (or whatever the next-counter value is) — even though there are already ops at seq 485+ from this author in the persisted state.
|
||||
|
||||
The fix is to inspect `MAX(inner.seq)` for ops where `author == local_keypair.public()` after the replay, and seed the BFT CRDT's local seq counter from `max + 1`. The exact API for "seed the seq counter" depends on the bft-json-crdt library — may need a small upstream change if not already exposed.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Start a fresh huskies server with an empty database. Verify `crdt_ops` is empty.
|
||||
2. Create several stories via `create_story` or similar — observe ops being persisted at incrementing seqs (1, 2, 3, ...).
|
||||
3. Note the highest seq via `sqlite3 .huskies/pipeline.db "SELECT MAX(seq) FROM crdt_ops;"` — call this N.
|
||||
4. Stop the server and start it again (or `rebuild_and_restart`).
|
||||
5. Create another story via `create_story`.
|
||||
6. Query `SELECT seq, created_at FROM crdt_ops ORDER BY created_at DESC LIMIT 5;`
|
||||
|
||||
## Actual Result
|
||||
|
||||
The new op (just created in step 5) is persisted with `seq = 1` (or some small value), NOT `seq = N + 1`. The lamport clock has been reset.
|
||||
|
||||
Concretely on 2026-04-09 we observed seqs in `crdt_ops` ordered by `created_at` DESC of: 7, 6, 492, 4, 3 — i.e. post-restart writes were at seqs 3, 4, 6, 7 even though the highest pre-restart seq was 492.
|
||||
|
||||
## Expected Result
|
||||
|
||||
After restart, the local node's seq counter must resume from `MAX(inner.seq)` across all persisted ops where `author == local_keypair.public()`, plus 1. The next op written by the local node should have `seq = N + 1` where N is the previous local high-water mark.
|
||||
|
||||
Equivalent stated: `inner.seq` on the local author's ops must be monotonically increasing across the entire lifetime of the local node's keypair, not just within a single process invocation.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] After a server restart, the next CRDT op written by the local node has seq = MAX(local_author_seq from crdt_ops) + 1, not 1
|
||||
- [ ] Regression test: seed crdt_ops with an op at seq=100 by the local author, restart the CRDT subsystem (or call init() in a test harness), trigger a write_item, assert the new op has seq=101
|
||||
- [ ] Regression test: a brand-new node (no pre-existing ops) still starts at seq=1 (no off-by-one introduced by the fix)
|
||||
- [ ] Inter-node test: simulate two nodes A and B, A writes ops up to seq=50, A restarts, A writes a new op which should be seq=51, broadcast to B, assert B applies it in the correct causal position
|
||||
- [ ] If the fix requires changes to bft-json-crdt itself (to expose a way to seed the local seq), the upstream change is documented in the bug body and either landed or vendored
|
||||
- [ ] After this fix is in place, replay-on-restart for the existing data (8 stories in pipeline_items at seqs 485-492 with lifecycle moves at seqs 1-15) is verified to produce the correct in-memory state — OR the existing broken-seq data is migrated as part of the fix
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: "Migrate chat commands from filesystem lookup to CRDT/DB"
|
||||
---
|
||||
|
||||
# Story 512: Migrate chat commands from filesystem lookup to CRDT/DB
|
||||
|
||||
## User Story
|
||||
|
||||
**Depends on story 520** (typed pipeline state machine). This story is best implemented as a *consumer* of the typed transition API, not against the loose `PipelineItemView`. Wait for 520 to land first, then migrate the chat command lookups to use the typed `find_story_by_number → Result<PipelineItem, _>` helper from the new module.
|
||||
|
||||
---
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
## Context
|
||||
|
||||
All the slash-style chat commands in `server/src/chat/commands/{move_story,show,depends,unblock}.rs` and `server/src/chat/transport/matrix/{start,assign,delete}.rs` look up stories by **searching for `.huskies/work/*/N_*.md` filesystem files**. After the 491/492 migration moved story content out of the filesystem and into `pipeline_items` + CRDT, these commands silently fail with `"No story, bug, or spike with number {N} found"` for any story whose filesystem shadow doesn't exist — *even when the story is fully present in the DB and CRDT*.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user typing chat commands in the web UI or the matrix bot, I want move/show/depends/unblock/start/assign/delete to find any story that's in the pipeline regardless of whether its filesystem shadow exists, so the chat workflow stays usable post-migration.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Master commit `41515e3b` had 503's code, the in-memory CRDT view had 503 at stage='merge', the `pipeline_items` row existed (post my-sqlite-update at `5_done`), but `move 503 done` in the web UI returned **`No story, bug, or spike with number 503 found`** because no `.huskies/work/4_merge/503_*.md` file existed.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The MCP `move_story` tool already does this correctly: it goes through `lifecycle::move_item` which checks `crdt_state::read_item(story_id)` first. The chat commands need to use the same lookup helper. The fix should consolidate all "find story by number" logic into one shared function used by every command.
|
||||
|
||||
## Context
|
||||
|
||||
All the slash-style chat commands in `server/src/chat/commands/{move_story,show,depends,unblock}.rs` and `server/src/chat/transport/matrix/{start,assign,delete}.rs` look up stories by **searching for `.huskies/work/*/N_*.md` filesystem files**. After the 491/492 migration moved story content out of the filesystem and into `pipeline_items` + CRDT, these commands silently fail with `"No story, bug, or spike with number {N} found"` for any story whose filesystem shadow doesn't exist — *even when the story is fully present in the DB and CRDT*.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user typing chat commands in the web UI or the matrix bot, I want move/show/depends/unblock/start/assign/delete to find any story that's in the pipeline regardless of whether its filesystem shadow exists, so the chat workflow stays usable post-migration.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Master commit `41515e3b` had 503's code, the in-memory CRDT view had 503 at stage='merge', the `pipeline_items` row existed (post my-sqlite-update at `5_done`), but `move 503 done` in the web UI returned **`No story, bug, or spike with number 503 found`** because no `.huskies/work/4_merge/503_*.md` file existed.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The MCP `move_story` tool already does this correctly: it goes through `lifecycle::move_item` which checks `crdt_state::read_item(story_id)` first. The chat commands need to use the same lookup helper. The fix should consolidate all "find story by number" logic into one shared function used by every command.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All seven chat commands (move_story, show, depends, unblock, start, assign, delete) successfully find stories that exist in CRDT but have no filesystem shadow
|
||||
- [ ] Backward compat: commands still work for stories with only filesystem shadows (during the migration window)
|
||||
- [ ] A single shared `find_story_by_number` helper is introduced and used by every chat command
|
||||
- [ ] Lookup priority order is documented and consistent: CRDT first, then pipeline_items, then filesystem fallback
|
||||
- [ ] Regression test per command covering CRDT-only, filesystem-only, both-present, and not-found cases
|
||||
- [ ] Observed repro from 2026-04-09 (move 503 done failing even though 503 was fully present in CRDT and pipeline_items) is the canonical regression case
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: "Startup reconcile pass that detects drift between CRDT, pipeline_items, and filesystem shadows"
|
||||
---
|
||||
|
||||
# Story 513: Startup reconcile pass that detects drift between CRDT, pipeline_items, and filesystem shadows
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Post-491/492, huskies has **four places state lives** that can drift apart:
|
||||
|
||||
1. `crdt_ops` table — the persisted CRDT operation log (intended source of truth)
|
||||
2. In-memory CRDT view — `state.crdt.doc.items` reconstructed from `crdt_ops` on startup, mutated by `apply_and_persist` during runtime
|
||||
3. `pipeline_items` table — a shadow / materialised view, written to as a shadow alongside CRDT writes
|
||||
4. Filesystem shadows in `.huskies/work/N_stage/*.md` — legacy rendering, still written by some paths and read by others
|
||||
|
||||
There is currently **no reconcile pass** that detects drift between them. We've watched this drift bite repeatedly today: stories appear in some views and not others, lifecycle moves happen in one but not another, my direct sqlite UPDATE was invisible to the API, etc. Each individual view looks "fine" in isolation, but the drift only becomes visible when a user notices a story behaving inconsistently.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer or operator running huskies, I want a startup reconcile pass that compares all four state sources and either reconciles them automatically (preferred) or logs structured warnings about the drift, so I can detect and diagnose state corruption before it causes user-visible bugs.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Throughout this session we observed: 478 in pipeline_items but missing from CRDT (after a direct sqlite insert), 503 in CRDT at stage=merge but pipeline_items at stage=5_done (after my UPDATE), filesystem shadows in `1_backlog/` for stories that were already in 5_done in the DB (bug 510), etc. None of these were detected by huskies itself — they were all only found by running ad-hoc `SELECT` queries during incident response.
|
||||
|
||||
## Scope
|
||||
|
||||
This is the *detection* story, not the *fix-the-drift* story. The reconcile pass should:
|
||||
|
||||
- Run at startup (after CRDT replay, before serving requests)
|
||||
- Compare each story's stage across all four sources
|
||||
- Emit structured log lines for each drift type (CRDT-only, FS-only, DB-only, stage mismatch, etc.)
|
||||
- Optionally surface a count to the matrix bot startup announcement (e.g. "⚠️ 3 stories have CRDT/DB drift — see logs")
|
||||
|
||||
The actual *fix-the-drift* logic (what to do when drift is detected) is a separate, larger story.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] At server startup, after CRDT replay, a reconcile_state() function runs that walks all four state sources and detects drift
|
||||
- [ ] Each drift type is logged with a structured line: e.g. `[reconcile] DRIFT story=X crdt_stage=Y db_stage=Z fs_stage=W` (or `MISSING` for absent)
|
||||
- [ ] If any drift is detected, the matrix bot startup announcement includes a count and a suggestion to check the server logs
|
||||
- [ ] The reconcile pass completes in < 1 second for a typical pipeline (~100 stories) so it doesn't slow startup meaningfully
|
||||
- [ ] Tests cover: no drift (clean state), CRDT-only story, DB-only story, FS-only story, stage mismatch between CRDT and DB
|
||||
- [ ] Documentation in README.md explains the reconcile pass and what each drift type means
|
||||
- [ ] The pass is opt-out via a config flag in case it produces noise during the migration window
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: "delete_story should do a full cleanup (CRDT op + DB row + filesystem shadow + worktree + pending timers)"
|
||||
---
|
||||
|
||||
# Story 514: delete_story should do a full cleanup (CRDT op + DB row + filesystem shadow + worktree + pending timers)
|
||||
|
||||
## User Story
|
||||
|
||||
**Depends on story 520** (typed pipeline state machine). With 520 in place, `delete_story` becomes a single typed transition (`* → Archived(Abandoned)` or a hard-delete CRDT op) followed by event subscribers that handle the worktree, timers, and filesystem cleanup. This story should be re-shaped as the consumer migration once 520 lands.
|
||||
|
||||
---
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
## Context
|
||||
|
||||
The MCP `delete_story` tool currently only **removes the filesystem markdown** from `.huskies/work/N_stage/`. It does NOT:
|
||||
|
||||
- Remove the row from `pipeline_items`
|
||||
- Write a CRDT delete op to `crdt_ops`
|
||||
- Tear down the in-memory CRDT entry
|
||||
- Remove the `.huskies/worktrees/N_…/` worktree
|
||||
- Cancel any pending rate-limit retry timers in `.huskies/timers.json`
|
||||
|
||||
So after `delete_story`, the story keeps appearing in `get_pipeline_status` (because the in-memory CRDT still has it), the timer fires and re-spawns an agent, the agent runs in the still-existing worktree, and the user has no idea why the "deleted" story keeps coming back.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user calling `delete_story` (via MCP, web UI, or chat command), I want a complete tear-down of all state associated with that story across every layer, so the story is actually gone — no in-memory cache entries, no pending agents, no timers, no worktree, no shadow files, no future spawns.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Repeatedly throughout the session. The most concrete example was around 17:20: I called `delete_story 478_…`, the tool returned success, the markdown file at `.huskies/work/1_backlog/478_…md` was removed, but at 17:25:17 the rate-limit retry timer fired and **re-spawned a coder-1 on the deleted story** because the worktree still existed, the pipeline_items row still existed, and the timer entry still existed in `.huskies/timers.json`. We then had to do sqlite surgery + manual worktree removal + manual timers.json edit to actually kill 478.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The current `delete_story` is on the legacy filesystem path. The fix needs to wrap it in a transaction that touches every layer:
|
||||
|
||||
1. Cancel any pending timers for this story_id (read timers.json, filter, write back)
|
||||
2. Stop any running/pending agents for this story_id (call `agent_pool.stop_agent` for each)
|
||||
3. Remove the worktree if it exists (`git worktree remove`)
|
||||
4. Write a CRDT delete op (`apply_and_persist` with a delete op)
|
||||
5. Wait for the persist task to confirm
|
||||
6. Delete the row from `pipeline_items` directly (or trust the materialiser to drop it)
|
||||
7. Remove the filesystem shadow
|
||||
|
||||
Each step should be best-effort with logging — partial failures should be visible, not silent.
|
||||
|
||||
## Context
|
||||
|
||||
The MCP `delete_story` tool currently only **removes the filesystem markdown** from `.huskies/work/N_stage/`. It does NOT:
|
||||
|
||||
- Remove the row from `pipeline_items`
|
||||
- Write a CRDT delete op to `crdt_ops`
|
||||
- Tear down the in-memory CRDT entry
|
||||
- Remove the `.huskies/worktrees/N_…/` worktree
|
||||
- Cancel any pending rate-limit retry timers in `.huskies/timers.json`
|
||||
|
||||
So after `delete_story`, the story keeps appearing in `get_pipeline_status` (because the in-memory CRDT still has it), the timer fires and re-spawns an agent, the agent runs in the still-existing worktree, and the user has no idea why the "deleted" story keeps coming back.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user calling `delete_story` (via MCP, web UI, or chat command), I want a complete tear-down of all state associated with that story across every layer, so the story is actually gone — no in-memory cache entries, no pending agents, no timers, no worktree, no shadow files, no future spawns.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Repeatedly throughout the session. The most concrete example was around 17:20: I called `delete_story 478_…`, the tool returned success, the markdown file at `.huskies/work/1_backlog/478_…md` was removed, but at 17:25:17 the rate-limit retry timer fired and **re-spawned a coder-1 on the deleted story** because the worktree still existed, the pipeline_items row still existed, and the timer entry still existed in `.huskies/timers.json`. We then had to do sqlite surgery + manual worktree removal + manual timers.json edit to actually kill 478.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The current `delete_story` is on the legacy filesystem path. The fix needs to wrap it in a transaction that touches every layer:
|
||||
|
||||
1. Cancel any pending timers for this story_id (read timers.json, filter, write back)
|
||||
2. Stop any running/pending agents for this story_id (call `agent_pool.stop_agent` for each)
|
||||
3. Remove the worktree if it exists (`git worktree remove`)
|
||||
4. Write a CRDT delete op (`apply_and_persist` with a delete op)
|
||||
5. Wait for the persist task to confirm
|
||||
6. Delete the row from `pipeline_items` directly (or trust the materialiser to drop it)
|
||||
7. Remove the filesystem shadow
|
||||
|
||||
Each step should be best-effort with logging — partial failures should be visible, not silent.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] delete_story returns success only when ALL of the following are true: no row in pipeline_items, no op in crdt_ops referencing the story_id (or a delete op present), no in-memory CRDT entry, no worktree directory, no timer entries, no filesystem shadow
|
||||
- [ ] Each tear-down step has its own log line so partial failures are diagnosable
|
||||
- [ ] If any tear-down step fails, the tool returns an error with which step failed and what was already torn down (so the user can finish the cleanup manually)
|
||||
- [ ] After delete_story, the story does NOT appear in get_pipeline_status, the web UI, or list_agents
|
||||
- [ ] After delete_story, no rate-limit retry timer can re-spawn an agent on the deleted story
|
||||
- [ ] Regression test using the 2026-04-09 repro: schedule a rate-limit timer for the story, call delete_story, fast-forward 5 minutes, assert no agent spawned
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: "Add a debug MCP tool to dump the in-memory CRDT state for inspection"
|
||||
---
|
||||
|
||||
# Story 515: Add a debug MCP tool to dump the in-memory CRDT state for inspection
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
When diagnosing CRDT/state issues today, we had no way to look at the **in-memory** CRDT state directly. The closest available views were:
|
||||
|
||||
- `get_pipeline_status` — gives a summarised pipeline-shaped view (active/backlog/done) but hides the raw item structure, the index map, the lamport clock state, etc.
|
||||
- Querying `crdt_ops` directly via sqlite — gives the *persisted* state, which can diverge from the in-memory state (we saw this with bug 511, where post-restart writes use reset seq counters)
|
||||
- `read_item(story_id)` in `crdt_state.rs` — exists, returns a `PipelineItemView`, but is not exposed via MCP or HTTP
|
||||
|
||||
The result: every time I needed to check the CRDT state, I was either inferring it from `get_pipeline_status` (lossy) or querying the persisted ops (lagging the in-memory state). Neither gave me the ground truth.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer debugging huskies state issues, I want an MCP tool (or HTTP debug endpoint) that returns a structured dump of the in-memory CRDT state, so I can see exactly what the running server thinks is true without inferring from summaries.
|
||||
|
||||
## Suggested API
|
||||
|
||||
- Tool name: `mcp__huskies__dump_crdt`
|
||||
- Args: optional `story_id` filter (single story) or no args (dump everything)
|
||||
- Returns: JSON with one entry per item containing: `story_id`, all field values (`stage`, `name`, `agent`, `retry_count`, `blocked`, `depends_on`), the CRDT path/index bytes (for cross-referencing with `crdt_ops`), the local lamport seq counter, and a flag indicating whether the item is `is_deleted`
|
||||
- Returns metadata: total item count, current local seq counter value, count of pending ops in `persist_tx` channel (if observable)
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
This story would have saved us significant debugging time. Specific examples:
|
||||
|
||||
- When 478 was missing from `get_pipeline_status` after the manual sqlite insert, we had to infer "the API reads from in-memory CRDT, not from pipeline_items" by looking at source code. A `dump_crdt 478_…` call would have returned "not found" immediately, confirming the same conclusion.
|
||||
- When 503 was showing at stage=merge in the API but only had a creation op at stage=1_backlog in `crdt_ops`, we had to manually search for content-indexed update ops to figure out where the post-restart updates went. A dump tool showing the current in-memory state vs the persisted op count would have made the divergence obvious.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] New MCP tool `dump_crdt` is registered and callable
|
||||
- [ ] With no args, returns all items in the in-memory CRDT as a structured JSON list
|
||||
- [ ] With a story_id arg, returns just that one item (or null if not found)
|
||||
- [ ] Each item entry includes: story_id, stage, name, agent, retry_count, blocked, depends_on, content_index (hex), is_deleted
|
||||
- [ ] Returns top-level metadata: total_items, max_local_seq, pending_persist_ops_count (if available), in_memory_state_loaded (bool)
|
||||
- [ ] Tool description is clear that this is a debug tool, not for normal pipeline introspection (those should use get_pipeline_status)
|
||||
- [ ] Optional: also expose via HTTP at `/debug/crdt` for browser inspection
|
||||
- [ ] Documented in README.md under a 'debugging' section
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: "update_story.description should create the ## Description section if it doesn't exist (instead of erroring)"
|
||||
---
|
||||
|
||||
# Story 516: update_story.description should create the ## Description section if it doesn't exist (instead of erroring)
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The MCP `update_story` tool's `description` parameter "replaces the `## Description` section content". If the section doesn't exist in the story file, the call **errors out** with `Section '## Description' not found in story file.`
|
||||
|
||||
This becomes a real problem when:
|
||||
|
||||
1. A story was created via `create_story` (which is buggy per 509 and writes a stub template with no `## Description` section)
|
||||
2. The user later wants to add a description via `update_story`
|
||||
3. The update fails with the cryptic "section not found" error
|
||||
|
||||
We hit this exact scenario today: after bug 509 dropped the descriptions of 6 stories (500, 504, 505, 506, 507, 508), I tried to recover them by calling `update_story` with `description=...` — and the call errored out because the stub template the buggy `create_story` had written had no `## Description` section. We had to fall back to stuffing everything into the `user_story` field.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user calling `update_story.description` on any story (regardless of how it was originally created), I want the call to either replace the existing `## Description` section OR create one if it doesn't exist, so I never have to think about the template structure.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The simplest fix is in the `update_story_in_file` (or equivalent) function: when looking for the `## Description` section, if not found, **insert it** at a sensible location — probably between `## User Story` and `## Acceptance Criteria` — and then write the description content there.
|
||||
|
||||
Related: this story partially covers the workaround for bug 509 (create_story drops description). If 509 is fixed first, the templates would always have a `## Description` section and this wouldn't matter. But this fix is still valuable for older stories created before 509 lands, AND for stories created via legacy paths that don't use the canonical template.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
```
|
||||
> update_story story_id=500_story_remove_duplicate_pty_debug_log_lines description="..."
|
||||
Error: Section '## Description' not found in story file.
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] update_story.description succeeds whether or not the target story has a pre-existing ## Description section
|
||||
- [ ] When the section is missing, it is created at a consistent location (between ## User Story and ## Acceptance Criteria)
|
||||
- [ ] When the section exists, the existing replace-content behaviour is preserved (no regression)
|
||||
- [ ] Unit test covering both: section-exists path AND section-missing path
|
||||
- [ ] Symmetric fix for update_story.user_story (if it has the same brittleness)
|
||||
- [ ] Error messages for genuine failure modes (file not found, write failed) are still distinct from the now-silent missing-section case
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: "Remove filesystem-shadow fallback paths from lifecycle.rs (finish the migration to CRDT-only)"
|
||||
---
|
||||
|
||||
# Story 517: Remove filesystem-shadow fallback paths from lifecycle.rs (finish the migration to CRDT-only)
|
||||
|
||||
## User Story
|
||||
|
||||
**Depends on story 520** (typed pipeline state machine). Once 520 lands and consumers are migrated to the typed transition API, the lifecycle module no longer needs filesystem fallbacks — all state changes go through the typed `transition` function and the event bus. This story becomes the natural cleanup pass after 520 + 512 + 514 land.
|
||||
|
||||
---
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
## Context
|
||||
|
||||
`server/src/agents/lifecycle.rs::move_item` (the helper that backs `move_story_to_current`, `move_story_to_done`, `move_story_to_merge`, etc.) has **three execution paths**:
|
||||
|
||||
1. **CRDT-first path** (the "happy" post-migration path) — calls `crdt_state::read_item(story_id)`, then `db::move_item_stage`, which writes a CRDT op and broadcasts events
|
||||
2. **Content-store fallback** — if the story isn't in CRDT but exists in the db's content store, import it via `db::write_item_with_content`
|
||||
3. **Filesystem fallback** — if neither, scan `.huskies/work/N_stage/` for a markdown file, import it to the DB
|
||||
|
||||
Paths 2 and 3 are **migration scaffolding**. They were necessary while stories existed only on disk and the CRDT was empty, but post-491/492 they should be unnecessary. Worse, they actively *cause* drift today:
|
||||
|
||||
- The filesystem fallback can re-import stale shadow files into the DB, undoing intentional deletes
|
||||
- The path 3 search is blind to which stage a story "should" be in per the DB — it picks whatever stage dir has the file, which can promote stale shadows
|
||||
- This is the mechanism that makes bug 510 (split-brain shadow promotion) possible
|
||||
|
||||
`move_story_to_current` is hardcoded to read from `["1_backlog"]`, which is also part of the same legacy filesystem assumption.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer maintaining huskies, I want the lifecycle code to operate exclusively on the CRDT/DB and never touch filesystem shadows, so state drift is eliminated and the post-migration architecture is consistent.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
1. Inventory every code path in `lifecycle.rs` that touches the filesystem under `.huskies/work/`
|
||||
2. For each, determine whether it's a *read* (legacy fallback — can be removed if we're confident all stories are in CRDT now) or a *write* (legacy mirror — can be deferred to a separate filesystem-renderer task that derives state from CRDT)
|
||||
3. Remove the read fallbacks
|
||||
4. Move the writes to a downstream materialiser task that writes the filesystem shadows from CRDT events (so they're strictly read-only renderings)
|
||||
5. Run the bug-510 reconcile pass at startup (story TBD) before this lands, to ensure no story is stranded with only a filesystem shadow
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
We watched the filesystem fallback paths cause harm multiple times today:
|
||||
- Bug 510 split-brain: filesystem shadows in `1_backlog/` got re-promoted by timer fires after the DB had already moved the story to `5_done`
|
||||
- The 478 worktree's `move_story_to_current` no-op'd because there was no `1_backlog` shadow — even though 478 was in `4_merge` per the DB (this was actually correct behaviour given the function's narrow `from = ["1_backlog"]`, but it surfaces how filesystem-bound the function is)
|
||||
- Lifecycle moves were happening on the filesystem without writing CRDT ops (we initially mis-diagnosed this as "no transition ops in CRDT" before finding bug 511)
|
||||
|
||||
## Context
|
||||
|
||||
`server/src/agents/lifecycle.rs::move_item` (the helper that backs `move_story_to_current`, `move_story_to_done`, `move_story_to_merge`, etc.) has **three execution paths**:
|
||||
|
||||
1. **CRDT-first path** (the "happy" post-migration path) — calls `crdt_state::read_item(story_id)`, then `db::move_item_stage`, which writes a CRDT op and broadcasts events
|
||||
2. **Content-store fallback** — if the story isn't in CRDT but exists in the db's content store, import it via `db::write_item_with_content`
|
||||
3. **Filesystem fallback** — if neither, scan `.huskies/work/N_stage/` for a markdown file, import it to the DB
|
||||
|
||||
Paths 2 and 3 are **migration scaffolding**. They were necessary while stories existed only on disk and the CRDT was empty, but post-491/492 they should be unnecessary. Worse, they actively *cause* drift today:
|
||||
|
||||
- The filesystem fallback can re-import stale shadow files into the DB, undoing intentional deletes
|
||||
- The path 3 search is blind to which stage a story "should" be in per the DB — it picks whatever stage dir has the file, which can promote stale shadows
|
||||
- This is the mechanism that makes bug 510 (split-brain shadow promotion) possible
|
||||
|
||||
`move_story_to_current` is hardcoded to read from `["1_backlog"]`, which is also part of the same legacy filesystem assumption.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer maintaining huskies, I want the lifecycle code to operate exclusively on the CRDT/DB and never touch filesystem shadows, so state drift is eliminated and the post-migration architecture is consistent.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
1. Inventory every code path in `lifecycle.rs` that touches the filesystem under `.huskies/work/`
|
||||
2. For each, determine whether it's a *read* (legacy fallback — can be removed if we're confident all stories are in CRDT now) or a *write* (legacy mirror — can be deferred to a separate filesystem-renderer task that derives state from CRDT)
|
||||
3. Remove the read fallbacks
|
||||
4. Move the writes to a downstream materialiser task that writes the filesystem shadows from CRDT events (so they're strictly read-only renderings)
|
||||
5. Run the bug-510 reconcile pass at startup (story TBD) before this lands, to ensure no story is stranded with only a filesystem shadow
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
We watched the filesystem fallback paths cause harm multiple times today:
|
||||
- Bug 510 split-brain: filesystem shadows in `1_backlog/` got re-promoted by timer fires after the DB had already moved the story to `5_done`
|
||||
- The 478 worktree's `move_story_to_current` no-op'd because there was no `1_backlog` shadow — even though 478 was in `4_merge` per the DB (this was actually correct behaviour given the function's narrow `from = ["1_backlog"]`, but it surfaces how filesystem-bound the function is)
|
||||
- Lifecycle moves were happening on the filesystem without writing CRDT ops (we initially mis-diagnosed this as "no transition ops in CRDT" before finding bug 511)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Inventory of every filesystem touch in lifecycle.rs is documented in the story body or a follow-up comment
|
||||
- [ ] All read fallbacks in lifecycle.rs (paths 2 and 3 above) are removed
|
||||
- [ ] All write paths in lifecycle.rs that mirror to the filesystem are moved to a separate materialiser task driven by CRDT events
|
||||
- [ ] After the change, lifecycle.rs has zero direct std::fs:: calls under .huskies/work/
|
||||
- [ ] move_story_to_current no longer hardcodes from=['1_backlog'] — it reads the source stage from CRDT
|
||||
- [ ] Regression: the existing 'try filesystem fallback' tests are updated to test the new CRDT-only path instead of being deleted
|
||||
- [ ] A pre-flight script verifies all existing stories are in CRDT before this change lands (so nothing gets stranded)
|
||||
- [ ] Bug 510 (split-brain shadows) no longer reproduces after this change
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
---
|
||||
name: "apply_and_persist should log when persist_tx send fails instead of silently dropping the op"
|
||||
---
|
||||
|
||||
# Story 518: apply_and_persist should log when persist_tx send fails instead of silently dropping the op
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
`server/src/crdt_state.rs::apply_and_persist` updates the in-memory CRDT and then sends the signed op to the persistence task via a channel:
|
||||
|
||||
```rust
|
||||
fn apply_and_persist<F>(state: &mut CrdtState, op_fn: F) {
|
||||
let raw_op = op_fn(state);
|
||||
let signed = raw_op.sign(&state.keypair);
|
||||
state.crdt.apply(signed.clone()); // in-memory update
|
||||
let _ = state.persist_tx.send(signed.clone()); // ← fire-and-forget, error dropped
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The `let _ = ...` discards the return value of `send()`. If the channel is closed (because the persistence task panicked, was shut down, or has dropped its receiver), the op is silently dropped from persistence — but the in-memory CRDT is already updated. The next restart will replay only the persisted ops, and the in-memory state will quietly diverge from the persisted state.
|
||||
|
||||
This is also one of the candidate causes for some of the state drift we've been chasing. It's hard to rule out because there's no log line to confirm whether the persist task is still alive or whether sends are succeeding.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer or operator, I want any failure of `persist_tx.send()` to be logged immediately at WARN or ERROR level, so silent persistence loss is detectable instead of invisible.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Spent significant time investigating whether persist sends were silently failing. Eventually ruled it out empirically (we found that ops WERE being persisted, just with reset seq counters per bug 511). But the diagnosis would have been minutes instead of an hour if there was a log line to check.
|
||||
|
||||
## Fix (small)
|
||||
|
||||
```rust
|
||||
if let Err(e) = state.persist_tx.send(signed.clone()) {
|
||||
crate::slog_error!(
|
||||
"[crdt] Failed to send op to persist task: {e}; persist task may be dead. \
|
||||
In-memory state is now ahead of persisted state."
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Apply the same fix at every `let _ = state.persist_tx.send(...)` site in crdt_state.rs (there are at least 2 — one in apply_and_persist, one in apply_remote_op).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Every call site of `state.persist_tx.send(...)` in crdt_state.rs logs at ERROR level on send failure
|
||||
- [ ] The error message includes the channel error and a clear note that 'in-memory and persisted state may have diverged'
|
||||
- [ ] Unit test: shut down the persist receiver (drop the rx end), call write_item, assert an error is logged
|
||||
- [ ] No regression in the happy path (no extra log lines on success)
|
||||
- [ ] Consider: also expose a counter / metric for persist send failures so it can be monitored without grepping logs
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: "mergemaster should detect no-commits-ahead-of-master and fail loudly instead of exiting silently"
|
||||
---
|
||||
|
||||
# Story 519: mergemaster should detect no-commits-ahead-of-master and fail loudly instead of exiting silently
|
||||
|
||||
## User Story
|
||||
|
||||
**Depends on story 520** (typed pipeline state machine). Once 520 lands, this story largely *evaporates*: `Stage::Merge` is defined as `Merge { feature_branch: BranchName, commits_ahead: NonZeroU32 }`, so a merge state with zero commits ahead is **structurally unrepresentable**. The transition `Current → Merge` (or `Qa → Merge`) is required to provide a NonZeroU32 — the type system enforces it. This story remains useful as a *defensive runtime check* during the migration window before 520 lands; afterwards, it should be closed as redundant.
|
||||
|
||||
---
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
## Context
|
||||
|
||||
When mergemaster runs on a story whose worktree has **zero commits ahead of master** (e.g. because `create_worktree` always creates from master and the original feature branch was never checked out into the worktree), it currently:
|
||||
|
||||
1. Spawns its claude session
|
||||
2. Runs `merge_agent_work` MCP tool
|
||||
3. Finds nothing to merge
|
||||
4. Exits cleanly with `[agent:N:mergemaster] Done. Session: ...`
|
||||
5. **Does not log any error or warning**
|
||||
6. **Spends real money** on the empty session — we observed `cost=$0.82` for one such no-op run
|
||||
|
||||
The user has no signal that the merge didn't actually happen. The matrix bot fires a "QA → Merge" stage notification (because the story did move stages internally), then nothing — no `🎉 Merge → Done` notification follows. Master is unchanged.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user watching the pipeline, I want mergemaster to detect "this worktree has no commits ahead of master" *before* spending money on a Claude session, and fail loudly with a clear error so I know to investigate the upstream cause (probably the worktree got reset to master).
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Around 18:31:51, mergemaster spawned for 478 in a worktree that had been reset to master by the orphan cleanup logic at 18:29:54. By the time mergemaster ran, the worktree was on master with zero commits ahead. It ran a session, spent $0.82, exited "Done", and didn't merge anything. We didn't notice for several minutes because the failure was completely silent. We had to manually `git log master..feature/story-478_…` to confirm there was no merge commit on master.
|
||||
|
||||
## Fix
|
||||
|
||||
In mergemaster's startup sequence (probably in advance.rs or wherever the mergemaster session is spawned), add a pre-flight check:
|
||||
|
||||
```rust
|
||||
let commits_ahead = git_commits_ahead(worktree_path, "master")?;
|
||||
if commits_ahead == 0 {
|
||||
slog_error!(
|
||||
"[mergemaster] worktree {worktree_path} has no commits ahead of master; \
|
||||
refusing to spawn merge session. Likely cause: worktree was reset to \
|
||||
master after the feature branch's commits were created. Investigate the \
|
||||
worktree's git state before retrying."
|
||||
);
|
||||
return Err("no commits to merge".into());
|
||||
}
|
||||
```
|
||||
|
||||
This costs ~milliseconds (one git command) and saves the cost of an entire Claude session per false-positive.
|
||||
|
||||
## Context
|
||||
|
||||
When mergemaster runs on a story whose worktree has **zero commits ahead of master** (e.g. because `create_worktree` always creates from master and the original feature branch was never checked out into the worktree), it currently:
|
||||
|
||||
1. Spawns its claude session
|
||||
2. Runs `merge_agent_work` MCP tool
|
||||
3. Finds nothing to merge
|
||||
4. Exits cleanly with `[agent:N:mergemaster] Done. Session: ...`
|
||||
5. **Does not log any error or warning**
|
||||
6. **Spends real money** on the empty session — we observed `cost=$0.82` for one such no-op run
|
||||
|
||||
The user has no signal that the merge didn't actually happen. The matrix bot fires a "QA → Merge" stage notification (because the story did move stages internally), then nothing — no `🎉 Merge → Done` notification follows. Master is unchanged.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a user watching the pipeline, I want mergemaster to detect "this worktree has no commits ahead of master" *before* spending money on a Claude session, and fail loudly with a clear error so I know to investigate the upstream cause (probably the worktree got reset to master).
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
Around 18:31:51, mergemaster spawned for 478 in a worktree that had been reset to master by the orphan cleanup logic at 18:29:54. By the time mergemaster ran, the worktree was on master with zero commits ahead. It ran a session, spent $0.82, exited "Done", and didn't merge anything. We didn't notice for several minutes because the failure was completely silent. We had to manually `git log master..feature/story-478_…` to confirm there was no merge commit on master.
|
||||
|
||||
## Fix
|
||||
|
||||
In mergemaster's startup sequence (probably in advance.rs or wherever the mergemaster session is spawned), add a pre-flight check:
|
||||
|
||||
```rust
|
||||
let commits_ahead = git_commits_ahead(worktree_path, "master")?;
|
||||
if commits_ahead == 0 {
|
||||
slog_error!(
|
||||
"[mergemaster] worktree {worktree_path} has no commits ahead of master; \
|
||||
refusing to spawn merge session. Likely cause: worktree was reset to \
|
||||
master after the feature branch's commits were created. Investigate the \
|
||||
worktree's git state before retrying."
|
||||
);
|
||||
return Err("no commits to merge".into());
|
||||
}
|
||||
```
|
||||
|
||||
This costs ~milliseconds (one git command) and saves the cost of an entire Claude session per false-positive.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Before mergemaster spawns its Claude session, it runs `git log master..HEAD --oneline` (or equivalent) on the worktree
|
||||
- [ ] If the result is empty (zero commits ahead), mergemaster exits early with an ERROR log line and does NOT spawn the session
|
||||
- [ ] The error message is specific enough that the user can diagnose the upstream cause (e.g. mentions 'worktree was reset' and suggests checking the worktree's branch)
|
||||
- [ ] The matrix bot sends a clear failure notification (NOT a successful 🎉 emoji) when this happens
|
||||
- [ ] The story does not advance to a 'done' state when mergemaster exits this way; it stays in 4_merge with a clear blocked status
|
||||
- [ ] Regression test: create a worktree on master (no feature commits), invoke mergemaster, assert the early exit happens and no Claude session is spawned
|
||||
- [ ] Cost saving observed in the 2026-04-09 incident ($0.82 per no-op session) is documented in the test as the motivation
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
---
|
||||
name: "Typed pipeline state machine in Rust (foundation: replaces stringly-typed CRDT views with strict enums, subsumes 436)"
|
||||
---
|
||||
|
||||
# Story 520: Typed pipeline state machine in Rust (foundation: replaces stringly-typed CRDT views with strict enums, subsumes 436)
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Today huskies represents pipeline state as a loose JSON document inside the BFT JSON CRDT. Each story has fields like `stage: String`, `agent: String`, `retry_count: f64`, `blocked: bool`, `depends_on: String` (JSON-encoded list, double-encoded). This stringly-typed representation allows **many impossible states** to be representable in the data model:
|
||||
|
||||
- `stage = "9_invalid"` — typo, no compile error
|
||||
- `stage = "5_done"` + `blocked = true` — a done story is blocked? what does that mean?
|
||||
- `stage = "4_merge"` with no commits ahead of master — the silent mergemaster failure mode (today's story 478)
|
||||
- A coder agent assigned to a story in `4_merge` — bug 502, the loop we fought all day today
|
||||
- `retry_count = 3.7` — fractional retry counts (it's an f64 because that's what JSON CRDTs do)
|
||||
- `agent = "coder-1"` AND `stage = "1_backlog"` — backlog story has an agent? sentinel encoding via empty string
|
||||
|
||||
Multiple bugs filed today (501, 502, 510, 511) exist *because* the type system can't enforce the pipeline invariants. **Patching individual symptoms forever is the wrong strategy.** The right strategy is to make impossible states unrepresentable at the Rust type level, using a typed state machine layered on top of the loose CRDT. The CRDT can stay loose at the persistence layer (it has to be — that's what makes it merge correctly across nodes), but every consumer above the CRDT operates on strict typed enums.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a developer working on huskies, I want the pipeline state to be expressed as a strict Rust state machine where impossible states and impossible transitions are compile-time errors, so future bugs in this category become structural rather than runtime drift.
|
||||
|
||||
## Design
|
||||
|
||||
### Two enum hierarchies
|
||||
|
||||
**Synced state (CRDT-backed, converges across nodes):**
|
||||
|
||||
```rust
|
||||
enum Stage {
|
||||
Backlog,
|
||||
Current,
|
||||
Qa,
|
||||
Merge { feature_branch: BranchName, commits_ahead: NonZeroU32 },
|
||||
Done { merged_at: DateTime<Utc>, merge_commit: GitSha },
|
||||
Archived { archived_at: DateTime<Utc>, reason: ArchiveReason },
|
||||
}
|
||||
|
||||
enum ArchiveReason {
|
||||
Completed, // normal accept_story → archived
|
||||
Abandoned, // user explicitly abandoned
|
||||
Superseded { by: StoryId },
|
||||
Blocked { reason: String }, // was bug 436's `blocked: true`
|
||||
MergeFailed { reason: String }, // was bug 436's `merge_failure`
|
||||
ReviewHeld { reason: String }, // was bug 436's `review_hold`
|
||||
}
|
||||
|
||||
struct PipelineItem {
|
||||
story_id: StoryId, // newtype, validated
|
||||
name: String,
|
||||
stage: Stage, // typed enum, all variants are valid by construction
|
||||
depends_on: Vec<StoryId>, // parsed, not stringified
|
||||
retry_count: u32, // not f64
|
||||
// No more separate `blocked`, `merge_failure`, `review_hold` — folded into Stage::Archived
|
||||
}
|
||||
```
|
||||
|
||||
**Per-node execution state (CRDT-backed under node_id key, local-authored but globally-readable):**
|
||||
|
||||
```rust
|
||||
enum ExecutionState {
|
||||
Idle,
|
||||
Pending { agent: AgentName, since: DateTime<Utc> },
|
||||
Running { agent: AgentName, started_at: DateTime<Utc>, last_heartbeat: DateTime<Utc> },
|
||||
RateLimited { agent: AgentName, resume_at: DateTime<Utc> },
|
||||
Completed { agent: AgentName, exit_code: i32, completed_at: DateTime<Utc> },
|
||||
}
|
||||
|
||||
// In the CRDT document, ExecutionState is stored under each node's pubkey:
|
||||
// crdt.execution_state: { node_pubkey → { story_id → ExecutionState } }
|
||||
```
|
||||
|
||||
The execution state lives in the CRDT under **each node's pubkey**. Each node only writes to entries where `node_pubkey == self.pubkey`, so there's no merge conflict — concurrent writes from the same author follow LWW, concurrent writes from different authors target different entries entirely. All nodes can READ all execution states across the mesh.
|
||||
|
||||
**This per-node-keyed CRDT pattern enables:**
|
||||
|
||||
- **Cross-node observability** — matrix bot can show "node A is running coder-1 on story X, node B is rate-limited on story Y"
|
||||
- **Heartbeat detection** — if a node hasn't updated its execution_state in N minutes, the entry is "stale" (laptop closed, process crashed, oom kill, etc.)
|
||||
- **Foundation for story 479** (CRDT work claiming) — a node knows what other nodes are doing *before* claiming work
|
||||
- **Stuck job recovery** — if node A's heartbeat dies mid-run, node B can see the stuck state and decide whether to take over
|
||||
- **Crash forensics** — the last persisted ExecutionState before a crash is preserved in CRDT, accessible from any node
|
||||
|
||||
### The transition function
|
||||
|
||||
```rust
|
||||
fn transition(
|
||||
state: PipelineItem,
|
||||
event: PipelineEvent,
|
||||
) -> Result<PipelineItem, TransitionError>
|
||||
```
|
||||
|
||||
Pure function. Takes the current state and an event, returns either the new state or a TransitionError. The compiler enforces that the result of every transition is structurally valid — you can't construct a `Stage::Merge` without `commits_ahead: NonZeroU32`, you can't construct a `Stage::Done` without a `merge_commit: GitSha`, etc.
|
||||
|
||||
**The set of valid transitions is small** (roughly 10):
|
||||
|
||||
- `Backlog → Current` — deps met, auto-assign promotes
|
||||
- `Current → Qa` — gates start
|
||||
- `Current → Merge` — qa: server, gates auto-pass
|
||||
- `Qa → Merge` — gates pass
|
||||
- `Qa → Current` — gates fail, retry
|
||||
- `Merge → Done` — mergemaster squash succeeds; *requires `Merge.commits_ahead > 0`*
|
||||
- `Done → Archived(Completed)` — accept_story
|
||||
- `* → Archived(Blocked / MergeFailed / ReviewHeld)` — stuck-state move
|
||||
- `Archived(Blocked) → Backlog` — unblock
|
||||
|
||||
Anything else is a `TransitionError`. The compiler refuses to compile code that constructs invalid transitions.
|
||||
|
||||
### The event subscriber pattern
|
||||
|
||||
State changes fire events on a bus. Side-effect handlers subscribe independently:
|
||||
|
||||
```rust
|
||||
type TransitionEvent = (PipelineItem /* before */, PipelineItem /* after */);
|
||||
|
||||
bus.subscribe("matrix-bot", |before, after| matrix_bot.notify_stage_change(before, after));
|
||||
bus.subscribe("filesystem", |before, after| fs_renderer.update(after));
|
||||
bus.subscribe("pipeline-table", |before, after| pipeline_items_table.upsert(after));
|
||||
bus.subscribe("auto-assign", |before, after| auto_assign.poke_if_relevant(after));
|
||||
bus.subscribe("web-ui-broadcast", |before, after| ws_clients.broadcast(after));
|
||||
```
|
||||
|
||||
Each subscriber is independent and concerns itself only with its own dispatch. Adding a new side effect = adding a new subscriber, not editing the transition function. **The "many things happen on state changes" complexity moves out of the state machine and into the bus consumers**, where each piece is testable in isolation.
|
||||
|
||||
### Projection layer (loose CRDT ↔ typed Rust)
|
||||
|
||||
The bft-json-crdt JSON document is the persistence layer. The typed enums are the application layer. A projection function bridges them at one carefully-controlled boundary:
|
||||
|
||||
```rust
|
||||
impl TryFrom<&PipelineItemCrdt> for PipelineItem {
|
||||
type Error = ProjectionError;
|
||||
fn try_from(crdt: &PipelineItemCrdt) -> Result<Self, ProjectionError> { ... }
|
||||
}
|
||||
|
||||
impl From<&PipelineItem> for PipelineItemCrdt {
|
||||
fn from(item: &PipelineItem) -> Self { ... }
|
||||
}
|
||||
```
|
||||
|
||||
When the CRDT contains data the typed layer can't parse (e.g. a stage value from a future huskies version, OR a merge that produces an inconsistent intermediate state), `try_from` returns a `ProjectionError`. The error surfaces to the caller — it doesn't silently propagate as garbage. The validation happens at exactly one point: the projection boundary.
|
||||
|
||||
## What this subsumes
|
||||
|
||||
**Story 436** ("Unify story stuck states into a single status field") is subsumed by the `Stage::Archived { reason: ArchiveReason }` variant. The unified status field IS the `ArchiveReason` enum. Story 436 is marked superseded by this story.
|
||||
|
||||
## What this enables (concrete bug eliminations)
|
||||
|
||||
- **Bug 502** becomes unrepresentable: there's no way to construct `Stage::Merge` with a Coder agent — Coder agents only attach to `Current` / `Qa` stages, and that constraint is in the type signature of the transition function.
|
||||
- **Bug 510** becomes irrelevant: there's no "stage='1_backlog' filesystem shadow vs stage='5_done' DB" drift, because Stage is a typed enum with a single source of truth (the CRDT), and projections are derived deterministically.
|
||||
- **Bug 519** (mergemaster silent on no-op merge) becomes unrepresentable: `Stage::Merge` requires `commits_ahead: NonZeroU32`. You can't construct a Merge state with zero commits.
|
||||
- **Bug 511** (lamport seq reset) becomes detectable: the projection layer notices when CRDT data fails to parse cleanly and surfaces a ProjectionError instead of silently producing garbage in-memory state.
|
||||
- **Story 479** (CRDT work claiming) has a clean foundation: ExecutionState gives every node visibility into what every other node is doing, including stale-heartbeat detection.
|
||||
- **Future state machine bugs become compile errors**, not runtime drift.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. Define the `Stage`, `ArchiveReason`, `ExecutionState`, `PipelineItem` types in a new module (e.g. `server/src/pipeline_state.rs`).
|
||||
2. Implement the projection layer (try_from / from for PipelineItemCrdt).
|
||||
3. Implement the `transition` function with exhaustive valid transitions.
|
||||
4. Implement the event bus.
|
||||
5. Migrate consumers ONE AT A TIME — chat commands, lifecycle, API, auto-assign, matrix bot. Each migration is isolated; the compiler tells you when you've missed something.
|
||||
6. Once nothing reads the loose `PipelineItemView` anymore, delete it.
|
||||
7. Story 436 closes when this lands.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] A new module (e.g. server/src/pipeline_state.rs) defines the Stage, ArchiveReason, ExecutionState, and PipelineItem types with the variants described in the design
|
||||
- [ ] Stage::Merge has a NonZeroU32 commits_ahead field (so the bug 519 silent no-op merge is unrepresentable)
|
||||
- [ ] Stage::Done has GitSha merge_commit and DateTime merged_at fields (so a 'done' story always has merge metadata)
|
||||
- [ ] ArchiveReason enum subsumes the old blocked / merge_failure / review_hold front matter fields, with a sub-reason variant for each
|
||||
- [ ] PipelineItem.depends_on is Vec<StoryId>, not String (no more JSON-as-string)
|
||||
- [ ] ExecutionState lives in the CRDT under per-node-pubkey keys; each node only writes to its own subspace (validated by CRDT signature check)
|
||||
- [ ] Last_heartbeat field is updated periodically by the running node so other nodes can detect stale entries
|
||||
- [ ] A pure transition(state, event) -> Result<PipelineItem, TransitionError> function exists and is exhaustively pattern-matched
|
||||
- [ ] Every valid transition listed in the design (~10) is implemented and unit-tested with both success and error cases
|
||||
- [ ] The TryFrom<&PipelineItemCrdt> for PipelineItem projection function handles every currently-valid CRDT state and returns a structured ProjectionError for invalid ones (instead of silently propagating garbage)
|
||||
- [ ] An event bus pattern is in place where matrix bot, filesystem renderer, pipeline_items materialiser, auto-assign, and web UI broadcaster are independent subscribers
|
||||
- [ ] All call sites that previously read item.stage as a string or used the blocked / merge_failure / review_hold fields are migrated to the typed enum API
|
||||
- [ ] Story 436 is closed as superseded by this story
|
||||
- [ ] Bug 502 has a regression test that confirms the type system prevents the loop (the test should be a compile-fail test if possible)
|
||||
- [ ] Bug 510 (filesystem shadow split-brain) no longer reproduces after this lands, because the typed state machine has a single source of truth
|
||||
- [ ] Documentation in README.md or a new ARCHITECTURE.md explains the type hierarchy, the transition function, the event bus pattern, and the per-node ExecutionState convention
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: "MCP/HTTP capability to write a CRDT tombstone (delete op) for a story, to clear it from in-memory state"
|
||||
---
|
||||
|
||||
# Story 521: MCP/HTTP capability to write a CRDT tombstone (delete op) for a story, to clear it from in-memory state
|
||||
|
||||
## User Story
|
||||
|
||||
**Note:** content stuffed into user_story per bug 509 workaround.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Today (2026-04-09) we discovered the hard way that **there is no way to remove a story from huskies's running in-memory state without restarting the server process**. The state machines that keep stories alive include:
|
||||
|
||||
1. The persisted CRDT op log (`crdt_ops` table) — direct sqlite DELETE works
|
||||
2. The in-memory CRDT view (`CRDT_STATE` global in `server/src/crdt_state.rs`) — **no eviction API**
|
||||
3. The in-memory content store (`CONTENT_STORE` in `server/src/db/mod.rs:46`) — has `delete_content()` but no MCP / HTTP exposure
|
||||
4. The shadow `pipeline_items` table — direct sqlite DELETE works
|
||||
5. Filesystem shadows under `.huskies/work/` — `find -delete` works
|
||||
6. `timers.json` — direct file edit works
|
||||
|
||||
If a story gets into a bad state (split-brain, ghost row, runaway timer respawning it), we can scrub all the *persistent* layers (1, 4, 5, 6) but the *in-memory* layers (2, 3) keep regenerating it because some periodic code reads in-memory state and writes new ops based on what it sees. The only way to clear in-memory state today is `docker restart huskies`, which is heavy and disrupts the matrix bot, web UI, and any in-flight agents.
|
||||
|
||||
We need a **scoped, surgical capability** to write a CRDT tombstone op for a single story_id, which:
|
||||
- Marks the in-memory item as `is_deleted = true`
|
||||
- Persists the tombstone op to `crdt_ops` so future replays don't resurrect the story
|
||||
- Removes the story from `CONTENT_STORE`
|
||||
- Cleans up any pending `timers.json` entries for the story
|
||||
- Cancels any running agents on the story
|
||||
|
||||
…and exposes it as an MCP tool (e.g. `mcp__huskies__purge_story`) and ideally an HTTP endpoint, so an operator can "kill it with fire" without restarting the server.
|
||||
|
||||
## Real user story
|
||||
|
||||
As a huskies operator, I want a single MCP/HTTP call that completely removes a story from every layer of state — persistent AND in-memory — so I never have to restart the entire server just to clean up one stuck story.
|
||||
|
||||
## Observed 2026-04-09
|
||||
|
||||
We spent the last hour of this session whack-a-moling stories 503 and 478. Even after:
|
||||
- `DELETE FROM pipeline_items WHERE id LIKE '503%'` ✓
|
||||
- `DELETE FROM crdt_ops WHERE op_json LIKE '%503_bug_depends_on%'` ✓
|
||||
- `mcp stop_agent + remove_worktree` for the running coders ✓
|
||||
- `find .huskies/work -name '503_*' -delete` ✓
|
||||
- emptying `timers.json` (multiple times — kept getting re-populated) ✓
|
||||
|
||||
…503 kept reappearing in `current` with new agents being spawned. The root cause: the in-memory `CRDT_STATE` (loaded from `crdt_ops` at startup at 18:19) still had 503 and 478 as live items, and a periodic code path was reading `crdt_state::read_all_items()`, seeing them as live, and triggering the auto-assign / rate-limit-retry chain.
|
||||
|
||||
Final resolution: `docker restart huskies` to wipe the in-memory state. Worked, but it's a sledgehammer.
|
||||
|
||||
## Implementation note
|
||||
|
||||
The bft-json-crdt library appears to support per-item delete via the `is_deleted: bool` field on each CRDT item (visible in the persisted op JSON we inspected today). Writing a delete op should look something like:
|
||||
|
||||
```rust
|
||||
crdt_state::apply_and_persist(&mut state, |s| {
|
||||
s.crdt.doc.items[idx].delete() // or whatever the BFT JSON CRDT delete API is
|
||||
})
|
||||
```
|
||||
|
||||
The op gets signed, applied to the in-memory state (marking the item deleted), and persisted to crdt_ops via the existing channel. Then `read_all_items()` should filter out `is_deleted: true` entries (it may already do this — verify in `extract_item_view`).
|
||||
|
||||
## Why this is distinct from bug 514 (delete_story full cleanup)
|
||||
|
||||
Bug 514 is about making the existing `delete_story` MCP tool do a full cleanup across all the layers we know about. **This** story is specifically about acquiring the *capability* to write a CRDT tombstone — without that, bug 514 can't be implemented correctly because it has no way to clear in-memory state. So 521 is a prerequisite for 514.
|
||||
|
||||
It's also a prerequisite for properly handling the fix for bug 510 (split-brain shadows) — when the reconcile pass detects a stale story, it needs a way to actually evict it. That eviction is what this story provides.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] A new MCP tool (e.g. `mcp__huskies__purge_story`) is registered and callable
|
||||
- [ ] The tool takes a story_id and returns a structured result indicating which layers were cleared (CRDT op, content store, timers, agents, worktree, filesystem)
|
||||
- [ ] The tool writes a signed CRDT tombstone op (is_deleted: true) for the item, applies it to the in-memory CRDT, and persists it to crdt_ops
|
||||
- [ ] After the tool runs, `read_all_items()` does NOT return the purged story (verify the filter handles is_deleted)
|
||||
- [ ] After the tool runs, `read_content(story_id)` returns None (CONTENT_STORE entry is removed)
|
||||
- [ ] After the tool runs, `timers.json` has no entries for the story
|
||||
- [ ] After the tool runs, no agents are running on the story (stop_agent is called for any active ones)
|
||||
- [ ] After the tool runs, the worktree at `.huskies/worktrees/{story_id}/` is removed
|
||||
- [ ] After the tool runs, the filesystem shadow at `.huskies/work/*/{story_id}.md` is removed
|
||||
- [ ] Idempotent: calling purge_story twice on the same story_id is safe and doesn't error
|
||||
- [ ] Bug 514 (delete_story full cleanup) is updated to use this purge capability internally
|
||||
- [ ] Regression test: insert a story via the normal write path, call purge_story, restart the server, verify the story is still gone (i.e. the tombstone persisted correctly)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: "Exclude git worktrees from loc command output"
|
||||
---
|
||||
|
||||
# Story 468: Exclude git worktrees from loc command output
|
||||
|
||||
## User Story
|
||||
|
||||
As a user running the `loc` bot command, I want worktree directories (`.huskies/worktrees/`) to be excluded from the file listing so that the output only shows project source files, not duplicated code from agent worktrees.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] loc command excludes files under .huskies/worktrees/ from line counts
|
||||
- [ ] loc command excludes files under target/ directories from line counts
|
||||
- [ ] Unit test verifies worktree paths are filtered out
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "Scaffold missing rate_limit_notifications and timezone in default project.toml"
|
||||
---
|
||||
|
||||
# Bug 469: Scaffold missing rate_limit_notifications and timezone in default project.toml
|
||||
|
||||
## Description
|
||||
|
||||
The scaffold template for `project.toml` does not include the `rate_limit_notifications` or `timezone` fields. New projects get defaults (notifications on, no timezone), but these settings aren't visible or documented in the generated config file. Users have to discover them manually.
|
||||
|
||||
The 455 rename also stripped these fields from the huskies project's own `project.toml` because it was regenerated from the scaffold.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Run `huskies init` on a new project
|
||||
2. Check the generated `.huskies/project.toml`
|
||||
|
||||
## Actual Result
|
||||
|
||||
No `rate_limit_notifications` or `timezone` fields in generated project.toml.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Both fields present with commented defaults so users know they exist.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "Reduce timer tick interval to 1 second and suppress idle tick logging"
|
||||
---
|
||||
|
||||
# Story 470: Reduce timer tick interval to 1 second and suppress idle tick logging
|
||||
|
||||
## User Story
|
||||
|
||||
As a user scheduling timers, I want the tick loop to check every 1 second instead of 30 so timers fire promptly, without flooding the logs with an entry every second when nothing is due.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Timer tick interval changed from 30 seconds to 1 second
|
||||
- [ ] No log entry on idle ticks (when take_due returns empty)
|
||||
- [ ] Log entry only when a timer actually fires (due list non-empty)
|
||||
- [ ] Startup log line still shows number of pending timers loaded
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Bot command to show overall test coverage"
|
||||
---
|
||||
|
||||
# Story 471: Bot command to show overall test coverage
|
||||
|
||||
## User Story
|
||||
|
||||
As a user on any chat transport (Matrix, WhatsApp, Slack, or web UI), I want a `coverage` command that runs the test suite with coverage instrumentation and reports the overall coverage percentage, so I can track code quality from any interface.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] New bot command `coverage` registered in the command registry
|
||||
- [ ] Runs test coverage (e.g. cargo llvm-cov or cargo tarpaulin) and parses the overall percentage
|
||||
- [ ] Reports overall line coverage percentage in chat response
|
||||
- [ ] Command appears in `help` output
|
||||
- [ ] Returns a clear error if the coverage tool is not installed
|
||||
- [ ] Command works across all transports (Matrix, WhatsApp, Slack, web UI) via the shared command registry
|
||||
- [ ] Available as both a bot command in chat and a slash command in Claude Code
|
||||
- [ ] By default, reads cached coverage from .coverage_baseline for an instant response without rerunning tests
|
||||
- [ ] Optional `coverage run` variant reruns script/test_coverage and reports fresh results
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: "Discord chat transport"
|
||||
agent: coder-opus
|
||||
---
|
||||
|
||||
# Story 472: Discord chat transport
|
||||
|
||||
## User Story
|
||||
|
||||
As a user who uses Discord, I want to control huskies from a Discord server so I can create stories, check status, start agents, and chat with the bot from Discord like I can from Matrix.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Discord transport implements the ChatTransport trait (send_message, send_typing, etc.)
|
||||
- [ ] Bot connects via Discord gateway websocket using serenity crate
|
||||
- [ ] All shared bot commands (status, help, start, unblock, etc.) work from Discord
|
||||
- [ ] Stage transition and block notifications are posted to configured Discord channel(s)
|
||||
- [ ] LLM fallthrough works — non-command messages are forwarded to Claude Code
|
||||
- [ ] Config in bot.toml: discord_token, discord_channel_ids, allowed_users
|
||||
- [ ] Bot only responds when mentioned or in configured channels
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "Split Chat.tsx into smaller components"
|
||||
---
|
||||
|
||||
# Refactor 473: Split Chat.tsx into smaller components
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
Chat.tsx is 1513 lines and growing. ChatInput and ChatHeader are already split out. Break up the remaining monolith into focused components — likely candidates: message list/rendering, websocket connection management, message bubbles, typing indicators, and any other distinct UI concerns.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Chat.tsx reduced to under 500 lines by extracting components
|
||||
- [ ] Message list/rendering extracted into its own component
|
||||
- [ ] Message bubble rendering extracted into its own component
|
||||
- [ ] WebSocket connection logic extracted (hook or provider)
|
||||
- [ ] All existing Chat.test.tsx tests still pass
|
||||
- [ ] No visual or behavioural regressions in the chat UI
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: "Per-file test coverage report with improvement targets"
|
||||
---
|
||||
|
||||
# Story 474: Per-file test coverage report with improvement targets
|
||||
|
||||
## User Story
|
||||
|
||||
As a developer, I want a standardised JSON output format for test coverage so that any language's coverage tool can produce it and huskies can read it without language-specific logic.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Define a standard `.coverage_report.json` format: `{ "overall": float, "threshold": float, "files": [{ "path": string, "coverage": float }] }`
|
||||
- [ ] Update huskies' own `script/test_coverage` to write `.coverage_report.json` in this format (Rust via `cargo llvm-cov --json`, frontend via vitest)
|
||||
- [ ] `coverage` bot command reads `.coverage_report.json` and shows overall percentage plus top 5 lowest-covered files as improvement targets
|
||||
- [ ] Document the `.coverage_report.json` format in `.huskies/README.md` so other projects can produce it from any language
|
||||
- [ ] Huskies server has zero language-specific coverage logic — all intelligence is in the project's `script/test_coverage`
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Deduplicate lifecycle.rs move functions into a shared parameterised helper"
|
||||
---
|
||||
|
||||
# Refactor 475: Deduplicate lifecycle.rs move functions into a shared parameterised helper
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
The move_story_to_current, move_story_to_done, move_story_to_merge, move_story_to_qa, and reject_story_from_qa functions share the same pattern: build paths, check idempotency, find source file in one or more stages, rename, clear front matter fields, log. Extract a shared `move_story()` helper parameterised by source stages, target stage, and fields to clear. The named functions become thin wrappers. The existing `move_story_to_stage` function should also use this shared helper. 27 existing tests provide a safety net.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Single shared move_story helper function parameterised by source stages, target stage, and fields to clear
|
||||
- [ ] All existing named move functions (move_story_to_current, move_story_to_done, move_story_to_merge, move_story_to_qa, reject_story_from_qa) become thin wrappers
|
||||
- [ ] move_story_to_stage also delegates to the shared helper
|
||||
- [ ] All 27 existing tests pass unchanged
|
||||
- [ ] Net reduction of at least 150 lines from lifecycle.rs
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "Split agents/pool/lifecycle.rs into submodules"
|
||||
agent: coder-opus
|
||||
---
|
||||
|
||||
# Refactor 476: Split agents/pool/lifecycle.rs into submodules
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
pool/lifecycle.rs is 1812 lines with 4 public functions (start_agent, stop_agent, wait_for_agent, remove_agents_for_story) plus 29 tests. start_agent is by far the largest — it handles agent selection, worktree creation, process spawning, and completion callbacks. Split into submodules: start.rs (agent start + selection logic), stop.rs (stop + cleanup), wait.rs (wait_for_agent), with tests co-located in each module.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] pool/lifecycle.rs split into submodules (e.g. start.rs, stop.rs, wait.rs)
|
||||
- [ ] Each submodule contains its related tests
|
||||
- [ ] All 29 existing tests pass unchanged
|
||||
- [ ] Public API unchanged — re-export from pool/mod.rs if needed
|
||||
- [ ] No functional changes, purely structural
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: "Scaffold does not generate agents.toml for new projects"
|
||||
---
|
||||
|
||||
# Bug 481: Scaffold does not copy agent definitions from project.toml to new projects
|
||||
|
||||
## Description
|
||||
|
||||
When scaffolding a new project with `huskies init`, the `[[agent]]` definitions from huskies' own `agents.toml` are not included in the new project's config. The scaffold generates a minimal `project.toml` with basic settings (default_qa, max_coders, etc.) but no `agents.toml` file at all.
|
||||
|
||||
Without agent definitions (prompts, system prompts, model, max_turns, budget), the agents run with no guidance — they don't know about the SDTW process, worktrees, feature branches, or the rule about not committing to master. Result: agents make changes directly in master.
|
||||
|
||||
The scaffold should generate a default `agents.toml` with the battle-tested agent definitions (coder, QA, mergemaster) including their prompts and system prompts so new projects get sensible agent behaviour out of the box.
|
||||
|
||||
Note: agent definitions were split from `project.toml` into `agents.toml` in story #482.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Run `huskies init` on a new project
|
||||
2. Check the generated `.huskies/project.toml`
|
||||
3. Note: no `[[agent]]` blocks present
|
||||
4. Start a coder agent on a story
|
||||
5. Agent works directly in master with no worktree, no feature branch, no process
|
||||
|
||||
## Actual Result
|
||||
|
||||
No agent definitions in scaffolded project.toml. Agents run with defaults and make changes directly in master.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Scaffolded project.toml includes default agent definitions (coder, QA, mergemaster) with prompts that enforce the SDTW process, worktrees, and feature branches.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "Split agent definitions from project.toml into agents.toml"
|
||||
---
|
||||
|
||||
# Refactor 482: Split agent definitions from project.toml into agents.toml
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
Move all `[[agent]]` blocks from `.huskies/project.toml` into a separate `.huskies/agents.toml`. The server loads agents from agents.toml and merges with project.toml config. Falls back to inline `[[agent]]` blocks in project.toml for backwards compatibility. The watcher should detect changes to agents.toml and hot-reload. This is a prerequisite for bug 481 (scaffold copies default agents to new projects) — agents.toml becomes the embeddable template.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All [[agent]] blocks moved from .huskies/project.toml to .huskies/agents.toml
|
||||
- [ ] Server loads agent config from agents.toml, falls back to inline [[agent]] in project.toml for backwards compat
|
||||
- [ ] Watcher detects agents.toml changes and triggers hot-reload
|
||||
- [ ] project.toml is significantly smaller (only project settings remain)
|
||||
- [ ] agents.toml is the canonical default template for scaffolding (prerequisite for bug 481)
|
||||
- [ ] All existing agent functionality unchanged
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: "Timer slash command not wired up in web UI"
|
||||
---
|
||||
|
||||
# Bug 483: Timer slash command not wired up in web UI
|
||||
|
||||
## Description
|
||||
|
||||
Three async bot commands are not wired up in the web UI's `bot_command.rs` dispatch: **timer**, **htop**, and **rmtree**. They fall through to `dispatch_sync` which calls the registry stub that returns `None`, resulting in "Unknown command."
|
||||
|
||||
The fix: add async dispatch branches for all three in `dispatch_command`:
|
||||
- `"timer" => dispatch_timer(args, project_root).await`
|
||||
- `"rmtree" => dispatch_rmtree(args, project_root, agents).await`
|
||||
- `"htop"` — either implement a simplified version or return a "not available in web UI" message (htop is a live dashboard designed for Matrix)
|
||||
|
||||
Commands already correctly dispatched: assign, start, delete, rebuild.
|
||||
Reset is handled by the frontend (clears local state) — not needed server-side.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Open the web UI
|
||||
2. Type `/timer list` or `/timer 463 14:00`
|
||||
3. See "Unknown command: /timer"
|
||||
|
||||
## Actual Result
|
||||
|
||||
Unknown command: `/timer`. Type `/help` to see available commands.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Timer command works in the web UI the same as it does via Matrix.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "Story dependencies in pipeline auto-assign"
|
||||
---
|
||||
|
||||
# Story 484: Story dependencies in pipeline auto-assign
|
||||
|
||||
## User Story
|
||||
|
||||
As a user creating stories that depend on each other, I want to specify dependencies in the story front matter so dependent stories stay in backlog until their dependencies are done, then automatically move to current.
|
||||
|
||||
Stories with `depends_on` stay in backlog. A dependency check loop (similar to the timer tick) periodically scans backlog for stories whose dependencies have all reached done/archived. When all deps are met, the story is moved to current and the normal auto-assign picks it up — ensuring the worktree is created from post-dependency master.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] New optional `depends_on` field in story front matter accepts a list of story numbers (e.g. `depends_on: [477, 478]`)
|
||||
- [ ] Stories with unmet dependencies stay in **backlog**, not current
|
||||
- [ ] A dependency check loop (similar to the timer tick loop) periodically scans backlog for stories whose `depends_on` stories have all reached done or archived
|
||||
- [ ] When all deps are met, the loop moves the story from backlog to current — the normal auto-assign then picks it up with a worktree based on post-dependency master
|
||||
- [ ] Status command shows dependency info for stories waiting on deps
|
||||
- [ ] Stories with no depends_on field behave as before (no change)
|
||||
- [ ] Bot command `depends <number> <dep1> [dep2...]` to set dependencies from chat (all transports) and web UI slash command
|
||||
- [ ] Command wired up in bot_command.rs dispatch for web UI and registered in shared command registry for all chat transports
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: "Documentation site for huskies.dev"
|
||||
---
|
||||
|
||||
# Story 485: Documentation site for huskies.dev
|
||||
|
||||
## User Story
|
||||
|
||||
As a new user discovering huskies, I want documentation on huskies.dev so I can learn how to set up and use the tool without having to read the source code.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Docs site lives under website/ directory alongside the existing landing page
|
||||
- [ ] Accessible from huskies.dev (e.g. huskies.dev/docs/ or docs.huskies.dev)
|
||||
- [ ] Navigation link added to the main site header
|
||||
- [ ] Getting started / quickstart guide (Docker setup, first story, first agent run)
|
||||
- [ ] Configuration reference: project.toml, agents.toml, bot.toml
|
||||
- [ ] Bot commands reference (auto-generated from command registry or manually maintained)
|
||||
- [ ] Pipeline stages explained (backlog, current, QA, merge, done)
|
||||
- [ ] Chat transports guide (Matrix, WhatsApp, Slack, web UI)
|
||||
- [ ] CLI reference (huskies, huskies init, huskies agent)
|
||||
- [ ] Matches the visual style of the existing landing page
|
||||
- [ ] Static HTML — no build step required
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: "Display story dependencies in web UI and chat commands"
|
||||
---
|
||||
|
||||
# Story 487: Display story dependencies in web UI and chat commands
|
||||
|
||||
## User Story
|
||||
|
||||
As a user managing stories with dependencies, I want to see dependency information in the web UI story panel and in chat/slash command output, so I can understand which stories are blocked and why without reading the raw markdown files.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] TODO
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "SQLite shadow write for pipeline state via sqlx"
|
||||
agent: "coder-opus"
|
||||
---
|
||||
|
||||
# Story 489: SQLite shadow write for pipeline state via sqlx
|
||||
|
||||
## User Story
|
||||
|
||||
As a developer, I want pipeline state dual-written to SQLite (via sqlx) alongside the existing filesystem directories, so we have a database layer ready for CRDT integration without changing any existing behaviour.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Add sqlx with SQLite feature as a dependency
|
||||
- [ ] Migrations embedded at compile time via sqlx::migrate! macro, run on startup
|
||||
- [ ] Schema uses backend-agnostic SQL (TEXT, INTEGER, no vendor-specific types) so migrations work on both SQLite and Postgres
|
||||
- [ ] Every move_story_to_X and pipeline state change writes to both .huskies/work/ directories AND SQLite
|
||||
- [ ] Reads still come from the filesystem (SQLite is shadow-only)
|
||||
- [ ] SQLite database stored at .huskies/pipeline.db
|
||||
- [ ] pipeline_items table: id, name, stage, agent, retry_count, blocked, depends_on, created_at, updated_at
|
||||
- [ ] All existing pipeline operations work unchanged from the user's perspective
|
||||
- [ ] agent: coder-opus
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "CRDT state layer backed by SQLite"
|
||||
agent: coder-opus
|
||||
depends_on: [489]
|
||||
---
|
||||
|
||||
# Story 490: CRDT state layer backed by SQLite
|
||||
|
||||
## User Story
|
||||
|
||||
As a developer, I want the BFT JSON CRDT document backed by SQLite for persistence, so CRDT ops survive restarts and the state layer is ready for multi-node sync.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] BFT CRDT crate (crates/bft-json-crdt/) integrated into the server
|
||||
- [ ] CRDT ops persisted to SQLite via sqlx (backend-agnostic schema)
|
||||
- [ ] Pipeline state reads switch from filesystem to CRDT document
|
||||
- [ ] Pipeline state writes go through CRDT ops (which persist to SQLite)
|
||||
- [ ] Filesystem .huskies/work/ directories still updated as a secondary output for backwards compat during transition
|
||||
- [ ] CRDT state reconstructed from SQLite on startup
|
||||
- [ ] agent: coder-opus
|
||||
- [ ] depends_on: [489]
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Watcher fires on CRDT state transitions instead of filesystem events"
|
||||
agent: coder-opus
|
||||
depends_on: [490]
|
||||
---
|
||||
|
||||
# Story 491: Watcher fires on CRDT state transitions instead of filesystem events
|
||||
|
||||
## User Story
|
||||
|
||||
As a developer, I want the auto-assign loop, notifications, and WebSocket UI updates to subscribe to CRDT state transitions instead of filesystem directory watches.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Auto-assign triggers on CRDT state change (story entering a stage) instead of filesystem watcher
|
||||
- [ ] Stage transition notifications fire from CRDT state changes
|
||||
- [ ] WebSocket UI updates fire from CRDT state changes
|
||||
- [ ] The filesystem watcher for .huskies/work/ is removed or deprecated
|
||||
- [ ] Timer tick loop and dependency tick loop read from CRDT state
|
||||
- [ ] agent: coder-opus
|
||||
- [ ] depends_on: [490]
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Remove filesystem pipeline state and store story content in database"
|
||||
agent: coder-opus
|
||||
depends_on: [491]
|
||||
---
|
||||
|
||||
# Story 492: Remove filesystem pipeline state and store story content in database
|
||||
|
||||
## User Story
|
||||
|
||||
As a developer, I want to remove the .huskies/work/ stage directories entirely and store story content (markdown, front matter) in the database, so the CRDT + SQLite is the sole source of truth.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Story markdown content and front matter stored in SQLite (backend-agnostic schema)
|
||||
- [ ] .huskies/work/ stage directories no longer used for pipeline state or story storage
|
||||
- [ ] All pipeline operations (create, move, read, delete stories) work against the database
|
||||
- [ ] Migration path for existing projects: on startup, import any .huskies/work/ stories into the DB and archive the directories
|
||||
- [ ] Worktrees and config files (project.toml, agents.toml, bot.toml) remain on filesystem
|
||||
- [ ] agent: coder-opus
|
||||
- [ ] depends_on: [491]
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: "Story dependency chain not firing due to front matter format issues"
|
||||
---
|
||||
|
||||
# Bug 493: Story dependency chain not firing due to front matter format issues
|
||||
|
||||
## Description
|
||||
|
||||
Two issues prevent the dependency tick loop from working:
|
||||
|
||||
1. **create_story puts depends_on in AC text, not front matter**: When `depends_on: [489]` is passed as an acceptance criterion string, it ends up as a checkbox item (`- [ ] depends_on: [489]`) instead of YAML front matter. Stories 490, 491, 492 are affected.
|
||||
|
||||
2. **update_story stores depends_on as a quoted string instead of YAML array**: The `front_matter` parameter serializes `[490]` as the string `"[490]"` instead of the YAML array `[490]`. Stories 478, 479, 480 are affected — their front matter shows `depends_on: "[490]"` instead of `depends_on: [490]`.
|
||||
|
||||
The dependency tick loop reads `depends_on` from YAML front matter and expects an integer array. Neither format matches.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Create a story with `depends_on: [489]` in acceptance criteria
|
||||
2. Or use update_story with `front_matter: {"depends_on": "[490]"}`
|
||||
3. Check the generated front matter
|
||||
4. Observe dependency tick loop does not promote the story
|
||||
|
||||
## Actual Result
|
||||
|
||||
depends_on either missing from front matter (in AC text as checkbox) or stored as quoted string instead of YAML array.
|
||||
|
||||
## Expected Result
|
||||
|
||||
depends_on stored as a proper YAML array in front matter: `depends_on: [489]`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: "MCP tool to run project test suite"
|
||||
---
|
||||
|
||||
# Story 494: MCP tool to run project test suite
|
||||
|
||||
## User Story
|
||||
|
||||
As an LLM agent or web UI user, I want an MCP tool that runs the project's test suite so I can verify code changes without shelling out to Bash.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] New MCP tool `run_tests` that executes `script/test` and returns pass/fail with output
|
||||
- [ ] Available as a bot command (`test`) in all chat transports
|
||||
- [ ] Available as a slash command (`/test`) in the web UI
|
||||
- [ ] Returns structured result: pass/fail, test count, and truncated output for failures
|
||||
- [ ] Runs in the project root (or optionally in a specified worktree path)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: "Status traffic light dots use unsupported HTML colouring - switch to emoji"
|
||||
---
|
||||
|
||||
# Bug 495: Status traffic light dots use unsupported HTML colouring - switch to emoji
|
||||
|
||||
## Description
|
||||
|
||||
The status command uses Unicode dots (●, ◑, ✗, ○) with `<font data-mx-color>` HTML tags for colouring. Element X (and most modern Matrix clients) doesn't support inline text colouring via any HTML method — not `data-mx-color`, not `style="color:"`, nothing.
|
||||
|
||||
Switch to coloured emoji which render natively in all clients:
|
||||
- 🟢 running normally (was ● green)
|
||||
- 🟠 throttled/rate limited (was ◑ orange)
|
||||
- 🔴 blocked (was ✗ red)
|
||||
- ⚪ idle / no agent (was ○ grey)
|
||||
|
||||
Remove the `build_pipeline_status_html` colour-wrapping logic since it's dead code with emoji.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Run `@timmy status` in Element X
|
||||
2. Observe dots are not coloured
|
||||
|
||||
## Actual Result
|
||||
|
||||
Plain uncoloured Unicode dots.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Coloured indicators visible in all Matrix clients.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: "Hard rate limit without reset_at never auto-schedules retry"
|
||||
---
|
||||
|
||||
# Bug 496: Hard rate limit without reset_at never auto-schedules retry
|
||||
|
||||
## Description
|
||||
|
||||
When the API returns a hard rate limit block (`status=rejected`) without a `reset_at` timestamp, `pty.rs` downgrades it to a `RateLimitWarning` instead of a `RateLimitHardBlock`. The auto-scheduler only listens for `RateLimitHardBlock` events, so no timer is set and the agent is never restarted. The agent sits idle until the 300s inactivity timeout kills it, and the story is stuck.
|
||||
|
||||
In practice, most hard blocks come without `reset_at` (as seen in the logs: "no reset_at in rate_limit_info"). This means the auto-resume feature from story 423 almost never fires.
|
||||
|
||||
Fix: when there's a hard block without `reset_at`, either:
|
||||
1. Send `RateLimitHardBlock` with a default backoff time (e.g. `Utc::now() + 5 minutes`)
|
||||
2. Or add a separate retry mechanism that doesn't depend on knowing the exact reset time
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Run an agent that hits the API rate limit
|
||||
2. Observe logs show "no reset_at in rate_limit_info"
|
||||
3. Agent gets killed by inactivity timeout
|
||||
4. Story sits in current with no agent, never restarted
|
||||
|
||||
## Actual Result
|
||||
|
||||
Hard block without reset_at is downgraded to RateLimitWarning. No timer set. Agent dies and story is stuck.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Hard block without reset_at triggers a retry with a default backoff (e.g. 5 minutes). Agent is automatically restarted when the backoff expires.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: "Dependency promotion loop missing — stories with met deps never move from backlog to current"
|
||||
---
|
||||
|
||||
# Bug 497: Dependency promotion loop missing — stories with met deps never move from backlog to current
|
||||
|
||||
## Description
|
||||
|
||||
Story 484 implemented dependency checking in auto-assign (skip stories with unmet deps) but did NOT implement the backlog-to-current promotion loop. Stories with `depends_on` that have all deps met sit in backlog forever — nothing moves them to current.
|
||||
|
||||
The AC for 484 specified: "A dependency check loop (similar to the timer tick loop) periodically scans backlog for stories whose depends_on stories have all reached done or archived. When all deps are met, the loop moves the story from backlog to current."
|
||||
|
||||
This loop was never built. Need a periodic tick (like the timer tick) that scans backlog, checks `depends_on` against done/archived, and promotes ready stories to current.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
1. Create story A with no deps, create story B with `depends_on: [A]`
|
||||
2. Both in backlog
|
||||
3. Move A to current, let it complete through to done
|
||||
4. Observe B stays in backlog forever
|
||||
|
||||
## Actual Result
|
||||
|
||||
Story B stays in backlog despite all deps being met.
|
||||
|
||||
## Expected Result
|
||||
|
||||
Story B is automatically promoted from backlog to current once story A reaches done.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Bug is fixed and verified
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: "Web UI shows project name in browser tab with huskies favicon"
|
||||
---
|
||||
|
||||
# Story 499: Web UI shows project name in browser tab with huskies favicon
|
||||
|
||||
## User Story
|
||||
|
||||
As a user running huskies on multiple projects, I want the browser tab to show the project name instead of hardcoded "Huskies", and I want a huskies favicon, so I can distinguish tabs and have proper branding.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Browser tab title shows the project folder name when a project is open (e.g. 'reclaimer | Huskies')
|
||||
- [ ] Browser tab title shows 'Huskies' when no project is open
|
||||
- [ ] A huskies-themed SVG favicon is served and shown in the browser tab
|
||||
- [ ] The Vite default favicon is replaced by the huskies favicon
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
Generated
+1770
-142
File diff suppressed because it is too large
Load Diff
+15
-5
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["server"]
|
||||
members = ["server", "crates/bft-json-crdt", "crates/source-map-gen"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -17,19 +17,23 @@ notify = "8.2.0"
|
||||
poem = { version = "3", features = ["websocket", "test"] }
|
||||
poem-openapi = { version = "5", features = ["swagger-ui"] }
|
||||
portable-pty = "0.9.0"
|
||||
reqwest = { version = "0.13.2", features = ["json", "stream"] }
|
||||
reqwest = { version = "0.13.3", features = ["json", "stream"] }
|
||||
rust-embed = "8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_urlencoded = "0.7"
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.11.0"
|
||||
hmac = "0.13"
|
||||
subtle = "2"
|
||||
base64 = "0.22"
|
||||
serde_yaml = "0.9"
|
||||
strip-ansi-escapes = "0.2"
|
||||
tempfile = "3"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||
toml = "1.1.0"
|
||||
uuid = { version = "1.22.0", features = ["v4", "serde"] }
|
||||
tokio-tungstenite = "0.29.0"
|
||||
toml = "1.1.2"
|
||||
uuid = { version = "1.23.1", features = ["v4", "serde"] }
|
||||
tokio-tungstenite = { version = "0.29.0", features = ["connect", "rustls-tls-native-roots"] }
|
||||
walkdir = "2.5.0"
|
||||
filetime = "0.2"
|
||||
matrix-sdk = { version = "0.16.0", default-features = false, features = [
|
||||
@@ -42,3 +46,9 @@ pulldown-cmark = { version = "0.13.3", default-features = false, features = [
|
||||
] }
|
||||
regex = "1"
|
||||
libc = "0.2"
|
||||
sqlx = { version = "=0.9.0-alpha.1", default-features = false, features = [
|
||||
"runtime-tokio",
|
||||
"sqlite",
|
||||
"macros",
|
||||
"migrate",
|
||||
] }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Huskies
|
||||
|
||||
A story-driven development server that manages work items, spawns coding agents, and runs them through a pipeline from backlog to done. Ships as a single Rust binary with an embedded React frontend.
|
||||
A story-driven development server that manages work items, spawns coding agents, and runs them through a pipeline from backlog to done. Ships as a single Rust binary with an embedded React frontend. Can also be run in WhatsApp, Matrix, and Slack chats.
|
||||
|
||||
## Getting started with Claude Code
|
||||
|
||||
1. Download the huskies binary (or build from source — see below).
|
||||
1. Download the huskies binary (or build from source — see below). Add it to your $PATH.
|
||||
|
||||
2. From your project directory, scaffold and start the server:
|
||||
|
||||
@@ -14,16 +14,14 @@ huskies init --port 3000
|
||||
|
||||
This creates a `.huskies/` directory with the pipeline structure, `project.toml`, and `.mcp.json`. The `.mcp.json` file lets Claude Code discover huskies' MCP tools automatically.
|
||||
|
||||
3. Open a Claude Code session in the same project directory. Claude will pick up the MCP tools from `.mcp.json`.
|
||||
Huskies also ships an embedded React frontend. Once the server is running, open `http://localhost:3000` to see the pipeline board, agent status, and chat interface.
|
||||
|
||||
3. Open a Claude Code session in the same project directory, or visit http://localhost:3000/.
|
||||
|
||||
4. Tell Claude: "help me set up this project with huskies." Claude will walk you through the setup wizard — generating project context, tech stack docs, and test/release scripts. Review each step and confirm or ask to retry.
|
||||
|
||||
Once setup is complete, Claude can create stories, start agents, check status, and manage the full pipeline via MCP tools — no commands to memorize.
|
||||
|
||||
## Web UI
|
||||
|
||||
Huskies also ships an embedded React frontend. Once the server is running, open `http://localhost:3000` to see the pipeline board, agent status, and chat interface.
|
||||
|
||||
## Chat transports
|
||||
|
||||
Huskies can be controlled via bot commands in **Matrix**, **WhatsApp**, and **Slack**. Configure a transport in `.huskies/bot.toml` — see the example files:
|
||||
@@ -33,12 +31,14 @@ Huskies can be controlled via bot commands in **Matrix**, **WhatsApp**, and **Sl
|
||||
- `.huskies/bot.toml.whatsapp-twilio.example`
|
||||
- `.huskies/bot.toml.slack.example`
|
||||
|
||||
## Prerequisites
|
||||
## Prerequisites for building
|
||||
|
||||
- Rust (2024 edition)
|
||||
- Node.js and npm
|
||||
- Docker (for Linux cross-compilation and container deployment)
|
||||
- `cross` (`cargo install cross`) for Linux static builds
|
||||
- `cross` (`cargo install cross`) optional, for Linux static builds. Only needed if you are building for a different architecture, e.g. if you want to build a Linux binary from a Mac.
|
||||
|
||||
You only need these installed if you want to build Huskies yourself. Alternately, you can just download and run the `huskies` binary for your system from https://code.crashlabs.io/crashlabs/huskies/releases
|
||||
|
||||
## Building for production
|
||||
|
||||
@@ -57,7 +57,11 @@ cross build --release --target x86_64-unknown-linux-musl
|
||||
Docker:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml build
|
||||
script/docker_rebuild
|
||||
|
||||
# or
|
||||
|
||||
script/docker_restart
|
||||
```
|
||||
|
||||
## Running in development
|
||||
@@ -75,6 +79,13 @@ cd frontend && npm install && npm run dev
|
||||
|
||||
Configuration lives in `.huskies/project.toml`. See `.huskies/bot.toml.*.example` for transport setup.
|
||||
|
||||
## Architecture
|
||||
|
||||
Internal architecture documentation lives in [`docs/architecture/`](docs/architecture/):
|
||||
|
||||
- [Service module conventions](docs/architecture/service-modules.md) — layout, layering rules, and patterns for `server/src/service/`
|
||||
- [Future extraction targets](docs/architecture/future-extractions.md) — recommended order for remaining handler extractions
|
||||
|
||||
## Releasing
|
||||
|
||||
Requires a Gitea API token in `.env` (`GITEA_TOKEN=your_token`).
|
||||
@@ -85,6 +96,137 @@ script/release 0.7.1
|
||||
|
||||
This bumps version in `Cargo.toml` and `package.json`, builds macOS arm64 and Linux amd64 binaries, tags the repo, and publishes a Gitea release with changelog and binaries attached.
|
||||
|
||||
## License
|
||||
## Multi-node CRDT sync (rendezvous)
|
||||
|
||||
Huskies nodes can replicate pipeline state in real-time over WebSocket. Add a
|
||||
`rendezvous` field to `.huskies/project.toml` to configure a peer:
|
||||
|
||||
```toml
|
||||
rendezvous = "ws://other-host:3001/crdt-sync"
|
||||
```
|
||||
|
||||
On startup, this node opens an outbound WebSocket connection to the configured
|
||||
URL and exchanges CRDT ops bidirectionally. The connection is fully symmetric:
|
||||
both sides send a bulk state dump on connect, then stream individual ops as they
|
||||
are applied locally.
|
||||
|
||||
### Reconnect behaviour
|
||||
|
||||
If the peer is unreachable on startup (or the connection drops mid-session), the
|
||||
client retries with exponential backoff starting at 1 s and capping at 30 s.
|
||||
Failures are logged at **WARN**; after 10 consecutive failures the level escalates
|
||||
to **ERROR** to surface persistent connectivity problems.
|
||||
|
||||
### Deployment topologies
|
||||
|
||||
**Peer-to-peer (two nodes pointing at each other):**
|
||||
|
||||
```
|
||||
Node A ←→ Node B
|
||||
```
|
||||
|
||||
Configure each node with the other's `/crdt-sync` URL. Both nodes exchange ops
|
||||
directly. Supported by this story — ops propagate in both directions and both
|
||||
nodes converge to the same state. Works well for two machines collaborating on
|
||||
the same project.
|
||||
|
||||
**Hub-and-spoke (many clients → one central rendezvous):**
|
||||
|
||||
```
|
||||
Client 1 ──┐
|
||||
Client 2 ──┤── Hub node
|
||||
Client 3 ──┘
|
||||
```
|
||||
|
||||
Point multiple client nodes at a single "hub" node. The hub receives ops from
|
||||
all clients and re-broadcasts them. Clients do *not* connect to each other —
|
||||
convergence is mediated through the hub. The hub itself runs a normal huskies
|
||||
instance with `rendezvous` unset (it only accepts inbound connections).
|
||||
|
||||
> **Caveat:** Hub-to-client relay depends on the hub's `/crdt-sync` inbound
|
||||
> WebSocket handler re-broadcasting every received op to all other connected
|
||||
> peers. That broadcast happens automatically via the shared `SYNC_TX` channel
|
||||
> (each locally-applied remote op is re-emitted), so hub-and-spoke works today
|
||||
> but has not been load-tested. Follow-up work may be needed for large fan-out
|
||||
> (many spoke clients) to avoid broadcast-channel lag.
|
||||
|
||||
## Startup reconcile pass
|
||||
|
||||
On startup, after CRDT replay and database initialisation, huskies runs a
|
||||
**reconcile pass** that compares pipeline state across three sources:
|
||||
|
||||
1. **In-memory CRDT** — the primary source of truth, reconstructed from
|
||||
`crdt_ops` on startup.
|
||||
2. **`pipeline_items` table** — a shadow/materialised view written alongside
|
||||
CRDT updates, used for fast DB queries.
|
||||
3. **Filesystem shadows** (`.huskies/work/N_stage/*.md`) — legacy rendering
|
||||
still written by some paths and read by agent worktrees.
|
||||
|
||||
Any disagreement between these sources is **drift**. The reconcile pass logs a
|
||||
structured line for each drifted item:
|
||||
|
||||
```
|
||||
[reconcile] DRIFT story=X crdt_stage=Y db_stage=Z fs_stage=W
|
||||
```
|
||||
|
||||
(`MISSING` is used where a source has no record for that story.)
|
||||
|
||||
### Drift types
|
||||
|
||||
| Type | Meaning |
|
||||
|------|---------|
|
||||
| `CRDT-only` | Story present in CRDT but absent from `pipeline_items` |
|
||||
| `DB-only` | Story present in `pipeline_items` but absent from CRDT |
|
||||
| `FS-only` | Story on the filesystem but absent from both CRDT and DB |
|
||||
| `stage-mismatch` | Story present in both CRDT and DB but with different stage values |
|
||||
|
||||
Note: a filesystem shadow that lags behind the CRDT/DB stage (both of which
|
||||
agree) is **not** treated as drift — the FS is a best-effort rendering and is
|
||||
allowed to lag.
|
||||
|
||||
If any drift is detected, the Matrix/Slack/WhatsApp bot startup announcement
|
||||
includes a count and a suggestion to check the server logs.
|
||||
|
||||
### Opt-out
|
||||
|
||||
Set `reconcile_on_startup = false` in `.huskies/project.toml` to disable the
|
||||
pass during the migration window if it produces noise.
|
||||
## Debugging
|
||||
|
||||
### Inspecting the in-memory CRDT state
|
||||
|
||||
When diagnosing state issues, use the `dump_crdt` MCP tool or the `/debug/crdt` HTTP endpoint to inspect the raw in-memory CRDT state directly. These surfaces show the ground truth that the running server holds — not a summarised pipeline view and not the persisted SQLite ops.
|
||||
|
||||
**MCP tool** (from Claude Code or any MCP client):
|
||||
|
||||
```
|
||||
mcp__huskies__dump_crdt
|
||||
# dump everything
|
||||
{}
|
||||
|
||||
# restrict to a single item
|
||||
{"story_id": "42_story_my_feature"}
|
||||
```
|
||||
|
||||
**HTTP endpoint** (browser or curl):
|
||||
|
||||
```bash
|
||||
# dump everything
|
||||
curl http://localhost:3001/debug/crdt
|
||||
|
||||
# restrict to a single item
|
||||
curl "http://localhost:3001/debug/crdt?story_id=42_story_my_feature"
|
||||
```
|
||||
|
||||
Both return a JSON document with:
|
||||
|
||||
- **`metadata`** — `in_memory_state_loaded`, `total_items`, `total_ops_in_list`, `max_seq_in_list`, `persisted_ops_count`, `pending_persist_ops_count`
|
||||
- **`items`** — one entry per CRDT list item (including tombstoned/deleted entries), each with `story_id`, `stage`, `name`, `agent`, `retry_count`, `blocked`, `depends_on`, `content_index` (hex OpId for cross-referencing with `crdt_ops`), and `is_deleted`
|
||||
|
||||
> **This is a debug tool.** For normal pipeline introspection use `get_pipeline_status` or `GET /api/pipeline` instead.
|
||||
|
||||
## Source Map
|
||||
|
||||
See `.huskies/specs/tech/STACK.md` for the full source map.
|
||||
|
||||
GPL-3.0. See [LICENSE](LICENSE).
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
*.js linguist-generated
|
||||
*.json linguist-generated
|
||||
@@ -0,0 +1 @@
|
||||
target
|
||||
Generated
+1924
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "bft-json-crdt"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["lib"]
|
||||
|
||||
[features]
|
||||
default = ["bft", "logging-list", "logging-json"]
|
||||
logging-list = ["logging-base"]
|
||||
logging-json = ["logging-base"]
|
||||
logging-base = []
|
||||
bft = []
|
||||
|
||||
[dependencies]
|
||||
bft-crdt-derive = { path = "bft-crdt-derive" }
|
||||
colored = "2.0.0"
|
||||
fastcrypto = "0.1.9"
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
rand = "0.8"
|
||||
random_color = "0.6.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0.85", features = ["preserve_order"] }
|
||||
serde_with = "3.18"
|
||||
sha2 = "0.10.6"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0.85", features = ["preserve_order"] }
|
||||
|
||||
[[bench]]
|
||||
name = "speed"
|
||||
harness = false
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Jacky Zhao
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Byzantine Fault Tolerant CRDTs
|
||||
|
||||
This work is mainly inspired by implementing Martin Kleppmann's 2022 paper on *Making CRDTs Byzantine Fault Tolerant*[^2]
|
||||
on top of a simplified [Automerge](https://automerge.org/) implementation.
|
||||
|
||||
The goal is to show a working prototype that demonstrated in simple code the ideas behind
|
||||
1. An Automerge-like CRDT
|
||||
2. How a primitive list CRDT can be composed to create complex CRDTs like JSON
|
||||
2. How to add Byzantine Fault Tolerance to arbitrary CRDTs
|
||||
|
||||
Unlike most other CRDT implementations, I leave out many performance optimizations that would make the basic algorithm harder to understand.
|
||||
|
||||
Check out the [accompanying blog post for this project!](https://jzhao.xyz/posts/bft-json-crdt)
|
||||
|
||||
## Benchmarks
|
||||
Although this implementation does not optimize for performance, it still nonetheless performs quite well.
|
||||
|
||||
Benchmarking happened on a 2019 MacBook Pro with a 2.6GHz i7.
|
||||
Numbers are compared to Automerge which report their performance benchmarks [here](https://github.com/automerge/automerge-perf)
|
||||
|
||||
| # Ops | Raw String (JS) | Ours (basic) | Ours (BFT) | Automerge (JS) | Automerge (Rust) |
|
||||
|--|--|--|--|--|--|
|
||||
|10k | n/a | 0.081s | 1.793s | 1.6s | 0.047s |
|
||||
|100k | n/a | 9.321s | 38.842s | 43.0s | 0.597s |
|
||||
|All (259k)| 0.61s | 88.610s | 334.960s | Out of Memory| 1.780s |
|
||||
|Memory | 0.1MB | 27.6MB | 59.5MB | 880MB | 232.5MB |
|
||||
|
||||
## Flamegraph
|
||||
To get some flamegraphs of the time graph on MacOS, run:
|
||||
|
||||
```bash
|
||||
sudo cargo flamegraph --dev --root --bench speed
|
||||
```
|
||||
|
||||
## Further Work
|
||||
This is mostly a learning/instructional project but there are a few places where performance improvements are obvious:
|
||||
|
||||
1. This is backed by `std::Vec` which isn't great for random insert. Replace with a B-tree or something that provides better insert and find performance
|
||||
1. [Diamond Types](https://github.com/josephg/diamond-types) and [Automerge (Rust)](https://github.com/automerge/automerge-rs) use a B-tree
|
||||
2. Yjs is backed by a doubly linked-list and caches last ~5-10 accessed locations (assumes that most edits happen sequentially; seeks are rare)
|
||||
3. (funnily enough, main performance hit is dominated by find and not insert, see [this flamegraph](./flamegraphs/flamegraph_unoptimized.svg))
|
||||
2. Avoid calling `find` so many times. A few Automerge optimizations that were not implemented
|
||||
1. Use an index hint (especially for local inserts)
|
||||
2. Skipping the second `find` operation in `integrate` if sequence number is already larger
|
||||
3. Improve storage requirement. As of now, a single `Op` weighs in at *over* 168 bytes. This doesn't even fit in a single cache line!
|
||||
4. Implement 'transactions' for a group of changes that should be considered atomic.
|
||||
1. This would also speed up Ed25519 signature verification time by batching.
|
||||
2. For example, a peer might create an atomic 'transaction' that contains a bunch of changes.
|
||||
5. Currently, each character is a single op. Similar to Yjs, we can combine runs of characters into larger entities like what André, Luc, et al.[^1] suggest
|
||||
6. Implement proper persistence using SQLLite or something similar
|
||||
7. Compile the project to WASM and implement a transport layer so it can be used in browser. Something similar to [Yjs' WebRTC Connector](https://github.com/yjs/y-webrtc) could work.
|
||||
|
||||
[^1]: André, Luc, et al. "Supporting adaptable granularity of changes for massive-scale collaborative editing." 9th IEEE International Conference on Collaborative Computing: Networking, Applications and Worksharing. IEEE, 2013.
|
||||
[^2]: Kleppmann, Martin. "Making CRDTs Byzantine Fault Tolerant." Proceedings of the 9th Workshop on Principles and Practice of Consistency for Distributed Data. 2022.
|
||||
|
||||
## Acknowledgements
|
||||
Thank you to [Nalin Bhardwaj](https://nibnalin.me/) for helping me with my cryptography questions and [Martin Kleppmann](https://martin.kleppmann.com/)
|
||||
for his teaching materials and lectures which taught me a significant portion of what I've learned about distributed systems and CRDTs.
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Benchmarks for BFT JSON CRDT operation throughput.
|
||||
use bft_json_crdt::{
|
||||
json_crdt::JsonValue, keypair::make_author, list_crdt::ListCrdt, op::Op, op::ROOT_ID,
|
||||
};
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use rand::seq::SliceRandom;
|
||||
|
||||
fn bench_insert_100_root(c: &mut Criterion) {
|
||||
c.bench_function("bench insert 100 root", |b| {
|
||||
b.iter(|| {
|
||||
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
|
||||
for i in 0..100 {
|
||||
list.insert(ROOT_ID, i);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_insert_100_linear(c: &mut Criterion) {
|
||||
c.bench_function("bench insert 100 linear", |b| {
|
||||
b.iter(|| {
|
||||
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
|
||||
let mut prev = ROOT_ID;
|
||||
for i in 0..100 {
|
||||
let op = list.insert(prev, i);
|
||||
prev = op.id;
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_insert_many_agents_conflicts(c: &mut Criterion) {
|
||||
c.bench_function("bench insert many agents conflicts", |b| {
|
||||
b.iter(|| {
|
||||
const N: u8 = 10;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut crdts: Vec<ListCrdt<i64>> = Vec::with_capacity(N as usize);
|
||||
let mut logs: Vec<Op<JsonValue>> = Vec::new();
|
||||
for i in 0..N {
|
||||
let list = ListCrdt::new(make_author(i), vec![]);
|
||||
crdts.push(list);
|
||||
for _ in 0..5 {
|
||||
let op = crdts[i as usize].insert(ROOT_ID, i as i32);
|
||||
logs.push(op);
|
||||
}
|
||||
}
|
||||
|
||||
logs.shuffle(&mut rng);
|
||||
for op in logs {
|
||||
for c in &mut crdts {
|
||||
if op.author() != c.our_id {
|
||||
c.apply(op.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(crdts.windows(2).all(|w| w[0].view() == w[1].view()));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_insert_100_root,
|
||||
bench_insert_100_linear,
|
||||
bench_insert_many_agents_conflicts
|
||||
);
|
||||
criterion_main!(benches);
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "bft-crdt-derive"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86f0b0d4bf799edbc74508c1e8bf170ff5f41238e5f8225603ca7caaae2b7860"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eda0fc3b0fb7c975631757e14d9049da17374063edb6ebbcbc54d880d4fe94e9"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"thiserror",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ea3d908b0e36316caf9e9e2c4625cdde190a7e6f440d794667ed17a1855e725"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.147"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.103"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a864042229133ada95abf3b54fdc62ef5ccabe9515b64717bcb9a1919e59445d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10deb33631e3c9018b9baf9dcbbc4f737320d2b576bac10f6aefa048fa407e3e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "982d17546b47146b28f7c22e3d08465f6b8903d0ea13c1660d9d84a6e7adcdbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ceab39d59e4c9499d4e5a8ee0e2735b891bb7308ac83dfb4e80cad195c9f6f3"
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "bft-crdt-derive"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
indexmap = { version = "2.2.6", features = ["serde"] }
|
||||
proc-macro2 = "1.0.47"
|
||||
proc-macro-crate = "3"
|
||||
quote = "1.0.21"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Procedural macros for the BFT JSON CRDT library.
|
||||
//!
|
||||
//! Provides `#[add_crdt_fields]` to inject `path` and `id` fields into a struct,
|
||||
//! and `#[derive(CrdtNode)]` to auto-implement the [`CrdtNode`] trait for structs
|
||||
//! whose fields are themselves [`CrdtNode`]s.
|
||||
|
||||
use proc_macro::TokenStream as OgTokenStream;
|
||||
use proc_macro2::{Ident, Span, TokenStream};
|
||||
use proc_macro_crate::{crate_name, FoundCrate};
|
||||
use quote::{quote, quote_spanned, ToTokens};
|
||||
use syn::{
|
||||
parse::{self, Parser},
|
||||
parse_macro_input,
|
||||
spanned::Spanned,
|
||||
Data, DeriveInput, Field, Fields, ItemStruct, LitStr, Type,
|
||||
};
|
||||
|
||||
/// Helper to get tokenstream representing the parent crate
|
||||
fn get_crate_name() -> TokenStream {
|
||||
let cr8 = crate_name("bft-json-bft-crdt").unwrap_or(FoundCrate::Itself);
|
||||
match cr8 {
|
||||
FoundCrate::Itself => quote! { ::bft_json_crdt },
|
||||
FoundCrate::Name(name) => {
|
||||
let ident = Ident::new(&name, Span::call_site());
|
||||
quote! { ::#ident }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proc macro to insert a keypair and path field on a given struct
|
||||
#[proc_macro_attribute]
|
||||
pub fn add_crdt_fields(args: OgTokenStream, input: OgTokenStream) -> OgTokenStream {
|
||||
let mut input = parse_macro_input!(input as ItemStruct);
|
||||
let crate_name = get_crate_name();
|
||||
let _ = parse_macro_input!(args as parse::Nothing);
|
||||
|
||||
if let syn::Fields::Named(ref mut fields) = input.fields {
|
||||
fields.named.push(
|
||||
Field::parse_named
|
||||
.parse2(quote! { path: Vec<#crate_name::op::PathSegment> })
|
||||
.unwrap(),
|
||||
);
|
||||
fields.named.push(
|
||||
Field::parse_named
|
||||
.parse2(quote! { id: #crate_name::keypair::AuthorId })
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
quote! {
|
||||
#input
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Proc macro to automatically derive the CRDTNode trait
|
||||
#[proc_macro_derive(CrdtNode)]
|
||||
pub fn derive_json_crdt(input: OgTokenStream) -> OgTokenStream {
|
||||
// parse the input tokens into a syntax tree
|
||||
let input = parse_macro_input!(input as DeriveInput);
|
||||
let crate_name = get_crate_name();
|
||||
|
||||
// used in the quasi-quotation below as `#name`
|
||||
let ident = input.ident;
|
||||
let ident_str = LitStr::new(&ident.to_string(), ident.span());
|
||||
|
||||
let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
|
||||
match input.data {
|
||||
Data::Struct(data) => match &data.fields {
|
||||
Fields::Named(fields) => {
|
||||
let mut field_impls = vec![];
|
||||
let mut ident_literals = vec![];
|
||||
let mut ident_strings = vec![];
|
||||
let mut tys = vec![];
|
||||
// parse all named fields
|
||||
for field in &fields.named {
|
||||
let ident = field.ident.as_ref().expect("Failed to get struct field identifier");
|
||||
if ident != "path" && ident != "id" {
|
||||
let ty = match &field.ty {
|
||||
Type::Path(t) => t.to_token_stream(),
|
||||
_ => return quote_spanned! { field.span() => compile_error!("Field should be a primitive or struct which implements CRDTNode") }.into(),
|
||||
};
|
||||
let str_literal = LitStr::new(&ident.to_string(), ident.span());
|
||||
ident_strings.push(str_literal.clone());
|
||||
ident_literals.push(ident.clone());
|
||||
tys.push(ty.clone());
|
||||
field_impls.push(quote! {
|
||||
#ident: <#ty as CrdtNode>::new(
|
||||
id,
|
||||
#crate_name::op::join_path(path.clone(), #crate_name::op::PathSegment::Field(#str_literal.to_string()))
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let expanded = quote! {
|
||||
impl #impl_generics #crate_name::json_crdt::CrdtNodeFromValue for #ident #ty_generics #where_clause {
|
||||
fn node_from(value: #crate_name::json_crdt::JsonValue, id: #crate_name::keypair::AuthorId, path: Vec<#crate_name::op::PathSegment>) -> Result<Self, String> {
|
||||
if let #crate_name::json_crdt::JsonValue::Object(mut obj) = value {
|
||||
Ok(#ident {
|
||||
path: path.clone(),
|
||||
id,
|
||||
#(#ident_literals: if let Some(val) = obj.remove(#ident_strings) {
|
||||
val.into_node(
|
||||
id,
|
||||
#crate_name::op::join_path(path.clone(), #crate_name::op::PathSegment::Field(#ident_strings.to_string()))
|
||||
)
|
||||
.unwrap()
|
||||
} else {
|
||||
<#tys as #crate_name::json_crdt::CrdtNode>::new(
|
||||
id,
|
||||
#crate_name::op::join_path(path.clone(), #crate_name::op::PathSegment::Field(#ident_strings.to_string()))
|
||||
)
|
||||
}),*
|
||||
})
|
||||
} else {
|
||||
Err(format!("failed to convert {:?} -> {}<T>", value, #ident_str.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// I'm pulling this out so that we can see actual CRD content in debug output.
|
||||
//
|
||||
// The plan is to mostly get rid of the macros anyway, so it's a reasonable first step.
|
||||
// It could (alternately) be just as good to keep the macros and change this function to
|
||||
// output actual field content instead of just field names.
|
||||
//
|
||||
// impl #impl_generics std::fmt::Debug for #ident #ty_generics #where_clause {
|
||||
// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// let mut fields = Vec::new();
|
||||
// #(fields.push(format!("{}", #ident_strings.to_string()));)*
|
||||
// write!(f, "{{ {:?} }}", fields.join(", "))
|
||||
// }
|
||||
// }
|
||||
|
||||
impl #impl_generics #crate_name::json_crdt::CrdtNode for #ident #ty_generics #where_clause {
|
||||
fn apply(&mut self, op: #crate_name::op::Op<#crate_name::json_crdt::JsonValue>) -> #crate_name::json_crdt::OpState {
|
||||
let path = op.path.clone();
|
||||
let author = op.id.clone();
|
||||
if !#crate_name::op::ensure_subpath(&self.path, &op.path) {
|
||||
#crate_name::debug::debug_path_mismatch(self.path.to_owned(), op.path);
|
||||
return #crate_name::json_crdt::OpState::ErrPathMismatch;
|
||||
}
|
||||
|
||||
if self.path.len() == op.path.len() {
|
||||
return #crate_name::json_crdt::OpState::ErrApplyOnStruct;
|
||||
} else {
|
||||
let idx = self.path.len();
|
||||
if let #crate_name::op::PathSegment::Field(path_seg) = &op.path[idx] {
|
||||
match &path_seg[..] {
|
||||
#(#ident_strings => {
|
||||
return self.#ident_literals.apply(op.into());
|
||||
}),*
|
||||
_ => {},
|
||||
};
|
||||
};
|
||||
return #crate_name::json_crdt::OpState::ErrPathMismatch
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self) -> #crate_name::json_crdt::JsonValue {
|
||||
let mut view_map = indexmap::IndexMap::new();
|
||||
#(view_map.insert(#ident_strings.to_string(), self.#ident_literals.view().into());)*
|
||||
#crate_name::json_crdt::JsonValue::Object(view_map)
|
||||
}
|
||||
|
||||
fn new(id: #crate_name::keypair::AuthorId, path: Vec<#crate_name::op::PathSegment>) -> Self {
|
||||
Self {
|
||||
path: path.clone(),
|
||||
id,
|
||||
#(#field_impls),*
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl #crate_name::debug::DebugView for #ident {
|
||||
#[cfg(feature = "logging-base")]
|
||||
fn debug_view(&self, indent: usize) -> String {
|
||||
let inner_spacing = " ".repeat(indent + 2);
|
||||
let path_str = #crate_name::op::print_path(self.path.clone());
|
||||
let mut inner = vec![];
|
||||
#(inner.push(format!("{}\"{}\": {}", inner_spacing, #ident_strings, self.#ident_literals.debug_view(indent + 4)));)*
|
||||
let inner_str = inner.join("\n");
|
||||
format!("{} @ /{}\n{}", #ident_str, path_str, inner_str)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "logging-base"))]
|
||||
fn debug_view(&self, _indent: usize) -> String {
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Hand the output tokens back to the compiler
|
||||
expanded.into()
|
||||
}
|
||||
_ => {
|
||||
quote_spanned! { ident.span() => compile_error!("Cannot derive CRDT on tuple or unit structs"); }
|
||||
.into()
|
||||
}
|
||||
},
|
||||
_ => quote_spanned! { ident.span() => compile_error!("Cannot derive CRDT on enums or unions"); }.into(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 502 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 300 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,336 @@
|
||||
//! Debug helpers and the [`DebugView`] trait for rendering CRDT internals.
|
||||
//!
|
||||
//! Most items in this module are no-ops in release builds. They are activated by
|
||||
//! the `logging-base`, `logging-json`, and `logging-list` Cargo features so that
|
||||
//! debug output can be toggled without changing production code.
|
||||
|
||||
use crate::{
|
||||
json_crdt::{BaseCrdt, CrdtNode, SignedOp},
|
||||
keypair::SignedDigest,
|
||||
list_crdt::ListCrdt,
|
||||
op::{Op, OpId, PathSegment},
|
||||
};
|
||||
|
||||
#[cfg(feature = "logging-base")]
|
||||
use {
|
||||
crate::{
|
||||
keypair::{lsb_32, AuthorId},
|
||||
op::{print_hex, print_path, ROOT_ID},
|
||||
},
|
||||
colored::Colorize,
|
||||
random_color::{Luminosity, RandomColor},
|
||||
};
|
||||
|
||||
#[cfg(feature = "logging-list")]
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
|
||||
#[cfg(feature = "logging-base")]
|
||||
fn author_to_hex(author: AuthorId) -> String {
|
||||
format!("{:#010x}", lsb_32(author))
|
||||
}
|
||||
|
||||
#[cfg(feature = "logging-base")]
|
||||
fn display_op_id<T: CrdtNode>(op: &Op<T>) -> String {
|
||||
let [r, g, b] = RandomColor::new()
|
||||
.luminosity(Luminosity::Light)
|
||||
.seed(lsb_32(op.author))
|
||||
.to_rgb_array();
|
||||
format!(
|
||||
"[{},{}]",
|
||||
author_to_hex(op.author).bold().truecolor(r, g, b),
|
||||
op.seq.to_string().yellow()
|
||||
)
|
||||
}
|
||||
|
||||
/// Log a type-mismatch warning when deserialising a JSON value into a CRDT node fails.
|
||||
pub fn debug_type_mismatch(_msg: String) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
println!(" {}\n {_msg}", "type mismatch! ignoring this node".red(),);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a path-mismatch warning when an operation's path does not match the CRDT's path.
|
||||
pub fn debug_path_mismatch(_our_path: Vec<PathSegment>, _op_path: Vec<PathSegment>) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
println!(
|
||||
" {}\n current path: {}\n op path: {}",
|
||||
"path mismatch!".red(),
|
||||
print_path(_our_path),
|
||||
print_path(_op_path),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a warning when an operation is applied to a primitive (terminal) CRDT node.
|
||||
pub fn debug_op_on_primitive(_op_path: Vec<PathSegment>) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
println!(
|
||||
" {} this is an error, ignoring op.\n op path: {}",
|
||||
"trying to apply() on a primitive!".red(),
|
||||
print_path(_op_path),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "logging-base")]
|
||||
fn display_author(author: AuthorId) -> String {
|
||||
let [r, g, b] = RandomColor::new()
|
||||
.luminosity(Luminosity::Light)
|
||||
.seed(lsb_32(author))
|
||||
.to_rgb_array();
|
||||
format!(" {} ", author_to_hex(author))
|
||||
.black()
|
||||
.on_truecolor(r, g, b)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Render CRDT state as an indented human-readable string for debugging.
|
||||
pub trait DebugView {
|
||||
/// Return a multi-line debug string for this CRDT node, indented by `indent` spaces.
|
||||
fn debug_view(&self, indent: usize) -> String;
|
||||
}
|
||||
|
||||
impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
||||
/// Print the current document state as an indented debug tree (no-op in release builds).
|
||||
pub fn debug_view(&self) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!("document is now:\n{}", self.doc.debug_view(0));
|
||||
}
|
||||
|
||||
/// Log an attempt to apply `op` before the result is known (no-op in release builds).
|
||||
pub fn log_try_apply(&self, _op: &SignedOp) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!(
|
||||
"{} trying to apply operation {} from {}",
|
||||
display_author(self.id),
|
||||
&print_hex(&_op.signed_digest)[..6],
|
||||
display_author(_op.inner.author())
|
||||
);
|
||||
}
|
||||
|
||||
/// Log a signature-digest verification failure for `op` (no-op in release builds).
|
||||
pub fn debug_digest_failure(&self, _op: SignedOp) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!(
|
||||
" {} cannot confirm signed_digest from {}",
|
||||
"digest failure!".red(),
|
||||
display_author(_op.author())
|
||||
);
|
||||
}
|
||||
|
||||
/// Log that a causal dependency identified by `missing` has not yet been received.
|
||||
pub fn log_missing_causal_dep(&self, _missing: &SignedDigest) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
println!(
|
||||
" {} haven't received op with digest {}",
|
||||
"missing causal dependency".red(),
|
||||
print_hex(_missing)
|
||||
);
|
||||
}
|
||||
|
||||
/// Log that `op` is about to be integrated into the document (no-op in release builds).
|
||||
pub fn log_actually_apply(&self, _op: &SignedOp) {
|
||||
#[cfg(feature = "logging-json")]
|
||||
{
|
||||
println!(
|
||||
" applying op to path: /{}",
|
||||
print_path(_op.inner.path.clone())
|
||||
);
|
||||
println!("{}", _op.inner.debug_view(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Op<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Log an operation hash verification failure showing expected and computed IDs.
|
||||
pub fn debug_hash_failure(&self) {
|
||||
#[cfg(feature = "logging-base")]
|
||||
{
|
||||
println!(" {}", "hash failure!".red());
|
||||
println!(" expected: {}", print_hex(&self.id));
|
||||
println!(" computed: {}", print_hex(&self.hash_to_id()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DebugView for T
|
||||
where
|
||||
T: Display,
|
||||
{
|
||||
#[cfg(feature = "logging-base")]
|
||||
fn debug_view(&self, _indent: usize) -> String {
|
||||
self.to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "logging-base"))]
|
||||
fn debug_view(&self, _indent: usize) -> String {
|
||||
"".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DebugView for Op<T>
|
||||
where
|
||||
T: DebugView + CrdtNode,
|
||||
{
|
||||
#[cfg(not(feature = "logging-base"))]
|
||||
fn debug_view(&self, _indent: usize) -> String {
|
||||
"".to_string()
|
||||
}
|
||||
|
||||
#[cfg(feature = "logging-json")]
|
||||
fn debug_view(&self, indent: usize) -> String {
|
||||
let op_id = display_op_id(self);
|
||||
let content = if self.id == ROOT_ID && self.content.is_none() {
|
||||
"root".blue().bold().to_string()
|
||||
} else {
|
||||
self.content
|
||||
.as_ref()
|
||||
.map_or("[empty]".to_string(), |c| c.debug_view(indent + 2))
|
||||
};
|
||||
let content_str = if self.is_deleted && self.id != ROOT_ID {
|
||||
content.red().strikethrough().to_string()
|
||||
} else {
|
||||
content
|
||||
};
|
||||
|
||||
format!("{op_id} {content_str}")
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Print the full operation log as a tree, optionally highlighting one operation (no-op in release builds).
|
||||
pub fn log_ops(&self, _highlight: Option<OpId>) {
|
||||
#[cfg(feature = "logging-list")]
|
||||
{
|
||||
let mut lines = Vec::<String>::new();
|
||||
|
||||
// do in-order traversal
|
||||
let res: Vec<&Op<T>> = self.ops.iter().collect();
|
||||
if res.is_empty() {
|
||||
println!("[empty]");
|
||||
}
|
||||
|
||||
// figure out parent-child hierarchies from origins
|
||||
let mut parent_child_map: HashMap<OpId, Vec<OpId>> = HashMap::new();
|
||||
for op in &res {
|
||||
let children = parent_child_map.entry(op.origin).or_default();
|
||||
children.push(op.id);
|
||||
}
|
||||
|
||||
let is_last = |op: &Op<T>| -> bool {
|
||||
if op.id == ROOT_ID {
|
||||
return true;
|
||||
}
|
||||
if let Some(children) = parent_child_map.get(&op.origin) {
|
||||
return *children.last().unwrap() == op.id;
|
||||
}
|
||||
false
|
||||
};
|
||||
|
||||
// make stack of origins
|
||||
let mut stack: Vec<(OpId, &str)> = Vec::new();
|
||||
stack.push((ROOT_ID, ""));
|
||||
let mut prev = None;
|
||||
for op in &res {
|
||||
let origin_idx = self.find_idx(op.origin).unwrap();
|
||||
let origin = &res[origin_idx];
|
||||
let origin_id = origin.id;
|
||||
if let Some(prev) = prev {
|
||||
if origin_id == prev {
|
||||
// went down one layer, add to stack
|
||||
let stack_prefix_char = if is_last(origin) { " " } else { "│ " };
|
||||
stack.push((prev, stack_prefix_char));
|
||||
}
|
||||
}
|
||||
|
||||
// pop back up until we reach the right origin
|
||||
while stack.last().unwrap().0 != origin_id {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
let cur_char = if is_last(op) { "╰─" } else { "├─" };
|
||||
let prefixes = stack.iter().map(|s| s.1).collect::<Vec<_>>().join("");
|
||||
let highlight_text = if _highlight.is_some() && _highlight.unwrap() == op.id {
|
||||
if op.is_deleted {
|
||||
"<- deleted".bold().red()
|
||||
} else {
|
||||
"<- inserted".bold().green()
|
||||
}
|
||||
.to_string()
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
let content = if op.id == ROOT_ID {
|
||||
"root".blue().bold().to_string()
|
||||
} else {
|
||||
op.content
|
||||
.as_ref()
|
||||
.map_or("[empty]".to_string(), |c| c.hash())
|
||||
};
|
||||
if op.is_deleted && op.id != ROOT_ID {
|
||||
lines.push(format!(
|
||||
"{}{}{} {} {}",
|
||||
prefixes,
|
||||
cur_char,
|
||||
display_op_id(op),
|
||||
content.strikethrough().red(),
|
||||
highlight_text
|
||||
));
|
||||
} else {
|
||||
lines.push(format!(
|
||||
"{}{}{} {} {}",
|
||||
prefixes,
|
||||
cur_char,
|
||||
display_op_id(op),
|
||||
content,
|
||||
highlight_text
|
||||
));
|
||||
}
|
||||
prev = Some(op.id);
|
||||
}
|
||||
|
||||
// full string
|
||||
let flat = self.iter().map(|t| t.hash()).collect::<Vec<_>>().join("");
|
||||
lines.push(format!("Flattened result: {}", flat));
|
||||
println!("{}", lines.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Log the insert or delete being performed for `op` (no-op in release builds).
|
||||
pub fn log_apply(&self, _op: &Op<T>) {
|
||||
#[cfg(feature = "logging-list")]
|
||||
{
|
||||
if _op.is_deleted {
|
||||
println!(
|
||||
"{} Performing a delete of {}@{}",
|
||||
display_author(self.our_id),
|
||||
display_op_id(_op),
|
||||
_op.sequence_num(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(content) = _op.content.as_ref() {
|
||||
println!(
|
||||
"{} Performing an insert of {}@{}: '{}' after {}",
|
||||
display_author(self.our_id),
|
||||
display_op_id(_op),
|
||||
_op.sequence_num(),
|
||||
content.hash(),
|
||||
display_op_id(_op)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! [`BaseCrdt`] — the top-level causal-delivery wrapper around any [`CrdtNode`].
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use fastcrypto::ed25519::Ed25519KeyPair;
|
||||
use fastcrypto::traits::KeyPair;
|
||||
|
||||
use crate::debug::DebugView;
|
||||
use crate::keypair::SignedDigest;
|
||||
|
||||
use super::{CrdtNode, OpState, SignedOp, CAUSAL_QUEUE_MAX};
|
||||
|
||||
/// The base struct for a JSON CRDT. Allows for declaring causal
|
||||
/// dependencies across fields. It only accepts messages of [`SignedOp`] for BFT.
|
||||
pub struct BaseCrdt<T: CrdtNode> {
|
||||
/// Public key of this CRDT
|
||||
pub id: crate::keypair::AuthorId,
|
||||
|
||||
/// Internal base CRDT
|
||||
pub doc: T,
|
||||
|
||||
/// In a real world scenario, this would be a proper hash graph that allows for
|
||||
/// efficient reconciliation of missing dependencies. We naively keep a hash set
|
||||
/// of messages we've seen (represented by their [`SignedDigest`]).
|
||||
received: HashSet<SignedDigest>,
|
||||
message_q: HashMap<SignedDigest, Vec<SignedOp>>,
|
||||
|
||||
/// Total count of ops currently held in [`message_q`] waiting for their causal
|
||||
/// dependencies to be delivered. Used to enforce [`CAUSAL_QUEUE_MAX`].
|
||||
queue_len: usize,
|
||||
}
|
||||
|
||||
impl<T: CrdtNode + DebugView> BaseCrdt<T> {
|
||||
/// Create a new BaseCRDT of the given type. Multiple BaseCRDTs
|
||||
/// can be created from a single keypair but you are responsible for
|
||||
/// routing messages to the right BaseCRDT. Usually you should just make a single
|
||||
/// struct that contains all the state you need.
|
||||
pub fn new(keypair: &Ed25519KeyPair) -> Self {
|
||||
let id = keypair.public().0.to_bytes();
|
||||
Self {
|
||||
id,
|
||||
doc: T::new(id, vec![]),
|
||||
received: HashSet::new(),
|
||||
message_q: HashMap::new(),
|
||||
queue_len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a signed operation to this BaseCRDT, verifying integrity and routing to the right
|
||||
/// nested CRDT
|
||||
pub fn apply(&mut self, op: SignedOp) -> OpState {
|
||||
// self.log_try_apply(&op);
|
||||
|
||||
#[cfg(feature = "bft")]
|
||||
if !op.is_valid_digest() {
|
||||
self.debug_digest_failure(op);
|
||||
return OpState::ErrDigestMismatch;
|
||||
}
|
||||
|
||||
let op_id = op.signed_digest;
|
||||
|
||||
// Self-loop / dedup guard: if we have already processed this op (identified by
|
||||
// its signed_digest), return immediately without re-applying it. This prevents
|
||||
// echo loops where an op we broadcast to a peer comes back to us.
|
||||
if self.received.contains(&op_id) {
|
||||
return OpState::AlreadySeen;
|
||||
}
|
||||
|
||||
if !op.depends_on.is_empty() {
|
||||
for origin in &op.depends_on {
|
||||
if !self.received.contains(origin) {
|
||||
self.log_missing_causal_dep(origin);
|
||||
|
||||
// Bounded queue overflow: evict the oldest op from the largest
|
||||
// pending bucket before adding the new one. See CAUSAL_QUEUE_MAX.
|
||||
if self.queue_len >= CAUSAL_QUEUE_MAX {
|
||||
if let Some(bucket) = self.message_q.values_mut().max_by_key(|v| v.len()) {
|
||||
if !bucket.is_empty() {
|
||||
bucket.remove(0);
|
||||
self.queue_len = self.queue_len.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.message_q.entry(*origin).or_default().push(op);
|
||||
self.queue_len += 1;
|
||||
return OpState::MissingCausalDependencies;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apply
|
||||
// self.log_actually_apply(&op);
|
||||
let status = self.doc.apply(op.inner);
|
||||
// self.debug_view();
|
||||
|
||||
// Only record the op as seen when it applied successfully. If the op
|
||||
// was rejected (e.g. ErrHashMismatch from a tampered payload), we must
|
||||
// NOT add its signed_digest to `received`: a legitimate op that shares
|
||||
// the same signed_digest (e.g. the un-tampered original) would otherwise
|
||||
// be silently dropped as AlreadySeen.
|
||||
// Only mark as received and unblock dependents when the op was actually
|
||||
// applied. If we insert on error (e.g. ErrHashMismatch), a subsequent
|
||||
// apply of a *legitimate* op with the same signed_digest would be
|
||||
// silently dropped as AlreadySeen, preventing equivocation detection
|
||||
// from working correctly.
|
||||
if status == OpState::Ok {
|
||||
self.received.insert(op_id);
|
||||
|
||||
// apply all of its causal dependents if there are any
|
||||
let dependent_queue = self.message_q.remove(&op_id);
|
||||
if let Some(mut q) = dependent_queue {
|
||||
self.queue_len = self.queue_len.saturating_sub(q.len());
|
||||
for dependent in q.drain(..) {
|
||||
self.apply(dependent);
|
||||
}
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
/// Number of ops currently held in the causal-order queue waiting for their
|
||||
/// dependencies to be satisfied.
|
||||
pub fn causal_queue_len(&self) -> usize {
|
||||
self.queue_len
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! JSON CRDT public interface: core traits, re-exports, and integration tests.
|
||||
// TODO: serde's json object serialization and deserialization (correctly) do not define anything
|
||||
// object field order in JSON objects. However, the hash check impl in bft-json-bft-crdt does take order
|
||||
// into account. This is going to cause problems later for non-Rust implementations, BFT hash checking
|
||||
// currently depends on JSON serialization/deserialization object order. This shouldn't be the case
|
||||
// but I've hacked in an IndexMap for the moment to get the PoC working. To see the problem, replace this with
|
||||
// a std HashMap, everything will screw up (annoyingly, only *most* of the time).
|
||||
|
||||
use crate::debug::debug_op_on_primitive;
|
||||
use crate::keypair::AuthorId;
|
||||
use crate::op::{Hashable, Op, PathSegment};
|
||||
|
||||
pub use bft_crdt_derive::*;
|
||||
|
||||
mod base;
|
||||
mod signed_op;
|
||||
mod value;
|
||||
|
||||
pub use base::BaseCrdt;
|
||||
pub use signed_op::{OpState, SignedOp, CAUSAL_QUEUE_MAX};
|
||||
pub use value::JsonValue;
|
||||
|
||||
/// Anything that can be nested in a JSON CRDT
|
||||
pub trait CrdtNode: CrdtNodeFromValue + Hashable + Clone {
|
||||
/// Create a new CRDT of this type
|
||||
fn new(id: AuthorId, path: Vec<PathSegment>) -> Self;
|
||||
/// Apply an operation to this CRDT, forwarding if necessary
|
||||
fn apply(&mut self, op: Op<JsonValue>) -> OpState;
|
||||
/// Get a JSON representation of the value in this node
|
||||
fn view(&self) -> JsonValue;
|
||||
}
|
||||
|
||||
/// The following types can be used as a 'terminal' type in CRDTs
|
||||
pub trait MarkPrimitive: Into<JsonValue> + Default {}
|
||||
impl MarkPrimitive for bool {}
|
||||
impl MarkPrimitive for i32 {}
|
||||
impl MarkPrimitive for i64 {}
|
||||
impl MarkPrimitive for f64 {}
|
||||
impl MarkPrimitive for char {}
|
||||
impl MarkPrimitive for String {}
|
||||
impl MarkPrimitive for JsonValue {}
|
||||
|
||||
/// Implement CrdtNode for non-CRDTs
|
||||
/// This is a stub implementation so most functions don't do anything/log an error
|
||||
impl<T> CrdtNode for T
|
||||
where
|
||||
T: CrdtNodeFromValue + MarkPrimitive + Hashable + Clone,
|
||||
{
|
||||
fn apply(&mut self, _op: Op<JsonValue>) -> OpState {
|
||||
OpState::ErrApplyOnPrimitive
|
||||
}
|
||||
|
||||
fn view(&self) -> JsonValue {
|
||||
self.to_owned().into()
|
||||
}
|
||||
|
||||
fn new(_id: AuthorId, _path: Vec<PathSegment>) -> Self {
|
||||
debug_op_on_primitive(_path);
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallibly create a CRDT Node from a JSON Value
|
||||
pub trait CrdtNodeFromValue: Sized {
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String>;
|
||||
}
|
||||
|
||||
/// Fallibly cast a JSON Value into a CRDT Node
|
||||
pub trait IntoCrdtNode<T>: Sized {
|
||||
fn into_node(self, id: AuthorId, path: Vec<PathSegment>) -> Result<T, String>;
|
||||
}
|
||||
|
||||
/// [`CrdtNodeFromValue`] implies [`IntoCrdtNode<T>`]
|
||||
impl<T> IntoCrdtNode<T> for JsonValue
|
||||
where
|
||||
T: CrdtNodeFromValue,
|
||||
{
|
||||
fn into_node(self, id: AuthorId, path: Vec<PathSegment>) -> Result<T, String> {
|
||||
T::node_from(self, id, path)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trivial conversion from [`JsonValue`] to [`JsonValue`] as [`CrdtNodeFromValue`]
|
||||
impl CrdtNodeFromValue for JsonValue {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
json_crdt::{add_crdt_fields, BaseCrdt, CrdtNode, IntoCrdtNode, JsonValue, OpState},
|
||||
keypair::make_keypair,
|
||||
list_crdt::ListCrdt,
|
||||
lww_crdt::LwwRegisterCrdt,
|
||||
op::{print_path, ROOT_ID},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn test_derive_basic() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
x: LwwRegisterCrdt<f64>,
|
||||
y: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
let keypair = make_keypair();
|
||||
let crdt = BaseCrdt::<Player>::new(&keypair);
|
||||
assert_eq!(print_path(crdt.doc.x.path), "x");
|
||||
assert_eq!(print_path(crdt.doc.y.path), "y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_derive_nested() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Position {
|
||||
x: LwwRegisterCrdt<f64>,
|
||||
y: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
pos: Position,
|
||||
balance: LwwRegisterCrdt<f64>,
|
||||
messages: ListCrdt<String>,
|
||||
}
|
||||
|
||||
let keypair = make_keypair();
|
||||
let crdt = BaseCrdt::<Player>::new(&keypair);
|
||||
assert_eq!(print_path(crdt.doc.pos.x.path), "pos.x");
|
||||
assert_eq!(print_path(crdt.doc.pos.y.path), "pos.y");
|
||||
assert_eq!(print_path(crdt.doc.balance.path), "balance");
|
||||
assert_eq!(print_path(crdt.doc.messages.path), "messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lww_ops() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
a: LwwRegisterCrdt<f64>,
|
||||
b: LwwRegisterCrdt<bool>,
|
||||
c: LwwRegisterCrdt<String>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Test>::new(&kp2);
|
||||
|
||||
let _1_a_1 = base1.doc.a.set(3.0).sign(&kp1);
|
||||
let _1_b_1 = base1.doc.b.set(true).sign(&kp1);
|
||||
let _2_a_1 = base2.doc.a.set(1.5).sign(&kp2);
|
||||
let _2_a_2 = base2.doc.a.set(2.13).sign(&kp2);
|
||||
let _2_c_1 = base2.doc.c.set("abc".to_string()).sign(&kp2);
|
||||
|
||||
assert_eq!(base1.doc.a.view(), json!(3.0).into());
|
||||
assert_eq!(base2.doc.a.view(), json!(2.13).into());
|
||||
assert_eq!(base1.doc.b.view(), json!(true).into());
|
||||
assert_eq!(base2.doc.c.view(), json!("abc").into());
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 3.0,
|
||||
"b": true,
|
||||
"c": null,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
base2.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 2.13,
|
||||
"b": null,
|
||||
"c": "abc",
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(base2.apply(_1_a_1), OpState::Ok);
|
||||
assert_eq!(base2.apply(_1_b_1), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_a_1), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_a_2), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2_c_1), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": 2.13,
|
||||
"b": true,
|
||||
"c": "abc"
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vec_and_map_ops() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
a: ListCrdt<String>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Test>::new(&kp2);
|
||||
|
||||
let _1a = base1.doc.a.insert(ROOT_ID, "a".to_string()).sign(&kp1);
|
||||
let _1b = base1.doc.a.insert(_1a.id(), "b".to_string()).sign(&kp1);
|
||||
let _2c = base2.doc.a.insert(ROOT_ID, "c".to_string()).sign(&kp2);
|
||||
let _2d = base2.doc.a.insert(_1b.id(), "d".to_string()).sign(&kp2);
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"a": ["a", "b"],
|
||||
})
|
||||
);
|
||||
|
||||
// as _1b hasn't been delivered to base2 yet
|
||||
assert_eq!(
|
||||
base2.doc.view().into_json(),
|
||||
json!({
|
||||
"a": ["c"],
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(base2.apply(_1b), OpState::MissingCausalDependencies);
|
||||
assert_eq!(base2.apply(_1a), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2d), OpState::Ok);
|
||||
assert_eq!(base1.apply(_2c), OpState::Ok);
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_causal_field_dependency() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Item {
|
||||
name: LwwRegisterCrdt<String>,
|
||||
soulbound: LwwRegisterCrdt<bool>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Player {
|
||||
inventory: ListCrdt<Item>,
|
||||
balance: LwwRegisterCrdt<f64>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Player>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Player>::new(&kp2);
|
||||
|
||||
// require balance update to happen before inventory update
|
||||
let _add_money = base1.doc.balance.set(5000.0).sign(&kp1);
|
||||
let _spend_money = base1
|
||||
.doc
|
||||
.balance
|
||||
.set(3000.0)
|
||||
.sign_with_dependencies(&kp1, vec![&_add_money]);
|
||||
|
||||
let sword: JsonValue = json!({
|
||||
"name": "Sword",
|
||||
"soulbound": true,
|
||||
})
|
||||
.into();
|
||||
let _new_inventory_item = base1
|
||||
.doc
|
||||
.inventory
|
||||
.insert_idx(0, sword)
|
||||
.sign_with_dependencies(&kp1, vec![&_spend_money]);
|
||||
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"balance": 3000.0,
|
||||
"inventory": [
|
||||
{
|
||||
"name": "Sword",
|
||||
"soulbound": true
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
// do it completely out of order
|
||||
assert_eq!(
|
||||
base2.apply(_new_inventory_item),
|
||||
OpState::MissingCausalDependencies
|
||||
);
|
||||
assert_eq!(
|
||||
base2.apply(_spend_money),
|
||||
OpState::MissingCausalDependencies
|
||||
);
|
||||
assert_eq!(base2.apply(_add_money), OpState::Ok);
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_2d_grid() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Game {
|
||||
grid: ListCrdt<ListCrdt<LwwRegisterCrdt<bool>>>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let kp2 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Game>::new(&kp1);
|
||||
let mut base2 = BaseCrdt::<Game>::new(&kp2);
|
||||
|
||||
// init a 2d grid
|
||||
let row0: JsonValue = json!([true, false]).into();
|
||||
let row1: JsonValue = json!([false, true]).into();
|
||||
let construct1 = base1.doc.grid.insert_idx(0, row0).sign(&kp1);
|
||||
let construct2 = base1.doc.grid.insert_idx(1, row1).sign(&kp1);
|
||||
|
||||
assert_eq!(base2.apply(construct1), OpState::Ok);
|
||||
assert_eq!(base2.apply(construct2.clone()), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[true, false], [false, true]]
|
||||
})
|
||||
);
|
||||
|
||||
let set1 = base1.doc.grid[0][0].set(false).sign(&kp1);
|
||||
let set2 = base2.doc.grid[1][1].set(false).sign(&kp2);
|
||||
assert_eq!(base1.apply(set2), OpState::Ok);
|
||||
assert_eq!(base2.apply(set1), OpState::Ok);
|
||||
|
||||
assert_eq!(base1.doc.view().into_json(), base2.doc.view().into_json());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false, false], [false, false]]
|
||||
})
|
||||
);
|
||||
|
||||
let topright = base1.doc.grid[0].id_at(1).unwrap();
|
||||
base1.doc.grid[0].delete(topright);
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false], [false, false]]
|
||||
})
|
||||
);
|
||||
|
||||
base1.doc.grid.delete(construct2.id());
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"grid": [[false]]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arb_json() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
reg: LwwRegisterCrdt<JsonValue>,
|
||||
}
|
||||
|
||||
let kp1 = make_keypair();
|
||||
let mut base1 = BaseCrdt::<Test>::new(&kp1);
|
||||
|
||||
let base_val: JsonValue = json!({
|
||||
"a": true,
|
||||
"b": "asdf",
|
||||
"c": {
|
||||
"d": [],
|
||||
"e": [ false ]
|
||||
}
|
||||
})
|
||||
.into();
|
||||
base1.doc.reg.set(base_val).sign(&kp1);
|
||||
assert_eq!(
|
||||
base1.doc.view().into_json(),
|
||||
json!({
|
||||
"reg": {
|
||||
"a": true,
|
||||
"b": "asdf",
|
||||
"c": {
|
||||
"d": [],
|
||||
"e": [ false ]
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_json_types() {
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Nested {
|
||||
list: ListCrdt<f64>,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Test {
|
||||
reg: LwwRegisterCrdt<bool>,
|
||||
strct: ListCrdt<Nested>,
|
||||
}
|
||||
|
||||
let key = make_keypair();
|
||||
let mut crdt = BaseCrdt::<Test>::new(&key);
|
||||
|
||||
// wrong type should not go through
|
||||
crdt.doc.reg.set(32);
|
||||
assert_eq!(crdt.doc.reg.view(), json!(null).into());
|
||||
crdt.doc.reg.set(true);
|
||||
assert_eq!(crdt.doc.reg.view(), json!(true).into());
|
||||
|
||||
// set nested
|
||||
let mut list_view: JsonValue = crdt.doc.strct.view().into();
|
||||
assert_eq!(list_view, json!([]).into());
|
||||
|
||||
// only keeps actual numbers
|
||||
let list: JsonValue = json!({"list": [0, 123, -0.45, "char", []]}).into();
|
||||
crdt.doc.strct.insert_idx(0, list);
|
||||
list_view = crdt.doc.strct.view().into();
|
||||
assert_eq!(list_view, json!([{ "list": [0, 123, -0.45]}]).into());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! [`SignedOp`], [`OpState`], and the causal queue capacity constant.
|
||||
|
||||
use fastcrypto::traits::VerifyingKey;
|
||||
use fastcrypto::{
|
||||
ed25519::{Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature},
|
||||
traits::{KeyPair, ToFromBytes},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_with::{serde_as, Bytes};
|
||||
|
||||
use crate::keypair::{sha256, sign, AuthorId, SignedDigest};
|
||||
use crate::op::{print_hex, print_path, Op, OpId};
|
||||
|
||||
use super::{CrdtNode, JsonValue};
|
||||
|
||||
/// Enum representing possible outcomes of applying an operation to a CRDT
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum OpState {
|
||||
/// Operation applied successfully
|
||||
Ok,
|
||||
/// Tried to apply an operation to a non-CRDT primitive (i.e. f64, bool, etc.)
|
||||
/// If you would like a mutable primitive, wrap it in a [`LWWRegisterCRDT`]
|
||||
ErrApplyOnPrimitive,
|
||||
/// Tried to apply an operation to a static struct CRDT
|
||||
/// If you would like a mutable object, use a [`Value`]
|
||||
ErrApplyOnStruct,
|
||||
/// Tried to apply an operation that contains content of the wrong type.
|
||||
/// In other words, the content cannot be coerced to the CRDT at the path specified.
|
||||
ErrMismatchedType,
|
||||
/// The signed digest of the message did not match the claimed author of the message.
|
||||
/// This can happen if the message was tampered with during delivery
|
||||
ErrDigestMismatch,
|
||||
/// The hash of the message did not match the contents of the message.
|
||||
/// This can happen if the author tried to perform an equivocation attack by creating an
|
||||
/// operation and modifying it has already been created
|
||||
ErrHashMismatch,
|
||||
/// Tried to apply an operation to a non-existent path. The author may have forgotten to attach
|
||||
/// a causal dependency
|
||||
ErrPathMismatch,
|
||||
/// Trying to modify/delete the sentinel (zero-th) node element that is used for book-keeping
|
||||
ErrListApplyToEmpty,
|
||||
/// We have not received all of the causal dependencies of this operation. It has been queued
|
||||
/// up and will be executed when its causal dependencies have been delivered
|
||||
MissingCausalDependencies,
|
||||
/// This op has already been applied (identified by its `signed_digest`).
|
||||
/// The CRDT state is unchanged — this is a no-op (idempotent self-loop guard).
|
||||
AlreadySeen,
|
||||
}
|
||||
|
||||
/// Maximum total number of ops that may sit in the causal-order hold queue at any
|
||||
/// one time, summed across all pending dependency buckets.
|
||||
///
|
||||
/// **Overflow policy: drop oldest.**
|
||||
/// When the limit is reached, the oldest pending op in the largest dependency bucket
|
||||
/// is silently evicted before the new op is queued. Rationale: a misbehaving or
|
||||
/// heavily-partitioned peer can send ops whose causal ancestors never arrive, causing
|
||||
/// unbounded memory growth. Dropping the oldest entry preserves the most recent
|
||||
/// information and caps memory use. The peer can reconnect and receive a fresh bulk
|
||||
/// state dump to recover any dropped ops.
|
||||
pub const CAUSAL_QUEUE_MAX: usize = 256;
|
||||
|
||||
/// An [`Op<Value>`] with a few bits of extra metadata
|
||||
#[serde_as]
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct SignedOp {
|
||||
// Note that this can be different from the author of the inner op as the inner op could have been created
|
||||
// by a different person
|
||||
author: AuthorId,
|
||||
/// Signed hash using priv key of author. Effectively [`OpID`] Use this as the ID to figure out what has been delivered already
|
||||
#[serde_as(as = "Bytes")]
|
||||
pub signed_digest: SignedDigest,
|
||||
pub inner: Op<JsonValue>,
|
||||
/// List of causal dependencies
|
||||
#[serde_as(as = "Vec<Bytes>")]
|
||||
pub depends_on: Vec<SignedDigest>,
|
||||
}
|
||||
|
||||
impl SignedOp {
|
||||
pub fn id(&self) -> OpId {
|
||||
self.inner.id
|
||||
}
|
||||
|
||||
pub fn author(&self) -> AuthorId {
|
||||
self.author
|
||||
}
|
||||
|
||||
/// Creates a digest of the following fields. Any changes in the fields will change the signed digest
|
||||
/// - id (hash of the following)
|
||||
/// - origin
|
||||
/// - author
|
||||
/// - seq
|
||||
/// - is_deleted
|
||||
/// - path
|
||||
/// - dependencies
|
||||
fn digest(&self) -> [u8; 32] {
|
||||
let path_string = print_path(self.inner.path.clone());
|
||||
let dependency_string = self
|
||||
.depends_on
|
||||
.iter()
|
||||
.map(print_hex)
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let fmt_str = format!("{:?},{path_string},{dependency_string}", self.id());
|
||||
sha256(fmt_str)
|
||||
}
|
||||
|
||||
/// Sign this digest with the given keypair. Shouldn't need to be called manually,
|
||||
/// just use [`SignedOp::from_op`] instead
|
||||
fn sign_digest(&mut self, keypair: &Ed25519KeyPair) {
|
||||
self.signed_digest = sign(keypair, &self.digest()).sig.to_bytes()
|
||||
}
|
||||
|
||||
/// Ensure digest was actually signed by the author it claims to be signed by
|
||||
pub fn is_valid_digest(&self) -> bool {
|
||||
let digest = Ed25519Signature::from_bytes(&self.signed_digest);
|
||||
let pubkey = Ed25519PublicKey::from_bytes(&self.author());
|
||||
match (digest, pubkey) {
|
||||
(Ok(digest), Ok(pubkey)) => pubkey.verify(&self.digest(), &digest).is_ok(),
|
||||
(_, _) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a normal op and add all the needed metadata
|
||||
pub fn from_op<T: CrdtNode>(
|
||||
value: Op<T>,
|
||||
keypair: &Ed25519KeyPair,
|
||||
depends_on: Vec<SignedDigest>,
|
||||
) -> Self {
|
||||
let author = keypair.public().0.to_bytes();
|
||||
let mut new = Self {
|
||||
inner: Op {
|
||||
content: value.content.map(|c| c.view()),
|
||||
origin: value.origin,
|
||||
author: value.author,
|
||||
seq: value.seq,
|
||||
path: value.path,
|
||||
is_deleted: value.is_deleted,
|
||||
id: value.id,
|
||||
},
|
||||
author,
|
||||
signed_digest: [0u8; 64],
|
||||
depends_on,
|
||||
};
|
||||
new.sign_digest(keypair);
|
||||
new
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! The [`JsonValue`] enum and all its conversions to/from primitive and CRDT types.
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{keypair::AuthorId, list_crdt::ListCrdt, lww_crdt::LwwRegisterCrdt, op::PathSegment};
|
||||
|
||||
use super::{CrdtNode, CrdtNodeFromValue};
|
||||
|
||||
/// An enum representing a JSON value
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum JsonValue {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Number(f64),
|
||||
String(String),
|
||||
Array(Vec<JsonValue>),
|
||||
Object(IndexMap<String, JsonValue>),
|
||||
}
|
||||
|
||||
impl Display for JsonValue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
JsonValue::Null => "null".to_string(),
|
||||
JsonValue::Bool(b) => b.to_string(),
|
||||
JsonValue::Number(n) => n.to_string(),
|
||||
JsonValue::String(s) => format!("\"{s}\""),
|
||||
JsonValue::Array(arr) => {
|
||||
if arr.len() > 1 {
|
||||
format!(
|
||||
"[\n{}\n]",
|
||||
arr.iter()
|
||||
.map(|x| format!(" {x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n")
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"[ {} ]",
|
||||
arr.iter()
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
JsonValue::Object(obj) => format!(
|
||||
"{{ {} }}",
|
||||
obj.iter()
|
||||
.map(|(k, v)| format!(" \"{k}\": {v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n")
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for JsonValue {
|
||||
fn default() -> Self {
|
||||
Self::Null
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow easy conversion to and from serde's JSON format. This allows us to use the [`json!`]
|
||||
/// macro
|
||||
impl From<JsonValue> for serde_json::Value {
|
||||
fn from(value: JsonValue) -> Self {
|
||||
match value {
|
||||
JsonValue::Null => serde_json::Value::Null,
|
||||
JsonValue::Bool(x) => serde_json::Value::Bool(x),
|
||||
JsonValue::Number(x) => {
|
||||
serde_json::Value::Number(serde_json::Number::from_f64(x).unwrap())
|
||||
}
|
||||
JsonValue::String(x) => serde_json::Value::String(x),
|
||||
JsonValue::Array(x) => {
|
||||
serde_json::Value::Array(x.iter().map(|a| a.clone().into()).collect())
|
||||
}
|
||||
JsonValue::Object(x) => serde_json::Value::Object(
|
||||
x.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone().into()))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Value> for JsonValue {
|
||||
fn from(value: serde_json::Value) -> Self {
|
||||
match value {
|
||||
serde_json::Value::Null => JsonValue::Null,
|
||||
serde_json::Value::Bool(x) => JsonValue::Bool(x),
|
||||
serde_json::Value::Number(x) => JsonValue::Number(x.as_f64().unwrap()),
|
||||
serde_json::Value::String(x) => JsonValue::String(x),
|
||||
serde_json::Value::Array(x) => {
|
||||
JsonValue::Array(x.iter().map(|a| a.clone().into()).collect())
|
||||
}
|
||||
serde_json::Value::Object(x) => JsonValue::Object(
|
||||
x.iter()
|
||||
.map(|(k, v)| (k.clone(), v.clone().into()))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonValue {
|
||||
pub fn into_json(self) -> serde_json::Value {
|
||||
self.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from primitive types to [`JsonValue`]
|
||||
impl From<bool> for JsonValue {
|
||||
fn from(val: bool) -> Self {
|
||||
JsonValue::Bool(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i64> for JsonValue {
|
||||
fn from(val: i64) -> Self {
|
||||
JsonValue::Number(val as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<i32> for JsonValue {
|
||||
fn from(val: i32) -> Self {
|
||||
JsonValue::Number(val as f64)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<f64> for JsonValue {
|
||||
fn from(val: f64) -> Self {
|
||||
JsonValue::Number(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for JsonValue {
|
||||
fn from(val: String) -> Self {
|
||||
JsonValue::String(val)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<char> for JsonValue {
|
||||
fn from(val: char) -> Self {
|
||||
JsonValue::String(val.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Option<T>> for JsonValue
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn from(val: Option<T>) -> Self {
|
||||
match val {
|
||||
Some(x) => x.view(),
|
||||
None => JsonValue::Null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Vec<T>> for JsonValue
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn from(value: Vec<T>) -> Self {
|
||||
JsonValue::Array(value.iter().map(|x| x.view()).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from bool to CRDT
|
||||
impl CrdtNodeFromValue for bool {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Bool(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> bool"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from f64 to CRDT
|
||||
impl CrdtNodeFromValue for f64 {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Number(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> f64"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from i64 to CRDT
|
||||
impl CrdtNodeFromValue for i64 {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Number(x) = value {
|
||||
Ok(x as i64)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> f64"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from String to CRDT
|
||||
impl CrdtNodeFromValue for String {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::String(x) = value {
|
||||
Ok(x)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> String"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversions from char to CRDT
|
||||
impl CrdtNodeFromValue for char {
|
||||
fn node_from(value: JsonValue, _id: AuthorId, _path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::String(x) = value.clone() {
|
||||
x.chars().next().ok_or(format!(
|
||||
"failed to convert {value:?} -> char: found a zero-length string"
|
||||
))
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> char"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNodeFromValue for LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
let mut crdt = LwwRegisterCrdt::new(id, path);
|
||||
crdt.set(value);
|
||||
Ok(crdt)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNodeFromValue for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn node_from(value: JsonValue, id: AuthorId, path: Vec<PathSegment>) -> Result<Self, String> {
|
||||
if let JsonValue::Array(arr) = value {
|
||||
let mut crdt = ListCrdt::new(id, path);
|
||||
let result: Result<(), String> =
|
||||
arr.into_iter().enumerate().try_for_each(|(i, val)| {
|
||||
crdt.insert_idx(i, val);
|
||||
Ok(())
|
||||
});
|
||||
result?;
|
||||
Ok(crdt)
|
||||
} else {
|
||||
Err(format!("failed to convert {value:?} -> ListCRDT<T>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Ed25519 keypair utilities and type aliases for node identity and signing.
|
||||
//!
|
||||
//! Provides the [`AuthorId`] and [`SignedDigest`] type aliases, a SHA-256 helper,
|
||||
//! and convenience wrappers around the `fastcrypto` Ed25519 primitives used
|
||||
//! throughout the CRDT codebase.
|
||||
|
||||
use fastcrypto::traits::VerifyingKey;
|
||||
pub use fastcrypto::{
|
||||
ed25519::{
|
||||
Ed25519KeyPair, Ed25519PublicKey, Ed25519Signature, ED25519_PUBLIC_KEY_LENGTH,
|
||||
ED25519_SIGNATURE_LENGTH,
|
||||
},
|
||||
traits::{KeyPair, Signer},
|
||||
// Verifier,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Represents the ID of a unique node. An Ed25519 public key
|
||||
pub type AuthorId = [u8; ED25519_PUBLIC_KEY_LENGTH];
|
||||
|
||||
/// A signed message
|
||||
pub type SignedDigest = [u8; ED25519_SIGNATURE_LENGTH];
|
||||
|
||||
/// Create a fake public key from a u8
|
||||
pub fn make_author(n: u8) -> AuthorId {
|
||||
let mut id = [0u8; ED25519_PUBLIC_KEY_LENGTH];
|
||||
id[0] = n;
|
||||
id
|
||||
}
|
||||
|
||||
/// Get the least significant 32 bits of a public key
|
||||
pub fn lsb_32(pubkey: AuthorId) -> u32 {
|
||||
((pubkey[0] as u32) << 24)
|
||||
+ ((pubkey[1] as u32) << 16)
|
||||
+ ((pubkey[2] as u32) << 8)
|
||||
+ (pubkey[3] as u32)
|
||||
}
|
||||
|
||||
/// SHA256 hash of a string
|
||||
pub fn sha256(input: String) -> [u8; 32] {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(input.as_bytes());
|
||||
let result = hasher.finalize();
|
||||
let mut bytes = [0u8; 32];
|
||||
bytes.copy_from_slice(&result[..]);
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Generate a random Ed25519 keypair from OS rng
|
||||
pub fn make_keypair() -> Ed25519KeyPair {
|
||||
let mut csprng = rand::thread_rng();
|
||||
Ed25519KeyPair::generate(&mut csprng)
|
||||
}
|
||||
|
||||
/// Sign a byte array
|
||||
pub fn sign(keypair: &Ed25519KeyPair, message: &[u8]) -> Ed25519Signature {
|
||||
keypair.sign(message)
|
||||
}
|
||||
|
||||
/// Verify a byte array was signed by the given pubkey
|
||||
pub fn verify(pubkey: Ed25519PublicKey, message: &[u8], signature: Ed25519Signature) -> bool {
|
||||
pubkey.verify(message, &signature).is_ok()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! BFT JSON CRDT library — a Byzantine Fault-Tolerant replicated JSON document
|
||||
//! built on an RGA list CRDT, an LWW register CRDT, and a signed-op substrate.
|
||||
//!
|
||||
//! Each document is identified by an Ed25519 keypair. Operations are signed and
|
||||
//! carry causal dependencies so that every node converges to the same value
|
||||
//! regardless of message delivery order.
|
||||
|
||||
/// Debug helpers and the [`DebugView`] trait for rendering CRDT internals.
|
||||
pub mod debug;
|
||||
/// JSON CRDT public interface: core traits, types, and signed-op substrate.
|
||||
pub mod json_crdt;
|
||||
/// Ed25519 keypair utilities and primitive type aliases used throughout the crate.
|
||||
pub mod keypair;
|
||||
/// RGA-style list CRDT that can store any [`CrdtNode`] as its element type.
|
||||
pub mod list_crdt;
|
||||
/// Last-writer-wins (LWW) register CRDT for single-value fields.
|
||||
pub mod lww_crdt;
|
||||
/// Core operation types: [`Op`], [`PathSegment`], and hashing helpers.
|
||||
pub mod op;
|
||||
|
||||
extern crate self as bft_json_crdt;
|
||||
@@ -0,0 +1,478 @@
|
||||
//! RGA-style list CRDT that stores any [`CrdtNode`] as its element type.
|
||||
//!
|
||||
//! Implements the Replicated Growable Array (RGA) algorithm with causal ordering.
|
||||
//! Concurrent inserts at the same position are resolved by sequence number then
|
||||
//! by author public key so that all replicas converge to the same sequence.
|
||||
|
||||
use crate::{
|
||||
debug::debug_path_mismatch,
|
||||
json_crdt::{CrdtNode, JsonValue, OpState},
|
||||
keypair::AuthorId,
|
||||
op::*,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
cmp::{max, Ordering},
|
||||
collections::HashMap,
|
||||
fmt::Debug,
|
||||
ops::{Index, IndexMut},
|
||||
};
|
||||
|
||||
/// An RGA-like list CRDT that can store a CRDT-like datatype
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Public key for this node
|
||||
pub our_id: AuthorId,
|
||||
/// Path to this CRDT
|
||||
pub path: Vec<PathSegment>,
|
||||
/// List of all the operations we know of
|
||||
pub ops: Vec<Op<T>>,
|
||||
/// Queue of messages where K is the ID of the message yet to arrive
|
||||
/// and V is the list of operations depending on it
|
||||
message_q: HashMap<OpId, Vec<Op<T>>>,
|
||||
/// The sequence number of this node
|
||||
our_seq: SequenceNumber,
|
||||
}
|
||||
|
||||
impl<T> ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Create a new List CRDT with the given [`AuthorID`] (it should be unique)
|
||||
pub fn new(id: AuthorId, path: Vec<PathSegment>) -> ListCrdt<T> {
|
||||
let ops = vec![Op::make_root()];
|
||||
ListCrdt {
|
||||
our_id: id,
|
||||
path,
|
||||
ops,
|
||||
message_q: HashMap::new(),
|
||||
our_seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current Lamport sequence number for this list.
|
||||
pub fn our_seq(&self) -> SequenceNumber {
|
||||
self.our_seq
|
||||
}
|
||||
|
||||
/// Advance the internal sequence counter to at least `seq`.
|
||||
///
|
||||
/// After `advance_seq(n)`, the next local op will carry `seq = max(our_seq, n) + 1`
|
||||
/// instead of the default `1`. Used on restart to resume the Lamport clock
|
||||
/// from the document-wide floor so that newly-created registers don't
|
||||
/// re-emit low sequence numbers.
|
||||
pub fn advance_seq(&mut self, seq: SequenceNumber) {
|
||||
self.our_seq = max(self.our_seq, seq);
|
||||
}
|
||||
|
||||
/// Locally insert some content causally after the given operation
|
||||
pub fn insert<U: Into<JsonValue>>(&mut self, after: OpId, content: U) -> Op<JsonValue> {
|
||||
let mut op = Op::new(
|
||||
after,
|
||||
self.our_id,
|
||||
self.our_seq + 1,
|
||||
false,
|
||||
Some(content.into()),
|
||||
self.path.to_owned(),
|
||||
);
|
||||
|
||||
// we need to know the op ID before setting the path as [`PathSegment::Index`] requires an
|
||||
// [`OpID`]
|
||||
let new_path = join_path(self.path.to_owned(), PathSegment::Index(op.id));
|
||||
op.path = new_path;
|
||||
self.apply(op.clone());
|
||||
op
|
||||
}
|
||||
|
||||
/// Shorthand function to insert at index locally. Indexing ignores deleted items
|
||||
pub fn insert_idx<U: Into<JsonValue> + Clone>(
|
||||
&mut self,
|
||||
idx: usize,
|
||||
content: U,
|
||||
) -> Op<JsonValue> {
|
||||
let mut i = 0;
|
||||
for op in &self.ops {
|
||||
if !op.is_deleted {
|
||||
if idx == i {
|
||||
return self.insert(op.id, content);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
panic!("index {idx} out of range (length of {i})")
|
||||
}
|
||||
|
||||
/// Shorthand to figure out the OpID of something with a given index.
|
||||
/// Useful for declaring a causal dependency if you didn't create the original
|
||||
pub fn id_at(&self, idx: usize) -> Option<OpId> {
|
||||
let mut i = 0;
|
||||
for op in &self.ops {
|
||||
if !op.is_deleted {
|
||||
if idx == i {
|
||||
return Some(op.id);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Mark a node as deleted. If the node doesn't exist, it will be stuck
|
||||
/// waiting for that node to be created.
|
||||
pub fn delete(&mut self, id: OpId) -> Op<JsonValue> {
|
||||
let op = Op::new(
|
||||
id,
|
||||
self.our_id,
|
||||
self.our_seq + 1,
|
||||
true,
|
||||
None,
|
||||
join_path(self.path.to_owned(), PathSegment::Index(id)),
|
||||
);
|
||||
self.apply(op.clone());
|
||||
op
|
||||
}
|
||||
|
||||
/// Find the idx of an operation with the given [`OpID`]
|
||||
pub fn find_idx(&self, id: OpId) -> Option<usize> {
|
||||
self.ops.iter().position(|op| op.id == id)
|
||||
}
|
||||
|
||||
/// Apply an operation (both local and remote) to this local list CRDT.
|
||||
/// Forwards it to a nested CRDT if necessary.
|
||||
pub fn apply(&mut self, op: Op<JsonValue>) -> OpState {
|
||||
if !op.is_valid_hash() {
|
||||
return OpState::ErrHashMismatch;
|
||||
}
|
||||
|
||||
if !ensure_subpath(&self.path, &op.path) {
|
||||
return OpState::ErrPathMismatch;
|
||||
}
|
||||
|
||||
// haven't reached end yet, navigate to inner CRDT
|
||||
if op.path.len() - 1 > self.path.len() {
|
||||
if let Some(PathSegment::Index(op_id)) = op.path.get(self.path.len()) {
|
||||
let op_id = op_id.to_owned();
|
||||
if let Some(idx) = self.find_idx(op_id) {
|
||||
if self.ops[idx].content.is_none() {
|
||||
return OpState::ErrListApplyToEmpty;
|
||||
} else {
|
||||
return self.ops[idx].content.as_mut().unwrap().apply(op);
|
||||
}
|
||||
} else {
|
||||
debug_path_mismatch(
|
||||
join_path(self.path.to_owned(), PathSegment::Index(op_id)),
|
||||
op.path,
|
||||
);
|
||||
return OpState::ErrPathMismatch;
|
||||
};
|
||||
} else {
|
||||
debug_path_mismatch(self.path.to_owned(), op.path);
|
||||
return OpState::ErrPathMismatch;
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, this is just a direct replacement
|
||||
self.integrate(op.into())
|
||||
}
|
||||
|
||||
/// Main CRDT logic of integrating an op properly into our local log
|
||||
/// without causing conflicts. This is basically a really fancy
|
||||
/// insertion sort.
|
||||
///
|
||||
/// Effectively, we
|
||||
/// 1) find the parent item
|
||||
/// 2) find the right spot to insert before the next node
|
||||
fn integrate(&mut self, new_op: Op<T>) -> OpState {
|
||||
let op_id = new_op.id;
|
||||
let seq = new_op.sequence_num();
|
||||
let origin_id = self.find_idx(new_op.origin);
|
||||
|
||||
if origin_id.is_none() {
|
||||
self.message_q
|
||||
.entry(new_op.origin)
|
||||
.or_default()
|
||||
.push(new_op);
|
||||
return OpState::MissingCausalDependencies;
|
||||
}
|
||||
|
||||
let new_op_parent_idx = origin_id.unwrap();
|
||||
|
||||
// if its a delete operation, we don't need to do much
|
||||
self.log_apply(&new_op);
|
||||
if new_op.is_deleted {
|
||||
let op = &mut self.ops[new_op_parent_idx];
|
||||
op.is_deleted = true;
|
||||
return OpState::Ok;
|
||||
}
|
||||
|
||||
// otherwise, we are in an insert case
|
||||
// start looking from right after parent
|
||||
// stop when we reach end of document
|
||||
let mut i = new_op_parent_idx + 1;
|
||||
while i < self.ops.len() {
|
||||
let op = &self.ops[i];
|
||||
let op_parent_idx = self.find_idx(op.origin).unwrap();
|
||||
|
||||
// idempotency
|
||||
if op.id == new_op.id {
|
||||
return OpState::Ok;
|
||||
}
|
||||
|
||||
// first, lets compare causal origins
|
||||
match new_op_parent_idx.cmp(&op_parent_idx) {
|
||||
Ordering::Greater => break,
|
||||
Ordering::Equal => {
|
||||
// our parents our equal, we are siblings
|
||||
// siblings are sorted first by sequence number then by author id
|
||||
match new_op.sequence_num().cmp(&op.sequence_num()) {
|
||||
Ordering::Greater => break,
|
||||
Ordering::Equal => {
|
||||
// conflict, resolve arbitrarily but deterministically
|
||||
// tie-break on author id as that is unique
|
||||
if new_op.author() > op.author() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ordering::Less => (),
|
||||
}
|
||||
}
|
||||
Ordering::Less => (),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// insert at i
|
||||
self.ops.insert(i, new_op);
|
||||
self.our_seq = max(self.our_seq, seq);
|
||||
self.log_ops(Some(op_id));
|
||||
|
||||
// apply all of its causal dependents if there are any
|
||||
let dependent_queue = self.message_q.remove(&op_id);
|
||||
if let Some(mut q) = dependent_queue {
|
||||
for dependent in q.drain(..) {
|
||||
self.integrate(dependent);
|
||||
}
|
||||
}
|
||||
OpState::Ok
|
||||
}
|
||||
|
||||
/// Make an iterator out of list CRDT contents, ignoring deleted items and empty content
|
||||
pub fn iter(&self) -> impl Iterator<Item = &T> {
|
||||
self.ops
|
||||
.iter()
|
||||
.filter(|op| !op.is_deleted && op.content.is_some())
|
||||
.map(|op| op.content.as_ref().unwrap())
|
||||
}
|
||||
|
||||
/// Convenience function to get a vector of visible list elements
|
||||
pub fn view(&self) -> Vec<T> {
|
||||
self.iter().map(|i| i.to_owned()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Debug for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"[{}]",
|
||||
self.ops
|
||||
.iter()
|
||||
.map(|op| format!("{:?}", op.id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows us to index into a List CRDT like we would with an array
|
||||
impl<T> Index<usize> for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
type Output = T;
|
||||
fn index(&self, idx: usize) -> &Self::Output {
|
||||
let mut i = 0;
|
||||
for op in &self.ops {
|
||||
if !op.is_deleted && op.content.is_some() {
|
||||
if idx == i {
|
||||
return op.content.as_ref().unwrap();
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
panic!("index {idx} out of range (length of {i})")
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows us to mutably index into a List CRDT like we would with an array
|
||||
impl<T> IndexMut<usize> for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn index_mut(&mut self, idx: usize) -> &mut Self::Output {
|
||||
let mut i = 0;
|
||||
for op in &mut self.ops {
|
||||
if !op.is_deleted && op.content.is_some() {
|
||||
if idx == i {
|
||||
return op.content.as_mut().unwrap();
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
panic!("index {idx} out of range (length of {i})")
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNode for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn apply(&mut self, op: Op<JsonValue>) -> OpState {
|
||||
self.apply(op.into())
|
||||
}
|
||||
|
||||
fn view(&self) -> JsonValue {
|
||||
self.view().into()
|
||||
}
|
||||
|
||||
fn new(id: AuthorId, path: Vec<PathSegment>) -> Self {
|
||||
Self::new(id, path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "logging-base")]
|
||||
use crate::debug::DebugView;
|
||||
#[cfg(feature = "logging-base")]
|
||||
impl<T> DebugView for ListCrdt<T>
|
||||
where
|
||||
T: CrdtNode + DebugView,
|
||||
{
|
||||
fn debug_view(&self, indent: usize) -> String {
|
||||
let spacing = " ".repeat(indent);
|
||||
let path_str = print_path(self.path.clone());
|
||||
let inner = self
|
||||
.ops
|
||||
.iter()
|
||||
.map(|op| {
|
||||
format!(
|
||||
"{spacing}{}: {}",
|
||||
&print_hex(&op.id)[..6],
|
||||
op.debug_view(indent)
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("List CRDT @ /{path_str}\n{inner}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::{json_crdt::OpState, keypair::make_author, list_crdt::ListCrdt, op::ROOT_ID};
|
||||
|
||||
#[test]
|
||||
fn test_list_simple() {
|
||||
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
|
||||
let _one = list.insert(ROOT_ID, 1);
|
||||
let _two = list.insert(_one.id, 2);
|
||||
let _three = list.insert(_two.id, 3);
|
||||
let _four = list.insert(_one.id, 4);
|
||||
assert_eq!(list.view(), vec![1, 4, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advance_seq_resumes_from_floor() {
|
||||
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
|
||||
list.advance_seq(100);
|
||||
assert_eq!(list.our_seq(), 100);
|
||||
let op = list.insert(ROOT_ID, 42);
|
||||
assert_eq!(
|
||||
op.seq, 101,
|
||||
"first op after advance_seq(100) must have seq=101"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_idempotence() {
|
||||
let mut list = ListCrdt::<i64>::new(make_author(1), vec![]);
|
||||
let op = list.insert(ROOT_ID, 1);
|
||||
for _ in 1..10 {
|
||||
assert_eq!(list.apply(op.clone()), OpState::Ok);
|
||||
}
|
||||
assert_eq!(list.view(), vec![1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_delete() {
|
||||
let mut list = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let _one = list.insert(ROOT_ID, 'a');
|
||||
let _two = list.insert(_one.id, 'b');
|
||||
let _three = list.insert(ROOT_ID, 'c');
|
||||
list.delete(_one.id);
|
||||
list.delete(_two.id);
|
||||
assert_eq!(list.view(), vec!['c']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_interweave_chars() {
|
||||
let mut list = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let _one = list.insert(ROOT_ID, 'a');
|
||||
let _two = list.insert(_one.id, 'b');
|
||||
let _three = list.insert(ROOT_ID, 'c');
|
||||
assert_eq!(list.view(), vec!['c', 'a', 'b']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_conflicting_agents() {
|
||||
let mut list1 = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let mut list2 = ListCrdt::new(make_author(2), vec![]);
|
||||
let _1_a = list1.insert(ROOT_ID, 'a');
|
||||
assert_eq!(list2.apply(_1_a.clone()), OpState::Ok);
|
||||
let _2_b = list2.insert(_1_a.id, 'b');
|
||||
assert_eq!(list1.apply(_2_b.clone()), OpState::Ok);
|
||||
|
||||
let _2_d = list2.insert(ROOT_ID, 'd');
|
||||
let _2_y = list2.insert(_2_b.id, 'y');
|
||||
let _1_x = list1.insert(_2_b.id, 'x');
|
||||
|
||||
// create artificial delay, then apply out of order
|
||||
assert_eq!(list2.apply(_1_x), OpState::Ok);
|
||||
assert_eq!(list1.apply(_2_y), OpState::Ok);
|
||||
assert_eq!(list1.apply(_2_d), OpState::Ok);
|
||||
|
||||
assert_eq!(list1.view(), vec!['d', 'a', 'b', 'y', 'x']);
|
||||
assert_eq!(list1.view(), list2.view());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_delete_multiple_agent() {
|
||||
let mut list1 = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let mut list2 = ListCrdt::new(make_author(2), vec![]);
|
||||
let _1_a = list1.insert(ROOT_ID, 'a');
|
||||
assert_eq!(list2.apply(_1_a.clone()), OpState::Ok);
|
||||
let _2_b = list2.insert(_1_a.id, 'b');
|
||||
let del_1_a = list1.delete(_1_a.id);
|
||||
assert_eq!(list1.apply(_2_b), OpState::Ok);
|
||||
assert_eq!(list2.apply(del_1_a), OpState::Ok);
|
||||
|
||||
assert_eq!(list1.view(), vec!['b']);
|
||||
assert_eq!(list1.view(), list2.view());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_nested() {
|
||||
let mut list1 = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let _c = list1.insert(ROOT_ID, 'c');
|
||||
let _a = list1.insert(ROOT_ID, 'a');
|
||||
let _d = list1.insert(_c.id, 'd');
|
||||
let _b = list1.insert(_a.id, 'b');
|
||||
|
||||
assert_eq!(list1.view(), vec!['a', 'b', 'c', 'd']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Last-writer-wins (LWW) register CRDT.
|
||||
//!
|
||||
//! Implements a delete-wins LWW register for primitive values inside a nested
|
||||
//! JSON CRDT. Concurrent writes are resolved by sequence number; ties are broken
|
||||
//! by author public key so every node converges to the same value.
|
||||
|
||||
use crate::debug::DebugView;
|
||||
use crate::json_crdt::{CrdtNode, JsonValue, OpState};
|
||||
use crate::op::{join_path, print_path, Op, PathSegment, SequenceNumber};
|
||||
use std::cmp::{max, Ordering};
|
||||
use std::fmt::Debug;
|
||||
|
||||
use crate::keypair::AuthorId;
|
||||
|
||||
/// A simple delete-wins, last-writer-wins (LWW) register CRDT.
|
||||
/// Basically only for adding support for primitives within a more complex CRDT
|
||||
#[derive(Clone)]
|
||||
pub struct LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Public key for this node
|
||||
pub our_id: AuthorId,
|
||||
/// Path to this CRDT
|
||||
pub path: Vec<PathSegment>,
|
||||
/// Internal value of this CRDT. We wrap it in an Op to retain the author/sequence metadata
|
||||
value: Op<T>,
|
||||
/// The sequence number of this node
|
||||
our_seq: SequenceNumber,
|
||||
}
|
||||
|
||||
impl<T> LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Create a new register CRDT with the given [`AuthorID`] (it should be unique)
|
||||
pub fn new(id: AuthorId, path: Vec<PathSegment>) -> LwwRegisterCrdt<T> {
|
||||
LwwRegisterCrdt {
|
||||
our_id: id,
|
||||
path,
|
||||
value: Op::make_root(),
|
||||
our_seq: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current Lamport sequence number for this register.
|
||||
pub fn our_seq(&self) -> SequenceNumber {
|
||||
self.our_seq
|
||||
}
|
||||
|
||||
/// Advance the internal sequence counter to at least `seq`.
|
||||
///
|
||||
/// After `advance_seq(n)`, the next local op will carry `seq = max(our_seq, n) + 1`
|
||||
/// instead of the default `1`. Used on restart to resume the Lamport clock
|
||||
/// from the document-wide floor so that newly-created registers don't
|
||||
/// re-emit low sequence numbers.
|
||||
pub fn advance_seq(&mut self, seq: SequenceNumber) {
|
||||
self.our_seq = max(self.our_seq, seq);
|
||||
}
|
||||
|
||||
/// Sets the current value of the register
|
||||
pub fn set<U: Into<JsonValue>>(&mut self, content: U) -> Op<JsonValue> {
|
||||
let mut op = Op::new(
|
||||
self.value.id,
|
||||
self.our_id,
|
||||
self.our_seq + 1,
|
||||
false,
|
||||
Some(content.into()),
|
||||
self.path.to_owned(),
|
||||
);
|
||||
|
||||
// we need to know the op ID before setting the path as [`PathSegment::Index`] requires an
|
||||
// [`OpID`]
|
||||
let new_path = join_path(self.path.to_owned(), PathSegment::Index(op.id));
|
||||
op.path = new_path;
|
||||
self.apply(op.clone());
|
||||
op
|
||||
}
|
||||
|
||||
/// Apply an operation (both local and remote) to this local register CRDT.
|
||||
pub fn apply(&mut self, op: Op<JsonValue>) -> OpState {
|
||||
if !op.is_valid_hash() {
|
||||
return OpState::ErrHashMismatch;
|
||||
}
|
||||
|
||||
let op: Op<T> = op.into();
|
||||
let seq = op.sequence_num();
|
||||
|
||||
// take most recent update by sequence number
|
||||
match seq.cmp(&self.our_seq) {
|
||||
Ordering::Greater => {
|
||||
self.value = Op {
|
||||
id: self.value.id,
|
||||
..op
|
||||
};
|
||||
}
|
||||
Ordering::Equal => {
|
||||
// if we are equal, tie break on author
|
||||
if op.author() < self.value.author() {
|
||||
// we want to keep id constant so replace everything but id
|
||||
self.value = Op {
|
||||
id: self.value.id,
|
||||
..op
|
||||
};
|
||||
}
|
||||
}
|
||||
Ordering::Less => {} // LWW, ignore if its outdate
|
||||
};
|
||||
|
||||
// update bookkeeping
|
||||
self.our_seq = max(self.our_seq, seq);
|
||||
OpState::Ok
|
||||
}
|
||||
|
||||
fn view(&self) -> Option<T> {
|
||||
self.value.content.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> CrdtNode for LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn apply(&mut self, op: Op<JsonValue>) -> OpState {
|
||||
self.apply(op.into())
|
||||
}
|
||||
|
||||
fn view(&self) -> JsonValue {
|
||||
self.view().into()
|
||||
}
|
||||
|
||||
fn new(id: AuthorId, path: Vec<PathSegment>) -> Self {
|
||||
Self::new(id, path)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DebugView for LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode + DebugView,
|
||||
{
|
||||
fn debug_view(&self, indent: usize) -> String {
|
||||
let spacing = " ".repeat(indent);
|
||||
let path_str = print_path(self.path.clone());
|
||||
let inner = self.value.debug_view(indent + 2);
|
||||
format!("LWW Register CRDT @ /{path_str}\n{spacing}{inner}")
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Debug for LwwRegisterCrdt<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self.value.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::LwwRegisterCrdt;
|
||||
use crate::{json_crdt::OpState, keypair::make_author};
|
||||
|
||||
#[test]
|
||||
fn test_lww_simple() {
|
||||
let mut register = LwwRegisterCrdt::new(make_author(1), vec![]);
|
||||
assert_eq!(register.view(), None);
|
||||
register.set(1);
|
||||
assert_eq!(register.view(), Some(1));
|
||||
register.set(99);
|
||||
assert_eq!(register.view(), Some(99));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lww_multiple_writer() {
|
||||
let mut register1 = LwwRegisterCrdt::new(make_author(1), vec![]);
|
||||
let mut register2 = LwwRegisterCrdt::new(make_author(2), vec![]);
|
||||
let _a = register1.set('a');
|
||||
let _b = register1.set('b');
|
||||
let _c = register2.set('c');
|
||||
assert_eq!(register2.view(), Some('c'));
|
||||
assert_eq!(register1.apply(_c), OpState::Ok);
|
||||
assert_eq!(register2.apply(_b), OpState::Ok);
|
||||
assert_eq!(register2.apply(_a), OpState::Ok);
|
||||
assert_eq!(register1.view(), Some('b'));
|
||||
assert_eq!(register2.view(), Some('b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lww_idempotence() {
|
||||
let mut register = LwwRegisterCrdt::new(make_author(1), vec![]);
|
||||
let op = register.set(1);
|
||||
for _ in 1..10 {
|
||||
assert_eq!(register.apply(op.clone()), OpState::Ok);
|
||||
}
|
||||
assert_eq!(register.view(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_advance_seq_resumes_from_floor() {
|
||||
let mut register = LwwRegisterCrdt::<i64>::new(make_author(1), vec![]);
|
||||
register.advance_seq(100);
|
||||
assert_eq!(register.our_seq(), 100);
|
||||
let op = register.set(42);
|
||||
assert_eq!(
|
||||
op.seq, 101,
|
||||
"first op after advance_seq(100) must have seq=101"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lww_consistent_tiebreak() {
|
||||
let mut register1 = LwwRegisterCrdt::new(make_author(1), vec![]);
|
||||
let mut register2 = LwwRegisterCrdt::new(make_author(2), vec![]);
|
||||
let _a = register1.set('a');
|
||||
let _b = register2.set('b');
|
||||
assert_eq!(register1.apply(_b), OpState::Ok);
|
||||
assert_eq!(register2.apply(_a), OpState::Ok);
|
||||
let _c = register1.set('c');
|
||||
let _d = register2.set('d');
|
||||
assert_eq!(register2.apply(_c), OpState::Ok);
|
||||
assert_eq!(register1.apply(_d), OpState::Ok);
|
||||
assert_eq!(register1.view(), register2.view());
|
||||
assert_eq!(register1.view(), Some('c'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Core operation types for the BFT JSON CRDT.
|
||||
//!
|
||||
//! Defines [`Op`] (the fundamental unit of change), [`PathSegment`] (for
|
||||
//! addressing nested CRDTs), and [`SequenceNumber`] / [`OpId`] type aliases.
|
||||
//! Also provides hashing utilities used when computing operation identifiers.
|
||||
|
||||
use crate::debug::{debug_path_mismatch, debug_type_mismatch};
|
||||
use crate::json_crdt::{CrdtNode, CrdtNodeFromValue, IntoCrdtNode, JsonValue, SignedOp};
|
||||
use crate::keypair::{sha256, AuthorId};
|
||||
use fastcrypto::ed25519::Ed25519KeyPair;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Debug;
|
||||
|
||||
/// A lamport clock timestamp. Used to track document versions
|
||||
pub type SequenceNumber = u64;
|
||||
|
||||
/// A unique ID for a single [`Op<T>`]
|
||||
pub type OpId = [u8; 32];
|
||||
|
||||
/// The root/sentinel op
|
||||
pub const ROOT_ID: OpId = [0u8; 32];
|
||||
|
||||
/// Part of a path to get to a specific CRDT in a nested CRDT
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum PathSegment {
|
||||
Field(String),
|
||||
Index(OpId),
|
||||
}
|
||||
|
||||
/// Format a byte array as a hex string
|
||||
pub fn print_hex<const N: usize>(bytes: &[u8; N]) -> String {
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// Pretty print a path
|
||||
pub fn print_path(path: Vec<PathSegment>) -> String {
|
||||
path.iter()
|
||||
.map(|p| match p {
|
||||
PathSegment::Field(s) => s.to_string(),
|
||||
PathSegment::Index(i) => print_hex(i)[..6].to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
|
||||
/// Ensure our_path is a subpath of op_path. Note that two identical paths are considered subpaths
|
||||
/// of each other.
|
||||
pub fn ensure_subpath(our_path: &Vec<PathSegment>, op_path: &Vec<PathSegment>) -> bool {
|
||||
// if our_path is longer, it cannot be a subpath
|
||||
if our_path.len() > op_path.len() {
|
||||
debug_path_mismatch(our_path.to_owned(), op_path.to_owned());
|
||||
return false;
|
||||
}
|
||||
|
||||
// iterate to end of our_path, ensuring each element is the same
|
||||
for i in 0..our_path.len() {
|
||||
let ours = our_path.get(i);
|
||||
let theirs = op_path.get(i);
|
||||
if ours != theirs {
|
||||
debug_path_mismatch(our_path.to_owned(), op_path.to_owned());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Helper to easily append a [`PathSegment`] to a path
|
||||
pub fn join_path(path: Vec<PathSegment>, segment: PathSegment) -> Vec<PathSegment> {
|
||||
let mut p = path;
|
||||
p.push(segment);
|
||||
p
|
||||
}
|
||||
|
||||
/// Parse out the field from a [`PathSegment`]
|
||||
pub fn parse_field(path: Vec<PathSegment>) -> Option<String> {
|
||||
path.last().and_then(|segment| {
|
||||
if let PathSegment::Field(key) = segment {
|
||||
Some(key.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Represents a single node in a CRDT
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, PartialEq)]
|
||||
pub struct Op<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
pub origin: OpId,
|
||||
pub author: AuthorId, // pub key of author
|
||||
pub seq: SequenceNumber,
|
||||
pub content: Option<T>,
|
||||
pub path: Vec<PathSegment>, // path to get to target CRDT
|
||||
pub is_deleted: bool,
|
||||
pub id: OpId, // hash of the operation
|
||||
}
|
||||
|
||||
/// Something can be turned into a string. This allows us to use [`content`] as in
|
||||
/// input into the SHA256 hash
|
||||
pub trait Hashable {
|
||||
fn hash(&self) -> String;
|
||||
}
|
||||
|
||||
/// Anything that implements Debug is trivially hashable
|
||||
impl<T> Hashable for T
|
||||
where
|
||||
T: Debug,
|
||||
{
|
||||
fn hash(&self) -> String {
|
||||
format!("{self:?}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Conversion from Op<Value> -> Op<T> given that T is a CRDT that can be created from a JSON value
|
||||
impl Op<JsonValue> {
|
||||
/// Convert this `Op<JsonValue>` into an `Op<T>` by deserialising the content via `T::node_from`.
|
||||
pub fn into<T: CrdtNodeFromValue + CrdtNode>(self) -> Op<T> {
|
||||
let content = if let Some(inner_content) = self.content {
|
||||
match inner_content.into_node(self.id, self.path.clone()) {
|
||||
Ok(node) => Some(node),
|
||||
Err(msg) => {
|
||||
debug_type_mismatch(msg);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Op {
|
||||
content,
|
||||
origin: self.origin,
|
||||
author: self.author,
|
||||
seq: self.seq,
|
||||
path: self.path,
|
||||
is_deleted: self.is_deleted,
|
||||
id: self.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Op<T>
|
||||
where
|
||||
T: CrdtNode,
|
||||
{
|
||||
/// Sign this operation with `keypair`, producing a [`SignedOp`] with no causal dependencies.
|
||||
pub fn sign(self, keypair: &Ed25519KeyPair) -> SignedOp {
|
||||
SignedOp::from_op(self, keypair, vec![])
|
||||
}
|
||||
|
||||
/// Sign this operation and attach explicit causal `dependencies`.
|
||||
pub fn sign_with_dependencies(
|
||||
self,
|
||||
keypair: &Ed25519KeyPair,
|
||||
dependencies: Vec<&SignedOp>,
|
||||
) -> SignedOp {
|
||||
SignedOp::from_op(
|
||||
self,
|
||||
keypair,
|
||||
dependencies
|
||||
.iter()
|
||||
.map(|dep| dep.signed_digest)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the [`AuthorId`] (Ed25519 public key) of the node that created this operation.
|
||||
pub fn author(&self) -> AuthorId {
|
||||
self.author
|
||||
}
|
||||
|
||||
/// Return the Lamport sequence number carried by this operation.
|
||||
pub fn sequence_num(&self) -> SequenceNumber {
|
||||
self.seq
|
||||
}
|
||||
|
||||
/// Construct a new operation, computing its [`OpId`] hash from the supplied fields.
|
||||
pub fn new(
|
||||
origin: OpId,
|
||||
author: AuthorId,
|
||||
seq: SequenceNumber,
|
||||
is_deleted: bool,
|
||||
content: Option<T>,
|
||||
path: Vec<PathSegment>,
|
||||
) -> Op<T> {
|
||||
let mut op = Self {
|
||||
origin,
|
||||
id: ROOT_ID,
|
||||
author,
|
||||
seq,
|
||||
is_deleted,
|
||||
content,
|
||||
path,
|
||||
};
|
||||
op.id = op.hash_to_id();
|
||||
op
|
||||
}
|
||||
|
||||
/// Generate OpID by hashing our contents. Hash includes
|
||||
/// - content
|
||||
/// - origin
|
||||
/// - author
|
||||
/// - seq
|
||||
/// - is_deleted
|
||||
pub fn hash_to_id(&self) -> OpId {
|
||||
let content_str = match self.content.as_ref() {
|
||||
Some(content) => content.hash(),
|
||||
None => "".to_string(),
|
||||
};
|
||||
let fmt_str = format!(
|
||||
"{:?},{:?},{:?},{:?},{content_str}",
|
||||
self.origin, self.author, self.seq, self.is_deleted,
|
||||
);
|
||||
sha256(fmt_str)
|
||||
}
|
||||
|
||||
/// Rehashes the contents to make sure it matches the ID
|
||||
pub fn is_valid_hash(&self) -> bool {
|
||||
// make sure content is only none for deletion events
|
||||
if self.content.is_none() && !self.is_deleted {
|
||||
return false;
|
||||
}
|
||||
|
||||
// try to avoid expensive sig check if early fail
|
||||
let res = self.hash_to_id() == self.id;
|
||||
if !res {
|
||||
self.debug_hash_failure();
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
/// Special constructor for defining the sentinel root node
|
||||
pub fn make_root() -> Op<T> {
|
||||
Self {
|
||||
origin: ROOT_ID,
|
||||
id: ROOT_ID,
|
||||
author: [0u8; 32],
|
||||
seq: 0,
|
||||
is_deleted: false,
|
||||
content: None,
|
||||
path: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Integration tests verifying Byzantine fault tolerance of the CRDT.
|
||||
use bft_json_crdt::{
|
||||
json_crdt::{add_crdt_fields, BaseCrdt, CrdtNode, IntoCrdtNode, OpState},
|
||||
keypair::make_keypair,
|
||||
list_crdt::ListCrdt,
|
||||
lww_crdt::LwwRegisterCrdt,
|
||||
op::{Op, PathSegment, ROOT_ID},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
// What is potentially Byzantine behaviour?
|
||||
// 1. send valid updates
|
||||
// 2. send a mix of valid and invalid updates
|
||||
// a) messages with duplicate ID (attempt to overwrite old entries)
|
||||
// b) send incorrect sequence number to multiple nodes (which could lead to divergent state) -- this is called equivocation
|
||||
// c) ‘forge’ updates from another author (could happen when forwarding valid messages from peers)
|
||||
// 3. send malformed updates (e.g. missing fields)
|
||||
// this we don't test as we assume transport layer only allows valid messages
|
||||
// 4. overwhelm message queue by sending many updates far into the future
|
||||
// also untested! currently we keep an unbounded message queue
|
||||
// 5. block actual messages from honest actors (eclipse attack)
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct ListExample {
|
||||
list: ListCrdt<char>,
|
||||
}
|
||||
|
||||
// case 2a + 2b
|
||||
#[test]
|
||||
fn test_equivocation() {
|
||||
let key = make_keypair();
|
||||
let test_key = make_keypair();
|
||||
let mut crdt = BaseCrdt::<ListExample>::new(&key);
|
||||
let mut test_crdt = BaseCrdt::<ListExample>::new(&test_key);
|
||||
let _a = crdt.doc.list.insert(ROOT_ID, 'a').sign(&key);
|
||||
let _b = crdt.doc.list.insert(_a.id(), 'b').sign(&key);
|
||||
|
||||
// make a fake operation with same id as _b but different content
|
||||
let mut fake_op = _b.clone();
|
||||
fake_op.inner.content = Some('c'.into());
|
||||
|
||||
// also try modifying the sequence number
|
||||
let mut fake_op_seq = _b.clone();
|
||||
fake_op_seq.inner.seq = 99;
|
||||
fake_op_seq.inner.is_deleted = true;
|
||||
|
||||
assert_eq!(crdt.apply(fake_op.clone()), OpState::ErrHashMismatch);
|
||||
assert_eq!(crdt.apply(fake_op_seq.clone()), OpState::ErrHashMismatch);
|
||||
|
||||
assert_eq!(test_crdt.apply(fake_op_seq), OpState::ErrHashMismatch);
|
||||
assert_eq!(test_crdt.apply(fake_op), OpState::ErrHashMismatch);
|
||||
assert_eq!(test_crdt.apply(_a), OpState::Ok);
|
||||
assert_eq!(test_crdt.apply(_b), OpState::Ok);
|
||||
|
||||
// make sure it doesn't accept either of the fake operations
|
||||
assert_eq!(crdt.doc.list.view(), vec!['a', 'b']);
|
||||
assert_eq!(crdt.doc.list.view(), test_crdt.doc.list.view());
|
||||
}
|
||||
|
||||
// case 2c
|
||||
#[test]
|
||||
fn test_forge_update() {
|
||||
let key = make_keypair();
|
||||
let test_key = make_keypair();
|
||||
let mut crdt = BaseCrdt::<ListExample>::new(&key);
|
||||
let mut test_crdt = BaseCrdt::<ListExample>::new(&test_key);
|
||||
let _a = crdt.doc.list.insert(ROOT_ID, 'a').sign(&key);
|
||||
|
||||
let fake_key = make_keypair(); // generate a new keypair as we don't have private key of list.our_id
|
||||
let mut op = Op {
|
||||
origin: _a.inner.id,
|
||||
author: crdt.doc.id, // pretend to be the owner of list
|
||||
content: Some('b'),
|
||||
path: vec![PathSegment::Field("list".to_string())],
|
||||
seq: 1,
|
||||
is_deleted: false,
|
||||
id: ROOT_ID, // placeholder, to be generated
|
||||
};
|
||||
|
||||
// this is a completely valid hash and digest, just signed by the wrong person
|
||||
// as keypair.public != list.public
|
||||
op.id = op.hash_to_id();
|
||||
let signed = op.sign(&fake_key);
|
||||
|
||||
assert_eq!(crdt.apply(signed.clone()), OpState::ErrHashMismatch);
|
||||
assert_eq!(test_crdt.apply(signed), OpState::ErrHashMismatch);
|
||||
assert_eq!(test_crdt.apply(_a), OpState::Ok);
|
||||
|
||||
// make sure it doesn't accept fake operation
|
||||
assert_eq!(crdt.doc.list.view(), vec!['a']);
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Nested {
|
||||
a: Nested2,
|
||||
}
|
||||
|
||||
#[add_crdt_fields]
|
||||
#[derive(Clone, CrdtNode, Debug)]
|
||||
struct Nested2 {
|
||||
b: LwwRegisterCrdt<bool>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_update() {
|
||||
let key = make_keypair();
|
||||
let test_key = make_keypair();
|
||||
let mut crdt = BaseCrdt::<Nested>::new(&key);
|
||||
let mut test_crdt = BaseCrdt::<Nested>::new(&test_key);
|
||||
let mut _true = crdt.doc.a.b.set(true);
|
||||
_true.path = vec![PathSegment::Field("x".to_string())];
|
||||
let mut _false = crdt.doc.a.b.set(false);
|
||||
_false.path = vec![
|
||||
PathSegment::Field("a".to_string()),
|
||||
PathSegment::Index(_false.id),
|
||||
];
|
||||
|
||||
let signed_true = _true.sign(&key);
|
||||
let signed_false = _false.sign(&key);
|
||||
let mut signed_false_fake_path = signed_false.clone();
|
||||
signed_false_fake_path.inner.path = vec![
|
||||
PathSegment::Field("a".to_string()),
|
||||
PathSegment::Field("b".to_string()),
|
||||
];
|
||||
|
||||
assert_eq!(test_crdt.apply(signed_true), OpState::ErrPathMismatch);
|
||||
assert_eq!(test_crdt.apply(signed_false), OpState::ErrPathMismatch);
|
||||
assert_eq!(
|
||||
test_crdt.apply(signed_false_fake_path),
|
||||
OpState::ErrDigestMismatch
|
||||
);
|
||||
|
||||
// make sure it doesn't accept fake operation
|
||||
assert_eq!(crdt.doc.a.b.view(), json!(false).into());
|
||||
assert_eq!(test_crdt.doc.a.b.view(), json!(null).into());
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Integration tests verifying commutativity of CRDT operations.
|
||||
use bft_json_crdt::{
|
||||
json_crdt::{CrdtNode, JsonValue},
|
||||
keypair::make_author,
|
||||
list_crdt::ListCrdt,
|
||||
op::{Op, OpId, ROOT_ID},
|
||||
};
|
||||
use rand::{rngs::ThreadRng, seq::SliceRandom, Rng};
|
||||
|
||||
fn random_op<T: CrdtNode>(arr: &[Op<T>], rng: &mut ThreadRng) -> OpId {
|
||||
arr.choose(rng).map(|op| op.id).unwrap_or(ROOT_ID)
|
||||
}
|
||||
|
||||
const TEST_N: usize = 100;
|
||||
|
||||
#[test]
|
||||
fn test_list_fuzz_commutative() {
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut op_log = Vec::<Op<JsonValue>>::new();
|
||||
let mut op_log1 = Vec::<Op<JsonValue>>::new();
|
||||
let mut op_log2 = Vec::<Op<JsonValue>>::new();
|
||||
let mut l1 = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let mut l2 = ListCrdt::<char>::new(make_author(2), vec![]);
|
||||
let mut chk = ListCrdt::<char>::new(make_author(3), vec![]);
|
||||
for _ in 0..TEST_N {
|
||||
let letter1: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let letter2: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let op1 = if rng.gen_bool(4.0 / 5.0) {
|
||||
l1.insert(random_op(&op_log1, &mut rng), letter1)
|
||||
} else {
|
||||
l1.delete(random_op(&op_log1, &mut rng))
|
||||
};
|
||||
let op2 = if rng.gen_bool(4.0 / 5.0) {
|
||||
l2.insert(random_op(&op_log2, &mut rng), letter2)
|
||||
} else {
|
||||
l2.delete(random_op(&op_log2, &mut rng))
|
||||
};
|
||||
op_log1.push(op1.clone());
|
||||
op_log2.push(op2.clone());
|
||||
op_log.push(op1.clone());
|
||||
op_log.push(op2.clone());
|
||||
}
|
||||
|
||||
// shuffle ops
|
||||
op_log1.shuffle(&mut rng);
|
||||
op_log2.shuffle(&mut rng);
|
||||
|
||||
// apply to each other
|
||||
for op in op_log1 {
|
||||
l2.apply(op.clone());
|
||||
chk.apply(op.into());
|
||||
}
|
||||
for op in op_log2 {
|
||||
l1.apply(op.clone());
|
||||
chk.apply(op);
|
||||
}
|
||||
|
||||
// ensure all equal
|
||||
let l1_doc = l1.view();
|
||||
let l2_doc = l2.view();
|
||||
let chk_doc = chk.view();
|
||||
assert_eq!(l1_doc, l2_doc);
|
||||
assert_eq!(l1_doc, chk_doc);
|
||||
assert_eq!(l2_doc, chk_doc);
|
||||
|
||||
// now, allow cross mixing between both
|
||||
let mut op_log1 = Vec::<Op<JsonValue>>::new();
|
||||
let mut op_log2 = Vec::<Op<JsonValue>>::new();
|
||||
for _ in 0..TEST_N {
|
||||
let letter1: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let letter2: char = rng.gen_range(b'a'..=b'z') as char;
|
||||
let op1 = l1.insert(random_op(&op_log, &mut rng), letter1);
|
||||
let op2 = l2.insert(random_op(&op_log, &mut rng), letter2);
|
||||
op_log1.push(op1);
|
||||
op_log2.push(op2);
|
||||
}
|
||||
|
||||
for op in op_log1 {
|
||||
l2.apply(op.clone());
|
||||
chk.apply(op);
|
||||
}
|
||||
for op in op_log2 {
|
||||
l1.apply(op.clone());
|
||||
chk.apply(op);
|
||||
}
|
||||
|
||||
let l1_doc = l1.view();
|
||||
let l2_doc = l2.view();
|
||||
let chk_doc = chk.view();
|
||||
assert_eq!(l1_doc, l2_doc);
|
||||
assert_eq!(l1_doc, chk_doc);
|
||||
assert_eq!(l2_doc, chk_doc);
|
||||
}
|
||||
+259883
File diff suppressed because one or more lines are too long
Generated
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,76 @@
|
||||
//! Integration tests that replay the Kleppmann editing trace to validate list-CRDT correctness and performance.
|
||||
|
||||
use bft_json_crdt::keypair::make_author;
|
||||
use bft_json_crdt::list_crdt::ListCrdt;
|
||||
use bft_json_crdt::op::{OpId, ROOT_ID};
|
||||
use std::{fs::File, io::Read, time::Instant};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Edit {
|
||||
pos: usize,
|
||||
delete: bool,
|
||||
#[serde(default)]
|
||||
content: Option<char>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Trace {
|
||||
final_text: String,
|
||||
edits: Vec<Edit>,
|
||||
}
|
||||
|
||||
fn get_trace() -> Trace {
|
||||
let fp = "./tests/edits.json";
|
||||
match File::open(fp) {
|
||||
Err(e) => panic!("Open edits.json failed: {:?}", e.kind()),
|
||||
Ok(mut file) => {
|
||||
let mut content: String = String::new();
|
||||
file.read_to_string(&mut content)
|
||||
.expect("Problem reading file");
|
||||
serde_json::from_str(&content).expect("JSON was not well-formatted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Really large test to run Martin Kleppmann's
|
||||
/// editing trace over his paper
|
||||
/// Data source: https://github.com/automerge/automerge-perf
|
||||
// Commented out: takes 10+ minutes and 12GB+ RAM. Run manually with:
|
||||
// cargo test --package bft-json-crdt --test kleppmann_trace -- --ignored
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_editing_trace() {
|
||||
let t = get_trace();
|
||||
let mut list = ListCrdt::<char>::new(make_author(1), vec![]);
|
||||
let mut ops: Vec<OpId> = Vec::new();
|
||||
ops.push(ROOT_ID);
|
||||
let start = Instant::now();
|
||||
let edits = t.edits;
|
||||
for (i, op) in edits.into_iter().enumerate() {
|
||||
let origin = ops[op.pos];
|
||||
if op.delete {
|
||||
let delete_op = list.delete(origin);
|
||||
ops.push(delete_op.id);
|
||||
} else {
|
||||
let new_op = list.insert(origin, op.content.unwrap());
|
||||
ops.push(new_op.id);
|
||||
}
|
||||
|
||||
match i {
|
||||
10_000 | 100_000 => {
|
||||
println!("took {:?} to run {i} ops", start.elapsed());
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
println!("took {:?} to finish", start.elapsed());
|
||||
let result = list.iter().collect::<String>();
|
||||
let expected = t.final_text;
|
||||
assert_eq!(result.len(), expected.len());
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "source-map-gen"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
crate-type = ["lib"]
|
||||
|
||||
[[bin]]
|
||||
name = "source-map-check"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
@@ -0,0 +1,721 @@
|
||||
//! LLM-friendly source map generation and documentation coverage checking.
|
||||
//!
|
||||
//! Provides a [`LanguageAdapter`] trait that language-specific adapters implement,
|
||||
//! plus top-level dispatcher functions that route to the right adapter based on file
|
||||
//! extension (`.rs` → [`RustAdapter`], `.ts`/`.tsx` → [`TypeScriptAdapter`]).
|
||||
//!
|
||||
//! The entry point for agent spawn integration is [`update_for_worktree`], which
|
||||
//! runs `git diff --name-only` to find changed files and updates the source map for
|
||||
//! those that pass the documentation coverage check.
|
||||
|
||||
mod rust_adapter;
|
||||
mod ts_adapter;
|
||||
|
||||
pub use rust_adapter::RustAdapter;
|
||||
pub use ts_adapter::TypeScriptAdapter;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// A missing documentation failure for a single public item.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CheckFailure {
|
||||
/// Path to the file containing the undocumented item.
|
||||
pub file_path: PathBuf,
|
||||
/// 1-based line number of the item declaration.
|
||||
pub line: usize,
|
||||
/// Kind of item (e.g. `"fn"`, `"struct"`, `"module"`).
|
||||
pub item_kind: String,
|
||||
/// Name of the item.
|
||||
pub item_name: String,
|
||||
}
|
||||
|
||||
impl CheckFailure {
|
||||
/// Returns a human-readable direction a coding agent can act on directly.
|
||||
pub fn to_direction(&self) -> String {
|
||||
format!(
|
||||
"{}:{}: add a doc comment to {} `{}`",
|
||||
self.file_path.display(),
|
||||
self.line,
|
||||
self.item_kind,
|
||||
self.item_name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a documentation coverage check.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CheckResult {
|
||||
/// All checked items are documented.
|
||||
Ok,
|
||||
/// One or more items are missing documentation.
|
||||
Failures(Vec<CheckFailure>),
|
||||
}
|
||||
|
||||
/// Language-specific adapter for doc-coverage checking and source map generation.
|
||||
pub trait LanguageAdapter {
|
||||
/// Check documentation coverage for `files`.
|
||||
///
|
||||
/// Returns [`CheckResult::Ok`] when every public item in every file has a doc
|
||||
/// comment, or [`CheckResult::Failures`] listing each undocumented item as a
|
||||
/// direction the coding agent can act on.
|
||||
fn check(&self, files: &[&Path]) -> CheckResult;
|
||||
|
||||
/// Update the source map at `source_map_path` with entries for `passing_files`.
|
||||
///
|
||||
/// Reads the existing map, updates only the entries for the provided files, and
|
||||
/// writes back. Entries for files not in `passing_files` are preserved unchanged.
|
||||
/// Running twice with the same input produces identical file content (idempotent).
|
||||
fn update_source_map(
|
||||
&self,
|
||||
passing_files: &[&Path],
|
||||
source_map_path: &Path,
|
||||
) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// Returns the adapter for the given file extension, or `None` if unsupported.
|
||||
fn adapter_for_ext(ext: &str) -> Option<Box<dyn LanguageAdapter>> {
|
||||
match ext {
|
||||
"rs" => Some(Box::new(RustAdapter)),
|
||||
"ts" | "tsx" => Some(Box::new(TypeScriptAdapter)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse added line ranges from a unified diff output.
|
||||
///
|
||||
/// Returns the 1-based, inclusive line ranges in the new version of the file
|
||||
/// that were introduced by the diff. Lines that are context or deletions are
|
||||
/// not included.
|
||||
fn parse_added_ranges(diff: &str) -> Vec<std::ops::RangeInclusive<usize>> {
|
||||
let mut ranges = Vec::new();
|
||||
for line in diff.lines() {
|
||||
// Unified diff hunk header: @@ -old[,count] +new[,count] @@
|
||||
if !line.starts_with("@@") {
|
||||
continue;
|
||||
}
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
// Expected: ["@@", "-old[,count]", "+new[,count]", "@@", ...]
|
||||
if parts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let new_part = parts[2];
|
||||
let Some(new_info) = new_part.strip_prefix('+') else {
|
||||
continue;
|
||||
};
|
||||
let (start, count) = if let Some((s, c)) = new_info.split_once(',') {
|
||||
(
|
||||
s.parse::<usize>().unwrap_or(0),
|
||||
c.parse::<usize>().unwrap_or(0),
|
||||
)
|
||||
} else {
|
||||
(new_info.parse::<usize>().unwrap_or(0), 1usize)
|
||||
};
|
||||
if count > 0 && start > 0 {
|
||||
ranges.push(start..=start + count - 1);
|
||||
}
|
||||
}
|
||||
ranges
|
||||
}
|
||||
|
||||
/// Returns the 1-based line ranges in `file` that were added since `base` in `worktree`.
|
||||
///
|
||||
/// Uses `git diff --unified=0 {base}...HEAD -- {file}` and parses the hunk headers.
|
||||
/// Returns an empty `Vec` on git errors or when there are no added lines.
|
||||
pub fn added_line_ranges(
|
||||
worktree: &Path,
|
||||
base: &str,
|
||||
file: &Path,
|
||||
) -> Vec<std::ops::RangeInclusive<usize>> {
|
||||
let rel = file.strip_prefix(worktree).unwrap_or(file);
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"diff",
|
||||
"--unified=0",
|
||||
&format!("{base}...HEAD"),
|
||||
"--",
|
||||
&rel.to_string_lossy(),
|
||||
])
|
||||
.current_dir(worktree)
|
||||
.output();
|
||||
match output {
|
||||
Ok(o) => parse_added_ranges(&String::from_utf8_lossy(&o.stdout)),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check documentation coverage, reporting only violations in lines added since `base`.
|
||||
///
|
||||
/// Like [`check_files`], but filters each [`CheckFailure`] to items whose declaration
|
||||
/// line falls within a range added by `git diff {base}...HEAD` against `worktree`.
|
||||
/// Pre-existing undocumented items whose lines were not touched by the commit are
|
||||
/// silently ignored.
|
||||
pub fn check_files_ratcheted(files: &[&Path], worktree: &Path, base: &str) -> CheckResult {
|
||||
let mut by_ext: HashMap<String, Vec<&Path>> = HashMap::new();
|
||||
for &file in files {
|
||||
if let Some(ext) = file.extension().and_then(|e| e.to_str()) {
|
||||
by_ext.entry(ext.to_string()).or_default().push(file);
|
||||
}
|
||||
}
|
||||
let mut all_failures = Vec::new();
|
||||
for (ext, ext_files) in &by_ext {
|
||||
if let Some(adapter) = adapter_for_ext(ext)
|
||||
&& let CheckResult::Failures(failures) = adapter.check(ext_files)
|
||||
{
|
||||
for failure in failures {
|
||||
let added = added_line_ranges(worktree, base, &failure.file_path);
|
||||
// Only report if the item's declaration line is within an added range.
|
||||
// If added is empty (no additions or git error), skip — nothing new to blame.
|
||||
if !added.is_empty() && added.iter().any(|r| r.contains(&failure.line)) {
|
||||
all_failures.push(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if all_failures.is_empty() {
|
||||
CheckResult::Ok
|
||||
} else {
|
||||
CheckResult::Failures(all_failures)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check documentation coverage for a mixed list of files.
|
||||
///
|
||||
/// Dispatches each file to the appropriate [`LanguageAdapter`] based on its
|
||||
/// extension. Files with unsupported extensions are silently skipped.
|
||||
pub fn check_files(files: &[&Path]) -> CheckResult {
|
||||
let mut by_ext: HashMap<String, Vec<&Path>> = HashMap::new();
|
||||
for &file in files {
|
||||
if let Some(ext) = file.extension().and_then(|e| e.to_str()) {
|
||||
by_ext.entry(ext.to_string()).or_default().push(file);
|
||||
}
|
||||
}
|
||||
let mut all_failures = Vec::new();
|
||||
for (ext, ext_files) in &by_ext {
|
||||
if let Some(adapter) = adapter_for_ext(ext)
|
||||
&& let CheckResult::Failures(mut f) = adapter.check(ext_files)
|
||||
{
|
||||
all_failures.append(&mut f);
|
||||
}
|
||||
}
|
||||
if all_failures.is_empty() {
|
||||
CheckResult::Ok
|
||||
} else {
|
||||
CheckResult::Failures(all_failures)
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the source map at `source_map_path` with entries for `passing_files`.
|
||||
///
|
||||
/// Dispatches each file to the appropriate [`LanguageAdapter`] based on extension.
|
||||
/// Files with unsupported extensions are silently skipped.
|
||||
pub fn update_source_map(passing_files: &[&Path], source_map_path: &Path) -> Result<(), String> {
|
||||
let mut by_ext: HashMap<String, Vec<&Path>> = HashMap::new();
|
||||
for &file in passing_files {
|
||||
if let Some(ext) = file.extension().and_then(|e| e.to_str()) {
|
||||
by_ext.entry(ext.to_string()).or_default().push(file);
|
||||
}
|
||||
}
|
||||
for (ext, ext_files) in &by_ext {
|
||||
if let Some(adapter) = adapter_for_ext(ext) {
|
||||
adapter.update_source_map(ext_files, source_map_path)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update the source map for files that changed since `base_branch` in `worktree_path`.
|
||||
///
|
||||
/// 1. Runs `git diff --name-only {base_branch}...HEAD` in the worktree.
|
||||
/// 2. Checks doc coverage for each changed file (per-file).
|
||||
/// 3. Calls [`update_source_map`] with the files whose coverage check passes.
|
||||
///
|
||||
/// Errors are returned as `Err(String)`; callers in the spawn flow treat them as
|
||||
/// non-blocking warnings.
|
||||
pub fn update_for_worktree(
|
||||
worktree_path: &Path,
|
||||
base_branch: &str,
|
||||
source_map_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
.args(["diff", "--name-only", &format!("{base_branch}...HEAD")])
|
||||
.current_dir(worktree_path)
|
||||
.output()
|
||||
.map_err(|e| format!("git diff: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"git diff failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
|
||||
let changed: Vec<PathBuf> = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| worktree_path.join(l))
|
||||
.filter(|p| p.exists())
|
||||
.collect();
|
||||
|
||||
if changed.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Collect files that individually pass the doc check.
|
||||
let passing: Vec<&Path> = changed
|
||||
.iter()
|
||||
.map(PathBuf::as_path)
|
||||
.filter(|&p| matches!(check_files(&[p]), CheckResult::Ok))
|
||||
.collect();
|
||||
|
||||
if passing.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = source_map_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("create_dir_all: {e}"))?;
|
||||
}
|
||||
|
||||
update_source_map(&passing, source_map_path)
|
||||
}
|
||||
|
||||
/// Read the existing source map from `path` as a JSON object.
|
||||
///
|
||||
/// Returns an empty map if the file does not exist.
|
||||
pub(crate) fn read_map(path: &Path) -> Result<serde_json::Map<String, serde_json::Value>, String> {
|
||||
if !path.exists() {
|
||||
return Ok(serde_json::Map::new());
|
||||
}
|
||||
let content =
|
||||
std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
serde_json::from_str(&content).map_err(|e| format!("parse source map: {e}"))
|
||||
}
|
||||
|
||||
/// Write `map` to `path` as pretty-printed JSON.
|
||||
pub(crate) fn write_map(
|
||||
path: &Path,
|
||||
map: serde_json::Map<String, serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
let content = serde_json::to_string_pretty(&serde_json::Value::Object(map))
|
||||
.map_err(|e| format!("serialize: {e}"))?;
|
||||
std::fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_rs(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn write_ts(dir: &std::path::Path, name: &str, content: &str) -> PathBuf {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
// --- Rust happy path ---
|
||||
|
||||
#[test]
|
||||
fn rust_check_happy_path_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(
|
||||
tmp.path(),
|
||||
"foo.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn hello() {}\n",
|
||||
);
|
||||
assert_eq!(check_files(&[&path]), CheckResult::Ok);
|
||||
}
|
||||
|
||||
// --- Rust failure path ---
|
||||
|
||||
#[test]
|
||||
fn rust_check_missing_module_doc_yields_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(tmp.path(), "foo.rs", "/// A function.\npub fn hello() {}\n");
|
||||
let result = check_files(&[&path]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "module")),
|
||||
"expected module failure, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_check_missing_fn_doc_yields_failure_with_correct_fields() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(
|
||||
tmp.path(),
|
||||
"bar.rs",
|
||||
"//! Module doc.\n\npub fn undocumented() {}\n",
|
||||
);
|
||||
let result = check_files(&[&path]);
|
||||
if let CheckResult::Failures(failures) = result {
|
||||
let f = failures.iter().find(|f| f.item_kind == "fn").unwrap();
|
||||
assert_eq!(f.item_name, "undocumented");
|
||||
assert_eq!(f.file_path, path);
|
||||
assert_eq!(f.line, 3);
|
||||
} else {
|
||||
panic!("expected failures");
|
||||
}
|
||||
}
|
||||
|
||||
// --- TypeScript happy path ---
|
||||
|
||||
#[test]
|
||||
fn ts_check_happy_path_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/**\n * File doc.\n */\n\n/**\n * Does something.\n */\nexport function hello(): void {}\n",
|
||||
);
|
||||
assert_eq!(check_files(&[&path]), CheckResult::Ok);
|
||||
}
|
||||
|
||||
// --- TypeScript failure path ---
|
||||
|
||||
#[test]
|
||||
fn ts_check_missing_file_doc_yields_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let _path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/** A function. */\nexport function hello(): void {}\n",
|
||||
);
|
||||
// No file-level JSDoc (first non-empty line is not /**)
|
||||
// Actually this file DOES start with /**, so let's make one that doesn't
|
||||
let path2 = write_ts(tmp.path(), "app2.ts", "export function hello(): void {}\n");
|
||||
let result = check_files(&[&path2]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")),
|
||||
"expected file failure, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_check_missing_export_doc_yields_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/**\n * File doc.\n */\n\nexport function undocumented(): void {}\n",
|
||||
);
|
||||
let result = check_files(&[&path]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "function" && f.item_name == "undocumented")),
|
||||
"expected function failure, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Update idempotency ---
|
||||
|
||||
#[test]
|
||||
fn update_idempotent_same_input_twice() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let rs_path = write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn foo() {}\n",
|
||||
);
|
||||
let map_path = tmp.path().join("source-map.json");
|
||||
let files: &[&Path] = &[&rs_path];
|
||||
|
||||
update_source_map(files, &map_path).unwrap();
|
||||
let first = std::fs::read_to_string(&map_path).unwrap();
|
||||
|
||||
update_source_map(files, &map_path).unwrap();
|
||||
let second = std::fs::read_to_string(&map_path).unwrap();
|
||||
|
||||
assert_eq!(first, second, "update_source_map must be idempotent");
|
||||
}
|
||||
|
||||
// --- update_source_map preserves other entries ---
|
||||
|
||||
#[test]
|
||||
fn update_source_map_preserves_unrelated_entries() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let map_path = tmp.path().join("source-map.json");
|
||||
|
||||
// Write an initial map with an unrelated entry
|
||||
std::fs::write(&map_path, r#"{"unrelated/file.rs": ["fn old"]}"#).unwrap();
|
||||
|
||||
let rs_path = write_rs(
|
||||
tmp.path(),
|
||||
"new.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn bar() {}\n",
|
||||
);
|
||||
update_source_map(&[&rs_path], &map_path).unwrap();
|
||||
|
||||
let content = std::fs::read_to_string(&map_path).unwrap();
|
||||
assert!(
|
||||
content.contains("unrelated/file.rs"),
|
||||
"old entry should be preserved"
|
||||
);
|
||||
assert!(content.contains("new.rs"), "new entry should be added");
|
||||
}
|
||||
|
||||
// --- Gate tests: AC3 / AC4 ---
|
||||
|
||||
/// AC3: a worktree with a missing module doc fails gates with a recognisable
|
||||
/// error that references the missing file and line number.
|
||||
#[test]
|
||||
fn gate_missing_module_doc_fails_with_file_and_line_in_direction() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// File has a pub fn but NO //! module doc comment.
|
||||
let path = write_rs(tmp.path(), "missing_doc.rs", "pub fn no_module_doc() {}\n");
|
||||
let result = check_files(&[&path]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if !v.is_empty()),
|
||||
"expected failures for missing module doc, got {result:?}"
|
||||
);
|
||||
if let CheckResult::Failures(failures) = result {
|
||||
let module_failure = failures
|
||||
.iter()
|
||||
.find(|f| f.item_kind == "module")
|
||||
.expect("expected a module-level failure");
|
||||
let direction = module_failure.to_direction();
|
||||
// Direction must name the file so the agent can navigate directly to it.
|
||||
assert!(
|
||||
direction.contains("missing_doc.rs"),
|
||||
"direction must reference the file name: {direction}"
|
||||
);
|
||||
// Direction must contain a colon-separated line number.
|
||||
assert!(
|
||||
direction.contains(':'),
|
||||
"direction must contain a file:line reference: {direction}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// AC4: a worktree where every changed file has full docs passes gates (Ok result).
|
||||
#[test]
|
||||
fn gate_fully_documented_files_pass() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(
|
||||
tmp.path(),
|
||||
"fully_documented.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn greet() {}\n\n/// A struct.\npub struct Hello;\n",
|
||||
);
|
||||
assert_eq!(
|
||||
check_files(&[&path]),
|
||||
CheckResult::Ok,
|
||||
"fully documented file should produce no failures"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Ratchet tests: AC3 / AC4 ---
|
||||
|
||||
/// AC3: a file with N pre-existing undocumented items plus 1 new undocumented item
|
||||
/// added by the commit reports exactly 1 violation, not N+1.
|
||||
#[test]
|
||||
fn ratchet_only_new_undocumented_items_are_flagged() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
// Base commit: file with 2 undocumented public fns (pre-existing).
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module doc.\n\npub fn old_a() {}\npub fn old_b() {}\n",
|
||||
);
|
||||
Command::new("git")
|
||||
.args(["add", "lib.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "base"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Second commit: append 1 new undocumented fn.
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module doc.\n\npub fn old_a() {}\npub fn old_b() {}\npub fn new_c() {}\n",
|
||||
);
|
||||
Command::new("git")
|
||||
.args(["add", "lib.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add new_c"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let file = tmp.path().join("lib.rs");
|
||||
let result = check_files_ratcheted(&[file.as_path()], tmp.path(), "HEAD~1");
|
||||
match result {
|
||||
CheckResult::Failures(failures) => {
|
||||
assert_eq!(
|
||||
failures.len(),
|
||||
1,
|
||||
"expected exactly 1 failure (new_c), got {failures:?}"
|
||||
);
|
||||
assert_eq!(failures[0].item_name, "new_c");
|
||||
}
|
||||
CheckResult::Ok => panic!("expected 1 failure for new_c, got Ok"),
|
||||
}
|
||||
}
|
||||
|
||||
/// AC4: a commit that doesn't change a file does not blame it for pre-existing
|
||||
/// undocumented items.
|
||||
#[test]
|
||||
fn ratchet_unchanged_file_not_blamed() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
// Base commit: undocumented file.
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"untouched.rs",
|
||||
"//! Module doc.\n\npub fn old_undocumented() {}\n",
|
||||
);
|
||||
Command::new("git")
|
||||
.args(["add", "untouched.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "base"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Second commit: add a different, fully documented file; untouched.rs unchanged.
|
||||
write_rs(
|
||||
tmp.path(),
|
||||
"new_file.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn documented() {}\n",
|
||||
);
|
||||
Command::new("git")
|
||||
.args(["add", "new_file.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add new_file"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Simulate passing untouched.rs to the ratcheted check.
|
||||
// Since it has no added lines in the diff, it should produce no failures.
|
||||
let file = tmp.path().join("untouched.rs");
|
||||
let result = check_files_ratcheted(&[file.as_path()], tmp.path(), "HEAD~1");
|
||||
assert_eq!(
|
||||
result,
|
||||
CheckResult::Ok,
|
||||
"file not touched by the commit should not be blamed"
|
||||
);
|
||||
}
|
||||
|
||||
// --- parse_added_ranges unit tests ---
|
||||
|
||||
#[test]
|
||||
fn parse_added_ranges_single_hunk() {
|
||||
let diff = "@@ -0,0 +1,3 @@ some context\n+line1\n+line2\n+line3\n";
|
||||
let ranges = parse_added_ranges(diff);
|
||||
assert_eq!(ranges, vec![1..=3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_added_ranges_multiple_hunks() {
|
||||
let diff =
|
||||
"@@ -1,2 +1,3 @@\n context\n+new\n context\n@@ -10,0 +11,2 @@\n+added1\n+added2\n";
|
||||
let ranges = parse_added_ranges(diff);
|
||||
assert_eq!(ranges, vec![1..=3, 11..=12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_added_ranges_empty_diff() {
|
||||
let ranges = parse_added_ranges("");
|
||||
assert!(ranges.is_empty());
|
||||
}
|
||||
|
||||
// --- Spawn integration: update_for_worktree writes map at expected path ---
|
||||
|
||||
fn init_git_repo(dir: &Path) {
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git init");
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git config email");
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("git config name");
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("initial commit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_integration_map_written_at_expected_path() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
init_git_repo(tmp.path());
|
||||
|
||||
// Add a well-documented Rust file and commit it
|
||||
let rs_path = write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn greet() {}\n",
|
||||
);
|
||||
Command::new("git")
|
||||
.args(["add", "lib.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git add");
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "add lib.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git commit");
|
||||
|
||||
let huskies_dir = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&huskies_dir).unwrap();
|
||||
let map_path = huskies_dir.join("source-map.json");
|
||||
|
||||
// Simulate what spawn does: update_for_worktree with base = initial commit
|
||||
let result = update_for_worktree(tmp.path(), "HEAD~1", &map_path);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"update_for_worktree failed: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
// The map file must exist at the expected path
|
||||
assert!(
|
||||
map_path.exists(),
|
||||
"source map must be written at .huskies/source-map.json"
|
||||
);
|
||||
|
||||
let content = std::fs::read_to_string(&map_path).unwrap();
|
||||
let _ = rs_path; // used above
|
||||
assert!(
|
||||
content.contains("lib.rs"),
|
||||
"map must contain the documented file"
|
||||
);
|
||||
assert!(
|
||||
content.contains("fn greet"),
|
||||
"map must list the documented function"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! CLI for checking documentation coverage on files changed since a base branch.
|
||||
//!
|
||||
//! Usage: `source-map-check [--worktree <path>] [--base <branch>]`
|
||||
//!
|
||||
//! Exits with code 1 and prints LLM-friendly directions when public items are
|
||||
//! missing doc comments. Exits 0 (silently) when all changed files are fully
|
||||
//! documented or when there are no relevant changes to check.
|
||||
|
||||
use source_map_gen::{CheckResult, check_files_ratcheted};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let worktree = parse_arg(&args, "--worktree").unwrap_or_else(|| ".".to_string());
|
||||
let base = parse_arg(&args, "--base").unwrap_or_else(|| "master".to_string());
|
||||
|
||||
let worktree_path = Path::new(&worktree);
|
||||
|
||||
let output = match Command::new("git")
|
||||
.args(["diff", "--name-only", &format!("{base}...HEAD")])
|
||||
.current_dir(worktree_path)
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
eprintln!("source-map-check: git diff failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
// Base branch not found or other git error — skip the check gracefully.
|
||||
return;
|
||||
}
|
||||
|
||||
let changed: Vec<PathBuf> = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| worktree_path.join(l))
|
||||
.filter(|p| p.exists())
|
||||
.collect();
|
||||
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let file_refs: Vec<&Path> = changed.iter().map(PathBuf::as_path).collect();
|
||||
|
||||
match check_files_ratcheted(&file_refs, worktree_path, &base) {
|
||||
CheckResult::Ok => {}
|
||||
CheckResult::Failures(failures) => {
|
||||
eprintln!(
|
||||
"Doc coverage check failed. Add doc comments to the following items before committing:\n"
|
||||
);
|
||||
for f in &failures {
|
||||
eprintln!(" {}", f.to_direction());
|
||||
}
|
||||
eprintln!(
|
||||
"\nRe-run: cargo run -p source-map-gen --bin source-map-check -- --worktree . --base master"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a flag value from an argument list (e.g. `--flag value`).
|
||||
fn parse_arg(args: &[String], flag: &str) -> Option<String> {
|
||||
args.windows(2).find(|w| w[0] == flag).map(|w| w[1].clone())
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Rust documentation coverage adapter.
|
||||
//!
|
||||
//! Checks for:
|
||||
//! - A `//!` module-level doc comment somewhere in every `.rs` file.
|
||||
//! - A `///` doc comment immediately before every `pub` item (`fn`, `struct`,
|
||||
//! `enum`, `trait`, `type`, `const`, `static`, `mod`).
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{CheckFailure, CheckResult, LanguageAdapter};
|
||||
|
||||
/// Rust documentation coverage adapter.
|
||||
pub struct RustAdapter;
|
||||
|
||||
impl RustAdapter {
|
||||
fn check_file(&self, path: &Path) -> Vec<CheckFailure> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let mut failures = Vec::new();
|
||||
|
||||
// Module-level doc comment (//!)
|
||||
if !lines.iter().any(|l| l.trim_start().starts_with("//!")) {
|
||||
failures.push(CheckFailure {
|
||||
file_path: path.to_path_buf(),
|
||||
line: 1,
|
||||
item_kind: "module".to_string(),
|
||||
item_name: module_name(path),
|
||||
});
|
||||
}
|
||||
|
||||
// Public items missing /// doc comments
|
||||
for (i, &line) in lines.iter().enumerate() {
|
||||
if let Some((kind, name)) = parse_pub_item(line)
|
||||
&& !has_doc_before(&lines, i)
|
||||
{
|
||||
failures.push(CheckFailure {
|
||||
file_path: path.to_path_buf(),
|
||||
line: i + 1,
|
||||
item_kind: kind,
|
||||
item_name: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
failures
|
||||
}
|
||||
|
||||
/// Extract public item signatures from a Rust file as `"kind name"` strings.
|
||||
pub(crate) fn extract_items(path: &Path) -> Vec<String> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (kind, name) = parse_pub_item(line)?;
|
||||
Some(format!("{kind} {name}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageAdapter for RustAdapter {
|
||||
fn check(&self, files: &[&Path]) -> CheckResult {
|
||||
let failures: Vec<CheckFailure> = files.iter().flat_map(|&f| self.check_file(f)).collect();
|
||||
if failures.is_empty() {
|
||||
CheckResult::Ok
|
||||
} else {
|
||||
CheckResult::Failures(failures)
|
||||
}
|
||||
}
|
||||
|
||||
fn update_source_map(
|
||||
&self,
|
||||
passing_files: &[&Path],
|
||||
source_map_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
let mut map = crate::read_map(source_map_path)?;
|
||||
for &file in passing_files {
|
||||
let key = file.to_string_lossy().to_string();
|
||||
let items: Vec<serde_json::Value> = Self::extract_items(file)
|
||||
.into_iter()
|
||||
.map(serde_json::Value::String)
|
||||
.collect();
|
||||
map.insert(key, serde_json::Value::Array(items));
|
||||
}
|
||||
crate::write_map(source_map_path, map)
|
||||
}
|
||||
}
|
||||
|
||||
fn module_name(path: &Path) -> String {
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse a line as a public Rust item declaration.
|
||||
///
|
||||
/// Returns `(kind, name)` if the line declares a public item, `None` otherwise.
|
||||
fn parse_pub_item(line: &str) -> Option<(String, String)> {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Strip visibility: "pub(…)" or "pub "
|
||||
let rest = if let Some(r) = trimmed.strip_prefix("pub(") {
|
||||
let end = r.find(')')?;
|
||||
r[end + 1..].trim_start()
|
||||
} else if let Some(r) = trimmed.strip_prefix("pub ") {
|
||||
r.trim_start()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Handle "async fn"
|
||||
let rest = if let Some(r) = rest.strip_prefix("async ") {
|
||||
r.trim_start()
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
|
||||
// Match item keyword and extract name part
|
||||
let (kind, name_part) = if let Some(r) = rest.strip_prefix("fn ") {
|
||||
("fn", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("struct ") {
|
||||
("struct", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("enum ") {
|
||||
("enum", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("trait ") {
|
||||
("trait", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("type ") {
|
||||
("type", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("const ") {
|
||||
("const", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("static ") {
|
||||
("static", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("mod ") {
|
||||
("mod", r.trim_start())
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let name: String = name_part
|
||||
.chars()
|
||||
.take_while(|&c| c.is_alphanumeric() || c == '_')
|
||||
.collect();
|
||||
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((kind.to_string(), name))
|
||||
}
|
||||
|
||||
/// Return `true` if a `///` doc comment appears before the item at `item_idx`.
|
||||
///
|
||||
/// Scans backward from `item_idx`, skipping blank lines and `#[…]` attribute
|
||||
/// lines. Returns `true` if the first substantive line is a `///` comment.
|
||||
fn has_doc_before(lines: &[&str], item_idx: usize) -> bool {
|
||||
let mut i = item_idx;
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
let line = lines[i].trim();
|
||||
if line.starts_with("///") {
|
||||
return true;
|
||||
}
|
||||
if line.starts_with("#[") || line.starts_with("#![") || line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_rs(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_fully_documented_file_returns_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module doc.\n\n/// A function.\npub fn hello() {}\n\n/// A struct.\npub struct Foo;\n",
|
||||
);
|
||||
let adapter = RustAdapter;
|
||||
assert_eq!(adapter.check(&[&path]), CheckResult::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_detects_missing_module_doc() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(tmp.path(), "lib.rs", "/// A function.\npub fn hello() {}\n");
|
||||
let adapter = RustAdapter;
|
||||
let result = adapter.check(&[&path]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "module")),
|
||||
"expected module failure, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_detects_missing_fn_doc_with_correct_fields() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_rs(tmp.path(), "bar.rs", "//! Module.\n\npub fn no_doc() {}\n");
|
||||
let adapter = RustAdapter;
|
||||
let result = adapter.check(&[&path]);
|
||||
if let CheckResult::Failures(failures) = result {
|
||||
let f = failures.iter().find(|f| f.item_kind == "fn").unwrap();
|
||||
assert_eq!(f.item_name, "no_doc");
|
||||
assert_eq!(f.line, 3);
|
||||
assert_eq!(f.file_path, path);
|
||||
} else {
|
||||
panic!("expected failures");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_passes_item_with_attribute_before_doc() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Attribute between doc and item is fine; doc between attribute and item is fine too
|
||||
let path = write_rs(
|
||||
tmp.path(),
|
||||
"lib.rs",
|
||||
"//! Module.\n\n/// Doc.\n#[derive(Debug)]\npub struct Foo;\n",
|
||||
);
|
||||
let adapter = RustAdapter;
|
||||
assert_eq!(adapter.check(&[&path]), CheckResult::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pub_item_recognises_various_kinds() {
|
||||
assert_eq!(
|
||||
parse_pub_item("pub fn foo()"),
|
||||
Some(("fn".into(), "foo".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pub_item(" pub async fn bar()"),
|
||||
Some(("fn".into(), "bar".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pub_item("pub struct Baz"),
|
||||
Some(("struct".into(), "Baz".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pub_item("pub enum Qux"),
|
||||
Some(("enum".into(), "Qux".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pub_item("pub trait MyTrait"),
|
||||
Some(("trait".into(), "MyTrait".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_pub_item("pub(crate) fn inner()"),
|
||||
Some(("fn".into(), "inner".into()))
|
||||
);
|
||||
assert_eq!(parse_pub_item("fn private()"), None);
|
||||
assert_eq!(parse_pub_item("let x = 1;"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! TypeScript documentation coverage adapter.
|
||||
//!
|
||||
//! Checks for:
|
||||
//! - A leading file-level JSDoc comment (`/** … */`) at the top of every
|
||||
//! `.ts` / `.tsx` file.
|
||||
//! - A JSDoc comment before every exported declaration (`export function`,
|
||||
//! `export class`, `export type`, `export interface`, `export const`, etc.).
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::{CheckFailure, CheckResult, LanguageAdapter};
|
||||
|
||||
/// TypeScript documentation coverage adapter.
|
||||
pub struct TypeScriptAdapter;
|
||||
|
||||
impl TypeScriptAdapter {
|
||||
fn check_file(&self, path: &Path) -> Vec<CheckFailure> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let mut failures = Vec::new();
|
||||
|
||||
// File-level JSDoc: first non-empty line must start with "/**"
|
||||
if !has_file_level_jsdoc(&content) {
|
||||
failures.push(CheckFailure {
|
||||
file_path: path.to_path_buf(),
|
||||
line: 1,
|
||||
item_kind: "file".to_string(),
|
||||
item_name: file_stem(path),
|
||||
});
|
||||
}
|
||||
|
||||
// Exported items missing JSDoc
|
||||
for (i, &line) in lines.iter().enumerate() {
|
||||
if let Some((kind, name)) = parse_exported_item(line)
|
||||
&& !has_jsdoc_before(&lines, i)
|
||||
{
|
||||
failures.push(CheckFailure {
|
||||
file_path: path.to_path_buf(),
|
||||
line: i + 1,
|
||||
item_kind: kind,
|
||||
item_name: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
failures
|
||||
}
|
||||
|
||||
/// Extract exported item signatures from a TypeScript file as `"kind name"` strings.
|
||||
pub(crate) fn extract_items(path: &Path) -> Vec<String> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
content
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let (kind, name) = parse_exported_item(line)?;
|
||||
Some(format!("{kind} {name}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageAdapter for TypeScriptAdapter {
|
||||
fn check(&self, files: &[&Path]) -> CheckResult {
|
||||
let failures: Vec<CheckFailure> = files.iter().flat_map(|&f| self.check_file(f)).collect();
|
||||
if failures.is_empty() {
|
||||
CheckResult::Ok
|
||||
} else {
|
||||
CheckResult::Failures(failures)
|
||||
}
|
||||
}
|
||||
|
||||
fn update_source_map(
|
||||
&self,
|
||||
passing_files: &[&Path],
|
||||
source_map_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
let mut map = crate::read_map(source_map_path)?;
|
||||
for &file in passing_files {
|
||||
let key = file.to_string_lossy().to_string();
|
||||
let items: Vec<serde_json::Value> = Self::extract_items(file)
|
||||
.into_iter()
|
||||
.map(serde_json::Value::String)
|
||||
.collect();
|
||||
map.insert(key, serde_json::Value::Array(items));
|
||||
}
|
||||
crate::write_map(source_map_path, map)
|
||||
}
|
||||
}
|
||||
|
||||
fn file_stem(path: &Path) -> String {
|
||||
path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Return `true` if the file starts with a JSDoc block comment (`/**`).
|
||||
fn has_file_level_jsdoc(content: &str) -> bool {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
return trimmed.starts_with("/**");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Parse a line as an exported TypeScript declaration.
|
||||
///
|
||||
/// Returns `(kind, name)` for supported export forms, `None` otherwise.
|
||||
fn parse_exported_item(line: &str) -> Option<(String, String)> {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Strip "export default" or "export"
|
||||
let rest = if let Some(r) = trimmed.strip_prefix("export default ") {
|
||||
r.trim_start()
|
||||
} else if let Some(r) = trimmed.strip_prefix("export ") {
|
||||
r.trim_start()
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
// Strip optional "async"
|
||||
let rest = if let Some(r) = rest.strip_prefix("async ") {
|
||||
r.trim_start()
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
|
||||
let (kind, name_part) = if let Some(r) = rest.strip_prefix("function ") {
|
||||
("function", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("class ") {
|
||||
("class", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("type ") {
|
||||
("type", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("interface ") {
|
||||
("interface", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("const ") {
|
||||
("const", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("let ") {
|
||||
("let", r.trim_start())
|
||||
} else if let Some(r) = rest.strip_prefix("enum ") {
|
||||
("enum", r.trim_start())
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let name: String = name_part
|
||||
.chars()
|
||||
.take_while(|&c| c.is_alphanumeric() || c == '_')
|
||||
.collect();
|
||||
|
||||
if name.is_empty() {
|
||||
// "export default function() {}" — anonymous default export
|
||||
return Some((kind.to_string(), "default".to_string()));
|
||||
}
|
||||
|
||||
Some((kind.to_string(), name))
|
||||
}
|
||||
|
||||
/// Return `true` if a JSDoc comment appears before the item at `item_idx`.
|
||||
///
|
||||
/// Scans backward, skipping blank lines and decorator lines (`@…`). Returns
|
||||
/// `true` if the first substantive line ends with `*/` (closing a JSDoc block)
|
||||
/// or starts with `/**` (single-line JSDoc).
|
||||
fn has_jsdoc_before(lines: &[&str], item_idx: usize) -> bool {
|
||||
let mut i = item_idx;
|
||||
while i > 0 {
|
||||
i -= 1;
|
||||
let line = lines[i].trim();
|
||||
if line.is_empty() {
|
||||
// A blank line breaks the JSDoc–item adjacency: stop searching.
|
||||
return false;
|
||||
}
|
||||
if line.starts_with('@') {
|
||||
// Decorator — keep scanning upward
|
||||
continue;
|
||||
}
|
||||
return line.ends_with("*/") || line.starts_with("/**");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_ts(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, content).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_fully_documented_file_returns_ok() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/**\n * File doc.\n */\n\n/** Does something. */\nexport function hello(): void {}\n",
|
||||
);
|
||||
let adapter = TypeScriptAdapter;
|
||||
assert_eq!(adapter.check(&[&path]), CheckResult::Ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_detects_missing_file_jsdoc() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/** Does something. */\nexport function hello(): void {}\n",
|
||||
);
|
||||
// First non-empty line IS "/**", so this file passes the file-level check.
|
||||
// Use a file that starts with code instead.
|
||||
let path2 = write_ts(
|
||||
tmp.path(),
|
||||
"app2.ts",
|
||||
"import { foo } from './foo';\n/** A function. */\nexport function hello(): void {}\n",
|
||||
);
|
||||
let adapter = TypeScriptAdapter;
|
||||
let result = adapter.check(&[&path2]);
|
||||
assert!(
|
||||
matches!(&result, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")),
|
||||
"expected file failure, got {result:?}"
|
||||
);
|
||||
// The first file (starts with /**) should pass the file-level check
|
||||
let result2 = adapter.check(&[&path]);
|
||||
// It may still fail on the export if there's no separate export doc,
|
||||
// but the file-level check itself should pass (first line is /**)
|
||||
assert!(
|
||||
!matches!(&result2, CheckResult::Failures(v) if v.iter().any(|f| f.item_kind == "file")),
|
||||
"file starting with /** should not have file-level failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_detects_missing_export_jsdoc_with_correct_fields() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = write_ts(
|
||||
tmp.path(),
|
||||
"app.ts",
|
||||
"/**\n * File doc.\n */\n\nexport function undocumented(): void {}\n",
|
||||
);
|
||||
let adapter = TypeScriptAdapter;
|
||||
let result = adapter.check(&[&path]);
|
||||
if let CheckResult::Failures(failures) = result {
|
||||
let f = failures.iter().find(|f| f.item_kind == "function").unwrap();
|
||||
assert_eq!(f.item_name, "undocumented");
|
||||
assert_eq!(f.file_path, path);
|
||||
} else {
|
||||
panic!("expected failures");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_exported_item_recognises_various_kinds() {
|
||||
assert_eq!(
|
||||
parse_exported_item("export function foo()"),
|
||||
Some(("function".into(), "foo".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exported_item("export async function bar()"),
|
||||
Some(("function".into(), "bar".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exported_item("export class Baz"),
|
||||
Some(("class".into(), "Baz".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exported_item("export type Qux = string;"),
|
||||
Some(("type".into(), "Qux".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exported_item("export interface IFoo"),
|
||||
Some(("interface".into(), "IFoo".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_exported_item("export const MY_CONST = 1;"),
|
||||
Some(("const".into(), "MY_CONST".into()))
|
||||
);
|
||||
assert_eq!(parse_exported_item("function notExported()"), None);
|
||||
assert_eq!(parse_exported_item("const x = 1;"), None);
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -9,8 +9,8 @@
|
||||
|
||||
FROM rust:1.90-bookworm AS base
|
||||
|
||||
# Clippy is needed at runtime for acceptance gates (cargo clippy)
|
||||
RUN rustup component add clippy
|
||||
# Clippy and rustfmt are needed at runtime for acceptance gates
|
||||
RUN rustup component add clippy rustfmt
|
||||
|
||||
# ── System deps ──────────────────────────────────────────────────────
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -109,7 +109,8 @@ RUN groupadd -r huskies \
|
||||
&& chown -R huskies:huskies /usr/local/cargo /usr/local/rustup \
|
||||
&& chown -R huskies:huskies /app \
|
||||
&& mkdir -p /workspace/target /app/target \
|
||||
&& chown huskies:huskies /workspace/target /app/target
|
||||
&& chown huskies:huskies /workspace/target /app/target \
|
||||
&& git config --global init.defaultBranch master
|
||||
|
||||
# ── Entrypoint ───────────────────────────────────────────────────────
|
||||
# Validates required env vars (GIT_USER_NAME, GIT_USER_EMAIL) and
|
||||
|
||||
@@ -69,6 +69,16 @@ services:
|
||||
- workspace-target:/workspace/target
|
||||
- huskies-target:/app/target
|
||||
|
||||
# Isolate frontend node_modules from the host.
|
||||
# npm install pulls platform-specific native binaries (esbuild,
|
||||
# rollup, etc.) — macOS binaries won't run on Linux and vice versa.
|
||||
# Without this volume, building on the Mac host writes macOS
|
||||
# node_modules into the bind mount, then the Linux container tries
|
||||
# to execute them and fails. The Docker volume gives the container
|
||||
# its own Linux-native node_modules that doesn't collide with the
|
||||
# host's.
|
||||
- frontend-modules:/workspace/frontend/node_modules
|
||||
|
||||
# ── Security hardening ──────────────────────────────────────────
|
||||
# Read-only root filesystem. Only explicitly mounted volumes and
|
||||
# tmpfs paths are writable.
|
||||
@@ -108,6 +118,14 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Log rotation – test output goes to stdout via Stdio::inherit,
|
||||
# so container logs grow fast without rotation.
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
# Use tini as PID 1 to reap zombie child processes.
|
||||
# Without this, grandchild processes (esbuild, cargo, etc.) spawned by
|
||||
# npm/cargo during worktree setup and gate checks become zombies.
|
||||
@@ -122,3 +140,4 @@ volumes:
|
||||
claude-state:
|
||||
workspace-target:
|
||||
huskies-target:
|
||||
frontend-modules:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Future Service Module Extractions
|
||||
|
||||
Recommended order for extracting remaining HTTP handlers into `service/<domain>/`
|
||||
modules, following the conventions in [service-modules.md](service-modules.md).
|
||||
|
||||
## Recommended Order
|
||||
|
||||
1. **`settings`** — small surface, few dependencies, good warm-up
|
||||
2. **`oauth`** — reads/writes token files; pure validation logic separates cleanly
|
||||
3. **`wizard`** — stateless generation logic is already mostly pure; thin I/O layer
|
||||
4. **`project`** — project scaffolding; wraps `io::fs::scaffold`, clean separation
|
||||
5. **`io`** (search/shell) — wraps `io::search` and `io::shell`; pure query-building separable
|
||||
6. **`anthropic`** — token-proxy handler; pure request-shaping + thin HTTP I/O
|
||||
7. **`stories`** (workflow) — CRDT-backed story ops; typed errors for 400/404/409/500
|
||||
8. **`events`** — SSE handler; mostly framework wiring, but event filtering is pure
|
||||
|
||||
## Special Case: `ws`
|
||||
|
||||
The WebSocket handler (`http/ws.rs`) is a **dedicated harder extraction** because
|
||||
it mixes multiple concerns (chat dispatch, permission forwarding, SSE bridging)
|
||||
and depends on long-lived async streams. Extract it last, after the above list
|
||||
is complete and the service module pattern is well-established.
|
||||
|
||||
## Notes
|
||||
|
||||
- Each extraction should link back to `docs/architecture/service-modules.md`
|
||||
in the story description to maintain consistency.
|
||||
- The `agents` extraction (story 604) is the reference implementation every
|
||||
future extraction should follow.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Architecture Roadmap: Transports, Services, State Machine, CRDT
|
||||
|
||||
*Spike 613 — April 2026*
|
||||
|
||||
This document captures the current architecture across four key layers and charts
|
||||
the recommended next steps for each.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current State
|
||||
|
||||
### 1.1 Service Layer
|
||||
|
||||
Stories 604–619 established a clean service extraction pattern. The
|
||||
`server/src/service/` directory now has 21 sub-modules, each following the
|
||||
functional-core / imperative-shell convention documented in
|
||||
[service-modules.md](service-modules.md).
|
||||
|
||||
**Extracted so far:**
|
||||
`agents`, `anthropic`, `bot_command`, `common`, `diagnostics`, `events`,
|
||||
`file_io`, `gateway`, `git_ops`, `health`, `merge`, `notifications`, `oauth`,
|
||||
`pipeline`, `project`, `qa`, `settings`, `shell`, `story`, `timer`, `wizard`,
|
||||
`ws`
|
||||
|
||||
**Remaining in HTTP handlers** (see [future-extractions.md](future-extractions.md)):
|
||||
The list there was written before stories 615–619. After those stories landed,
|
||||
the remaining surface is smaller. The HTTP handlers still containing inline
|
||||
business logic are: `http/ws.rs` (WebSocket dispatch) and scattered ad-hoc
|
||||
helpers in `http/mcp/` that have not yet been migrated to typed service modules.
|
||||
|
||||
### 1.2 Chat Transports
|
||||
|
||||
Four transport backends implement `ChatTransport` (defined in `chat/mod.rs`):
|
||||
|
||||
| Transport | Connection model | Rooms / channels |
|
||||
|-----------|-----------------|-----------------|
|
||||
| Matrix | Long-lived WebSocket to homeserver | Dynamic (per-room history) |
|
||||
| Slack | HTTP webhook (Events API) | Fixed at startup from bot.toml |
|
||||
| WhatsApp | HTTP webhook (Meta Graph API or Twilio) | Ambient (tracked active senders) |
|
||||
| Discord | Gateway WebSocket + REST | Fixed at startup from bot.toml |
|
||||
|
||||
All four are instantiated manually in `main.rs` (~lines 567–690) and passed into
|
||||
`AppContext`. Stage-transition notifications are pushed through
|
||||
`service/notifications/`.
|
||||
|
||||
**Known issue (Bug 501):** The Matrix bot spawns its own `TimerStore` instead of
|
||||
consuming the shared `AppContext.timer_store`. This means MCP-tool cancellations
|
||||
and the bot's tick loop see different in-memory state.
|
||||
|
||||
### 1.3 Pipeline State Machine
|
||||
|
||||
`server/src/pipeline_state.rs` provides a typed, compile-time-safe state machine
|
||||
that replaces the old stringly-typed CRDT views.
|
||||
|
||||
**Synced stages (all nodes converge):**
|
||||
```
|
||||
Backlog → Coding → Qa → Merge { feature_branch, commits_ahead: NonZeroU32 }
|
||||
→ Done { merged_at, merge_commit }
|
||||
→ Archived { archived_at, reason }
|
||||
```
|
||||
|
||||
`ArchiveReason` subsumes the old `blocked`, `merge_failure`, and `review_hold`
|
||||
flags: `Completed | Abandoned | Superseded | Blocked | MergeFailed | ReviewHeld`.
|
||||
|
||||
`NonZeroU32` in `Merge` makes zero-commit merges structurally impossible.
|
||||
|
||||
**Per-node execution state (local, not replicated):**
|
||||
`Idle → Pending → Running → RateLimited → Completed`
|
||||
|
||||
**Status:** The typed state machine is defined and the projection layer
|
||||
(`PipelineItemView → PipelineItem via TryFrom`) is in place. Consumer
|
||||
migration — replacing ad-hoc string comparisons across the codebase — is the
|
||||
remaining work (tracked by Story 520).
|
||||
|
||||
### 1.4 CRDT Layer
|
||||
|
||||
`server/src/crdt_state.rs` + `crdt_sync.rs` form the distributed-state
|
||||
foundation:
|
||||
|
||||
- **Document model:** `PipelineDoc { items: ListCrdt<PipelineItemCrdt>, nodes: ListCrdt<NodePresenceCrdt> }`
|
||||
- **Registers:** `LwwRegisterCrdt<T>` for all mutable fields
|
||||
- **Persistence:** Ops stored in SQLite (`pipeline.db`); `CrdtEvent` broadcast on every stage change
|
||||
- **Sync protocol:** WebSocket `/crdt-sync` — bulk dump on connect (text), individual `SignedOp`s in real-time (binary)
|
||||
- **Backpressure:** Slow peers are disconnected; they reconnect and get a fresh bulk dump
|
||||
|
||||
**Filesystem shadows** (`huskies/work/`) are now a secondary output only — CRDT is
|
||||
the source of truth. Several clean-up stories (513, 517) remain backlogged to
|
||||
remove the remaining fallback paths.
|
||||
|
||||
---
|
||||
|
||||
## 2. Roadmap
|
||||
|
||||
### Phase A — Finish the State Machine Migration (Story 520)
|
||||
|
||||
**Goal:** Every pipeline query uses the typed `PipelineItem` enum instead of
|
||||
raw string comparisons on `stage`.
|
||||
|
||||
Work:
|
||||
1. Replace `stage == "current"` / `"qa"` / `"merge"` patterns in `agents/`,
|
||||
`http/mcp/`, `chat/commands/`, and `gateway.rs` with `matches!(item, PipelineItem::Coding)` etc.
|
||||
2. Remove the `PipelineItemView` → string projection paths once all consumers
|
||||
use the typed enum.
|
||||
3. Add exhaustive match tests in `pipeline_state.rs` so new stages cause
|
||||
compile-time failures, not silent mismatches.
|
||||
|
||||
### Phase B — Transport Registry Abstraction
|
||||
|
||||
**Goal:** Replace the manual transport wiring in `main.rs` with a pluggable
|
||||
registry, making it easy to add or remove transports without modifying the
|
||||
startup sequence.
|
||||
|
||||
Work:
|
||||
1. Define a `TransportRegistry` that holds `Vec<Box<dyn ChatTransport>>` keyed
|
||||
by `TransportKind` (Matrix, Slack, WhatsApp, Discord).
|
||||
2. Move the per-transport instantiation logic from `main.rs` into
|
||||
`service/transport/` following the service module conventions.
|
||||
3. Unify webhook signature verification (currently duplicated between Slack and
|
||||
WhatsApp) into a shared `service/transport/verify.rs`.
|
||||
4. Fix Bug 501: pass the shared `AppContext.timer_store` into the Matrix bot
|
||||
instead of spawning a private instance.
|
||||
5. Unify message history persistence (each transport currently owns a separate
|
||||
history file format) into a common `service/transport/history.rs`.
|
||||
|
||||
### Phase C — CRDT Cleanup (Stories 513, 517, 518, 519, 521)
|
||||
|
||||
**Goal:** Remove all legacy filesystem-first paths and complete the
|
||||
CRDT-as-source-of-truth migration.
|
||||
|
||||
Priority order (based on risk/value):
|
||||
1. **519** — Mergemaster must detect zero-commits-ahead and fail loudly instead of
|
||||
silently exiting. Structural fix: `Merge { commits_ahead: NonZeroU32 }` already
|
||||
enforces this — just ensure mergemaster reads from the typed enum.
|
||||
2. **518** — `apply_and_persist` should log when the persist tx fails instead of
|
||||
silently dropping ops.
|
||||
3. **513** — Startup reconciliation pass: detect drift between CRDT pipeline items
|
||||
and filesystem shadows, heal or report.
|
||||
4. **517** — Remove filesystem shadow fallback paths from `lifecycle.rs`.
|
||||
5. **521** — MCP HTTP capability to write a CRDT tombstone-delete op, clearing a
|
||||
story from in-memory state cleanly.
|
||||
6. **511** — Lamport clock inner seq resets to 1 on restart instead of resuming
|
||||
from `max(own_author_seq) + 1`. Low risk to fix, high risk to leave.
|
||||
|
||||
### Phase D — Distributed Node Authentication (Story 480)
|
||||
|
||||
**Goal:** Cryptographic node identity for the distributed mesh.
|
||||
|
||||
Nodes already carry an Ed25519 pubkey as their `node_id` in `NodePresenceCrdt`.
|
||||
Work:
|
||||
1. Sign each `SignedOp` with the node's Ed25519 key before broadcast.
|
||||
2. Verify signatures on receipt in `crdt_sync.rs` before applying ops.
|
||||
3. Expose the node's public key via `NodePresenceCrdt.address` so peers can
|
||||
bootstrap trust.
|
||||
4. Add a key-rotation path for long-lived nodes.
|
||||
|
||||
### Phase E — Build Agent Mode Polish (Story 479)
|
||||
|
||||
**Goal:** Stable headless build-agent mode (`huskies --rendezvous`) for
|
||||
distributing story processing across multiple machines.
|
||||
|
||||
Work:
|
||||
1. Resolve claim-timeout races: if a node claims a story and dies, the claim
|
||||
should expire after a configurable TTL and be re-claimable.
|
||||
2. Stale merge-job lock (Bug 498) — a lock left by a dead node should be
|
||||
detectable and clearable by the surviving cluster.
|
||||
3. CRDT Lamport clock fix (511) is a prerequisite — distributed agents need
|
||||
monotonically increasing sequences to converge correctly.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dependency Graph
|
||||
|
||||
```
|
||||
Phase A (State Machine)
|
||||
↓
|
||||
Phase B (Transport Registry) Phase C (CRDT Cleanup: 511, 518, 513, 517, 521, 519)
|
||||
↓
|
||||
Phase D (Cryptographic Auth)
|
||||
↓
|
||||
Phase E (Build Agent Polish)
|
||||
```
|
||||
|
||||
Phase A and C can progress in parallel. Phase B is independent of C/D/E.
|
||||
Phase D requires Phase C (especially 511 and 518). Phase E requires Phase D.
|
||||
|
||||
---
|
||||
|
||||
## 4. What NOT to Do
|
||||
|
||||
- **Don't split `crdt_state.rs` prematurely.** It's large but internally
|
||||
cohesive. A split should wait until the cleanup stories (Phase C) are done.
|
||||
- **Don't add a transport abstraction layer before fixing Bug 501.** A registry
|
||||
that instantiates a broken Matrix bot just propagates the bug.
|
||||
- **Don't extract `http/ws.rs` to a service module before Phase A is done.**
|
||||
The WebSocket handler touches pipeline state in string form; migrating it
|
||||
while the state machine migration is in progress will cause double-churn.
|
||||
@@ -0,0 +1,227 @@
|
||||
# Service Module Conventions
|
||||
|
||||
This document defines the layout, layering rules, and patterns for all service
|
||||
modules under `server/src/service/`. Every extraction from the HTTP handlers to
|
||||
a service module **must** follow these conventions.
|
||||
|
||||
---
|
||||
|
||||
## 1. Directory Layout
|
||||
|
||||
```
|
||||
server/src/service/<domain>/
|
||||
mod.rs — public API, typed Error, orchestration, integration tests
|
||||
io.rs — every side-effectful call; the ONLY file that may touch the
|
||||
filesystem, spawn processes, or call external crates that do
|
||||
<topic>.rs — pure logic for a named concern within the domain; no I/O
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- `<domain>` matches the HTTP handler filename (e.g. `agents`, `settings`,
|
||||
`oauth`).
|
||||
- **No file named `logic.rs`** — use a descriptive domain name instead
|
||||
(e.g. `selection.rs`, `token.rs`, `validation.rs`).
|
||||
- New topic files are added when a pure concern grows beyond ~50 lines or when
|
||||
it has independent test coverage needs.
|
||||
|
||||
---
|
||||
|
||||
## 2. The Functional-Core / Imperative-Shell Rule
|
||||
|
||||
```
|
||||
io.rs (imperative shell) ←→ mod.rs (orchestrator) ←→ <topic>.rs (functional core)
|
||||
```
|
||||
|
||||
| Layer | Allowed | Forbidden |
|
||||
|-------|---------|-----------|
|
||||
| `<topic>.rs` | Pure Rust, data-transformation, branching logic, pattern matching | Any I/O |
|
||||
| `io.rs` | `std::fs`, `std::process`, `tokio::fs`, network calls, `SystemTime::now` | Business logic beyond a thin wrapper |
|
||||
| `mod.rs` | Calls into `io.rs` and `<topic>.rs`; owns the `Error` type | Direct I/O without going through `io.rs` |
|
||||
|
||||
**Grep-enforceable check:** The following must NOT appear in any `service/<domain>/` file other than `io.rs`:
|
||||
|
||||
- `std::fs`
|
||||
- `std::process`
|
||||
- `std::thread::sleep`
|
||||
- `tokio::fs`
|
||||
- `reqwest`
|
||||
- `SystemTime::now`
|
||||
|
||||
---
|
||||
|
||||
## 3. Error Type Pattern
|
||||
|
||||
Each service domain declares its own typed error enum in `mod.rs`:
|
||||
|
||||
```rust
|
||||
/// Errors returned by `service::agents` operations.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
ProjectRootNotConfigured,
|
||||
AgentNotFound(String),
|
||||
WorkItemNotFound(String),
|
||||
WorktreeError(String),
|
||||
ConfigError(String),
|
||||
IoError(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error { ... }
|
||||
```
|
||||
|
||||
HTTP handlers map service errors to **specific** HTTP status codes:
|
||||
|
||||
| Error variant | HTTP status |
|
||||
|--------------|-------------|
|
||||
| `ProjectRootNotConfigured` | 400 Bad Request |
|
||||
| `AgentNotFound` | 404 Not Found |
|
||||
| `WorkItemNotFound` | 404 Not Found |
|
||||
| `WorktreeError` | 400 Bad Request |
|
||||
| `ConfigError` | 400 Bad Request |
|
||||
| `IoError` | 500 Internal Server Error |
|
||||
|
||||
**No generic `bad_request` for everything** — distinguish 400 vs 404 vs 500.
|
||||
|
||||
---
|
||||
|
||||
## 4. Test Pattern
|
||||
|
||||
### Chosen default pattern: fixture helpers in `io::test_helpers`
|
||||
|
||||
All filesystem setup for tests lives in a `#[cfg(test)] pub mod test_helpers`
|
||||
block inside `io.rs`. Test blocks in `mod.rs` and topic files call these
|
||||
helpers instead of importing `std::fs` directly.
|
||||
|
||||
**Grep-enforceable check for test code:** The following must NOT appear inside
|
||||
`#[cfg(test)]` blocks in any `service/<domain>/` file **other than `io.rs`**:
|
||||
|
||||
- `std::fs::` (any item)
|
||||
- `tokio::fs`
|
||||
- `std::process::` (any item)
|
||||
- `Command::new`
|
||||
|
||||
Run to verify:
|
||||
|
||||
```sh
|
||||
grep -rn --include='*.rs' \
|
||||
'std::fs::\|tokio::fs\|std::process::\|Command::new' \
|
||||
server/src/service/ | grep -v '/io\.rs'
|
||||
```
|
||||
|
||||
This must return zero matches (including lines inside `#[cfg(test)]` blocks).
|
||||
|
||||
### Pure topic files (`<topic>.rs`)
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Unit tests MUST:
|
||||
// - Use no tempdir, tokio runtime, or filesystem
|
||||
// - Cover every branch of every public function
|
||||
#[test]
|
||||
fn filter_removes_archived_agents() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### `io.rs`
|
||||
|
||||
```rust
|
||||
/// Fixture helpers — the ONLY place allowed to call std::fs in tests.
|
||||
#[cfg(test)]
|
||||
pub mod test_helpers {
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub fn make_work_dirs(tmp: &TempDir) { ... }
|
||||
pub fn make_stage_dirs(tmp: &TempDir) { ... }
|
||||
pub fn make_project_toml(tmp: &TempDir, content: &str) { ... }
|
||||
pub fn write_story_file(tmp: &TempDir, relative_path: &str, content: &str) { ... }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// IO tests MAY use tempdirs and real filesystem.
|
||||
// Keep them few and focused on the thin I/O wrapper contract.
|
||||
#[test]
|
||||
fn is_archived_returns_true_when_in_done() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### `mod.rs`
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use io::test_helpers::*; // ← fixture helpers; never import std::fs here
|
||||
|
||||
// Integration tests compose io + pure layers end-to-end.
|
||||
// May use tempdirs. Keep the count small — they are integration-level.
|
||||
#[tokio::test]
|
||||
async fn list_agents_excludes_archived() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Dependency Injection Pattern
|
||||
|
||||
Service functions take **only the dependencies they actually use**:
|
||||
|
||||
```rust
|
||||
// Good — takes only what it needs
|
||||
pub async fn start_agent(
|
||||
pool: &AgentPool,
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
agent_name: Option<&str>,
|
||||
) -> Result<AgentInfo, Error> { ... }
|
||||
|
||||
// Bad — takes the whole AppContext
|
||||
pub async fn start_agent(ctx: &AppContext, ...) -> Result<AgentInfo, Error> { ... }
|
||||
```
|
||||
|
||||
Standard injected dependencies for `service::agents`:
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `&AgentPool` | Agent lifecycle operations |
|
||||
| `&Path` (`project_root`) | Filesystem operations scoped to the project |
|
||||
| `&WorkflowState` | In-memory test result cache |
|
||||
|
||||
**The dependency set chosen for `agents` is the reference pattern for all future
|
||||
service module extractions.**
|
||||
|
||||
---
|
||||
|
||||
## 6. HTTP Handler Contract
|
||||
|
||||
After extraction, HTTP handlers are thin adapters:
|
||||
|
||||
```rust
|
||||
async fn start_agent(&self, payload: Json<StartAgentPayload>) -> OpenApiResult<...> {
|
||||
let project_root = self.ctx.agents.get_project_root(&self.ctx.state)
|
||||
.map_err(|e| bad_request(e))?; // extract from AppContext
|
||||
let info = service::agents::start_agent( // call service
|
||||
&self.ctx.agents, &project_root, &payload.story_id, payload.agent_name.as_deref(),
|
||||
).await.map_err(map_service_error)?; // map typed error → HTTP
|
||||
Ok(Json(AgentInfoResponse { ... })) // shape DTO
|
||||
}
|
||||
```
|
||||
|
||||
Handlers must contain **no**:
|
||||
- `std::fs` / file reads
|
||||
- `std::process` invocations
|
||||
- Inline load-mutate-save sequences
|
||||
- Inline validation that belongs in the service layer
|
||||
|
||||
---
|
||||
|
||||
## 7. Follow-up Extractions
|
||||
|
||||
See [future-extractions.md](future-extractions.md) for the recommended order
|
||||
and rationale for remaining extraction targets.
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/huskies.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Huskies</title>
|
||||
</head>
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.9.0",
|
||||
"name": "huskies",
|
||||
"version": "0.10.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.9.0",
|
||||
"name": "huskies",
|
||||
"version": "0.10.4",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
@@ -3832,9 +3832,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"name": "huskies",
|
||||
"private": true,
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- Left ear -->
|
||||
<polygon points="5,14 8,4 13,13" fill="#4b5563"/>
|
||||
<polygon points="6.5,12.5 8.5,6 12,12" fill="#9ca3af"/>
|
||||
<!-- Right ear -->
|
||||
<polygon points="27,14 24,4 19,13" fill="#4b5563"/>
|
||||
<polygon points="25.5,12.5 23.5,6 20,12" fill="#9ca3af"/>
|
||||
<!-- Head -->
|
||||
<circle cx="16" cy="18" r="12" fill="#6b7280"/>
|
||||
<!-- White face mask -->
|
||||
<ellipse cx="16" cy="21" rx="8" ry="7" fill="#f9fafb"/>
|
||||
<!-- Left eye white -->
|
||||
<circle cx="12" cy="16" r="3" fill="white"/>
|
||||
<!-- Left eye iris - blue (husky trait) -->
|
||||
<circle cx="12.3" cy="16" r="2" fill="#3b82f6"/>
|
||||
<!-- Left eye pupil -->
|
||||
<circle cx="12.3" cy="16" r="1" fill="#111827"/>
|
||||
<!-- Left eye highlight -->
|
||||
<circle cx="11.7" cy="15.3" r="0.5" fill="white"/>
|
||||
<!-- Right eye white -->
|
||||
<circle cx="20" cy="16" r="3" fill="white"/>
|
||||
<!-- Right eye iris - blue -->
|
||||
<circle cx="20.3" cy="16" r="2" fill="#3b82f6"/>
|
||||
<!-- Right eye pupil -->
|
||||
<circle cx="20.3" cy="16" r="1" fill="#111827"/>
|
||||
<!-- Right eye highlight -->
|
||||
<circle cx="19.7" cy="15.3" r="0.5" fill="white"/>
|
||||
<!-- Nose -->
|
||||
<ellipse cx="16" cy="22" rx="2.5" ry="1.8" fill="#1f2937"/>
|
||||
<!-- Nose highlight -->
|
||||
<ellipse cx="15.3" cy="21.3" rx="0.7" ry="0.5" fill="#6b7280"/>
|
||||
<!-- Mouth line -->
|
||||
<path d="M16,23.5 Q14,25 13,24.5" stroke="#9ca3af" stroke-width="0.6" fill="none" stroke-linecap="round"/>
|
||||
<path d="M16,23.5 Q18,25 19,24.5" stroke="#9ca3af" stroke-width="0.6" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user