From 9012d5ddc63bef365531494d169f35a91fae27bb Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Thu, 4 Jun 2026 09:06:14 -0400 Subject: [PATCH] Purge leftover Rust implementation Remove the legacy/ Rust tree (Cargo manifests, .rs sources, desktop entry), drop Rust-specific .gitignore blocks and README note, and strip now-dangling "ported from Rust" comments from the Go sources. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 10 - README.md | 3 +- internal/domain/domain.go | 4 +- internal/evidence/evidence.go | 4 +- internal/statemachine/statemachine.go | 6 +- legacy/Cargo.lock | 1102 -------- legacy/Cargo.toml | 19 - legacy/antidrift.desktop | 6 - legacy/src/constants.rs | 36 - legacy/src/context.rs | 156 -- legacy/src/domain.rs | 515 ---- legacy/src/event_log.rs | 583 ----- legacy/src/lib.rs | 6 - legacy/src/main.rs | 2121 --------------- legacy/src/session.rs | 3475 ------------------------- legacy/src/state_machine.rs | 327 --- legacy/src/window/linux.rs | 113 - legacy/src/window/mod.rs | 45 - legacy/src/window/windows.rs | 68 - 19 files changed, 8 insertions(+), 8591 deletions(-) delete mode 100644 legacy/Cargo.lock delete mode 100644 legacy/Cargo.toml delete mode 100644 legacy/antidrift.desktop delete mode 100644 legacy/src/constants.rs delete mode 100644 legacy/src/context.rs delete mode 100644 legacy/src/domain.rs delete mode 100644 legacy/src/event_log.rs delete mode 100644 legacy/src/lib.rs delete mode 100644 legacy/src/main.rs delete mode 100644 legacy/src/session.rs delete mode 100644 legacy/src/state_machine.rs delete mode 100644 legacy/src/window/linux.rs delete mode 100644 legacy/src/window/mod.rs delete mode 100644 legacy/src/window/windows.rs diff --git a/.gitignore b/.gitignore index 1afac72..58c31b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,3 @@ -# Generated by Cargo will have compiled files and executables -debug/ -target/ - -# These are backup files generated by rustfmt -**/*.rs.bk - -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb - # Local brainstorming companion artifacts .superpowers/ diff --git a/README.md b/README.md index 8f1ae36..bfa2013 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,7 @@ A personal focus operating system: treat each work session as an explicit commitment (next action, success condition, timebox), and make drift visible. -This is the Go reimagining. The original Rust implementation is preserved under -`legacy/` for reference. See `docs/superpowers/specs/` for the design. +See `docs/superpowers/specs/` for the design. ## Run diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 48f3b1d..c150878 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -1,5 +1,5 @@ -// Package domain holds the core commitment types and validation, ported from -// the original Rust implementation. These are pure data types with no I/O. +// Package domain holds the core commitment types and validation. These are +// pure data types with no I/O. package domain import ( diff --git a/internal/evidence/evidence.go b/internal/evidence/evidence.go index 6f53921..4ce5cb5 100644 --- a/internal/evidence/evidence.go +++ b/internal/evidence/evidence.go @@ -30,8 +30,8 @@ type Source interface { Watch(ctx context.Context, onChange func(WindowSnapshot)) } -// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket -// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl. +// titleNoise matches the clock/ratio/percent runs that pollute bucket +// keys (e.g. "1:23", "50.5%", "-3.0"). var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`) // extraSpace collapses the whitespace runs left behind by the scrubs below. diff --git a/internal/statemachine/statemachine.go b/internal/statemachine/statemachine.go index 18f6e92..03bf451 100644 --- a/internal/statemachine/statemachine.go +++ b/internal/statemachine/statemachine.go @@ -1,5 +1,5 @@ // Package statemachine holds the pure runtime and commitment transition -// functions ported from the Rust implementation. No I/O, no shared state. +// functions. No I/O, no shared state. package statemachine import ( @@ -9,8 +9,8 @@ import ( ) // RuntimeAction enumerates runtime transitions. Activate's policy acceptance -// and admin-override exit targets from the Rust enum are flattened into -// distinct actions so the action type stays a simple value. +// and admin-override exit targets are flattened into distinct actions so the +// action type stays a simple value. type RuntimeAction string const ( diff --git a/legacy/Cargo.lock b/legacy/Cargo.lock deleted file mode 100644 index bc861a3..0000000 --- a/legacy/Cargo.lock +++ /dev/null @@ -1,1102 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "antidrift" -version = "0.1.0" -dependencies = [ - "anyhow", - "fs2", - "hex", - "ratatui", - "regex", - "serde", - "serde_json", - "sha2", - "shellexpand", - "uuid", - "winapi", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "compact_str" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "ryu", - "static_assertions", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crossterm" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" -dependencies = [ - "bitflags", - "crossterm_winapi", - "libc", - "mio", - "parking_lot", - "signal-hook", - "signal-hook-mio", - "winapi", -] - -[[package]] -name = "crossterm_winapi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" -dependencies = [ - "winapi", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "fs2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" - -[[package]] -name = "js-sys" -version = "0.3.99" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.178" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" - -[[package]] -name = "libredox" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" -dependencies = [ - "bitflags", - "libc", -] - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.5", -] - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.48.0", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "ratatui" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16546c5b5962abf8ce6e2881e722b4e0ae3b6f1a08a26ae3573c55853ca68d3" -dependencies = [ - "bitflags", - "cassowary", - "compact_str", - "crossterm", - "itertools", - "lru", - "paste", - "stability", - "strum", - "strum_macros", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.16", - "libredox", - "thiserror", -] - -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.146" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217ca874ae0207aac254aa02c957ded05585a90892cc8d87f9e5fa49669dadd8" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shellexpand" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" -dependencies = [ - "dirs", -] - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" -dependencies = [ - "libc", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "stability" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn", -] - -[[package]] -name = "syn" -version = "2.0.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "thiserror" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "typenum" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.122" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] diff --git a/legacy/Cargo.toml b/legacy/Cargo.toml deleted file mode 100644 index 4c4a4ad..0000000 --- a/legacy/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "antidrift" -version = "0.1.0" -edition = "2021" - -[dependencies] -anyhow = "1.0.86" -fs2 = "0.4" -hex = "0.4.3" -ratatui = "0.27.0" -regex = "1.10.5" -shellexpand = "3.1.0" -serde = { version = "1.0", features = ["derive", "rc"] } -serde_json = "1.0" -sha2 = "0.10.8" -uuid = { version = "1", features = ["v7"] } - -[target.'cfg(windows)'.dependencies] -winapi = { version = "0.3", features = ["winuser", "processthreadsapi", "windef"] } diff --git a/legacy/antidrift.desktop b/legacy/antidrift.desktop deleted file mode 100644 index 093988b..0000000 --- a/legacy/antidrift.desktop +++ /dev/null @@ -1,6 +0,0 @@ -[Desktop Entry] -Name=AntiDrift -Exec=antidrift -Terminal=false -Type=Application -StartupNotify=false diff --git a/legacy/src/constants.rs b/legacy/src/constants.rs deleted file mode 100644 index 9ec3b33..0000000 --- a/legacy/src/constants.rs +++ /dev/null @@ -1,36 +0,0 @@ -pub const APP_TITLE: &str = "AntiDrift"; -pub const DEFAULT_DURATION: &str = "25"; -pub const DURATION_TITLE: &str = "Duration"; -#[allow(dead_code)] -pub const DEGRADED_EVIDENCE_TITLE: &str = "Evidence"; -pub const ENTER_SUCCESS_CONDITION: &str = "Provide success condition! "; -pub const EVENT_LOG_FILE: &str = "~/.antidrift_events.jsonl"; -pub const INTENTION_TITLE: &str = "Intention"; -pub const PAUSED: &str = "paused"; -pub const PREVIOUS_SESSIONS_TITLE: &str = "Previous Sessions"; -pub const PROVIDE_INTENTION: &str = "Provide intention! "; -pub const PROVIDE_VALID_DURATION: &str = "Provide valid duration in minutes! "; -pub const RATE_TITLES: &str = "Press 1, 2, 3 to rate titles!"; -pub const READY_TO_START: &str = "Ready to start next session."; -pub const SESSION_IN_PROGRESS: &str = "Session In-Progress"; -pub const SESSION_PAUSED: &str = "Session is paused. Unpause with 'p'."; -pub const SESSION_STATS_TITLE: &str = "Session Stats"; -pub const STATUS_FILE: &str = "~/.antidrift_status"; -pub const HISTORY_FILE: &str = "~/.antidrift_history.jsonl"; -pub const STATUS_TITLE: &str = "Status"; -pub const STATUS_CONFIGURE: &str = "🔄 antidrift configure session"; -pub const STATUS_LOCKED: &str = "🔒 antidrift locked"; -pub const STATUS_PLANNING: &str = "🧭 antidrift planning"; -pub const STATUS_PAUSED: &str = "⏸ antidrift paused"; -pub const STATUS_RATE_SESSION: &str = "☑ rate antidrift session!"; -pub const STATUS_QUIT: &str = "antidrift shutting down"; -pub const STATUS_TRANSITION: &str = "↔ antidrift transition"; -pub const SUCCESS_CONDITION_TITLE: &str = "Success Condition"; -pub const TRANSITION_DURATION_TITLE: &str = "Transition Minutes"; -pub const TRANSITION_REASON_TITLE: &str = "Transition Reason"; -pub const TRANSITION_RETURN_TITLE: &str = "Return Target"; -pub const VIOLATION_TITLE: &str = "Violation"; -pub const VIOLATION_REASON_TITLE: &str = "Dismissal Reason"; -pub const VIOLATION_STATUS: &str = "Unknown context detected. Enter a reason to continue."; -pub const PROVIDE_TRANSITION_REASON: &str = "Provide transition reason! "; -pub const PROVIDE_TRANSITION_RETURN: &str = "Provide return target! "; diff --git a/legacy/src/context.rs b/legacy/src/context.rs deleted file mode 100644 index b772a1f..0000000 --- a/legacy/src/context.rs +++ /dev/null @@ -1,156 +0,0 @@ -use crate::domain::AllowedContext; - -pub fn domain_allowed(context: &AllowedContext, candidate: &str) -> bool { - let candidate = normalize_domain(candidate); - if candidate.is_empty() { - return false; - } - - context.domains.iter().any(|allowed| { - let allowed = normalize_domain(allowed); - !allowed.is_empty() - && (candidate == allowed - || candidate - .strip_suffix(&allowed) - .is_some_and(|prefix| prefix.ends_with('.'))) - }) -} - -pub fn window_class_allowed(context: &AllowedContext, candidate: &str) -> bool { - let candidate = normalize_casefolded(candidate); - if candidate.is_empty() { - return false; - } - - context - .window_classes - .iter() - .any(|allowed| normalize_casefolded(allowed) == candidate) -} - -pub fn window_title_allowed(context: &AllowedContext, candidate: &str) -> bool { - let candidate = normalize_casefolded(candidate); - if candidate.is_empty() { - return false; - } - - context.window_title_substrings.iter().any(|allowed| { - let allowed = normalize_casefolded(allowed); - !allowed.is_empty() && candidate.contains(&allowed) - }) -} - -pub fn command_allowed(context: &AllowedContext, candidate: &str) -> bool { - let candidate = executable_basename(candidate); - if candidate.is_empty() { - return false; - } - - context - .commands - .iter() - .any(|allowed| executable_basename(allowed) == candidate) -} - -fn normalize_domain(value: &str) -> String { - value.trim().trim_end_matches('.').to_lowercase() -} - -fn normalize_casefolded(value: &str) -> String { - value.trim().to_lowercase() -} - -fn executable_basename(command: &str) -> String { - let executable = command.split_whitespace().next().unwrap_or_default(); - executable - .trim() - .rsplit(['/', '\\']) - .next() - .unwrap_or_default() - .to_lowercase() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::AllowedContext; - - #[test] - fn domains_match_exact_or_subdomain() { - let context = AllowedContext { - domains: vec!["github.com".to_string()], - ..AllowedContext::default() - }; - - assert!(domain_allowed(&context, "github.com")); - assert!(domain_allowed(&context, "docs.github.com")); - assert!(!domain_allowed(&context, "evilgithub.com")); - } - - #[test] - fn window_classes_match_case_insensitively_after_normalization() { - let context = AllowedContext { - window_classes: vec!["code".to_string()], - ..AllowedContext::default() - }; - - assert!(window_class_allowed(&context, "Code")); - assert!(!window_class_allowed(&context, "firefox")); - } - - #[test] - fn titles_match_by_substring() { - let context = AllowedContext { - window_title_substrings: vec!["antidrift".to_string()], - ..AllowedContext::default() - }; - - assert!(window_title_allowed(&context, "Commitment OS - AntiDrift")); - assert!(!window_title_allowed(&context, "random video")); - } - - #[test] - fn domains_ignore_whitespace_case_and_trailing_dots() { - let context = AllowedContext { - domains: vec![" GitHub.COM. ".to_string()], - ..AllowedContext::default() - }; - - assert!(domain_allowed(&context, " DOCS.GITHUB.COM. ")); - } - - #[test] - fn window_class_matching_trims_allowed_and_candidate_values() { - let context = AllowedContext { - window_classes: vec![" code ".to_string()], - ..AllowedContext::default() - }; - - assert!(window_class_allowed(&context, " Code ")); - } - - #[test] - fn title_matching_trims_allowed_substrings() { - let context = AllowedContext { - window_title_substrings: vec![" antidrift ".to_string()], - ..AllowedContext::default() - }; - - assert!(window_title_allowed(&context, "Commitment OS - AntiDrift")); - } - - #[test] - fn commands_match_executable_basename() { - let context = AllowedContext { - commands: vec!["cargo".to_string()], - ..AllowedContext::default() - }; - - assert!(command_allowed( - &context, - "/home/felixm/.cargo/bin/cargo test" - )); - assert!(command_allowed(&context, "cargo")); - assert!(!command_allowed(&context, "/usr/bin/git status")); - } -} diff --git a/legacy/src/domain.rs b/legacy/src/domain.rs deleted file mode 100644 index af2dcb4..0000000 --- a/legacy/src/domain.rs +++ /dev/null @@ -1,515 +0,0 @@ -use serde::{Deserialize, Serialize}; -use std::fmt; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use uuid::Uuid; - -pub const POLICY_SCHEMA_VERSION: u16 = 1; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CommitmentSource { - Manual, - Planner, - Recurring, - Recovery, - Template, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CommitmentState { - Draft, - Active, - Paused, - Completed, - Abandoned, - Violated, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RuntimeState { - Locked, - Planning, - Active, - Transition, - Review, - AdminOverride, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EnforcementLevel { - Observe, - Warn, - Block, - Locked, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EvidenceHealth { - Available, - Degraded(String), - Unavailable(String), -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct AllowedContext { - pub window_classes: Vec, - pub window_title_substrings: Vec, - pub domains: Vec, - pub repos: Vec, - pub commands: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Commitment { - pub id: String, - pub created_at_unix_secs: u64, - pub source: CommitmentSource, - pub project_id: Option, - pub template_id: Option, - pub next_action: String, - pub success_condition: String, - pub timebox_secs: u64, - pub transition_policy_id: Option, - pub state: CommitmentState, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct PolicySnapshot { - pub id: String, - pub commitment_id: String, - pub schema_version: u16, - pub created_at_unix_secs: u64, - pub runtime_state: RuntimeState, - pub enforcement_level: EnforcementLevel, - pub allowed_context: AllowedContext, - pub required_monitors: Vec, - pub violation_actions: Vec, - pub expires_at_unix_secs: Option, - pub generated_by_agent_version: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum CommitmentValidationError { - MissingId, - MissingNextAction, - MissingSuccessCondition, - MissingTimebox, -} - -impl fmt::Display for CommitmentValidationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - CommitmentValidationError::MissingId => write!(f, "commitment id is required"), - CommitmentValidationError::MissingNextAction => write!(f, "next action is required"), - CommitmentValidationError::MissingSuccessCondition => { - write!(f, "success condition is required") - } - CommitmentValidationError::MissingTimebox => write!(f, "timebox must be nonzero"), - } - } -} - -impl std::error::Error for CommitmentValidationError {} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum PolicySnapshotValidationError { - WrongSchemaVersion, - MissingId, - MissingCommitmentId, - MissingGeneratedByAgentVersion, -} - -impl fmt::Display for PolicySnapshotValidationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - PolicySnapshotValidationError::WrongSchemaVersion => { - write!(f, "policy schema version is unsupported") - } - PolicySnapshotValidationError::MissingId => write!(f, "policy id is required"), - PolicySnapshotValidationError::MissingCommitmentId => { - write!(f, "commitment id is required") - } - PolicySnapshotValidationError::MissingGeneratedByAgentVersion => { - write!(f, "generated agent version is required") - } - } - } -} - -impl std::error::Error for PolicySnapshotValidationError {} - -impl Commitment { - pub fn new_manual( - next_action: impl Into, - success_condition: impl Into, - timebox: Duration, - ) -> Result { - let next_action = next_action.into().trim().to_string(); - let success_condition = success_condition.into().trim().to_string(); - - if timebox.as_secs() == 0 { - return Err(CommitmentValidationError::MissingTimebox); - } - - let created_at_unix_secs = unix_secs_now(); - let commitment = Self { - id: unique_id("commitment"), - created_at_unix_secs, - source: CommitmentSource::Manual, - project_id: None, - template_id: None, - next_action, - success_condition, - timebox_secs: timebox.as_secs(), - transition_policy_id: None, - state: CommitmentState::Draft, - }; - commitment.validate()?; - Ok(commitment) - } - - pub fn validate(&self) -> Result<(), CommitmentValidationError> { - if self.id.trim().is_empty() { - return Err(CommitmentValidationError::MissingId); - } - if self.next_action.trim().is_empty() { - return Err(CommitmentValidationError::MissingNextAction); - } - if self.success_condition.trim().is_empty() { - return Err(CommitmentValidationError::MissingSuccessCondition); - } - if self.timebox_secs == 0 { - return Err(CommitmentValidationError::MissingTimebox); - } - Ok(()) - } -} - -impl PolicySnapshot { - pub fn for_runtime( - commitment_id: String, - runtime_state: RuntimeState, - enforcement_level: EnforcementLevel, - allowed_context: AllowedContext, - ) -> Self { - Self::try_for_runtime( - commitment_id, - runtime_state, - enforcement_level, - allowed_context, - ) - .expect("runtime policy snapshot requires a non-empty commitment id") - } - - pub fn try_for_runtime( - commitment_id: String, - runtime_state: RuntimeState, - enforcement_level: EnforcementLevel, - allowed_context: AllowedContext, - ) -> Result { - let commitment_id = commitment_id.trim().to_string(); - if commitment_id.is_empty() { - return Err(PolicySnapshotValidationError::MissingCommitmentId); - } - - let created_at_unix_secs = unix_secs_now(); - let policy = Self { - id: unique_id("policy"), - commitment_id, - schema_version: POLICY_SCHEMA_VERSION, - created_at_unix_secs, - runtime_state, - enforcement_level, - allowed_context, - required_monitors: Vec::new(), - violation_actions: Vec::new(), - expires_at_unix_secs: None, - generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(), - }; - policy.validate()?; - Ok(policy) - } - - pub fn validate(&self) -> Result<(), PolicySnapshotValidationError> { - if self.schema_version != POLICY_SCHEMA_VERSION { - return Err(PolicySnapshotValidationError::WrongSchemaVersion); - } - if self.id.trim().is_empty() { - return Err(PolicySnapshotValidationError::MissingId); - } - if self.commitment_id.trim().is_empty() { - return Err(PolicySnapshotValidationError::MissingCommitmentId); - } - if self.generated_by_agent_version.trim().is_empty() { - return Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion); - } - Ok(()) - } -} - -pub fn unix_secs_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} - -fn unique_id(prefix: &str) -> String { - format!("{prefix}-{}", Uuid::now_v7()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - #[test] - fn commitment_requires_concrete_action_and_success_condition() { - let err = Commitment::new_manual("", "tests pass", Duration::from_secs(1500)) - .expect_err("empty next action must be rejected"); - assert_eq!(err, CommitmentValidationError::MissingNextAction); - - let err = Commitment::new_manual("Refactor state", "", Duration::from_secs(1500)) - .expect_err("empty success condition must be rejected"); - assert_eq!(err, CommitmentValidationError::MissingSuccessCondition); - } - - #[test] - fn commitment_requires_nonzero_timebox() { - let err = Commitment::new_manual("Refactor state", "tests pass", Duration::ZERO) - .expect_err("zero timebox must be rejected"); - assert_eq!(err, CommitmentValidationError::MissingTimebox); - } - - #[test] - fn policy_snapshot_carries_runtime_contract() { - let commitment = Commitment::new_manual( - "Implement domain types", - "domain tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - let policy = PolicySnapshot::for_runtime( - commitment.id.clone(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext::default(), - ); - - assert_eq!(policy.commitment_id, commitment.id); - assert_eq!(policy.runtime_state, RuntimeState::Active); - assert_eq!(policy.enforcement_level, EnforcementLevel::Warn); - assert_eq!(policy.schema_version, 1); - } - - #[test] - fn commitment_ids_are_unique_for_rapid_consecutive_construction() { - let first = Commitment::new_manual( - "Implement first action", - "first tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - let second = Commitment::new_manual( - "Implement second action", - "second tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - - assert_ne!(first.id, second.id); - } - - #[test] - fn policy_snapshot_ids_are_unique_for_rapid_consecutive_construction() { - let first = PolicySnapshot::for_runtime( - "commitment-1".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext::default(), - ); - let second = PolicySnapshot::for_runtime( - "commitment-1".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext::default(), - ); - - assert_ne!(first.id, second.id); - } - - #[test] - fn commitment_serializes_enum_contract_as_snake_case() { - let commitment = Commitment::new_manual( - "Implement domain contracts", - "domain tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - - let json = serde_json::to_string(&commitment).unwrap(); - - assert!(json.contains(r#""source":"manual""#)); - assert!(json.contains(r#""state":"draft""#)); - } - - #[test] - fn policy_snapshot_serializes_enum_contract_as_snake_case() { - let policy = PolicySnapshot::for_runtime( - "commitment-1".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext::default(), - ); - - let json = serde_json::to_string(&policy).unwrap(); - - assert!(json.contains(r#""runtime_state":"active""#)); - assert!(json.contains(r#""enforcement_level":"warn""#)); - } - - #[test] - fn commitment_validate_rejects_invalid_public_field_values() { - let mut commitment = - Commitment::new_manual("Refactor state", "tests pass", Duration::from_secs(1500)) - .unwrap(); - commitment.id = " ".to_string(); - assert_eq!( - commitment.validate(), - Err(CommitmentValidationError::MissingId) - ); - - commitment.id = "commitment-1".to_string(); - commitment.next_action = " ".to_string(); - assert_eq!( - commitment.validate(), - Err(CommitmentValidationError::MissingNextAction) - ); - - commitment.next_action = "Refactor state".to_string(); - commitment.success_condition = " ".to_string(); - assert_eq!( - commitment.validate(), - Err(CommitmentValidationError::MissingSuccessCondition) - ); - - commitment.success_condition = "tests pass".to_string(); - commitment.timebox_secs = 0; - assert_eq!( - commitment.validate(), - Err(CommitmentValidationError::MissingTimebox) - ); - } - - #[test] - fn commitment_validate_rejects_invalid_deserialized_values() { - let json = r#"{ - "id": "", - "created_at_unix_secs": 1, - "source": "manual", - "project_id": null, - "template_id": null, - "next_action": "Refactor state", - "success_condition": "tests pass", - "timebox_secs": 1500, - "transition_policy_id": null, - "state": "draft" - }"#; - let commitment: Commitment = serde_json::from_str(json).unwrap(); - - assert_eq!( - commitment.validate(), - Err(CommitmentValidationError::MissingId) - ); - } - - #[test] - fn validation_errors_implement_std_error() { - fn assert_std_error() {} - - assert_std_error::(); - assert_std_error::(); - } - - #[test] - fn policy_snapshot_try_for_runtime_rejects_empty_commitment_id() { - let err = PolicySnapshot::try_for_runtime( - " ".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext::default(), - ) - .expect_err("empty commitment id must be rejected"); - - assert_eq!(err, PolicySnapshotValidationError::MissingCommitmentId); - } - - #[test] - fn policy_snapshot_validate_rejects_invalid_public_field_values() { - let mut policy = PolicySnapshot::for_runtime( - "commitment-1".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext::default(), - ); - policy.schema_version = POLICY_SCHEMA_VERSION + 1; - assert_eq!( - policy.validate(), - Err(PolicySnapshotValidationError::WrongSchemaVersion) - ); - - policy.schema_version = POLICY_SCHEMA_VERSION; - policy.id = " ".to_string(); - assert_eq!( - policy.validate(), - Err(PolicySnapshotValidationError::MissingId) - ); - - policy.id = "policy-1".to_string(); - policy.commitment_id = " ".to_string(); - assert_eq!( - policy.validate(), - Err(PolicySnapshotValidationError::MissingCommitmentId) - ); - - policy.commitment_id = "commitment-1".to_string(); - policy.generated_by_agent_version = " ".to_string(); - assert_eq!( - policy.validate(), - Err(PolicySnapshotValidationError::MissingGeneratedByAgentVersion) - ); - } - - #[test] - fn policy_snapshot_validate_rejects_invalid_deserialized_values() { - let json = r#"{ - "id": "", - "commitment_id": "commitment-1", - "schema_version": 1, - "created_at_unix_secs": 1, - "runtime_state": "active", - "enforcement_level": "warn", - "allowed_context": { - "window_classes": [], - "window_title_substrings": [], - "domains": [], - "repos": [], - "commands": [] - }, - "required_monitors": [], - "violation_actions": [], - "expires_at_unix_secs": null, - "generated_by_agent_version": "0.1.0" - }"#; - let policy: PolicySnapshot = serde_json::from_str(json).unwrap(); - - assert_eq!( - policy.validate(), - Err(PolicySnapshotValidationError::MissingId) - ); - } -} diff --git a/legacy/src/event_log.rs b/legacy/src/event_log.rs deleted file mode 100644 index 12497fe..0000000 --- a/legacy/src/event_log.rs +++ /dev/null @@ -1,583 +0,0 @@ -use crate::domain::{unix_secs_now, RuntimeState, POLICY_SCHEMA_VERSION}; -use anyhow::{anyhow, Context, Result}; -use fs2::FileExt; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use std::fs::{self, File, OpenOptions}; -use std::io::{BufRead, BufReader, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EventType { - CommitmentCreated, - RuntimeTransition, - CommitmentTransition, - PolicyApplied, - EvidenceObserved, - ViolationObserved, - TransitionStarted, - ReviewCompleted, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct EventRecord { - pub schema_version: u16, - pub sequence: u64, - pub timestamp_unix_secs: u64, - pub event_type: EventType, - pub commitment_id: Option, - pub runtime_state: RuntimeState, - pub payload_json: Value, - pub previous_hash: Option, - pub hash: String, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PendingEventRecord { - pub event_type: EventType, - pub runtime_state: RuntimeState, - pub commitment_id: Option, - pub payload_json: Value, -} - -#[derive(Debug)] -pub struct EventLog { - path: PathBuf, - next_sequence: u64, - previous_hash: Option, - needs_parent_sync_after_append: bool, -} - -impl EventLog { - pub fn open(path: impl AsRef) -> Result { - let path = path.as_ref().to_path_buf(); - create_parent_dirs(&path)?; - - let (mut file, created) = open_log_file(&path)?; - file.lock_shared() - .with_context(|| format!("lock event log for read {}", path.display()))?; - let (next_sequence, previous_hash) = load_tail_locked(&mut file, &path)?; - - Ok(Self { - path, - next_sequence, - previous_hash, - needs_parent_sync_after_append: created, - }) - } - - pub fn append( - &mut self, - event_type: EventType, - runtime_state: RuntimeState, - commitment_id: Option, - payload_json: Value, - ) -> Result { - let mut records = self.append_batch([PendingEventRecord { - event_type, - runtime_state, - commitment_id, - payload_json, - }])?; - records - .pop() - .ok_or_else(|| anyhow!("single event append produced no record")) - } - - pub fn append_batch( - &mut self, - events: impl IntoIterator, - ) -> Result> { - let events: Vec = events.into_iter().collect(); - if events.is_empty() { - return Ok(Vec::new()); - } - - create_parent_dirs(&self.path)?; - - let existed = self - .path - .try_exists() - .with_context(|| format!("check whether event log exists {}", self.path.display()))?; - let mut file = OpenOptions::new() - .create(true) - .read(true) - .append(true) - .open(&self.path) - .with_context(|| format!("open event log for append {}", self.path.display()))?; - file.lock_exclusive() - .with_context(|| format!("lock event log {}", self.path.display()))?; - - let (next_sequence, previous_hash) = load_tail_locked(&mut file, &self.path)?; - - let mut records = Vec::with_capacity(events.len()); - let mut sequence = next_sequence; - let mut record_previous_hash = previous_hash; - for event in events { - let mut record = EventRecord { - schema_version: POLICY_SCHEMA_VERSION, - sequence, - timestamp_unix_secs: unix_secs_now(), - event_type: event.event_type, - commitment_id: event.commitment_id, - runtime_state: event.runtime_state, - payload_json: event.payload_json, - previous_hash: record_previous_hash, - hash: String::new(), - }; - record.hash = record_hash(&record)?; - record_previous_hash = Some(record.hash.clone()); - sequence += 1; - records.push(record); - } - - let mut bytes = Vec::new(); - for record in &records { - serde_json::to_writer(&mut bytes, record).with_context(|| { - format!("serialize event log record to {}", self.path.display()) - })?; - bytes.push(b'\n'); - } - - file.write_all(&bytes).with_context(|| { - format!( - "write {} event log records to {}", - records.len(), - self.path.display() - ) - })?; - file.flush() - .with_context(|| format!("flush event log {}", self.path.display()))?; - file.sync_data() - .with_context(|| format!("sync event log {}", self.path.display()))?; - if !existed || self.needs_parent_sync_after_append { - sync_parent_dir(&self.path)?; - self.needs_parent_sync_after_append = false; - } - - self.next_sequence = sequence; - self.previous_hash = record_previous_hash; - - Ok(records) - } - - pub fn records(&self) -> Result> { - let mut file = OpenOptions::new() - .read(true) - .open(&self.path) - .with_context(|| format!("open event log for read {}", self.path.display()))?; - file.lock_shared() - .with_context(|| format!("lock event log for read {}", self.path.display()))?; - load_records_locked(&mut file, &self.path) - } -} - -fn create_parent_dirs(path: &Path) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!("create parent directories for event log {}", path.display()) - })?; - } - Ok(()) -} - -fn open_log_file(path: &Path) -> Result<(File, bool)> { - let existed = path - .try_exists() - .with_context(|| format!("check whether event log exists {}", path.display()))?; - let file = OpenOptions::new() - .create(true) - .read(true) - .append(true) - .open(path) - .with_context(|| format!("open event log {}", path.display()))?; - Ok((file, !existed)) -} - -fn load_tail_locked(file: &mut File, path: &Path) -> Result<(u64, Option)> { - let records = load_records_locked(file, path)?; - let next_sequence = records.last().map_or(1, |record| record.sequence + 1); - let previous_hash = records.last().map(|record| record.hash.clone()); - Ok((next_sequence, previous_hash)) -} - -fn load_records_locked(file: &mut File, path: &Path) -> Result> { - let mut next_sequence = 1; - let mut previous_hash = None; - let mut records = Vec::new(); - - file.seek(SeekFrom::Start(0)) - .with_context(|| format!("seek event log {}", path.display()))?; - for (line_index, line) in BufReader::new(file).lines().enumerate() { - let line_number = line_index + 1; - let line = line.with_context(|| { - format!("read event log line {line_number} from {}", path.display()) - })?; - if line.trim().is_empty() { - continue; - } - - let record: EventRecord = serde_json::from_str(&line).with_context(|| { - format!("parse event log line {line_number} from {}", path.display()) - })?; - validate_record(&record, next_sequence, previous_hash.as_deref()) - .map_err(|err| anyhow!("validate event log line {line_number}: {err}"))?; - - next_sequence += 1; - previous_hash = Some(record.hash.clone()); - records.push(record); - } - - Ok(records) -} - -#[cfg(unix)] -fn sync_parent_dir(path: &Path) -> Result<()> { - if let Some(parent) = path.parent() { - let dir = File::open(parent) - .with_context(|| format!("open parent directory for event log {}", path.display()))?; - dir.sync_all().with_context(|| { - format!( - "sync parent directory {} for event log {}", - parent.display(), - path.display() - ) - })?; - } - Ok(()) -} - -#[cfg(not(unix))] -fn sync_parent_dir(_path: &Path) -> Result<()> { - // Directory fsync is not exposed portably by std on all supported platforms. - // Linux and other Unix targets take the durable path above. - Ok(()) -} - -fn validate_record( - record: &EventRecord, - expected_sequence: u64, - expected_previous_hash: Option<&str>, -) -> Result<()> { - if record.schema_version != POLICY_SCHEMA_VERSION { - return Err(anyhow!( - "unsupported schema version at sequence {}: expected {}, found {}", - record.sequence, - POLICY_SCHEMA_VERSION, - record.schema_version - )); - } - if record.sequence != expected_sequence { - return Err(anyhow!( - "sequence mismatch: expected {}, found {}", - expected_sequence, - record.sequence - )); - } - if record.previous_hash.as_deref() != expected_previous_hash { - return Err(anyhow!( - "previous hash mismatch at sequence {}", - record.sequence - )); - } - - let expected_hash = record_hash(record)?; - if record.hash != expected_hash { - return Err(anyhow!("hash mismatch at sequence {}", record.sequence)); - } - - Ok(()) -} - -fn record_hash(record: &EventRecord) -> Result { - let mut hashable = record.clone(); - hashable.hash.clear(); - let bytes = serde_json::to_vec(&hashable).context("serialize event log record for hash")?; - Ok(hex::encode(Sha256::digest(bytes))) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::RuntimeState; - use serde_json::json; - use std::fs; - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn temp_log_path(name: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!( - "antidrift-event-log-{name}-{}-{nonce}.jsonl", - std::process::id() - )) - } - - #[test] - fn appends_hash_chained_jsonl_records() { - let path = temp_log_path("append"); - let mut log = EventLog::open(&path).unwrap(); - - let first = log - .append( - EventType::RuntimeTransition, - RuntimeState::Transition, - Some("commitment-1".to_string()), - json!({"from": "active", "to": "transition"}), - ) - .unwrap(); - let second = log - .append( - EventType::PolicyApplied, - RuntimeState::Active, - Some("commitment-1".to_string()), - json!({"policy_id": "policy-1"}), - ) - .unwrap(); - - assert_eq!(first.sequence, 1); - assert_eq!(second.sequence, 2); - assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str())); - assert_ne!(first.hash, second.hash); - - let contents = fs::read_to_string(&path).unwrap(); - assert_eq!(contents.lines().count(), 2); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn batch_appends_hash_chained_adjacent_records() { - let path = temp_log_path("batch"); - let mut log = EventLog::open(&path).unwrap(); - - let records = log - .append_batch([ - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some("commitment-1".to_string()), - payload_json: json!({"commitment_id": "commitment-1"}), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some("commitment-1".to_string()), - payload_json: json!({"policy_id": "policy-1"}), - }, - ]) - .unwrap(); - - assert_eq!(records.len(), 2); - assert_eq!(records[0].sequence, 1); - assert_eq!(records[1].sequence, 2); - assert_eq!( - records[1].previous_hash.as_deref(), - Some(records[0].hash.as_str()) - ); - - let contents = fs::read_to_string(&path).unwrap(); - assert_eq!(contents.lines().count(), 2); - EventLog::open(&path).unwrap(); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn opening_existing_log_validates_chain_and_continues_sequence() { - let path = temp_log_path("reopen"); - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::RuntimeTransition, - RuntimeState::Transition, - None, - json!({"from": "active"}), - ) - .unwrap(); - } - - let mut reopened = EventLog::open(&path).unwrap(); - let next = reopened - .append( - EventType::PolicyApplied, - RuntimeState::Active, - None, - json!({"policy_id": "policy-1"}), - ) - .unwrap(); - - assert_eq!(next.sequence, 2); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn opening_missing_log_creates_reopenable_file() { - let path = temp_log_path("open-missing"); - - EventLog::open(&path).unwrap(); - - assert!(path.exists()); - EventLog::open(&path).unwrap(); - fs::remove_file(path).unwrap(); - } - - #[test] - fn opening_malformed_json_log_fails() { - let path = temp_log_path("malformed"); - fs::write(&path, "{not json}\n").unwrap(); - - let err = EventLog::open(&path).expect_err("malformed JSONL must fail"); - - assert!(err.to_string().contains("parse event log")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn opening_tampered_log_chain_fails() { - let path = temp_log_path("tampered"); - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::RuntimeTransition, - RuntimeState::Transition, - None, - json!({"from": "active"}), - ) - .unwrap(); - log.append( - EventType::PolicyApplied, - RuntimeState::Active, - None, - json!({"policy_id": "policy-1"}), - ) - .unwrap(); - } - let contents = fs::read_to_string(&path).unwrap(); - fs::write(&path, contents.replace("\"sequence\":2", "\"sequence\":3")).unwrap(); - - let err = EventLog::open(&path).expect_err("tampered chain must fail"); - - assert!(err.to_string().contains("sequence mismatch")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn opening_log_with_unknown_record_field_fails() { - let path = temp_log_path("unknown-field"); - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::RuntimeTransition, - RuntimeState::Transition, - None, - json!({"from": "active"}), - ) - .unwrap(); - } - let contents = fs::read_to_string(&path).unwrap(); - let mut record_json: Value = - serde_json::from_str(contents.lines().next().unwrap()).unwrap(); - record_json["unexpected_field"] = json!("ignored-before-hashing"); - fs::write( - &path, - format!("{}\n", serde_json::to_string(&record_json).unwrap()), - ) - .unwrap(); - - let err = EventLog::open(&path).expect_err("unknown record fields must fail"); - - assert!(err.to_string().contains("parse event log")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn opening_log_with_unsupported_schema_version_fails() { - let path = temp_log_path("unsupported-schema"); - let mut record = EventRecord { - schema_version: 999, - sequence: 1, - timestamp_unix_secs: unix_secs_now(), - event_type: EventType::RuntimeTransition, - commitment_id: None, - runtime_state: RuntimeState::Transition, - payload_json: json!({"from": "active"}), - previous_hash: None, - hash: String::new(), - }; - record.hash = record_hash(&record).unwrap(); - fs::write( - &path, - format!("{}\n", serde_json::to_string(&record).unwrap()), - ) - .unwrap(); - - let err = EventLog::open(&path).expect_err("unsupported schema version must fail"); - - assert!(err.to_string().contains("unsupported schema version")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn stale_handle_reloads_tail_before_append() { - let path = temp_log_path("stale-handle"); - let mut log1 = EventLog::open(&path).unwrap(); - let mut log2 = EventLog::open(&path).unwrap(); - - let first = log1 - .append( - EventType::RuntimeTransition, - RuntimeState::Transition, - None, - json!({"from": "active"}), - ) - .unwrap(); - let second = log2 - .append( - EventType::PolicyApplied, - RuntimeState::Active, - None, - json!({"policy_id": "policy-1"}), - ) - .unwrap(); - - assert_eq!(second.sequence, 2); - assert_eq!(second.previous_hash.as_deref(), Some(first.hash.as_str())); - EventLog::open(&path).unwrap(); - fs::remove_file(path).unwrap(); - } - - #[test] - fn append_creates_parent_dirs_and_writes_reopenable_file() { - let path = temp_log_path("append-parents") - .with_file_name("nested") - .join("events.jsonl"); - let mut log = EventLog { - path: path.clone(), - next_sequence: 1, - previous_hash: None, - needs_parent_sync_after_append: false, - }; - - let record = log - .append( - EventType::RuntimeTransition, - RuntimeState::Transition, - None, - json!({"from": "active"}), - ) - .unwrap(); - - assert_eq!(record.sequence, 1); - EventLog::open(&path).unwrap(); - fs::remove_file(&path).unwrap(); - fs::remove_dir(path.parent().unwrap()).unwrap(); - } -} diff --git a/legacy/src/lib.rs b/legacy/src/lib.rs deleted file mode 100644 index ab1236d..0000000 --- a/legacy/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod context; -pub mod domain; -pub mod event_log; -pub mod session; -pub mod state_machine; -pub mod window; diff --git a/legacy/src/main.rs b/legacy/src/main.rs deleted file mode 100644 index 6e735a0..0000000 --- a/legacy/src/main.rs +++ /dev/null @@ -1,2121 +0,0 @@ -use anyhow::Result; -use serde::{Serialize, Serializer}; -use shellexpand; -use std::collections::HashMap; -use std::io::{stdout, Write}; -use std::path::Path; -use std::rc::Rc; -use std::time::{Duration, Instant}; // <--- Add this -mod constants; - -use antidrift::{ - context::{window_class_allowed, window_title_allowed}, - domain::{EvidenceHealth, PolicySnapshot, RuntimeState}, - session::SessionController, - window, -}; -use ratatui::{ - crossterm::{ - event::{self, Event, KeyCode}, - execute, - terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, SetTitle, - }, - ExecutableCommand, - }, - prelude::*, - style::Color, - widgets::*, -}; - -#[derive(Debug, PartialEq)] -enum State { - InputIntention, - InputSuccessCondition, - InputDuration, - InputTransitionReason, - InputTransitionReturn, - InputTransitionDuration, - InProgress, - Paused, - ViolationPrompt, - End, - ShouldQuit, -} - -fn serialize_duration(duration: &Duration, serializer: S) -> Result -where - S: Serializer, -{ - serializer.serialize_u64(duration.as_secs()) -} - -#[derive(Serialize)] -struct SessionResult { - intention: String, - #[serde(serialize_with = "serialize_duration")] - duration: Duration, - session_ratings: Vec, - rating: u8, - rating_f64: f64, -} - -impl SessionResult { - fn rate(&mut self) { - let mut rating = 0_f64; - let total_duration = self - .session_ratings - .iter() - .map(|r| r.duration) - .fold(Duration::ZERO, |acc, dur| acc.saturating_add(dur)); - - for session_rating in &self.session_ratings { - let ratio: f64 = session_rating.duration.as_secs_f64() / total_duration.as_secs_f64(); - rating += (session_rating.rating as f64) * ratio; - } - - self.rating_f64 = rating; - - if rating > 2.5 { - self.rating = 3; - } else if rating > 2.0 { - self.rating = 2; - } else if rating > 1.0 { - self.rating = 1; - } else { - self.rating = 0; - } - } -} - -#[derive(Clone, Serialize)] -struct SessionRating { - window_title: Rc, - duration: Duration, - rating: u8, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct WindowIdentity { - title: String, - class: Option, -} - -impl From<&window::WindowSnapshot> for WindowIdentity { - fn from(snapshot: &window::WindowSnapshot) -> Self { - Self { - title: snapshot.title.clone(), - class: snapshot.class.clone(), - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct EvidenceObservationKey { - runtime_state: RuntimeState, - commitment_id: Option, - health: EvidenceHealth, -} - -struct App { - state: State, - user_intention: String, - user_success_condition: String, - user_duration_str: String, - user_duration: Duration, - transition_reason: String, - transition_return_target: String, - transition_duration_str: String, - violation_message: String, - violation_window_snapshot: Option, - violation_dismissal_reason: String, - current_window_title: Rc, - evidence_health: EvidenceHealth, - session_controller: SessionController, - current_window_time: Instant, - session_start: Instant, - session_stats: HashMap, Duration>, - session_remaining: Duration, - session_ratings: Vec, - session_ratings_index: usize, - session_results: Vec, - acknowledged_unknown_window: Option, - acknowledged_unknown_window_handoff_pending: bool, - last_recorded_evidence: Option, - last_tick_50ms: Instant, - last_tick_1s: Instant, -} - -fn save_session(session: &SessionResult) { - let path = shellexpand::tilde(constants::HISTORY_FILE).to_string(); - - // 1. Open file in append mode, create if missing - let file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path); - - if let Ok(mut f) = file { - // 2. Serialize to JSON - if let Ok(json) = serde_json::to_string(session) { - // 3. Write newline appended - let _ = writeln!(f, "{}", json); - } - } -} - -impl App { - fn new() -> Result { - let event_log_path = shellexpand::tilde(constants::EVENT_LOG_FILE).to_string(); - Self::new_with_event_log_path(event_log_path) - } - - fn new_with_event_log_path(event_log_path: impl AsRef) -> Result { - let window_snapshot = window::get_snapshot(); - let mut session_controller = SessionController::new(event_log_path)?; - match session_controller.runtime_state() { - RuntimeState::Locked => session_controller.enter_planning()?, - _ => {} - } - - let active_commitment = session_controller.active_commitment().cloned(); - let state = match session_controller.runtime_state() { - RuntimeState::Active if active_commitment.is_some() => State::InProgress, - RuntimeState::Transition if active_commitment.is_some() => State::Paused, - RuntimeState::Review => State::End, - _ => State::InputIntention, - }; - - let user_intention = active_commitment - .as_ref() - .map(|commitment| commitment.next_action.clone()) - .unwrap_or_default(); - let user_success_condition = active_commitment - .as_ref() - .map(|commitment| commitment.success_condition.clone()) - .unwrap_or_default(); - let user_duration = active_commitment - .as_ref() - .map(|commitment| Duration::from_secs(commitment.timebox_secs)) - .unwrap_or(Duration::ZERO); - let user_duration_str = active_commitment - .as_ref() - .map(|commitment| (commitment.timebox_secs / 60).to_string()) - .unwrap_or_else(|| constants::DEFAULT_DURATION.to_string()); - let restored_elapsed = session_controller - .timing_summary()? - .map(|summary| Duration::from_secs(summary.active_elapsed_secs)) - .unwrap_or(Duration::ZERO); - let restored_elapsed_capped = restored_elapsed.min(user_duration); - let now = Instant::now(); - let session_start = now.checked_sub(restored_elapsed_capped).unwrap_or(now); - let session_remaining = user_duration.saturating_sub(restored_elapsed); - - let mut session_ratings = Vec::new(); - if session_controller.runtime_state() == RuntimeState::Review { - session_ratings.push(recoverable_review_rating()); - } - - Ok(App { - state, - user_intention, - user_success_condition, - user_duration_str, - user_duration, - transition_reason: String::new(), - transition_return_target: String::new(), - transition_duration_str: "2".to_string(), - violation_message: String::new(), - violation_window_snapshot: None, - violation_dismissal_reason: String::new(), - current_window_title: window_snapshot.title.into(), - evidence_health: window_snapshot.health, - session_controller, - current_window_time: now, - session_start, - session_stats: HashMap::new(), - session_remaining, - session_ratings, - session_ratings_index: 0, - session_results: Vec::new(), - acknowledged_unknown_window: None, - acknowledged_unknown_window_handoff_pending: false, - last_recorded_evidence: None, - last_tick_50ms: now, - last_tick_1s: now, - }) - } - - fn handle_ticks(&mut self) -> Result<()> { - if self.last_tick_50ms.elapsed() >= Duration::from_millis(50) { - self.tick_50ms()?; - } - if self.last_tick_1s.elapsed() >= Duration::from_secs(1) { - self.tick_1s(); - } - Ok(()) - } - - fn to_in_progress(&mut self) -> Result<()> { - self.user_duration = - parse_duration_minutes(&self.user_duration_str).unwrap_or(Duration::ZERO); - - if self.user_intention.trim().is_empty() { - self.state = State::InputIntention; - return Ok(()); - } - if self.user_success_condition.trim().is_empty() { - self.state = State::InputSuccessCondition; - return Ok(()); - } - if self.user_duration == Duration::ZERO { - self.state = State::InputDuration; - return Ok(()); - } - - self.session_controller.start_manual_commitment( - self.user_intention.clone(), - self.user_success_condition.clone(), - self.user_duration, - )?; - - self.state = State::InProgress; - self.current_window_time = Instant::now(); - self.session_start = self.current_window_time; - self.session_stats = HashMap::new(); - self.session_ratings_index = 0; - Ok(()) - } - - fn pause(&mut self) -> Result<()> { - self.transition_reason.clear(); - self.transition_return_target = self.user_intention.clone(); - self.transition_duration_str = "2".to_string(); - self.state = State::InputTransitionReason; - Ok(()) - } - - fn start_transition_if_valid(&mut self) -> Result<()> { - if self.transition_reason.trim().is_empty() { - self.state = State::InputTransitionReason; - return Ok(()); - } - - if self.transition_return_target.trim().is_empty() { - self.state = State::InputTransitionReturn; - return Ok(()); - } - - let expected_duration = - parse_duration_minutes(&self.transition_duration_str).unwrap_or(Duration::ZERO); - - if expected_duration.is_zero() { - self.state = State::InputTransitionDuration; - return Ok(()); - } - - self.session_controller.start_transition( - self.transition_reason.clone(), - self.transition_return_target.clone(), - expected_duration, - )?; - self.state = State::Paused; - Ok(()) - } - - fn resume(&mut self) -> Result<()> { - if self.session_controller.runtime_state() == RuntimeState::Transition { - self.session_controller.return_from_transition()?; - } - self.state = State::InProgress; - Ok(()) - } - - fn enter_violation_prompt(&mut self, window_snapshot: &window::WindowSnapshot) { - self.violation_message = - format!("{}: {}", constants::VIOLATION_STATUS, window_snapshot.title); - self.violation_window_snapshot = Some(window_snapshot.clone()); - self.violation_dismissal_reason.clear(); - self.state = State::ViolationPrompt; - } - - fn dismiss_violation_if_valid(&mut self) -> Result<()> { - if self.violation_dismissal_reason.trim().is_empty() { - return Ok(()); - } - - let violation_window_snapshot = self.violation_window_snapshot.clone(); - let window_title = violation_window_snapshot - .as_ref() - .map(|snapshot| snapshot.title.clone()) - .unwrap_or_else(|| self.current_window_title.as_ref().clone()); - let evidence_health = violation_window_snapshot - .as_ref() - .map(|snapshot| snapshot.health.clone()) - .unwrap_or_else(|| self.evidence_health.clone()); - - self.session_controller.record_violation( - self.violation_dismissal_reason.clone(), - serde_json::json!({ - "message": self.violation_message, - "window_title": window_title, - "evidence_health": evidence_health, - }), - )?; - if let Some(window_snapshot) = violation_window_snapshot.as_ref() { - self.acknowledge_unknown_window(window_snapshot); - } - self.violation_window_snapshot = None; - self.state = State::InProgress; - Ok(()) - } - - fn to_end(&mut self) -> Result<()> { - match self.session_controller.runtime_state() { - RuntimeState::Transition => { - self.session_controller.return_from_transition()?; - self.session_controller.complete_for_review()?; - } - RuntimeState::Active => { - self.session_controller.complete_for_review()?; - } - _ => {} - } - self.state = State::End; - self.session_ratings = session_stats_as_vec(&self.session_stats); - self.ensure_recoverable_review_rating(); - Ok(()) - } - - fn ensure_recoverable_review_rating(&mut self) { - if self.session_controller.runtime_state() == RuntimeState::Review - && self.session_ratings.is_empty() - { - self.session_ratings.push(recoverable_review_rating()); - } - } - - fn cleanup(&self) { - let path = shellexpand::tilde(constants::STATUS_FILE).to_string(); - let _ = std::fs::remove_file(path); - } - - fn write_status(&self) { - let status = self.status_text(); - - let path = shellexpand::tilde(constants::STATUS_FILE).to_string(); - if let Ok(mut file) = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&path) - { - let _ = file.write_all(status.as_bytes()); - } - } - - fn status_text(&self) -> String { - match self.state { - State::InputTransitionReason - | State::InputTransitionReturn - | State::InputTransitionDuration - if self.session_controller.runtime_state() == RuntimeState::Active => - { - self.active_status_text() - } - State::InputIntention | State::InputSuccessCondition | State::InputDuration => { - match self.session_controller.runtime_state() { - RuntimeState::Locked => constants::STATUS_LOCKED.to_string(), - RuntimeState::Planning => constants::STATUS_PLANNING.to_string(), - RuntimeState::Transition => constants::STATUS_TRANSITION.to_string(), - _ => constants::STATUS_CONFIGURE.to_string(), - } - } - State::InputTransitionReason - | State::InputTransitionReturn - | State::InputTransitionDuration => match self.session_controller.runtime_state() { - RuntimeState::Locked => constants::STATUS_LOCKED.to_string(), - RuntimeState::Planning => constants::STATUS_PLANNING.to_string(), - RuntimeState::Transition => constants::STATUS_TRANSITION.to_string(), - _ => constants::STATUS_CONFIGURE.to_string(), - }, - State::InProgress => self.active_status_text(), - State::Paused => format!( - "{} - {}", - constants::STATUS_PAUSED, - duration_as_str(&self.session_remaining) - ), - State::ViolationPrompt => constants::VIOLATION_STATUS.to_string(), - State::End => constants::STATUS_RATE_SESSION.to_string(), - State::ShouldQuit => constants::STATUS_QUIT.to_string(), - } - } - - fn active_status_text(&self) -> String { - format!( - "🎯 {} - {}", - self.user_intention, - duration_as_str(&self.session_remaining) - ) - } - - fn tick_50ms(&mut self) -> Result<()> { - match self.state { - State::InputIntention | State::InputSuccessCondition | State::InputDuration => { - self.user_duration = - parse_duration_minutes(&self.user_duration_str).unwrap_or(Duration::ZERO); - - window::minimize_other(&constants::APP_TITLE); - } - State::InputTransitionReason - | State::InputTransitionReturn - | State::InputTransitionDuration - | State::InProgress => self.tick_active_session()?, - State::ShouldQuit => {} - State::Paused => { - window::minimize_other(&constants::APP_TITLE); - update_session_stats(self)?; - } - State::ViolationPrompt => {} - State::End => { - window::minimize_other(&constants::APP_TITLE); - } - } - - self.last_tick_50ms = Instant::now(); - Ok(()) - } - - fn tick_active_session(&mut self) -> Result<()> { - self.tick_active_session_with_snapshot(window::get_snapshot()) - } - - fn tick_active_session_with_snapshot( - &mut self, - window_snapshot: window::WindowSnapshot, - ) -> Result<()> { - let elapsed = self.session_start.elapsed(); - self.session_remaining = self.user_duration.saturating_sub(elapsed); - let window_snapshot = update_session_stats_with_snapshot(self, window_snapshot)?; - - let should_prompt = self.state == State::InProgress - && self.session_controller.runtime_state() == RuntimeState::Active - && !self.is_unknown_window_acknowledged(&window_snapshot) - && self - .session_controller - .active_policy() - .is_some_and(|policy| should_prompt_for_unknown_window(policy, &window_snapshot)); - - if should_prompt { - self.enter_violation_prompt(&window_snapshot); - return Ok(()); - } - - if self.session_remaining.is_zero() { - self.to_end()?; - } - - Ok(()) - } - - fn tick_1s(&mut self) { - self.last_tick_1s = Instant::now(); - self.write_status(); - - if self.state == State::Paused { - self.user_duration = self.user_duration.saturating_add(Duration::from_secs(1)); - } - } - - fn acknowledge_unknown_window(&mut self, window_snapshot: &window::WindowSnapshot) { - self.acknowledged_unknown_window = Some(WindowIdentity::from(window_snapshot)); - self.acknowledged_unknown_window_handoff_pending = true; - } - - fn is_unknown_window_acknowledged(&mut self, window_snapshot: &window::WindowSnapshot) -> bool { - let current = WindowIdentity::from(window_snapshot); - if self.acknowledged_unknown_window.as_ref() == Some(¤t) { - return true; - } - - if self.acknowledged_unknown_window_handoff_pending - && is_post_dismissal_app_foreground(window_snapshot) - { - return true; - } - - self.acknowledged_unknown_window = None; - self.acknowledged_unknown_window_handoff_pending = false; - false - } - - fn timeout(&self) -> Duration { - Duration::from_millis(50).saturating_sub(self.last_tick_50ms.elapsed()) - } - - fn complete_rating(&mut self) -> Result<()> { - self.ensure_recoverable_review_rating(); - let mut session_result = SessionResult { - intention: self.user_intention.clone(), - duration: self.session_start.elapsed(), - session_ratings: self.session_ratings.clone(), - rating: 0, - rating_f64: 0.0, - }; - session_result.rate(); - if self.session_controller.runtime_state() == RuntimeState::Review { - self.session_controller - .complete_review_and_return_to_planning( - session_result.rating, - session_result.rating_f64, - session_result.session_ratings.len() as u32, - )?; - } - self.state = State::InputIntention; - self.session_ratings.clear(); - save_session(&session_result); - self.session_results.push(session_result); - Ok(()) - } - - fn record_evidence_if_needed(&mut self, health: &EvidenceHealth) -> Result<()> { - if matches!(health, EvidenceHealth::Available) { - self.last_recorded_evidence = None; - return Ok(()); - } - - let key = EvidenceObservationKey { - runtime_state: self.session_controller.runtime_state(), - commitment_id: self - .session_controller - .active_commitment() - .map(|commitment| commitment.id.clone()), - health: health.clone(), - }; - if self.last_recorded_evidence.as_ref() == Some(&key) { - return Ok(()); - } - - self.session_controller.record_evidence(health.clone())?; - self.last_recorded_evidence = Some(key); - Ok(()) - } - - fn get_session_results(&self) -> Vec> { - self.session_results - .iter() - .map(|r| { - Line::from(Span::styled( - format!("{} {}", duration_as_str(&r.duration), r.intention,), - match r.rating { - 2 => Style::new().fg(Color::LightYellow), - 3 => Style::new().fg(Color::LightGreen), - _ => Style::new().fg(Color::LightRed), - }, - )) - }) - .collect() - } - - fn get_session_stats(&self) -> Vec> { - let mut zero_encountered = if self.state != State::End { - true - } else { - false - }; - - self.session_ratings - .iter() - .map(|s| { - Line::from(Span::styled( - format!("{}: {}", duration_as_str(&s.duration), s.window_title), - match s.rating { - 0 if !zero_encountered => { - zero_encountered = true; - Style::new().fg(Color::LightBlue) - } - 0 if zero_encountered => Style::new(), - 1 => Style::new().fg(Color::LightRed), - 2 => Style::new().fg(Color::LightYellow), - 3 => Style::new().fg(Color::LightGreen), - _ => Style::new().fg(Color::LightBlue), - }, - )) - }) - .collect() - } -} - -fn duration_as_str(duration: &Duration) -> String { - format!( - "{:3}:{:02}", - duration.as_secs() / 60, - duration.as_secs() % 60 - ) -} - -fn parse_duration_minutes(input: &str) -> Option { - let minutes = input.parse::().ok()?; - if minutes == 0 { - return None; - } - - minutes.checked_mul(60).map(Duration::from_secs) -} - -fn is_transition_input_state(state: &State) -> bool { - matches!( - state, - State::InputTransitionReason - | State::InputTransitionReturn - | State::InputTransitionDuration - ) -} - -fn should_prompt_for_unknown_window( - policy: &PolicySnapshot, - window_snapshot: &window::WindowSnapshot, -) -> bool { - let allowed_context = &policy.allowed_context; - let title_policy = !allowed_context.window_title_substrings.is_empty(); - let class_policy = !allowed_context.window_classes.is_empty(); - if !title_policy && !class_policy { - return false; - } - - let title_usable = usable_title_evidence(window_snapshot); - let class_usable = usable_class_evidence(window_snapshot); - if (title_policy && !title_usable) || (class_policy && !class_usable) { - return false; - } - - let title_matches = - title_policy && window_title_allowed(allowed_context, &window_snapshot.title); - let class_matches = class_policy - && window_snapshot - .class - .as_ref() - .is_some_and(|class| window_class_allowed(allowed_context, class)); - - !(title_matches || class_matches) -} - -fn is_post_dismissal_app_foreground(window_snapshot: &window::WindowSnapshot) -> bool { - window_snapshot - .title - .to_lowercase() - .contains(&constants::APP_TITLE.to_lowercase()) -} - -fn usable_title_evidence(window_snapshot: &window::WindowSnapshot) -> bool { - let title = window_snapshot.title.trim(); - !title.is_empty() - && title != "none" - && !evidence_unavailable(&window_snapshot.health) - && !degraded_evidence_mentions(&window_snapshot.health, "title unavailable") -} - -fn usable_class_evidence(window_snapshot: &window::WindowSnapshot) -> bool { - window_snapshot - .class - .as_deref() - .is_some_and(|class| !class.trim().is_empty()) - && !evidence_unavailable(&window_snapshot.health) - && !degraded_evidence_mentions(&window_snapshot.health, "class unavailable") -} - -fn evidence_unavailable(health: &EvidenceHealth) -> bool { - matches!(health, EvidenceHealth::Unavailable(_)) -} - -fn degraded_evidence_mentions(health: &EvidenceHealth, needle: &str) -> bool { - match health { - EvidenceHealth::Degraded(reason) => reason.to_lowercase().contains(needle), - EvidenceHealth::Available | EvidenceHealth::Unavailable(_) => false, - } -} - -fn session_stats_as_vec(session_stats: &HashMap, Duration>) -> Vec { - let mut stats: Vec<_> = session_stats - .iter() - .filter(|(_, duration)| duration.as_secs() > 30) - .map(|(title, duration)| SessionRating { - window_title: title.clone(), - duration: duration.clone(), - rating: 0, - }) - .collect(); - stats.sort_by(|a, b| b.duration.cmp(&a.duration)); - stats -} - -fn recoverable_review_rating() -> SessionRating { - SessionRating { - window_title: Rc::new("Recovered review summary".to_string()), - duration: Duration::from_secs(1), - rating: 0, - } -} - -fn main() -> Result<()> { - let result = run_app(); - let raw_mode_result = disable_raw_mode(); - let screen_result = stdout().execute(LeaveAlternateScreen).map(|_| ()); - - result?; - raw_mode_result?; - screen_result?; - Ok(()) -} - -fn run_app() -> Result<()> { - let mut app = App::new()?; - enable_raw_mode()?; - stdout().execute(EnterAlternateScreen)?; - execute!(stdout(), SetTitle(constants::APP_TITLE))?; - let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; - - let result = (|| -> Result<()> { - while app.state != State::ShouldQuit { - terminal.draw(|frame| ui(frame, &app))?; - handle_events(&mut app)?; - app.handle_ticks()?; - } - Ok(()) - })(); - - app.cleanup(); - result -} - -fn handle_events(app: &mut App) -> Result<()> { - if !event::poll(app.timeout())? { - return Ok(()); - } - - let Event::Key(key) = event::read()? else { - return Ok(()); - }; - - if key.kind != event::KeyEventKind::Press { - return Ok(()); - } - - handle_key_press(app, key.code) -} - -fn handle_key_press(app: &mut App, key_code: KeyCode) -> Result<()> { - if key_code == KeyCode::Esc && app.state != State::ViolationPrompt { - if app.state == State::End && app.session_controller.runtime_state() == RuntimeState::Review - { - return Ok(()); - } - app.state = State::ShouldQuit; - } - - match app.state { - State::InputIntention => match key_code { - KeyCode::Enter => app.state = State::InputSuccessCondition, - KeyCode::Tab => app.state = State::InputSuccessCondition, - KeyCode::Backspace => { - let _ = app.user_intention.pop(); - } - KeyCode::Char(c) => { - app.user_intention.push(c); - } - _ => {} - }, - State::InputSuccessCondition => match key_code { - KeyCode::Enter => app.state = State::InputDuration, - KeyCode::Tab => app.state = State::InputDuration, - KeyCode::Backspace => { - let _ = app.user_success_condition.pop(); - } - KeyCode::Char(c) => { - app.user_success_condition.push(c); - } - _ => {} - }, - State::InputDuration => match key_code { - KeyCode::Enter => app.to_in_progress()?, - KeyCode::Tab => app.state = State::InputIntention, - KeyCode::Backspace => { - let _ = app.user_duration_str.pop(); - } - KeyCode::Char(c) if c.is_ascii_digit() => { - app.user_duration_str.push(c); - } - _ => {} - }, - State::InputTransitionReason => match key_code { - KeyCode::Enter => app.state = State::InputTransitionReturn, - KeyCode::Tab => app.state = State::InputTransitionReturn, - KeyCode::Backspace => { - let _ = app.transition_reason.pop(); - } - KeyCode::Char(c) => { - app.transition_reason.push(c); - } - _ => {} - }, - State::InputTransitionReturn => match key_code { - KeyCode::Enter => app.state = State::InputTransitionDuration, - KeyCode::Tab => app.state = State::InputTransitionDuration, - KeyCode::Backspace => { - let _ = app.transition_return_target.pop(); - } - KeyCode::Char(c) => { - app.transition_return_target.push(c); - } - _ => {} - }, - State::InputTransitionDuration => match key_code { - KeyCode::Enter => app.start_transition_if_valid()?, - KeyCode::Tab => app.state = State::InputTransitionReason, - KeyCode::Backspace => { - let _ = app.transition_duration_str.pop(); - } - KeyCode::Char(c) if c.is_ascii_digit() => { - app.transition_duration_str.push(c); - } - _ => {} - }, - State::InProgress => match key_code { - KeyCode::Char('q') => { - app.to_end()?; - } - KeyCode::Char('p') => { - app.pause()?; - } - KeyCode::Char('a') => { - app.user_duration = app.user_duration.saturating_add(Duration::from_secs(60)); - } - KeyCode::Char('x') => { - app.user_duration = app.user_duration.saturating_sub(Duration::from_secs(60)); - } - _ => {} - }, - State::Paused => { - if key_code == KeyCode::Char('p') { - app.resume()?; - } - } - State::ViolationPrompt => match key_code { - KeyCode::Enter => app.dismiss_violation_if_valid()?, - KeyCode::Backspace => { - let _ = app.violation_dismissal_reason.pop(); - } - KeyCode::Char(c) => { - app.violation_dismissal_reason.push(c); - } - _ => {} - }, - State::ShouldQuit => (), - State::End => { - let code = match key_code { - KeyCode::Char('1') => 1, - KeyCode::Char('2') => 2, - KeyCode::Char('3') => 3, - _ => 0, - }; - - if app.session_ratings_index < app.session_ratings.len() && code != 0 { - app.session_ratings[app.session_ratings_index].rating = code; - app.session_ratings_index += 1; - } - - if app.session_ratings_index >= app.session_ratings.len() { - app.complete_rating()?; - } - } - } - - Ok(()) -} - -fn update_session_stats(app: &mut App) -> Result { - update_session_stats_with_snapshot(app, window::get_snapshot()) -} - -fn update_session_stats_with_snapshot( - app: &mut App, - window_snapshot: window::WindowSnapshot, -) -> Result { - app.evidence_health = window_snapshot.health.clone(); - app.record_evidence_if_needed(&window_snapshot.health)?; - let window_title = if app.state == State::Paused { - constants::PAUSED.to_string() - } else { - window_snapshot.title.clone() - }; - let window_title = Rc::new(window_title); - - let delta = app.current_window_time.elapsed(); - if app.current_window_title != window_title || (delta > Duration::from_secs(1)) { - let entry = app - .session_stats - .entry(app.current_window_title.clone()) - .or_insert_with(|| Duration::default()); - *entry += app.current_window_time.elapsed(); - app.current_window_time = Instant::now(); - app.current_window_title = window_title; - app.session_ratings = session_stats_as_vec(&app.session_stats); - } - - Ok(window_snapshot) -} - -fn ui(frame: &mut Frame, app: &App) { - if app.state == State::ViolationPrompt { - render_violation_prompt(frame, app); - return; - } - - let layout = Layout::vertical([ - Constraint::Min(3), - Constraint::Min(3), - Constraint::Min(3), - Constraint::Percentage(100), - Constraint::Min(3), - ]); - let [layout_intention, layout_success_condition, layout_duration, layout_titles, layout_status] = - layout.areas(frame.size()); - - let transition_input = is_transition_input_state(&app.state); - - let border_type_intention = - if app.state == State::InputIntention || app.state == State::InputTransitionReason { - BorderType::Thick - } else { - BorderType::Plain - }; - let input_intention = if transition_input { - Line::from(Span::raw(&app.transition_reason)) - } else { - Line::from(Span::raw(&app.user_intention)) - }; - let intention_title = if transition_input { - constants::TRANSITION_REASON_TITLE - } else { - constants::INTENTION_TITLE - }; - frame.render_widget( - Paragraph::new(input_intention).block( - Block::bordered() - .border_type(border_type_intention) - .title(intention_title), - ), - layout_intention, - ); - - let border_type_success_condition = - if app.state == State::InputSuccessCondition || app.state == State::InputTransitionReturn { - BorderType::Thick - } else { - BorderType::Plain - }; - let input_success_condition = if transition_input { - Line::from(Span::raw(&app.transition_return_target)) - } else { - Line::from(Span::raw(&app.user_success_condition)) - }; - let success_condition_title = if transition_input { - constants::TRANSITION_RETURN_TITLE - } else { - constants::SUCCESS_CONDITION_TITLE - }; - frame.render_widget( - Paragraph::new(input_success_condition).block( - Block::bordered() - .border_type(border_type_success_condition) - .title(success_condition_title), - ), - layout_success_condition, - ); - - let input_duration: Vec = match app.state { - State::InProgress | State::Paused => { - let s = duration_as_str(&app.session_remaining); - vec![Line::from(Span::raw(s))] - } - State::InputTransitionReason - | State::InputTransitionReturn - | State::InputTransitionDuration => { - vec![Line::from(Span::raw(&app.transition_duration_str))] - } - _ => { - vec![Line::from(Span::raw(&app.user_duration_str))] - } - }; - let border_type_duration = - if app.state == State::InputDuration || app.state == State::InputTransitionDuration { - BorderType::Thick - } else { - BorderType::Plain - }; - let duration_title = if transition_input { - constants::TRANSITION_DURATION_TITLE - } else { - constants::DURATION_TITLE - }; - frame.render_widget( - Paragraph::new(input_duration).block( - Block::bordered() - .border_type(border_type_duration) - .title(duration_title), - ), - layout_duration, - ); - - if matches!( - app.state, - State::InputIntention | State::InputSuccessCondition | State::InputDuration - ) { - let results = app.get_session_results(); - frame.render_widget( - Paragraph::new(results) - .block(Block::bordered().title(constants::PREVIOUS_SESSIONS_TITLE)), - layout_titles, - ); - } else { - let stats = app.get_session_stats(); - frame.render_widget( - Paragraph::new(stats).block(Block::bordered().title(constants::SESSION_STATS_TITLE)), - layout_titles, - ); - } - - let mut spans: Vec = Vec::new(); - if transition_input { - if app.transition_reason.trim().is_empty() { - let span = Span::styled( - constants::PROVIDE_TRANSITION_REASON, - Style::new().fg(Color::LightRed), - ); - spans.push(span); - } - - if app.transition_return_target.trim().is_empty() { - let span = Span::styled( - constants::PROVIDE_TRANSITION_RETURN, - Style::new().fg(Color::LightRed), - ); - spans.push(span); - } - - if parse_duration_minutes(&app.transition_duration_str).is_none() { - let span = Span::styled( - constants::PROVIDE_VALID_DURATION, - Style::new().fg(Color::LightRed), - ); - spans.push(span); - } - } else if app.user_intention.len() == 0 { - let span = Span::styled( - constants::PROVIDE_INTENTION, - Style::new().fg(Color::LightRed), - ); - spans.push(span); - } - - if app.user_success_condition.trim().is_empty() { - let span = Span::styled( - constants::ENTER_SUCCESS_CONDITION, - Style::new().fg(Color::LightRed), - ); - spans.push(span); - } - - if !transition_input && parse_duration_minutes(&app.user_duration_str).is_none() { - let span = Span::styled( - constants::PROVIDE_VALID_DURATION, - Style::new().fg(Color::LightRed), - ); - spans.push(span); - } - - match app.state { - State::InputIntention | State::InputSuccessCondition | State::InputDuration => { - if spans.len() == 0 { - let span = Span::styled( - constants::READY_TO_START, - Style::new().fg(Color::LightGreen), - ); - spans.push(span); - } - } - State::InputTransitionReason - | State::InputTransitionReturn - | State::InputTransitionDuration => {} - State::InProgress => { - let span = Span::styled( - constants::SESSION_IN_PROGRESS, - Style::new().fg(Color::LightGreen), - ); - spans.push(span); - } - State::Paused => { - let span = Span::styled(constants::SESSION_PAUSED, Style::new().fg(Color::LightBlue)); - spans.push(span); - } - State::ViolationPrompt => {} - State::ShouldQuit => {} - State::End => { - let span = Span::styled(constants::RATE_TITLES, Style::new().fg(Color::LightBlue)); - spans.push(span); - } - } - - if let Some(text) = evidence_status_text(&app.evidence_health) { - if !spans.is_empty() { - spans.push(Span::raw(" | ")); - } - spans.push(Span::styled( - text, - Style::new().fg(evidence_status_color(&app.evidence_health)), - )); - } - - let input_status: Vec = vec![Line::from(spans)]; - frame.render_widget( - Paragraph::new(input_status).block(Block::bordered().title(constants::STATUS_TITLE)), - layout_status, - ); -} - -fn evidence_status_text(health: &EvidenceHealth) -> Option { - match health { - EvidenceHealth::Available => None, - EvidenceHealth::Degraded(reason) => Some(format!("Evidence degraded: {}", reason)), - EvidenceHealth::Unavailable(reason) => Some(format!("Evidence unavailable: {}", reason)), - } -} - -fn evidence_status_color(health: &EvidenceHealth) -> Color { - match health { - EvidenceHealth::Available => Color::Reset, - EvidenceHealth::Degraded(_) => Color::LightYellow, - EvidenceHealth::Unavailable(_) => Color::LightRed, - } -} - -fn render_violation_prompt(frame: &mut Frame, app: &App) { - let layout = Layout::vertical([ - Constraint::Min(5), - Constraint::Length(3), - Constraint::Length(3), - ]); - let [layout_message, layout_reason, layout_status] = layout.areas(frame.size()); - - frame.render_widget( - Paragraph::new(Line::from(Span::raw(&app.violation_message))).block( - Block::bordered() - .border_type(BorderType::Thick) - .title(constants::VIOLATION_TITLE), - ), - layout_message, - ); - - frame.render_widget( - Paragraph::new(Line::from(Span::raw(&app.violation_dismissal_reason))).block( - Block::bordered() - .border_type(BorderType::Thick) - .title(constants::VIOLATION_REASON_TITLE), - ), - layout_reason, - ); - - frame.render_widget( - Paragraph::new(Line::from(Span::styled( - constants::VIOLATION_STATUS, - Style::new().fg(Color::LightRed), - ))) - .block(Block::bordered().title(constants::STATUS_TITLE)), - layout_status, - ); -} - -#[cfg(test)] -mod tests { - use super::*; - use antidrift::domain::{AllowedContext, EnforcementLevel, PolicySnapshot, RuntimeState}; - use antidrift::event_log::{EventLog, EventType}; - use std::fs; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn unique_event_log_path(test_name: &str) -> std::path::PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - std::env::temp_dir().join(format!( - "antidrift-{test_name}-{}-{}.jsonl", - std::process::id(), - nanos - )) - } - - #[test] - fn starting_session_requires_success_condition_and_logs_commitment() { - let path = unique_event_log_path("success-condition"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - - app.user_intention = "write tests".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - - assert_eq!(app.state, State::InputSuccessCondition); - assert_eq!( - app.session_controller.runtime_state(), - RuntimeState::Planning - ); - - app.user_success_condition = "tests pass".to_string(); - app.to_in_progress().unwrap(); - - assert_eq!(app.state, State::InProgress); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); - assert!(app.session_controller.active_commitment().is_some()); - - let _ = fs::remove_file(path); - } - - #[test] - fn unknown_window_prompt_is_inactive_for_empty_allowlist() { - let policy = PolicySnapshot::for_runtime( - "commitment-test".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext::default(), - ); - let snapshot = window::WindowSnapshot { - title: "Any Window".to_string(), - class: Some("any-class".to_string()), - health: antidrift::domain::EvidenceHealth::Available, - }; - - assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); - } - - #[test] - fn unknown_window_prompt_is_inactive_for_matching_title() { - let policy = PolicySnapshot::for_runtime( - "commitment-test".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext { - window_title_substrings: vec!["Editor".to_string()], - ..AllowedContext::default() - }, - ); - let snapshot = window::WindowSnapshot { - title: "Code Editor - antidrift".to_string(), - class: None, - health: antidrift::domain::EvidenceHealth::Available, - }; - - assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); - } - - #[test] - fn unknown_window_prompt_uses_shared_title_normalization() { - let policy = PolicySnapshot::for_runtime( - "commitment-test".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext { - window_title_substrings: vec![" Editor ".to_string()], - ..AllowedContext::default() - }, - ); - let snapshot = window::WindowSnapshot { - title: "Code Editor - antidrift".to_string(), - class: None, - health: antidrift::domain::EvidenceHealth::Available, - }; - - assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); - } - - #[test] - fn unknown_window_prompt_uses_shared_class_normalization() { - let policy = PolicySnapshot::for_runtime( - "commitment-test".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext { - window_classes: vec![" Code ".to_string()], - ..AllowedContext::default() - }, - ); - let snapshot = window::WindowSnapshot { - title: "Unrelated title".to_string(), - class: Some(" code ".to_string()), - health: antidrift::domain::EvidenceHealth::Available, - }; - - assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); - } - - #[test] - fn unknown_window_prompt_is_active_for_nonmatching_title() { - let policy = PolicySnapshot::for_runtime( - "commitment-test".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext { - window_title_substrings: vec!["Editor".to_string()], - ..AllowedContext::default() - }, - ); - let snapshot = window::WindowSnapshot { - title: "Browser - unrelated".to_string(), - class: None, - health: antidrift::domain::EvidenceHealth::Available, - }; - - assert!(should_prompt_for_unknown_window(&policy, &snapshot)); - } - - #[test] - fn unknown_window_prompt_ignores_unavailable_title_evidence() { - let policy = PolicySnapshot::for_runtime( - "commitment-test".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext { - window_title_substrings: vec!["Editor".to_string()], - ..AllowedContext::default() - }, - ); - let snapshot = window::WindowSnapshot::unavailable("no active window"); - - assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); - } - - #[test] - fn unknown_window_prompt_ignores_class_policy_without_class_evidence() { - let policy = PolicySnapshot::for_runtime( - "commitment-test".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext { - window_classes: vec!["Code".to_string()], - ..AllowedContext::default() - }, - ); - let snapshot = window::WindowSnapshot { - title: "Browser - unrelated".to_string(), - class: None, - health: antidrift::domain::EvidenceHealth::Degraded( - "window class unavailable on current Windows adapter".to_string(), - ), - }; - - assert!(!should_prompt_for_unknown_window(&policy, &snapshot)); - } - - #[test] - fn unknown_window_prompt_requires_usable_evidence_for_mixed_policy() { - let policy = PolicySnapshot::for_runtime( - "commitment-test".to_string(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext { - window_title_substrings: vec!["Editor".to_string()], - window_classes: vec!["Code".to_string()], - ..AllowedContext::default() - }, - ); - let missing_class = window::WindowSnapshot { - title: "Browser - unrelated".to_string(), - class: None, - health: antidrift::domain::EvidenceHealth::Degraded( - "window class unavailable on current Windows adapter".to_string(), - ), - }; - let proved_unknown = window::WindowSnapshot { - title: "Browser - unrelated".to_string(), - class: Some("firefox".to_string()), - health: antidrift::domain::EvidenceHealth::Available, - }; - - assert!(!should_prompt_for_unknown_window(&policy, &missing_class)); - assert!(should_prompt_for_unknown_window(&policy, &proved_unknown)); - } - - #[test] - fn acknowledged_unknown_window_is_suppressed_until_window_changes() { - let path = unique_event_log_path("violation-ack-suppression"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - let unknown = window::WindowSnapshot { - title: "AntiDrift".to_string(), - class: Some("terminal".to_string()), - health: antidrift::domain::EvidenceHealth::Available, - }; - let other_unknown = window::WindowSnapshot { - title: "Browser - unrelated".to_string(), - class: Some("firefox".to_string()), - health: antidrift::domain::EvidenceHealth::Available, - }; - - assert!(!app.is_unknown_window_acknowledged(&unknown)); - app.acknowledge_unknown_window(&unknown); - - assert!(app.is_unknown_window_acknowledged(&unknown)); - assert!(!app.is_unknown_window_acknowledged(&other_unknown)); - assert!(!app.is_unknown_window_acknowledged(&unknown)); - - let _ = fs::remove_file(path); - } - - #[test] - fn violation_prompt_ignores_esc() { - let path = unique_event_log_path("violation-prompt-esc"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - app.state = State::ViolationPrompt; - app.violation_message = "Unknown context detected: Browser - unrelated".to_string(); - - handle_key_press(&mut app, KeyCode::Esc).unwrap(); - - assert_eq!(app.state, State::ViolationPrompt); - assert!(app.violation_dismissal_reason.is_empty()); - - let _ = fs::remove_file(path); - } - - #[test] - fn violation_prompt_requires_reason_then_logs_and_returns_to_progress() { - let path = unique_event_log_path("violation-prompt-dismissal"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - app.state = State::ViolationPrompt; - app.violation_message = "Unknown context detected: Browser - unrelated".to_string(); - - app.dismiss_violation_if_valid().unwrap(); - assert_eq!(app.state, State::ViolationPrompt); - - app.violation_dismissal_reason = "Needed to inspect docs".to_string(); - app.dismiss_violation_if_valid().unwrap(); - - assert_eq!(app.state, State::InProgress); - let records = EventLog::open(&path).unwrap().records().unwrap(); - assert!(records - .iter() - .any(|record| record.event_type == EventType::ViolationObserved - && record.payload_json["reason"] == "Needed to inspect docs")); - - let _ = fs::remove_file(path); - } - - #[test] - fn violation_dismissal_acknowledges_prompted_window_not_current_foreground() { - let path = unique_event_log_path("violation-prompt-original-ack"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - let prompted_unknown = window::WindowSnapshot { - title: format!( - "Prompted Unknown Window {}", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - ), - class: Some("prompted-unknown-class".to_string()), - health: antidrift::domain::EvidenceHealth::Available, - }; - let dismissal_foreground = window::WindowSnapshot { - title: "AntiDrift Terminal".to_string(), - class: Some("terminal".to_string()), - health: antidrift::domain::EvidenceHealth::Degraded( - "terminal foreground during dismissal".to_string(), - ), - }; - - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - app.enter_violation_prompt(&prompted_unknown); - app.current_window_title = Rc::new(dismissal_foreground.title.clone()); - app.evidence_health = dismissal_foreground.health.clone(); - app.violation_dismissal_reason = "Needed to inspect docs".to_string(); - - app.dismiss_violation_if_valid().unwrap(); - - assert_eq!(app.state, State::InProgress); - assert!(app.is_unknown_window_acknowledged(&prompted_unknown)); - assert!(app.is_unknown_window_acknowledged(&dismissal_foreground)); - assert!(app.is_unknown_window_acknowledged(&prompted_unknown)); - - let records = EventLog::open(&path).unwrap().records().unwrap(); - let violation = records - .iter() - .find(|record| record.event_type == EventType::ViolationObserved) - .expect("dismissal should record a violation"); - assert_eq!( - violation.payload_json["evidence"]["window_title"], - prompted_unknown.title - ); - assert_eq!( - violation.payload_json["evidence"]["message"], - format!( - "{}: {}", - constants::VIOLATION_STATUS, - prompted_unknown.title - ) - ); - assert_eq!( - violation.payload_json["evidence"]["evidence_health"], - serde_json::json!("available") - ); - - let _ = fs::remove_file(path); - } - - #[test] - fn post_dismissal_app_foreground_does_not_prompt_or_clear_triggering_acknowledgement() { - let path = unique_event_log_path("violation-prompt-app-handoff"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - let prompted_unknown = window::WindowSnapshot { - title: "Browser - unrelated".to_string(), - class: Some("firefox".to_string()), - health: antidrift::domain::EvidenceHealth::Available, - }; - let dismissal_foreground = window::WindowSnapshot { - title: "AntiDrift".to_string(), - class: Some("terminal".to_string()), - health: antidrift::domain::EvidenceHealth::Available, - }; - let different_unknown = window::WindowSnapshot { - title: "Chat - unrelated".to_string(), - class: Some("chat".to_string()), - health: antidrift::domain::EvidenceHealth::Available, - }; - - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - - let commitment_id = app - .session_controller - .active_commitment() - .unwrap() - .id - .clone(); - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::PolicyApplied, - RuntimeState::Active, - Some(commitment_id.clone()), - serde_json::json!({ - "schema_version": antidrift::domain::POLICY_SCHEMA_VERSION, - "policy_id": "policy-app-handoff-test", - "commitment_id": commitment_id, - "runtime_state": "active", - "enforcement_level": "warn", - "allowed_context": { - "window_classes": [], - "window_title_substrings": ["Editor"], - "domains": [], - "repos": [], - "commands": [], - }, - }), - ) - .unwrap(); - - let mut app = App::new_with_event_log_path(&path).unwrap(); - app.enter_violation_prompt(&prompted_unknown); - app.violation_dismissal_reason = "Needed to inspect docs".to_string(); - app.dismiss_violation_if_valid().unwrap(); - - app.tick_active_session_with_snapshot(dismissal_foreground) - .unwrap(); - - assert_eq!(app.state, State::InProgress); - assert!(app.is_unknown_window_acknowledged(&prompted_unknown)); - - app.tick_active_session_with_snapshot(different_unknown) - .unwrap(); - - assert_eq!(app.state, State::ViolationPrompt); - - let _ = fs::remove_file(path); - } - - #[test] - fn rating_completion_returns_controller_to_planning_for_next_session() { - let path = unique_event_log_path("rating-completion"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - app.to_end().unwrap(); - - assert_eq!(app.state, State::End); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); - - app.session_ratings[0].rating = 2; - app.complete_rating().unwrap(); - - assert_eq!(app.state, State::InputIntention); - assert_eq!( - app.session_controller.runtime_state(), - RuntimeState::Planning - ); - - app.to_in_progress().unwrap(); - assert_eq!(app.state, State::InProgress); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); - - let records = EventLog::open(&path).unwrap().records().unwrap(); - let review_completed_index = records - .iter() - .position(|record| record.event_type == EventType::ReviewCompleted) - .expect("review completion must be recorded durably"); - let commitment_complete_index = records - .iter() - .position(|record| { - record.event_type == EventType::CommitmentTransition - && record.payload_json["action"] == "complete" - }) - .expect("review completion must complete the commitment"); - let planning_transition_index = records - .iter() - .position(|record| { - record.event_type == EventType::RuntimeTransition - && record.runtime_state == RuntimeState::Planning - && record.payload_json["action"] == "return_to_planning_after_review" - }) - .expect("review must return to planning after completion"); - assert_eq!(commitment_complete_index, review_completed_index + 1); - assert_eq!(planning_transition_index, commitment_complete_index + 1); - assert_eq!( - records[review_completed_index].commitment_id, - records[commitment_complete_index].commitment_id - ); - assert_eq!( - records[review_completed_index].payload_json["outcome"], - "completed" - ); - - let _ = fs::remove_file(path); - } - - #[test] - fn hydrated_review_session_stays_recoverable_until_rating_completion() { - let path = unique_event_log_path("hydrated-review-cycle"); - { - let mut app = App::new_with_event_log_path(&path).unwrap(); - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - app.to_end().unwrap(); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); - } - - let mut app = App::new_with_event_log_path(&path).unwrap(); - - assert_eq!(app.state, State::End); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); - assert_eq!(app.session_ratings.len(), 1); - - handle_key_press(&mut app, KeyCode::Esc).unwrap(); - assert_eq!(app.state, State::End); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); - - app.user_intention = "write next test".to_string(); - app.user_success_condition = "next test passes".to_string(); - app.user_duration_str = "1".to_string(); - let err = app - .to_in_progress() - .expect_err("review must be completed before starting another commitment"); - assert!(err - .chain() - .any(|cause| cause.to_string().contains("activate runtime"))); - - app.session_ratings[0].rating = 3; - app.complete_rating().unwrap(); - - assert_eq!(app.state, State::InputIntention); - assert_eq!( - app.session_controller.runtime_state(), - RuntimeState::Planning - ); - - app.to_in_progress().unwrap(); - - assert_eq!(app.state, State::InProgress); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); - assert_eq!( - app.session_controller - .active_commitment() - .unwrap() - .next_action, - "write next test" - ); - - let records = EventLog::open(&path).unwrap().records().unwrap(); - let review_completed_index = records - .iter() - .position(|record| record.event_type == EventType::ReviewCompleted) - .expect("review completion must be recorded"); - let commitment_complete_index = records - .iter() - .position(|record| { - record.event_type == EventType::CommitmentTransition - && record.payload_json["action"] == "complete" - }) - .expect("review completion must complete the commitment"); - let planning_transition_index = records - .iter() - .position(|record| { - record.event_type == EventType::RuntimeTransition - && record.runtime_state == RuntimeState::Planning - && record.payload_json["action"] == "return_to_planning_after_review" - }) - .expect("review completion must return to planning"); - assert_eq!(commitment_complete_index, review_completed_index + 1); - assert_eq!(planning_transition_index, commitment_complete_index + 1); - assert_eq!( - records[review_completed_index].commitment_id, - records[commitment_complete_index].commitment_id - ); - assert_eq!( - records[review_completed_index].payload_json["outcome"], - "completed" - ); - - let _ = fs::remove_file(path); - } - - #[test] - fn hydrated_active_session_keeps_elapsed_time() { - let path = unique_event_log_path("hydrated-active-timer"); - { - let mut app = App::new_with_event_log_path(&path).unwrap(); - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - } - - std::thread::sleep(Duration::from_millis(1100)); - - let app = App::new_with_event_log_path(&path).unwrap(); - - assert_eq!(app.state, State::InProgress); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); - assert!(app.session_start.elapsed() >= Duration::from_secs(1)); - assert!(app.session_remaining <= Duration::from_secs(59)); - - let _ = fs::remove_file(path); - } - - #[test] - fn hydrated_active_session_excludes_completed_pause_time() { - let path = unique_event_log_path("hydrated-active-after-pause-timer"); - { - let mut app = App::new_with_event_log_path(&path).unwrap(); - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - std::thread::sleep(Duration::from_millis(1200)); - app.pause().unwrap(); - app.transition_reason = "stretch".to_string(); - app.transition_return_target = "write tests".to_string(); - app.transition_duration_str = "2".to_string(); - app.start_transition_if_valid().unwrap(); - std::thread::sleep(Duration::from_millis(2200)); - app.resume().unwrap(); - } - - let app = App::new_with_event_log_path(&path).unwrap(); - - assert_eq!(app.state, State::InProgress); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); - assert!( - app.session_remaining >= Duration::from_secs(58), - "completed pause time should not reduce remaining time, got {:?}", - app.session_remaining - ); - - let _ = fs::remove_file(path); - } - - #[test] - fn hydrated_transition_session_freezes_remaining_at_pause_start() { - let path = unique_event_log_path("hydrated-open-pause-timer"); - { - let mut app = App::new_with_event_log_path(&path).unwrap(); - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - std::thread::sleep(Duration::from_millis(1200)); - app.pause().unwrap(); - app.transition_reason = "stretch".to_string(); - app.transition_return_target = "write tests".to_string(); - app.transition_duration_str = "2".to_string(); - app.start_transition_if_valid().unwrap(); - std::thread::sleep(Duration::from_millis(2200)); - } - - let app = App::new_with_event_log_path(&path).unwrap(); - - assert_eq!(app.state, State::Paused); - assert_eq!( - app.session_controller.runtime_state(), - RuntimeState::Transition - ); - assert!( - app.session_remaining >= Duration::from_secs(58), - "open pause time should not reduce remaining time, got {:?}", - app.session_remaining - ); - - let _ = fs::remove_file(path); - } - - #[test] - fn hydrated_transition_can_resume_end_and_return_to_planning() { - let path = unique_event_log_path("hydrated-transition-resume"); - { - let mut app = App::new_with_event_log_path(&path).unwrap(); - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - app.pause().unwrap(); - app.transition_reason = "stretch".to_string(); - app.transition_return_target = "write tests".to_string(); - app.transition_duration_str = "2".to_string(); - app.start_transition_if_valid().unwrap(); - } - - let mut app = App::new_with_event_log_path(&path).unwrap(); - assert_eq!(app.state, State::Paused); - assert_eq!( - app.session_controller.runtime_state(), - RuntimeState::Transition - ); - - app.resume().unwrap(); - assert_eq!(app.state, State::InProgress); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); - - app.to_end().unwrap(); - assert_eq!(app.state, State::End); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); - - app.complete_rating().unwrap(); - assert_eq!(app.state, State::InputIntention); - assert_eq!( - app.session_controller.runtime_state(), - RuntimeState::Planning - ); - - let _ = fs::remove_file(path); - } - - #[test] - fn pause_from_in_progress_opens_transition_reason_without_starting_controller_transition() { - let path = unique_event_log_path("transition-prompt"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - - app.pause().unwrap(); - - assert_eq!(app.state, State::InputTransitionReason); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); - assert_eq!(app.transition_reason, ""); - assert_eq!(app.transition_return_target, "write tests"); - assert_eq!(app.transition_duration_str, "2"); - - let _ = fs::remove_file(path); - } - - #[test] - fn valid_transition_inputs_enter_paused_and_start_controller_transition() { - let path = unique_event_log_path("valid-transition-inputs"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - app.pause().unwrap(); - - app.transition_reason = "stretch".to_string(); - app.transition_return_target = "write tests".to_string(); - app.transition_duration_str = "3".to_string(); - app.start_transition_if_valid().unwrap(); - - assert_eq!(app.state, State::Paused); - assert_eq!( - app.session_controller.runtime_state(), - RuntimeState::Transition - ); - - let _ = fs::remove_file(path); - } - - #[test] - fn missing_transition_reason_or_return_target_does_not_start_transition() { - let path = unique_event_log_path("invalid-transition-inputs"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - app.pause().unwrap(); - - app.transition_reason = "".to_string(); - app.transition_return_target = "write tests".to_string(); - app.transition_duration_str = "3".to_string(); - app.start_transition_if_valid().unwrap(); - assert_eq!(app.state, State::InputTransitionReason); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); - - app.transition_reason = "stretch".to_string(); - app.transition_return_target = "".to_string(); - app.start_transition_if_valid().unwrap(); - assert_eq!(app.state, State::InputTransitionReturn); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); - - let _ = fs::remove_file(path); - } - - #[test] - fn parse_duration_minutes_rejects_invalid_and_overflowing_values() { - assert_eq!(parse_duration_minutes(""), None); - assert_eq!(parse_duration_minutes("0"), None); - assert_eq!(parse_duration_minutes("abc"), None); - assert_eq!( - parse_duration_minutes("25"), - Some(Duration::from_secs(25 * 60)) - ); - assert_eq!(parse_duration_minutes(&u64::MAX.to_string()), None); - } - - #[test] - fn transition_prompt_tick_keeps_active_time_and_can_end_session() { - let path = unique_event_log_path("transition-prompt-active-time"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - app.pause().unwrap(); - app.session_start = Instant::now().checked_sub(Duration::from_secs(61)).unwrap(); - - app.tick_50ms().unwrap(); - - assert_eq!(app.state, State::End); - assert_eq!(app.session_remaining, Duration::ZERO); - assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); - - let _ = fs::remove_file(path); - } - - #[test] - fn transition_prompt_status_shows_active_countdown() { - let path = unique_event_log_path("transition-prompt-status"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - app.pause().unwrap(); - - assert_eq!( - app.status_text(), - format!( - "🎯 write tests - {}", - duration_as_str(&app.session_remaining) - ) - ); - - let _ = fs::remove_file(path); - } - - #[test] - fn evidence_status_text_describes_degraded_and_unavailable_health() { - assert_eq!(evidence_status_text(&EvidenceHealth::Available), None); - assert_eq!( - evidence_status_text(&EvidenceHealth::Degraded( - "window class unavailable".to_string(), - )), - Some("Evidence degraded: window class unavailable".to_string()) - ); - assert_eq!( - evidence_status_text(&EvidenceHealth::Unavailable( - "xdotool active window unavailable".to_string(), - )), - Some("Evidence unavailable: xdotool active window unavailable".to_string()) - ); - } - - #[test] - fn active_ticks_record_degraded_evidence_once_until_health_changes() { - let path = unique_event_log_path("evidence-dedup"); - let mut app = App::new_with_event_log_path(&path).unwrap(); - - app.user_intention = "write tests".to_string(); - app.user_success_condition = "tests pass".to_string(); - app.user_duration_str = "1".to_string(); - app.to_in_progress().unwrap(); - - let degraded = window::WindowSnapshot { - title: "Editor".to_string(), - class: None, - health: EvidenceHealth::Degraded("window class unavailable".to_string()), - }; - app.tick_active_session_with_snapshot(degraded.clone()) - .unwrap(); - app.tick_active_session_with_snapshot(degraded).unwrap(); - - let records = EventLog::open(&path).unwrap().records().unwrap(); - assert_eq!( - records - .iter() - .filter(|record| record.event_type == EventType::EvidenceObserved) - .count(), - 1 - ); - - let unavailable = window::WindowSnapshot::unavailable("xdotool active window unavailable"); - app.tick_active_session_with_snapshot(unavailable).unwrap(); - - let records = EventLog::open(&path).unwrap().records().unwrap(); - assert_eq!( - records - .iter() - .filter(|record| record.event_type == EventType::EvidenceObserved) - .count(), - 2 - ); - assert_eq!( - app.evidence_health, - EvidenceHealth::Unavailable("xdotool active window unavailable".to_string()) - ); - - let _ = fs::remove_file(path); - } -} diff --git a/legacy/src/session.rs b/legacy/src/session.rs deleted file mode 100644 index 3e847f0..0000000 --- a/legacy/src/session.rs +++ /dev/null @@ -1,3475 +0,0 @@ -use crate::domain::{ - unix_secs_now, AllowedContext, Commitment, CommitmentSource, CommitmentState, EnforcementLevel, - EvidenceHealth, PolicySnapshot, RuntimeState, POLICY_SCHEMA_VERSION, -}; -use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord}; -use crate::state_machine::{ - self, transition_commitment, transition_runtime, CommitmentAction, RuntimeAction, -}; -use anyhow::{anyhow, Context, Result}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::path::Path; -use std::time::Duration; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct RuntimeTransitionPayload { - schema_version: u16, - action: String, - from: RuntimeState, - to: RuntimeState, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct CommitmentTransitionPayload { - schema_version: u16, - commitment_id: String, - action: String, - from: CommitmentState, - to: CommitmentState, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct CommitmentCreatedPayload { - schema_version: u16, - commitment_id: String, - source: CommitmentSource, - next_action: String, - success_condition: String, - timebox_secs: u64, - state: CommitmentState, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct PolicyAppliedPayload { - schema_version: u16, - policy_id: String, - commitment_id: String, - runtime_state: RuntimeState, - enforcement_level: EnforcementLevel, - allowed_context: AllowedContext, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct TransitionStartedPayload { - schema_version: u16, - commitment_id: String, - transition_policy_id: String, - reason: String, - return_target: String, - expected_duration_secs: u64, - runtime_from: RuntimeState, - runtime_to: RuntimeState, - commitment_from: CommitmentState, - commitment_to: CommitmentState, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ViolationObservedPayload { - schema_version: u16, - reason: String, - evidence: Value, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct EvidenceObservedPayload { - schema_version: u16, - health: EvidenceHealth, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ReviewCompletedPayload { - schema_version: u16, - commitment_id: String, - outcome: CommitmentState, - rating: u8, - rating_f64: f64, - reviewed_window_count: u32, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct PendingTransitionPolicy { - commitment_id: String, - policy_id: String, - runtime_state: RuntimeState, - enforcement_level: EnforcementLevel, - allowed_context: AllowedContext, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct SessionTimingSummary { - pub active_elapsed_secs: u64, - pub currently_in_transition: bool, -} - -#[derive(Debug)] -pub struct SessionController { - runtime_state: RuntimeState, - active_commitment: Option, - active_policy: Option, - review_completed: bool, - event_log: EventLog, -} - -impl SessionController { - pub fn new(event_log_path: impl AsRef) -> Result { - let event_log = EventLog::open(event_log_path.as_ref()).with_context(|| { - format!( - "open session event log at {}", - event_log_path.as_ref().display() - ) - })?; - let (runtime_state, active_commitment, active_policy, review_completed) = - hydrate_session(&event_log).context("hydrate session from event log")?; - - Ok(Self { - runtime_state, - active_commitment, - active_policy, - review_completed, - event_log, - }) - } - - pub fn runtime_state(&self) -> RuntimeState { - self.runtime_state - } - - pub fn active_policy(&self) -> Option<&PolicySnapshot> { - self.active_policy.as_ref() - } - - pub fn active_commitment(&self) -> Option<&Commitment> { - self.active_commitment.as_ref() - } - - pub fn timing_summary(&self) -> Result> { - let Some(commitment) = self.active_commitment.as_ref() else { - return Ok(None); - }; - self.timing_summary_at(commitment, unix_secs_now()) - } - - fn timing_summary_at( - &self, - commitment: &Commitment, - now_unix_secs: u64, - ) -> Result> { - let commitment_id = commitment.id.as_str(); - let mut active_elapsed_secs = 0_u64; - let mut active_started_at: Option = None; - let mut open_transition_started_at: Option = None; - let mut saw_commitment = false; - - for record in self.event_log.records()? { - if record.commitment_id.as_deref() != Some(commitment_id) { - continue; - } - - match record.event_type { - EventType::CommitmentCreated => { - let payload: CommitmentCreatedPayload = - parse_payload(&record, "commitment created")?; - if payload.commitment_id == commitment_id { - active_started_at = Some(record.timestamp_unix_secs); - open_transition_started_at = None; - saw_commitment = true; - } - } - EventType::TransitionStarted => { - let payload: TransitionStartedPayload = - parse_payload(&record, "transition started")?; - if payload.commitment_id == commitment_id - && open_transition_started_at.is_none() - { - if let Some(started_at) = active_started_at.take() { - active_elapsed_secs = active_elapsed_secs.saturating_add( - record.timestamp_unix_secs.saturating_sub(started_at), - ); - } - open_transition_started_at = Some(record.timestamp_unix_secs); - } - } - EventType::RuntimeTransition => { - let payload: RuntimeTransitionPayload = - parse_payload(&record, "runtime transition")?; - if payload.action == "complete_for_review" { - if let Some(started_at) = active_started_at.take() { - active_elapsed_secs = active_elapsed_secs.saturating_add( - record.timestamp_unix_secs.saturating_sub(started_at), - ); - } - } - if payload.action == "return_to_active" - && open_transition_started_at.take().is_some() - { - active_started_at = Some(record.timestamp_unix_secs); - } - } - EventType::CommitmentTransition => { - let payload: CommitmentTransitionPayload = - parse_payload(&record, "commitment transition")?; - if payload.commitment_id == commitment_id - && payload.action == "return_from_pause" - && open_transition_started_at.take().is_some() - { - active_started_at = Some(record.timestamp_unix_secs); - } - } - _ => {} - } - } - - if !saw_commitment { - return Ok(None); - } - - if let Some(started_at) = active_started_at { - active_elapsed_secs = - active_elapsed_secs.saturating_add(now_unix_secs.saturating_sub(started_at)); - } - - Ok(Some(SessionTimingSummary { - active_elapsed_secs, - currently_in_transition: self.runtime_state == RuntimeState::Transition - && open_transition_started_at.is_some(), - })) - } - - pub fn enter_planning(&mut self) -> Result<()> { - let previous_runtime_state = self.runtime_state; - let next_runtime_state = - transition_runtime(previous_runtime_state, RuntimeAction::EnterPlanning) - .context("enter planning runtime transition")?; - - self.event_log - .append( - EventType::RuntimeTransition, - next_runtime_state, - self.active_commitment - .as_ref() - .map(|commitment| commitment.id.clone()), - serde_json::to_value(RuntimeTransitionPayload { - schema_version: POLICY_SCHEMA_VERSION, - action: "enter_planning".to_string(), - from: previous_runtime_state, - to: next_runtime_state, - }) - .context("serialize runtime transition payload")?, - ) - .context("log runtime transition into planning")?; - - self.runtime_state = next_runtime_state; - Ok(()) - } - - pub fn complete_for_review(&mut self) -> Result<()> { - let previous_runtime_state = self.runtime_state; - let next_runtime_state = - transition_runtime(previous_runtime_state, RuntimeAction::CompleteForReview) - .context("complete active session for review runtime transition")?; - let commitment_id = self - .active_commitment - .as_ref() - .ok_or_else(|| anyhow!("active commitment is required to complete for review"))? - .id - .clone(); - - self.event_log - .append( - EventType::RuntimeTransition, - next_runtime_state, - Some(commitment_id), - serde_json::to_value(RuntimeTransitionPayload { - schema_version: POLICY_SCHEMA_VERSION, - action: "complete_for_review".to_string(), - from: previous_runtime_state, - to: next_runtime_state, - }) - .context("serialize runtime transition payload")?, - ) - .context("log runtime transition into review")?; - - self.runtime_state = next_runtime_state; - self.active_policy = None; - self.review_completed = false; - Ok(()) - } - - pub fn complete_review_and_return_to_planning( - &mut self, - rating: u8, - rating_f64: f64, - reviewed_window_count: u32, - ) -> Result<()> { - let previous_runtime_state = self.runtime_state; - let next_runtime_state = - transition_runtime(previous_runtime_state, RuntimeAction::ContinuePlanning) - .context("continue planning after review runtime transition")?; - let active_commitment = self - .active_commitment - .as_ref() - .ok_or_else(|| anyhow!("active reviewed commitment is required to complete review"))?; - let commitment_id = active_commitment.id.clone(); - let previous_commitment_state = active_commitment.state.clone(); - let next_commitment_state = - if self.review_completed && previous_commitment_state == CommitmentState::Completed { - CommitmentState::Completed - } else { - transition_commitment( - previous_commitment_state.clone(), - CommitmentAction::Complete, - ) - .context("complete reviewed commitment")? - }; - let completion_payload = ReviewCompletedPayload { - schema_version: POLICY_SCHEMA_VERSION, - commitment_id: commitment_id.clone(), - outcome: CommitmentState::Completed, - rating, - rating_f64, - reviewed_window_count, - }; - validate_review_completed_payload(&completion_payload) - .context("validate review completed payload")?; - let transition_payload = RuntimeTransitionPayload { - schema_version: POLICY_SCHEMA_VERSION, - action: "return_to_planning_after_review".to_string(), - from: previous_runtime_state, - to: next_runtime_state, - }; - - if self.review_completed { - if previous_commitment_state == CommitmentState::Completed { - self.event_log - .append( - EventType::RuntimeTransition, - next_runtime_state, - Some(commitment_id.clone()), - serde_json::to_value(transition_payload) - .context("serialize runtime transition payload")?, - ) - .context("log runtime transition from review to planning")?; - } else { - self.event_log - .append_batch([ - PendingEventRecord { - event_type: EventType::CommitmentTransition, - runtime_state: RuntimeState::Review, - commitment_id: Some(commitment_id.clone()), - payload_json: serde_json::to_value(CommitmentTransitionPayload { - schema_version: POLICY_SCHEMA_VERSION, - commitment_id: commitment_id.clone(), - action: "complete".to_string(), - from: previous_commitment_state.clone(), - to: next_commitment_state.clone(), - }) - .context("serialize commitment transition payload")?, - }, - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: next_runtime_state, - commitment_id: Some(commitment_id.clone()), - payload_json: serde_json::to_value(transition_payload) - .context("serialize runtime transition payload")?, - }, - ]) - .context("log commitment completion and runtime transition to planning")?; - } - } else { - self.event_log - .append_batch([ - PendingEventRecord { - event_type: EventType::ReviewCompleted, - runtime_state: RuntimeState::Review, - commitment_id: Some(commitment_id.clone()), - payload_json: serde_json::to_value(completion_payload) - .context("serialize review completed payload")?, - }, - PendingEventRecord { - event_type: EventType::CommitmentTransition, - runtime_state: RuntimeState::Review, - commitment_id: Some(commitment_id.clone()), - payload_json: serde_json::to_value(CommitmentTransitionPayload { - schema_version: POLICY_SCHEMA_VERSION, - commitment_id: commitment_id.clone(), - action: "complete".to_string(), - from: previous_commitment_state.clone(), - to: next_commitment_state.clone(), - }) - .context("serialize commitment transition payload")?, - }, - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: next_runtime_state, - commitment_id: Some(commitment_id.clone()), - payload_json: serde_json::to_value(transition_payload) - .context("serialize runtime transition payload")?, - }, - ]) - .context("log review completion, commitment completion, and runtime transition")?; - } - - self.runtime_state = next_runtime_state; - self.active_commitment = None; - self.active_policy = None; - self.review_completed = false; - Ok(()) - } - - pub fn start_manual_commitment( - &mut self, - next_action: impl Into, - success_condition: impl Into, - timebox: Duration, - ) -> Result<()> { - let previous_runtime_state = self.runtime_state; - let next_runtime_state = transition_runtime( - previous_runtime_state, - RuntimeAction::Activate { - policy_accepted: true, - }, - ) - .context("activate runtime for manual commitment")?; - - let mut commitment = Commitment::new_manual(next_action, success_condition, timebox) - .context("create manual commitment")?; - commitment.state = - transition_commitment(commitment.state.clone(), CommitmentAction::Activate) - .context("activate manual commitment")?; - - let policy = PolicySnapshot::try_for_runtime( - commitment.id.clone(), - next_runtime_state, - EnforcementLevel::Warn, - AllowedContext::default(), - ) - .context("create active policy snapshot")?; - - self.event_log - .append_batch([ - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: next_runtime_state, - commitment_id: Some(commitment.id.clone()), - payload_json: serde_json::to_value(CommitmentCreatedPayload { - schema_version: POLICY_SCHEMA_VERSION, - commitment_id: commitment.id.clone(), - source: commitment.source.clone(), - next_action: commitment.next_action.clone(), - success_condition: commitment.success_condition.clone(), - timebox_secs: commitment.timebox_secs, - state: commitment.state.clone(), - }) - .context("serialize commitment payload")?, - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: next_runtime_state, - commitment_id: Some(commitment.id.clone()), - payload_json: serde_json::to_value(PolicyAppliedPayload { - schema_version: POLICY_SCHEMA_VERSION, - policy_id: policy.id.clone(), - commitment_id: policy.commitment_id.clone(), - runtime_state: policy.runtime_state, - enforcement_level: policy.enforcement_level, - allowed_context: policy.allowed_context.clone(), - }) - .context("serialize policy payload")?, - }, - ]) - .context("log commitment activation")?; - - self.runtime_state = next_runtime_state; - self.active_commitment = Some(commitment); - self.active_policy = Some(policy); - Ok(()) - } - - pub fn start_transition( - &mut self, - reason: impl Into, - return_target: impl Into, - expected_duration: Duration, - ) -> Result<()> { - let active_commitment = self - .active_commitment - .as_ref() - .ok_or_else(|| anyhow!("active commitment is required to start transition"))?; - let active_policy = self - .active_policy - .as_ref() - .ok_or_else(|| anyhow!("active policy is required to start transition"))?; - let composite = - state_machine::start_transition(self.runtime_state, active_commitment.state.clone()) - .context("start transition state change")?; - - let previous_commitment_state = active_commitment.state.clone(); - let next_commitment_state = composite.commitment_state.clone(); - let mut next_commitment = active_commitment.clone(); - next_commitment.state = next_commitment_state.clone(); - - let transition_policy = PolicySnapshot::try_for_runtime( - next_commitment.id.clone(), - composite.runtime_state, - active_policy.enforcement_level, - active_policy.allowed_context.clone(), - ) - .context("create transition policy snapshot")?; - next_commitment.transition_policy_id = Some(transition_policy.id.clone()); - - let transition_payload = TransitionStartedPayload { - schema_version: POLICY_SCHEMA_VERSION, - commitment_id: next_commitment.id.clone(), - transition_policy_id: transition_policy.id.clone(), - reason: reason.into().trim().to_string(), - return_target: return_target.into().trim().to_string(), - expected_duration_secs: expected_duration.as_secs(), - runtime_from: self.runtime_state, - runtime_to: composite.runtime_state, - commitment_from: previous_commitment_state, - commitment_to: next_commitment_state, - }; - validate_transition_started_payload(&transition_payload)?; - - self.event_log - .append_batch([ - PendingEventRecord { - event_type: EventType::TransitionStarted, - runtime_state: composite.runtime_state, - commitment_id: Some(next_commitment.id.clone()), - payload_json: serde_json::to_value(transition_payload) - .context("serialize transition started payload")?, - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: composite.runtime_state, - commitment_id: Some(next_commitment.id.clone()), - payload_json: serde_json::to_value(PolicyAppliedPayload { - schema_version: POLICY_SCHEMA_VERSION, - policy_id: transition_policy.id.clone(), - commitment_id: transition_policy.commitment_id.clone(), - runtime_state: transition_policy.runtime_state, - enforcement_level: transition_policy.enforcement_level, - allowed_context: transition_policy.allowed_context.clone(), - }) - .context("serialize transition policy payload")?, - }, - ]) - .context("log transition start")?; - - self.runtime_state = composite.runtime_state; - self.active_commitment = Some(next_commitment); - self.active_policy = Some(transition_policy); - Ok(()) - } - - pub fn return_from_transition(&mut self) -> Result<()> { - let active_commitment = self - .active_commitment - .as_ref() - .ok_or_else(|| anyhow!("active commitment is required to return from transition"))?; - let active_policy = self - .active_policy - .as_ref() - .ok_or_else(|| anyhow!("active policy is required to return from transition"))?; - - let previous_runtime_state = self.runtime_state; - let next_runtime_state = - transition_runtime(previous_runtime_state, RuntimeAction::ReturnToActive) - .context("return from transition runtime transition")?; - - let previous_commitment_state = active_commitment.state.clone(); - let next_commitment_state = transition_commitment( - previous_commitment_state.clone(), - CommitmentAction::ReturnFromPause, - ) - .context("return paused commitment to active")?; - - let mut next_commitment = active_commitment.clone(); - next_commitment.state = next_commitment_state.clone(); - next_commitment.transition_policy_id = None; - - let active_policy = PolicySnapshot::try_for_runtime( - next_commitment.id.clone(), - next_runtime_state, - active_policy.enforcement_level, - active_policy.allowed_context.clone(), - ) - .context("create returned active policy snapshot")?; - - self.event_log - .append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: next_runtime_state, - commitment_id: Some(next_commitment.id.clone()), - payload_json: serde_json::to_value(RuntimeTransitionPayload { - schema_version: POLICY_SCHEMA_VERSION, - action: "return_to_active".to_string(), - from: previous_runtime_state, - to: next_runtime_state, - }) - .context("serialize return runtime transition payload")?, - }, - PendingEventRecord { - event_type: EventType::CommitmentTransition, - runtime_state: next_runtime_state, - commitment_id: Some(next_commitment.id.clone()), - payload_json: serde_json::to_value(CommitmentTransitionPayload { - schema_version: POLICY_SCHEMA_VERSION, - commitment_id: next_commitment.id.clone(), - action: "return_from_pause".to_string(), - from: previous_commitment_state, - to: next_commitment_state, - }) - .context("serialize return commitment transition payload")?, - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: next_runtime_state, - commitment_id: Some(next_commitment.id.clone()), - payload_json: serde_json::to_value(PolicyAppliedPayload { - schema_version: POLICY_SCHEMA_VERSION, - policy_id: active_policy.id.clone(), - commitment_id: active_policy.commitment_id.clone(), - runtime_state: active_policy.runtime_state, - enforcement_level: active_policy.enforcement_level, - allowed_context: active_policy.allowed_context.clone(), - }) - .context("serialize returned active policy payload")?, - }, - ]) - .context("log return from transition")?; - - self.runtime_state = next_runtime_state; - self.active_commitment = Some(next_commitment); - self.active_policy = Some(active_policy); - Ok(()) - } - - pub fn record_violation(&mut self, reason: impl Into, evidence: Value) -> Result<()> { - let payload = ViolationObservedPayload { - schema_version: POLICY_SCHEMA_VERSION, - reason: reason.into().trim().to_string(), - evidence, - }; - validate_violation_observed_payload(&payload)?; - if self.runtime_state != RuntimeState::Active { - return Err(anyhow!("violation requires active runtime")); - } - let commitment_id = self - .active_commitment - .as_ref() - .ok_or_else(|| anyhow!("violation requires active commitment"))? - .id - .clone(); - - self.event_log - .append( - EventType::ViolationObserved, - RuntimeState::Active, - Some(commitment_id), - serde_json::to_value(payload).context("serialize violation observed payload")?, - ) - .context("log violation observation")?; - - Ok(()) - } - - pub fn record_evidence(&mut self, health: EvidenceHealth) -> Result<()> { - let payload = EvidenceObservedPayload { - schema_version: POLICY_SCHEMA_VERSION, - health, - }; - validate_evidence_observed_payload(&payload)?; - let runtime_state = self.runtime_state; - let commitment_id = self - .active_commitment - .as_ref() - .map(|commitment| commitment.id.clone()); - - self.event_log - .append( - EventType::EvidenceObserved, - runtime_state, - commitment_id, - serde_json::to_value(payload).context("serialize evidence observed payload")?, - ) - .context("log evidence observation")?; - - Ok(()) - } -} - -fn hydrate_session( - event_log: &EventLog, -) -> Result<( - RuntimeState, - Option, - Option, - bool, -)> { - let mut runtime_state = RuntimeState::Locked; - let mut active_commitment: Option = None; - let mut active_policy: Option = None; - let mut pending_transition_policy: Option = None; - let mut review_completed = false; - - for record in event_log.records()? { - if pending_transition_policy.is_some() && record.event_type != EventType::PolicyApplied { - return Err(anyhow!( - "pending transition policy expected PolicyApplied at sequence {}", - record.sequence - )); - } - - match record.event_type { - EventType::RuntimeTransition => { - let payload: RuntimeTransitionPayload = - parse_payload(&record, "runtime transition")?; - ensure_schema_version(payload.schema_version, record.sequence)?; - if payload.from != runtime_state { - return Err(anyhow!( - "runtime transition source mismatch at sequence {}: expected {:?}, found {:?}", - record.sequence, - runtime_state, - payload.from - )); - } - let action = match payload.action.as_str() { - "enter_planning" => RuntimeAction::EnterPlanning, - "complete_for_review" => RuntimeAction::CompleteForReview, - "return_to_active" => RuntimeAction::ReturnToActive, - "return_to_planning_after_review" => RuntimeAction::ContinuePlanning, - _ => { - return Err(anyhow!( - "unsupported runtime transition action '{}' at sequence {}", - payload.action, - record.sequence - )); - } - }; - match action { - RuntimeAction::CompleteForReview => { - let commitment = active_commitment.as_ref().ok_or_else(|| { - anyhow!( - "active commitment is required to enter review at sequence {}", - record.sequence - ) - })?; - ensure_record_commitment(&record, &commitment.id)?; - } - RuntimeAction::ContinuePlanning => { - let commitment = active_commitment.as_ref().ok_or_else(|| { - anyhow!( - "active reviewed commitment is required before returning to planning at sequence {}", - record.sequence - ) - })?; - let legacy_unlinked_review_return = record.commitment_id.is_none() - && !review_completed - && commitment.state == CommitmentState::Active; - if !legacy_unlinked_review_return { - ensure_record_commitment(&record, &commitment.id)?; - if !review_completed { - return Err(anyhow!( - "review completion is required before returning to planning at sequence {}", - record.sequence - )); - } - if commitment.state != CommitmentState::Completed { - return Err(anyhow!( - "reviewed commitment must be terminal before returning to planning at sequence {}", - record.sequence - )); - } - } - } - _ => {} - } - - let next = transition_runtime(runtime_state, action).with_context(|| { - format!("replay runtime transition at sequence {}", record.sequence) - })?; - if payload.to != next || record.runtime_state != next { - return Err(anyhow!( - "runtime transition target mismatch at sequence {}", - record.sequence - )); - } - runtime_state = next; - match action { - RuntimeAction::CompleteForReview => { - active_policy = None; - review_completed = false; - } - RuntimeAction::ContinuePlanning => { - active_commitment = None; - active_policy = None; - review_completed = false; - } - _ => {} - } - } - EventType::CommitmentTransition => { - let payload: CommitmentTransitionPayload = - parse_payload(&record, "commitment transition")?; - ensure_schema_version(payload.schema_version, record.sequence)?; - ensure_record_commitment(&record, &payload.commitment_id)?; - if record.runtime_state != runtime_state { - return Err(anyhow!( - "commitment transition runtime mismatch at sequence {}", - record.sequence - )); - } - let commitment = active_commitment.as_mut().ok_or_else(|| { - anyhow!( - "commitment transition references unknown commitment '{}' at sequence {}", - payload.commitment_id, - record.sequence - ) - })?; - if commitment.id != payload.commitment_id { - return Err(anyhow!( - "commitment transition commitment mismatch at sequence {}", - record.sequence - )); - } - if commitment.state != payload.from { - return Err(anyhow!( - "commitment transition source mismatch at sequence {}", - record.sequence - )); - } - let action = match payload.action.as_str() { - "return_from_pause" => CommitmentAction::ReturnFromPause, - "complete" => { - if runtime_state != RuntimeState::Review { - return Err(anyhow!( - "commitment completion requires review runtime at sequence {}", - record.sequence - )); - } - if !review_completed { - return Err(anyhow!( - "review completion is required before commitment completion at sequence {}", - record.sequence - )); - } - CommitmentAction::Complete - } - _ => { - return Err(anyhow!( - "unsupported commitment transition action '{}' at sequence {}", - payload.action, - record.sequence - )); - } - }; - let next = - transition_commitment(commitment.state.clone(), action).with_context(|| { - format!( - "replay commitment transition at sequence {}", - record.sequence - ) - })?; - if payload.to != next { - return Err(anyhow!( - "commitment transition target mismatch at sequence {}", - record.sequence - )); - } - commitment.state = next; - if commitment.state == CommitmentState::Active { - commitment.transition_policy_id = None; - } - } - EventType::CommitmentCreated => { - let payload: CommitmentCreatedPayload = - parse_payload(&record, "commitment created")?; - ensure_schema_version(payload.schema_version, record.sequence)?; - ensure_record_commitment(&record, &payload.commitment_id)?; - if active_commitment.is_some() || active_policy.is_some() { - return Err(anyhow!( - "commitment created while active commitment already exists at sequence {}", - record.sequence - )); - } - if runtime_state != RuntimeState::Planning { - return Err(anyhow!( - "commitment activation requires planning runtime at sequence {}", - record.sequence - )); - } - if record.runtime_state != RuntimeState::Active { - return Err(anyhow!( - "commitment activation runtime mismatch at sequence {}", - record.sequence - )); - } - if payload.state != CommitmentState::Active { - return Err(anyhow!( - "commitment activation state mismatch at sequence {}", - record.sequence - )); - } - let commitment = Commitment { - id: payload.commitment_id, - created_at_unix_secs: record.timestamp_unix_secs, - source: payload.source, - project_id: None, - template_id: None, - next_action: payload.next_action, - success_condition: payload.success_condition, - timebox_secs: payload.timebox_secs, - transition_policy_id: None, - state: payload.state, - }; - commitment.validate().with_context(|| { - format!("validate commitment at sequence {}", record.sequence) - })?; - active_commitment = Some(commitment); - } - EventType::PolicyApplied => { - let payload: PolicyAppliedPayload = parse_payload(&record, "policy applied")?; - ensure_schema_version(payload.schema_version, record.sequence)?; - if let Some(expected) = pending_transition_policy.take() { - apply_pending_transition_policy( - &record, - payload, - expected, - &mut runtime_state, - &mut active_commitment, - &mut active_policy, - )?; - continue; - } - ensure_record_commitment(&record, &payload.commitment_id)?; - let commitment = active_commitment.as_ref().ok_or_else(|| { - anyhow!( - "policy references unknown commitment '{}' at sequence {}", - payload.commitment_id, - record.sequence - ) - })?; - if commitment.id != payload.commitment_id { - return Err(anyhow!( - "policy commitment mismatch at sequence {}", - record.sequence - )); - } - let next = match (runtime_state, &commitment.state, payload.runtime_state) { - (RuntimeState::Planning, CommitmentState::Active, RuntimeState::Active) => { - transition_runtime( - runtime_state, - RuntimeAction::Activate { - policy_accepted: true, - }, - ) - .with_context(|| { - format!("replay policy application at sequence {}", record.sequence) - })? - } - (RuntimeState::Active, CommitmentState::Active, RuntimeState::Active) => { - RuntimeState::Active - } - _ => { - return Err(anyhow!( - "policy requires active commitment at sequence {}", - record.sequence - )); - } - }; - if payload.runtime_state != next || record.runtime_state != next { - return Err(anyhow!( - "policy runtime target mismatch at sequence {}", - record.sequence - )); - } - let policy = PolicySnapshot { - id: payload.policy_id, - commitment_id: payload.commitment_id, - schema_version: POLICY_SCHEMA_VERSION, - created_at_unix_secs: record.timestamp_unix_secs, - runtime_state: payload.runtime_state, - enforcement_level: payload.enforcement_level, - allowed_context: payload.allowed_context, - required_monitors: Vec::new(), - violation_actions: Vec::new(), - expires_at_unix_secs: None, - generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(), - }; - policy - .validate() - .with_context(|| format!("validate policy at sequence {}", record.sequence))?; - runtime_state = next; - if runtime_state == RuntimeState::Transition { - if let Some(commitment) = active_commitment.as_mut() { - commitment.transition_policy_id = Some(policy.id.clone()); - } - } - active_policy = Some(policy); - } - EventType::TransitionStarted => { - let payload: TransitionStartedPayload = - parse_payload(&record, "transition started")?; - ensure_schema_version(payload.schema_version, record.sequence)?; - validate_transition_started_payload(&payload).with_context(|| { - format!( - "validate transition started payload at sequence {}", - record.sequence - ) - })?; - let active = active_commitment.as_ref().ok_or_else(|| { - anyhow!( - "transition requires active commitment at sequence {}", - record.sequence - ) - })?; - let current_policy = active_policy.as_ref().ok_or_else(|| { - anyhow!( - "transition requires active policy at sequence {}", - record.sequence - ) - })?; - ensure_record_commitment(&record, &payload.commitment_id)?; - if payload.commitment_id != active.id { - return Err(anyhow!( - "transition commitment mismatch at sequence {}", - record.sequence - )); - } - if payload.runtime_from != runtime_state || payload.commitment_from != active.state - { - return Err(anyhow!( - "transition source mismatch at sequence {}", - record.sequence - )); - } - let composite = - state_machine::start_transition(runtime_state, active.state.clone()) - .with_context(|| { - format!("replay transition start at sequence {}", record.sequence) - })?; - if payload.runtime_to != composite.runtime_state - || payload.commitment_to != composite.commitment_state - || record.runtime_state != composite.runtime_state - { - return Err(anyhow!( - "transition target mismatch at sequence {}", - record.sequence - )); - } - let mut commitment = active.clone(); - commitment.state = composite.commitment_state; - commitment.transition_policy_id = Some(payload.transition_policy_id.clone()); - pending_transition_policy = Some(PendingTransitionPolicy { - commitment_id: commitment.id.clone(), - policy_id: payload.transition_policy_id, - runtime_state: composite.runtime_state, - enforcement_level: current_policy.enforcement_level, - allowed_context: current_policy.allowed_context.clone(), - }); - runtime_state = composite.runtime_state; - active_commitment = Some(commitment); - } - EventType::ViolationObserved => { - let payload: ViolationObservedPayload = - parse_payload(&record, "violation observed")?; - ensure_schema_version(payload.schema_version, record.sequence)?; - validate_violation_observed_payload(&payload).with_context(|| { - format!( - "validate violation observed payload at sequence {}", - record.sequence - ) - })?; - if runtime_state != RuntimeState::Active - || record.runtime_state != RuntimeState::Active - { - return Err(anyhow!( - "violation requires active runtime at sequence {}", - record.sequence - )); - } - let commitment_id = record.commitment_id.as_deref().ok_or_else(|| { - anyhow!( - "violation requires active commitment at sequence {}", - record.sequence - ) - })?; - let active = active_commitment.as_ref().ok_or_else(|| { - anyhow!( - "violation references unknown commitment '{}' at sequence {}", - commitment_id, - record.sequence - ) - })?; - if active.state != CommitmentState::Active { - return Err(anyhow!( - "violation requires active commitment at sequence {}", - record.sequence - )); - } - if active.id != commitment_id { - return Err(anyhow!( - "violation commitment mismatch at sequence {}", - record.sequence - )); - } - } - EventType::EvidenceObserved => { - let payload: EvidenceObservedPayload = parse_payload(&record, "evidence observed")?; - ensure_schema_version(payload.schema_version, record.sequence)?; - validate_evidence_observed_payload(&payload).with_context(|| { - format!( - "validate evidence observed payload at sequence {}", - record.sequence - ) - })?; - if record.runtime_state != runtime_state { - return Err(anyhow!( - "evidence runtime mismatch at sequence {}", - record.sequence - )); - } - if let Some(commitment_id) = record.commitment_id.as_deref() { - let active = active_commitment.as_ref().ok_or_else(|| { - anyhow!( - "evidence references unknown commitment '{}' at sequence {}", - commitment_id, - record.sequence - ) - })?; - if active.id != commitment_id { - return Err(anyhow!( - "evidence commitment mismatch at sequence {}", - record.sequence - )); - } - } - } - EventType::ReviewCompleted => { - let payload: ReviewCompletedPayload = parse_payload(&record, "review completed")?; - ensure_schema_version(payload.schema_version, record.sequence)?; - validate_review_completed_payload(&payload).with_context(|| { - format!( - "validate review completed payload at sequence {}", - record.sequence - ) - })?; - if runtime_state != RuntimeState::Review - || record.runtime_state != RuntimeState::Review - { - return Err(anyhow!( - "review completion requires review runtime at sequence {}", - record.sequence - )); - } - ensure_record_commitment(&record, &payload.commitment_id)?; - let commitment = active_commitment.as_ref().ok_or_else(|| { - anyhow!( - "active reviewed commitment is required for review completion at sequence {}", - record.sequence - ) - })?; - if commitment.id != payload.commitment_id { - return Err(anyhow!( - "review completion commitment mismatch at sequence {}", - record.sequence - )); - } - if commitment.state != CommitmentState::Active { - return Err(anyhow!( - "review completion requires active commitment at sequence {}", - record.sequence - )); - } - if review_completed { - return Err(anyhow!( - "review completion already recorded at sequence {}", - record.sequence - )); - } - review_completed = true; - } - } - } - - if pending_transition_policy.is_some() { - return Err(anyhow!( - "session replay ended with pending transition policy" - )); - } - if runtime_state == RuntimeState::Review && active_commitment.is_none() { - return Err(anyhow!( - "session replay ended in review without active reviewed commitment" - )); - } - if active_commitment.is_some() - && active_policy.is_none() - && runtime_state != RuntimeState::Review - { - return Err(anyhow!( - "session replay ended with active commitment but no active policy" - )); - } - if let (Some(commitment), Some(policy)) = (&active_commitment, &active_policy) { - if policy.commitment_id != commitment.id { - return Err(anyhow!( - "session replay ended with commitment/policy mismatch" - )); - } - if policy.runtime_state != runtime_state { - return Err(anyhow!("session replay ended with runtime/policy mismatch")); - } - } - - Ok(( - runtime_state, - active_commitment, - active_policy, - review_completed, - )) -} - -fn apply_pending_transition_policy( - record: &EventRecord, - payload: PolicyAppliedPayload, - expected: PendingTransitionPolicy, - runtime_state: &mut RuntimeState, - active_commitment: &mut Option, - active_policy: &mut Option, -) -> Result<()> { - ensure_record_commitment(record, &payload.commitment_id)?; - if payload.commitment_id != expected.commitment_id { - return Err(anyhow!( - "transition policy commitment mismatch at sequence {}", - record.sequence - )); - } - if record.runtime_state != expected.runtime_state - || payload.runtime_state != expected.runtime_state - { - return Err(anyhow!( - "transition policy runtime mismatch at sequence {}", - record.sequence - )); - } - if payload.enforcement_level != expected.enforcement_level { - return Err(anyhow!( - "transition policy enforcement mismatch at sequence {}", - record.sequence - )); - } - if payload.allowed_context != expected.allowed_context { - return Err(anyhow!( - "transition policy allowed context mismatch at sequence {}", - record.sequence - )); - } - if payload.policy_id != expected.policy_id { - return Err(anyhow!( - "transition policy id mismatch at sequence {}", - record.sequence - )); - } - - let policy = PolicySnapshot { - id: payload.policy_id, - commitment_id: payload.commitment_id, - schema_version: POLICY_SCHEMA_VERSION, - created_at_unix_secs: record.timestamp_unix_secs, - runtime_state: payload.runtime_state, - enforcement_level: payload.enforcement_level, - allowed_context: payload.allowed_context, - required_monitors: Vec::new(), - violation_actions: Vec::new(), - expires_at_unix_secs: None, - generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(), - }; - policy - .validate() - .with_context(|| format!("validate transition policy at sequence {}", record.sequence))?; - if let Some(commitment) = active_commitment.as_mut() { - if commitment.id != policy.commitment_id { - return Err(anyhow!( - "transition policy active commitment mismatch at sequence {}", - record.sequence - )); - } - commitment.transition_policy_id = Some(policy.id.clone()); - } else { - return Err(anyhow!( - "transition policy references unknown commitment at sequence {}", - record.sequence - )); - } - *runtime_state = expected.runtime_state; - *active_policy = Some(policy); - Ok(()) -} - -fn parse_payload(record: &EventRecord, description: &str) -> Result -where - T: for<'de> Deserialize<'de>, -{ - serde_json::from_value(record.payload_json.clone()).with_context(|| { - format!( - "parse {description} payload at sequence {}", - record.sequence - ) - }) -} - -fn ensure_schema_version(schema_version: u16, sequence: u64) -> Result<()> { - if schema_version != POLICY_SCHEMA_VERSION { - return Err(anyhow!( - "unsupported payload schema version at sequence {}: expected {}, found {}", - sequence, - POLICY_SCHEMA_VERSION, - schema_version - )); - } - Ok(()) -} - -fn ensure_record_commitment(record: &EventRecord, commitment_id: &str) -> Result<()> { - if record.commitment_id.as_deref() != Some(commitment_id) { - return Err(anyhow!( - "commitment id mismatch at sequence {}", - record.sequence - )); - } - Ok(()) -} - -fn validate_transition_started_payload(payload: &TransitionStartedPayload) -> Result<()> { - if payload.transition_policy_id.trim().is_empty() { - return Err(anyhow!("transition policy id is required")); - } - if payload.reason.trim().is_empty() { - return Err(anyhow!("transition reason is required")); - } - if payload.return_target.trim().is_empty() { - return Err(anyhow!("transition return target is required")); - } - if payload.expected_duration_secs == 0 { - return Err(anyhow!("transition expected duration must be nonzero")); - } - Ok(()) -} - -fn validate_violation_observed_payload(payload: &ViolationObservedPayload) -> Result<()> { - if payload.reason.trim().is_empty() { - return Err(anyhow!("violation reason is required")); - } - Ok(()) -} - -fn validate_evidence_observed_payload(payload: &EvidenceObservedPayload) -> Result<()> { - match &payload.health { - EvidenceHealth::Available => {} - EvidenceHealth::Degraded(reason) | EvidenceHealth::Unavailable(reason) - if reason.trim().is_empty() => - { - return Err(anyhow!("evidence reason is required")); - } - EvidenceHealth::Degraded(_) | EvidenceHealth::Unavailable(_) => {} - } - Ok(()) -} - -fn validate_review_completed_payload(payload: &ReviewCompletedPayload) -> Result<()> { - if payload.commitment_id.trim().is_empty() { - return Err(anyhow!("review commitment id is required")); - } - if payload.outcome != CommitmentState::Completed { - return Err(anyhow!("review outcome must be completed")); - } - if payload.rating > 3 { - return Err(anyhow!("review rating must be between 0 and 3")); - } - if !payload.rating_f64.is_finite() || !(0.0..=3.0).contains(&payload.rating_f64) { - return Err(anyhow!( - "review rating_f64 must be finite and between 0 and 3" - )); - } - if payload.rating > 0 && payload.reviewed_window_count == 0 { - return Err(anyhow!( - "reviewed window count is required when review rating is nonzero" - )); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::{ - CommitmentCreatedPayload, CommitmentTransitionPayload, PolicyAppliedPayload, - RuntimeTransitionPayload, TransitionStartedPayload, - }; - use crate::domain::{ - unix_secs_now, AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, - EvidenceHealth, RuntimeState, POLICY_SCHEMA_VERSION, - }; - use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord}; - use crate::session::SessionController; - use serde_json::json; - use std::fs; - use std::path::PathBuf; - use std::time::{Duration, SystemTime, UNIX_EPOCH}; - - fn temp_log_path(name: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!( - "antidrift-session-{name}-{}-{nonce}.jsonl", - std::process::id() - )) - } - - fn read_records(path: &PathBuf) -> Vec { - fs::read_to_string(path) - .unwrap() - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect() - } - - fn commitment_payload(commitment_id: &str, state: CommitmentState) -> serde_json::Value { - serde_json::to_value(CommitmentCreatedPayload { - schema_version: POLICY_SCHEMA_VERSION, - commitment_id: commitment_id.to_string(), - source: CommitmentSource::Manual, - next_action: "Implement session controller".to_string(), - success_condition: "session tests pass".to_string(), - timebox_secs: 1500, - state, - }) - .unwrap() - } - - fn policy_payload(commitment_id: &str) -> serde_json::Value { - serde_json::to_value(PolicyAppliedPayload { - schema_version: POLICY_SCHEMA_VERSION, - policy_id: "policy-test".to_string(), - commitment_id: commitment_id.to_string(), - runtime_state: RuntimeState::Active, - enforcement_level: EnforcementLevel::Warn, - allowed_context: AllowedContext::default(), - }) - .unwrap() - } - - fn transition_policy_payload(commitment_id: &str) -> serde_json::Value { - serde_json::to_value(PolicyAppliedPayload { - schema_version: POLICY_SCHEMA_VERSION, - policy_id: "policy-transition-test".to_string(), - commitment_id: commitment_id.to_string(), - runtime_state: RuntimeState::Transition, - enforcement_level: EnforcementLevel::Warn, - allowed_context: AllowedContext::default(), - }) - .unwrap() - } - - fn transition_started_payload() -> serde_json::Value { - serde_json::to_value(TransitionStartedPayload { - schema_version: POLICY_SCHEMA_VERSION, - commitment_id: "commitment-test".to_string(), - transition_policy_id: "policy-transition-test".to_string(), - reason: "Need to inspect failures".to_string(), - return_target: "Return to session controller".to_string(), - expected_duration_secs: 300, - runtime_from: RuntimeState::Active, - runtime_to: RuntimeState::Transition, - commitment_from: CommitmentState::Active, - commitment_to: CommitmentState::Paused, - }) - .unwrap() - } - - fn append_active_session_with_transition_payload( - path: &PathBuf, - transition_payload: serde_json::Value, - ) { - let commitment_id = "commitment-test"; - let mut log = EventLog::open(path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_payload(commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: policy_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::TransitionStarted, - runtime_state: RuntimeState::Transition, - commitment_id: Some(commitment_id.to_string()), - payload_json: transition_payload, - }, - ]) - .unwrap(); - } - - fn append_active_session_with_transition_and_policy_payload( - path: &PathBuf, - transition_payload: serde_json::Value, - policy_payload: serde_json::Value, - ) { - let commitment_id = "commitment-test"; - append_active_session_with_transition_payload(path, transition_payload); - let mut log = EventLog::open(path).unwrap(); - log.append( - EventType::PolicyApplied, - RuntimeState::Transition, - Some(commitment_id.to_string()), - policy_payload, - ) - .unwrap(); - } - - fn append_review_session_without_completion(path: &PathBuf) { - let commitment_id = "commitment-test"; - let mut log = EventLog::open(path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_payload(commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: policy_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Review, - commitment_id: Some(commitment_id.to_string()), - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "complete_for_review", - "from": "active", - "to": "review", - }), - }, - ]) - .unwrap(); - } - - fn review_completed_payload(commitment_id: &str) -> serde_json::Value { - json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "commitment_id": commitment_id, - "outcome": "completed", - "rating": 2, - "rating_f64": 2.0, - "reviewed_window_count": 1, - }) - } - - fn commitment_complete_payload(commitment_id: &str) -> serde_json::Value { - serde_json::to_value(CommitmentTransitionPayload { - schema_version: POLICY_SCHEMA_VERSION, - commitment_id: commitment_id.to_string(), - action: "complete".to_string(), - from: CommitmentState::Active, - to: CommitmentState::Completed, - }) - .unwrap() - } - - fn error_chain_contains(err: &anyhow::Error, expected: &str) -> bool { - err.chain() - .any(|cause| cause.to_string().contains(expected)) - } - - #[test] - fn starts_commitment_and_emits_policy() { - let path = temp_log_path("start"); - let mut session = SessionController::new(&path).unwrap(); - - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - - assert_eq!(session.runtime_state(), RuntimeState::Active); - assert_eq!( - session.active_policy().unwrap().enforcement_level, - EnforcementLevel::Warn - ); - assert!(session.active_commitment().is_some()); - - let records = read_records(&path); - assert!(records - .iter() - .any(|record| record.event_type == EventType::CommitmentCreated)); - assert!(records - .iter() - .any(|record| record.event_type == EventType::PolicyApplied)); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn records_violation_with_reason_evidence_and_active_commitment() { - let path = temp_log_path("violation-record"); - let mut session = SessionController::new(&path).unwrap(); - - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - let commitment_id = session.active_commitment().unwrap().id.clone(); - - session - .record_violation( - "Needed to inspect docs", - json!({ - "message": "Unknown context detected", - "window_title": "Browser - unrelated", - }), - ) - .unwrap(); - - let records = read_records(&path); - let violation = records - .iter() - .find(|record| record.event_type == EventType::ViolationObserved) - .unwrap(); - assert_eq!(violation.runtime_state, RuntimeState::Active); - assert_eq!( - violation.commitment_id.as_deref(), - Some(commitment_id.as_str()) - ); - assert_eq!(violation.payload_json["schema_version"], 1); - assert_eq!(violation.payload_json["reason"], "Needed to inspect docs"); - assert_eq!( - violation.payload_json["evidence"]["window_title"], - "Browser - unrelated" - ); - - let reopened = SessionController::new(&path).unwrap(); - assert_eq!(reopened.runtime_state(), RuntimeState::Active); - assert_eq!( - reopened.active_commitment().unwrap().id.as_str(), - commitment_id.as_str() - ); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn records_evidence_with_health_runtime_and_active_commitment() { - let path = temp_log_path("evidence-record"); - let mut session = SessionController::new(&path).unwrap(); - - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - let commitment_id = session.active_commitment().unwrap().id.clone(); - - session - .record_evidence(EvidenceHealth::Degraded( - "window class unavailable".to_string(), - )) - .unwrap(); - - let records = read_records(&path); - let evidence = records - .iter() - .find(|record| record.event_type == EventType::EvidenceObserved) - .unwrap(); - assert_eq!(evidence.runtime_state, RuntimeState::Active); - assert_eq!( - evidence.commitment_id.as_deref(), - Some(commitment_id.as_str()) - ); - assert_eq!(evidence.payload_json["schema_version"], 1); - assert_eq!( - evidence.payload_json["health"], - json!({"degraded": "window class unavailable"}) - ); - - let reopened = SessionController::new(&path).unwrap(); - assert_eq!(reopened.runtime_state(), RuntimeState::Active); - assert_eq!( - reopened.active_commitment().unwrap().id.as_str(), - commitment_id.as_str() - ); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_ignores_valid_evidence_without_changing_state() { - let path = temp_log_path("evidence-replay"); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_payload(commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: policy_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::EvidenceObserved, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "health": {"unavailable": "xdotool active window unavailable"}, - }), - }, - ]) - .unwrap(); - } - - let reopened = SessionController::new(&path).unwrap(); - - assert_eq!(reopened.runtime_state(), RuntimeState::Active); - assert_eq!( - reopened.active_commitment().unwrap().id.as_str(), - commitment_id - ); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_evidence_with_empty_reason() { - let path = temp_log_path("evidence-empty-reason"); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_payload(commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: policy_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::EvidenceObserved, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "health": {"degraded": " "}, - }), - }, - ]) - .unwrap(); - } - - let err = - SessionController::new(&path).expect_err("evidence with empty reason must be rejected"); - - assert!(error_chain_contains(&err, "evidence reason")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn record_violation_rejects_empty_reason() { - let path = temp_log_path("violation-empty-reason"); - let mut session = SessionController::new(&path).unwrap(); - - let err = session - .record_violation(" ", json!({"message": "Unknown context detected"})) - .expect_err("empty violation reason must fail"); - - assert!(err.to_string().contains("reason")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn record_violation_requires_active_runtime_and_commitment() { - let planning_path = temp_log_path("violation-planning"); - let mut planning = SessionController::new(&planning_path).unwrap(); - planning.enter_planning().unwrap(); - - let err = planning - .record_violation( - "Needed to inspect docs", - json!({"message": "Unknown context detected"}), - ) - .expect_err("planning violations must be rejected"); - assert!(error_chain_contains(&err, "active")); - fs::remove_file(planning_path).unwrap(); - - let transition_path = temp_log_path("violation-transition"); - let mut transition = SessionController::new(&transition_path).unwrap(); - transition.enter_planning().unwrap(); - transition - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - transition - .start_transition( - "Need to inspect failures", - "Return to session controller", - Duration::from_secs(300), - ) - .unwrap(); - - let err = transition - .record_violation( - "Needed to inspect docs", - json!({"message": "Unknown context detected"}), - ) - .expect_err("transition violations must be rejected"); - assert!(error_chain_contains(&err, "active")); - fs::remove_file(transition_path).unwrap(); - - let review_path = temp_log_path("violation-review"); - let mut review = SessionController::new(&review_path).unwrap(); - review.enter_planning().unwrap(); - review - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - review.complete_for_review().unwrap(); - - let err = review - .record_violation( - "Needed to inspect docs", - json!({"message": "Unknown context detected"}), - ) - .expect_err("review violations must be rejected"); - assert!(error_chain_contains(&err, "active")); - fs::remove_file(review_path).unwrap(); - } - - #[test] - fn replay_rejects_violation_without_active_commitment() { - let path = temp_log_path("violation-replay-no-commitment"); - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::ViolationObserved, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "reason": "Needed to inspect docs", - "evidence": {"message": "Unknown context detected"}, - }), - }, - ]) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("planning violation without commitment must be rejected"); - - assert!(error_chain_contains(&err, "active")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_active_violation_without_commitment_id() { - let path = temp_log_path("violation-replay-missing-commitment"); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_payload(commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: policy_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::ViolationObserved, - runtime_state: RuntimeState::Active, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "reason": "Needed to inspect docs", - "evidence": {"message": "Unknown context detected"}, - }), - }, - ]) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("active violation without commitment must be rejected"); - - assert!(error_chain_contains(&err, "active commitment")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_violation_for_wrong_commitment() { - let path = temp_log_path("violation-replay-wrong-commitment"); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_payload(commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: policy_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::ViolationObserved, - runtime_state: RuntimeState::Active, - commitment_id: Some("commitment-other".to_string()), - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "reason": "Needed to inspect docs", - "evidence": {"message": "Unknown context detected"}, - }), - }, - ]) - .unwrap(); - } - - let err = - SessionController::new(&path).expect_err("wrong violation commitment must be rejected"); - - assert!(error_chain_contains(&err, "commitment mismatch")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn activation_batch_emits_adjacent_stable_payloads() { - let path = temp_log_path("activation-batch"); - let mut session = SessionController::new(&path).unwrap(); - - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - - let records = read_records(&path); - assert_eq!(records.len(), 3); - assert_eq!(records[1].event_type, EventType::CommitmentCreated); - assert_eq!(records[2].event_type, EventType::PolicyApplied); - assert_eq!(records[2].sequence, records[1].sequence + 1); - assert_eq!( - records[2].previous_hash.as_deref(), - Some(records[1].hash.as_str()) - ); - - let commitment_payload = &records[1].payload_json; - assert_eq!(commitment_payload["schema_version"], 1); - let commitment_id = records[1].commitment_id.as_deref().unwrap(); - assert_eq!(commitment_payload["commitment_id"], commitment_id); - assert_eq!(commitment_payload["source"], "manual"); - assert_eq!( - commitment_payload["next_action"], - "Implement session controller" - ); - assert_eq!( - commitment_payload["success_condition"], - "session tests pass" - ); - assert_eq!(commitment_payload["timebox_secs"], 1500); - assert_eq!(commitment_payload["state"], "active"); - assert!(commitment_payload.get("created_at_unix_secs").is_none()); - - let policy_payload = &records[2].payload_json; - assert_eq!(policy_payload["schema_version"], 1); - assert_eq!(policy_payload["commitment_id"], commitment_id); - assert_eq!(policy_payload["runtime_state"], "active"); - assert_eq!(policy_payload["enforcement_level"], "warn"); - assert_eq!( - policy_payload["allowed_context"], - serde_json::json!({ - "window_classes": [], - "window_title_substrings": [], - "domains": [], - "repos": [], - "commands": [], - }) - ); - assert!(policy_payload.get("created_at_unix_secs").is_none()); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn hydrates_active_session_from_existing_log() { - let path = temp_log_path("hydrate-active"); - { - let mut session = SessionController::new(&path).unwrap(); - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - } - - let reopened = SessionController::new(&path).unwrap(); - - assert_eq!(reopened.runtime_state(), RuntimeState::Active); - assert!(reopened.active_commitment().is_some()); - assert!(reopened.active_policy().is_some()); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn completes_review_returns_to_planning_and_starts_next_commitment() { - let path = temp_log_path("review-cycle"); - let mut session = SessionController::new(&path).unwrap(); - - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - let commitment_id = session.active_commitment().unwrap().id.clone(); - - session.complete_for_review().unwrap(); - assert_eq!(session.runtime_state(), RuntimeState::Review); - assert_eq!( - session.active_commitment().unwrap().id.as_str(), - commitment_id.as_str() - ); - assert!(session.active_policy().is_none()); - - session - .complete_review_and_return_to_planning(2, 2.0, 1) - .unwrap(); - assert_eq!(session.runtime_state(), RuntimeState::Planning); - - session - .start_manual_commitment( - "Start another session", - "second session starts", - Duration::from_secs(600), - ) - .unwrap(); - assert_eq!(session.runtime_state(), RuntimeState::Active); - assert_eq!( - session.active_commitment().unwrap().next_action, - "Start another session" - ); - - drop(session); - let reopened = SessionController::new(&path).unwrap(); - assert_eq!(reopened.runtime_state(), RuntimeState::Active); - assert_eq!( - reopened.active_commitment().unwrap().next_action, - "Start another session" - ); - - let records = read_records(&path); - let review_transition = records - .iter() - .find(|record| { - record.event_type == EventType::RuntimeTransition - && record.runtime_state == RuntimeState::Review - }) - .unwrap(); - let payload: RuntimeTransitionPayload = - serde_json::from_value(review_transition.payload_json.clone()).unwrap(); - assert_eq!(payload.action, "complete_for_review"); - assert_eq!(payload.from, RuntimeState::Active); - assert_eq!(payload.to, RuntimeState::Review); - - let review_completed_index = records - .iter() - .position(|record| record.event_type == EventType::ReviewCompleted) - .unwrap(); - let commitment_complete_index = records - .iter() - .position(|record| { - record.event_type == EventType::CommitmentTransition - && record.payload_json["action"] == "complete" - }) - .unwrap(); - let planning_transition_index = records - .iter() - .position(|record| { - record.event_type == EventType::RuntimeTransition - && record.payload_json["action"] == "return_to_planning_after_review" - }) - .unwrap(); - assert_eq!(commitment_complete_index, review_completed_index + 1); - assert_eq!(planning_transition_index, commitment_complete_index + 1); - - let review_completed = &records[review_completed_index]; - assert_eq!( - review_completed.commitment_id.as_deref(), - Some(commitment_id.as_str()) - ); - assert_eq!( - review_completed.payload_json["commitment_id"], - commitment_id - ); - assert_eq!(review_completed.payload_json["outcome"], "completed"); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_review_to_planning_without_review_completed() { - let path = temp_log_path("review-missing-completion"); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_payload(commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: policy_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Review, - commitment_id: Some(commitment_id.to_string()), - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "complete_for_review", - "from": "active", - "to": "review", - }), - }, - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: Some(commitment_id.to_string()), - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "return_to_planning_after_review", - "from": "review", - "to": "planning", - }), - }, - ]) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("review to planning without completion must be rejected"); - - assert!(error_chain_contains(&err, "review completion")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_accepts_legacy_unlinked_review_to_planning() { - let path = temp_log_path("legacy-review-return"); - let first_commitment_id = "commitment-legacy-first"; - let second_commitment_id = "commitment-legacy-second"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(first_commitment_id.to_string()), - payload_json: commitment_payload(first_commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(first_commitment_id.to_string()), - payload_json: policy_payload(first_commitment_id), - }, - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Review, - commitment_id: Some(first_commitment_id.to_string()), - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "complete_for_review", - "from": "active", - "to": "review", - }), - }, - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "return_to_planning_after_review", - "from": "review", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(second_commitment_id.to_string()), - payload_json: commitment_payload(second_commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(second_commitment_id.to_string()), - payload_json: policy_payload(second_commitment_id), - }, - ]) - .unwrap(); - } - - let reopened = SessionController::new(&path).unwrap(); - - assert_eq!(reopened.runtime_state(), RuntimeState::Active); - assert_eq!( - reopened.active_commitment().unwrap().id, - second_commitment_id - ); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_tolerates_review_completed_without_changing_runtime() { - let path = temp_log_path("review-completed-no-transition"); - append_review_session_without_completion(&path); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::ReviewCompleted, - RuntimeState::Review, - Some(commitment_id.to_string()), - review_completed_payload(commitment_id), - ) - .unwrap(); - } - - let reopened = SessionController::new(&path).unwrap(); - - assert_eq!(reopened.runtime_state(), RuntimeState::Review); - assert_eq!( - reopened.active_commitment().unwrap().state, - CommitmentState::Active - ); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_review_completed_outside_review() { - let path = temp_log_path("review-completed-outside-review"); - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::ReviewCompleted, - runtime_state: RuntimeState::Planning, - commitment_id: Some("commitment-test".to_string()), - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "commitment_id": "commitment-test", - "outcome": "completed", - "rating": 2, - "rating_f64": 2.0, - "reviewed_window_count": 1, - }), - }, - ]) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("review completion outside review must be rejected"); - - assert!(error_chain_contains(&err, "review runtime")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_malformed_review_completed() { - let path = temp_log_path("review-completed-malformed"); - append_review_session_without_completion(&path); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::ReviewCompleted, - RuntimeState::Review, - Some(commitment_id.to_string()), - json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "commitment_id": commitment_id, - "outcome": "completed", - "rating": 4, - "rating_f64": 4.0, - "reviewed_window_count": 1, - }), - ) - .unwrap(); - } - - let err = SessionController::new(&path).expect_err("malformed review completion must fail"); - - assert!(error_chain_contains(&err, "review rating")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_review_completed_without_commitment_id() { - let path = temp_log_path("review-completed-missing-commitment"); - append_review_session_without_completion(&path); - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::ReviewCompleted, - RuntimeState::Review, - None, - json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "outcome": "completed", - "rating": 2, - "rating_f64": 2.0, - "reviewed_window_count": 1, - }), - ) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("review completion without commitment id must fail"); - - assert!(error_chain_contains(&err, "commitment")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_review_completed_for_wrong_commitment() { - let path = temp_log_path("review-completed-wrong-commitment"); - append_review_session_without_completion(&path); - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::ReviewCompleted, - RuntimeState::Review, - Some("commitment-other".to_string()), - review_completed_payload("commitment-other"), - ) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("wrong review completion commitment must fail"); - - assert!(error_chain_contains(&err, "commitment mismatch")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_review_completed_with_mismatched_envelope_commitment() { - let path = temp_log_path("review-completed-envelope-mismatch"); - append_review_session_without_completion(&path); - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::ReviewCompleted, - RuntimeState::Review, - Some("commitment-other".to_string()), - review_completed_payload("commitment-test"), - ) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("review completion envelope mismatch must fail"); - - assert!(error_chain_contains(&err, "commitment id mismatch")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_duplicate_review_completed() { - let path = temp_log_path("review-completed-duplicate"); - let commitment_id = "commitment-test"; - append_review_session_without_completion(&path); - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::ReviewCompleted, - runtime_state: RuntimeState::Review, - commitment_id: Some(commitment_id.to_string()), - payload_json: review_completed_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::ReviewCompleted, - runtime_state: RuntimeState::Review, - commitment_id: Some(commitment_id.to_string()), - payload_json: review_completed_payload(commitment_id), - }, - ]) - .unwrap(); - } - - let err = SessionController::new(&path).expect_err("duplicate review completion must fail"); - - assert!(error_chain_contains(&err, "already recorded")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_review_to_planning_before_commitment_terminal_completion() { - let path = temp_log_path("review-planning-before-terminal"); - let commitment_id = "commitment-test"; - append_review_session_without_completion(&path); - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::ReviewCompleted, - runtime_state: RuntimeState::Review, - commitment_id: Some(commitment_id.to_string()), - payload_json: review_completed_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: Some(commitment_id.to_string()), - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "return_to_planning_after_review", - "from": "review", - "to": "planning", - }), - }, - ]) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("review to planning before terminal commitment must fail"); - - assert!(error_chain_contains(&err, "terminal")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_rejects_commitment_completion_before_review_completed() { - let path = temp_log_path("review-terminal-before-completion"); - let commitment_id = "commitment-test"; - append_review_session_without_completion(&path); - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::CommitmentTransition, - RuntimeState::Review, - Some(commitment_id.to_string()), - commitment_complete_payload(commitment_id), - ) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("commitment completion before review completion must fail"); - - assert!(error_chain_contains(&err, "review completion")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn replay_completes_review_commitment_before_returning_to_planning() { - let path = temp_log_path("review-complete-terminal"); - let commitment_id = "commitment-test"; - append_review_session_without_completion(&path); - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::ReviewCompleted, - runtime_state: RuntimeState::Review, - commitment_id: Some(commitment_id.to_string()), - payload_json: review_completed_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::CommitmentTransition, - runtime_state: RuntimeState::Review, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_complete_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: Some(commitment_id.to_string()), - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "return_to_planning_after_review", - "from": "review", - "to": "planning", - }), - }, - ]) - .unwrap(); - } - - let reopened = SessionController::new(&path).unwrap(); - - assert_eq!(reopened.runtime_state(), RuntimeState::Planning); - assert!(reopened.active_commitment().is_none()); - assert!(reopened.active_policy().is_none()); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_standalone_commitment_created_on_replay() { - let path = temp_log_path("standalone-commitment"); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::CommitmentCreated, - RuntimeState::Active, - Some(commitment_id.to_string()), - commitment_payload(commitment_id, CommitmentState::Active), - ) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("standalone commitment without policy must be rejected"); - - assert!(error_chain_contains(&err, "planning")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_commitment_created_before_planning_transition_on_replay() { - let path = temp_log_path("commitment-before-planning"); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_payload(commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: policy_payload(commitment_id), - }, - ]) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("commitment activation before planning must be rejected"); - - assert!(error_chain_contains(&err, "planning")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_draft_commitment_before_policy_on_replay() { - let path = temp_log_path("draft-commitment-policy"); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_payload(commitment_id, CommitmentState::Draft), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: policy_payload(commitment_id), - }, - ]) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("draft commitment cannot be activated by policy replay"); - - assert!(error_chain_contains(&err, "commitment activation")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_stray_commitment_created_after_active_session_on_replay() { - let path = temp_log_path("stray-commitment"); - { - let mut session = SessionController::new(&path).unwrap(); - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::CommitmentCreated, - RuntimeState::Active, - Some("commitment-stray".to_string()), - commitment_payload("commitment-stray", CommitmentState::Active), - ) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("stray commitment must not replace active commitment"); - - assert!(error_chain_contains(&err, "active commitment")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_transition_started_with_wrong_envelope_commitment_on_replay() { - let path = temp_log_path("transition-wrong-commitment"); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_payload(commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: policy_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::TransitionStarted, - runtime_state: RuntimeState::Transition, - commitment_id: Some("commitment-other".to_string()), - payload_json: transition_started_payload(), - }, - ]) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("transition envelope commitment mismatch must be rejected"); - - assert!(error_chain_contains(&err, "commitment id mismatch")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_transition_started_without_envelope_commitment_on_replay() { - let path = temp_log_path("transition-missing-commitment"); - let commitment_id = "commitment-test"; - { - let mut log = EventLog::open(&path).unwrap(); - log.append_batch([ - PendingEventRecord { - event_type: EventType::RuntimeTransition, - runtime_state: RuntimeState::Planning, - commitment_id: None, - payload_json: json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "locked", - "to": "planning", - }), - }, - PendingEventRecord { - event_type: EventType::CommitmentCreated, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: commitment_payload(commitment_id, CommitmentState::Active), - }, - PendingEventRecord { - event_type: EventType::PolicyApplied, - runtime_state: RuntimeState::Active, - commitment_id: Some(commitment_id.to_string()), - payload_json: policy_payload(commitment_id), - }, - PendingEventRecord { - event_type: EventType::TransitionStarted, - runtime_state: RuntimeState::Transition, - commitment_id: None, - payload_json: transition_started_payload(), - }, - ]) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("transition without envelope commitment must be rejected"); - - assert!(error_chain_contains(&err, "commitment id mismatch")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_transition_started_with_empty_reason_on_replay() { - let path = temp_log_path("transition-empty-reason"); - let mut payload = transition_started_payload(); - payload["reason"] = json!(""); - - append_active_session_with_transition_payload(&path, payload); - - let err = SessionController::new(&path) - .expect_err("transition replay with empty reason must be rejected"); - - assert!(error_chain_contains(&err, "reason")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_transition_started_with_empty_return_target_on_replay() { - let path = temp_log_path("transition-empty-return-target"); - let mut payload = transition_started_payload(); - payload["return_target"] = json!(""); - - append_active_session_with_transition_payload(&path, payload); - - let err = SessionController::new(&path) - .expect_err("transition replay with empty return target must be rejected"); - - assert!(error_chain_contains(&err, "return target")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_transition_started_with_zero_duration_on_replay() { - let path = temp_log_path("transition-zero-duration"); - let mut payload = transition_started_payload(); - payload["expected_duration_secs"] = json!(0); - - append_active_session_with_transition_payload(&path, payload); - - let err = SessionController::new(&path) - .expect_err("transition replay with zero duration must be rejected"); - - assert!(error_chain_contains(&err, "expected duration")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn reopens_transition_with_transition_policy() { - let path = temp_log_path("hydrate-transition"); - { - let mut session = SessionController::new(&path).unwrap(); - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - session - .start_transition( - "Need to inspect failures", - "Return to session controller", - Duration::from_secs(300), - ) - .unwrap(); - } - - let reopened = SessionController::new(&path).unwrap(); - - assert_eq!(reopened.runtime_state(), RuntimeState::Transition); - assert_eq!( - reopened.active_commitment().unwrap().state, - CommitmentState::Paused - ); - assert!(reopened.active_policy().is_some()); - assert_eq!( - reopened.active_policy().unwrap().runtime_state, - RuntimeState::Transition - ); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn returns_from_transition_and_replays_active_state() { - let path = temp_log_path("return-transition"); - { - let mut session = SessionController::new(&path).unwrap(); - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - session - .start_transition( - "Need to inspect failures", - "Return to session controller", - Duration::from_secs(300), - ) - .unwrap(); - session.return_from_transition().unwrap(); - - assert_eq!(session.runtime_state(), RuntimeState::Active); - assert_eq!( - session.active_commitment().unwrap().state, - CommitmentState::Active - ); - assert_eq!( - session.active_policy().unwrap().runtime_state, - RuntimeState::Active - ); - } - - let reopened = SessionController::new(&path).unwrap(); - assert_eq!(reopened.runtime_state(), RuntimeState::Active); - assert_eq!( - reopened.active_commitment().unwrap().state, - CommitmentState::Active - ); - assert_eq!( - reopened.active_policy().unwrap().runtime_state, - RuntimeState::Active - ); - - let records = read_records(&path); - assert!(records - .iter() - .any(|record| record.event_type == EventType::CommitmentTransition)); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_transition_started_without_policy_on_replay() { - let path = temp_log_path("transition-without-policy"); - append_active_session_with_transition_payload(&path, transition_started_payload()); - - let err = SessionController::new(&path) - .expect_err("transition started without persisted policy must be rejected"); - - assert!(error_chain_contains(&err, "pending transition policy")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_transition_policy_enforcement_mismatch_on_replay() { - let path = temp_log_path("transition-policy-enforcement-mismatch"); - let commitment_id = "commitment-test"; - let mut payload = transition_policy_payload(commitment_id); - payload["enforcement_level"] = json!("block"); - append_active_session_with_transition_and_policy_payload( - &path, - transition_started_payload(), - payload, - ); - - let err = SessionController::new(&path) - .expect_err("transition policy with impossible enforcement must be rejected"); - - assert!(error_chain_contains(&err, "transition policy enforcement")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_non_policy_record_after_transition_started_on_replay() { - let path = temp_log_path("transition-interrupted-pair"); - append_active_session_with_transition_payload(&path, transition_started_payload()); - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::RuntimeTransition, - RuntimeState::Planning, - None, - json!({ - "schema_version": POLICY_SCHEMA_VERSION, - "action": "enter_planning", - "from": "transition", - "to": "planning", - }), - ) - .unwrap(); - } - - let err = - SessionController::new(&path).expect_err("transition policy pair must be adjacent"); - - assert!(error_chain_contains(&err, "expected PolicyApplied")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn hydrates_transition_from_valid_replay_pair() { - let path = temp_log_path("transition-valid-pair"); - append_active_session_with_transition_and_policy_payload( - &path, - transition_started_payload(), - transition_policy_payload("commitment-test"), - ); - - let reopened = SessionController::new(&path).unwrap(); - - assert_eq!(reopened.runtime_state(), RuntimeState::Transition); - assert_eq!( - reopened.active_commitment().unwrap().state, - CommitmentState::Paused - ); - assert_eq!( - reopened.active_policy().unwrap().runtime_state, - RuntimeState::Transition - ); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_transition_policy_runtime_mismatch_on_replay() { - let path = temp_log_path("transition-policy-runtime-mismatch"); - let commitment_id = "commitment-test"; - append_active_session_with_transition_payload(&path, transition_started_payload()); - { - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::PolicyApplied, - RuntimeState::Active, - Some(commitment_id.to_string()), - transition_policy_payload(commitment_id), - ) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("transition policy runtime mismatch must be rejected"); - - assert!(error_chain_contains( - &err, - "transition policy runtime mismatch" - )); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_extra_transition_policy_after_valid_transition_pair_on_replay() { - let path = temp_log_path("transition-extra-policy"); - let commitment_id = "commitment-test"; - append_active_session_with_transition_and_policy_payload( - &path, - transition_started_payload(), - transition_policy_payload(commitment_id), - ); - { - let mut payload = transition_policy_payload(commitment_id); - payload["policy_id"] = json!("policy-transition-extra"); - let mut log = EventLog::open(&path).unwrap(); - log.append( - EventType::PolicyApplied, - RuntimeState::Transition, - Some(commitment_id.to_string()), - payload, - ) - .unwrap(); - } - - let err = SessionController::new(&path) - .expect_err("extra transition policy outside the transition pair must be rejected"); - - assert!(error_chain_contains( - &err, - "policy requires active commitment" - )); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_transition_started_missing_policy_id_on_replay() { - let path = temp_log_path("transition-missing-policy-id"); - let mut payload = transition_started_payload(); - payload - .as_object_mut() - .unwrap() - .remove("transition_policy_id"); - append_active_session_with_transition_and_policy_payload( - &path, - payload, - transition_policy_payload("commitment-test"), - ); - - let err = SessionController::new(&path) - .expect_err("transition policy id is required in transition payload"); - - assert!(error_chain_contains(&err, "transition_policy_id")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn rejects_transition_started_empty_policy_id_on_replay() { - let path = temp_log_path("transition-empty-policy-id"); - let mut payload = transition_started_payload(); - payload["transition_policy_id"] = json!(""); - append_active_session_with_transition_payload(&path, payload); - - let err = - SessionController::new(&path).expect_err("empty transition policy id must be rejected"); - - assert!(error_chain_contains(&err, "transition policy id")); - fs::remove_file(path).unwrap(); - } - - #[test] - fn transition_started_payload_has_stable_schema() { - let path = temp_log_path("transition-payload"); - let mut session = SessionController::new(&path).unwrap(); - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - - session - .start_transition( - "Need to inspect failures", - "Return to session controller", - Duration::from_secs(300), - ) - .unwrap(); - - let records = read_records(&path); - let transition_index = records - .iter() - .position(|record| record.event_type == EventType::TransitionStarted) - .unwrap(); - let transition = &records[transition_index]; - let transition_policy = &records[transition_index + 1]; - - assert_eq!(transition.payload_json["schema_version"], 1); - assert_eq!( - transition.payload_json["commitment_id"], - transition.commitment_id.as_deref().unwrap() - ); - assert_eq!( - transition.payload_json["transition_policy_id"], - transition_policy.payload_json["policy_id"] - ); - assert_eq!( - transition.payload_json["reason"], - "Need to inspect failures" - ); - assert_eq!( - transition.payload_json["return_target"], - "Return to session controller" - ); - assert_eq!(transition.payload_json["expected_duration_secs"], 300); - assert_eq!(transition.payload_json["runtime_from"], "active"); - assert_eq!(transition.payload_json["runtime_to"], "transition"); - assert_eq!(transition.payload_json["commitment_from"], "active"); - assert_eq!(transition.payload_json["commitment_to"], "paused"); - assert!(transition.payload_json.get("runtime_state").is_none()); - assert!(transition.payload_json.get("commitment_state").is_none()); - - assert_eq!(transition_policy.event_type, EventType::PolicyApplied); - assert_eq!(transition_policy.sequence, transition.sequence + 1); - assert_eq!( - transition_policy.previous_hash.as_deref(), - Some(transition.hash.as_str()) - ); - assert_eq!(transition_policy.runtime_state, RuntimeState::Transition); - assert_eq!(transition_policy.commitment_id, transition.commitment_id); - assert_eq!( - transition_policy.payload_json["commitment_id"], - transition.commitment_id.as_deref().unwrap() - ); - assert_eq!( - transition_policy.payload_json["runtime_state"], - "transition" - ); - assert_eq!(transition_policy.payload_json["enforcement_level"], "warn"); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn transition_requires_reason_and_return_target() { - let path = temp_log_path("transition"); - let mut session = SessionController::new(&path).unwrap(); - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - - let err = session - .start_transition("", "review test results", Duration::from_secs(300)) - .expect_err("empty reason must fail"); - assert!(err.to_string().contains("reason")); - assert_eq!(session.runtime_state(), RuntimeState::Active); - - let err = session - .start_transition("Need to inspect failures", "", Duration::from_secs(300)) - .expect_err("empty return target must fail"); - assert!(err.to_string().contains("return target")); - assert_eq!(session.runtime_state(), RuntimeState::Active); - - session - .start_transition( - "Need to inspect failures", - "Return to session controller", - Duration::from_secs(300), - ) - .unwrap(); - - assert_eq!(session.runtime_state(), RuntimeState::Transition); - assert_eq!( - session.active_commitment().unwrap().state, - crate::domain::CommitmentState::Paused - ); - assert_eq!( - session.active_policy().unwrap().runtime_state, - RuntimeState::Transition - ); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn timing_summary_excludes_completed_transition_interval() { - let path = temp_log_path("timing-completed-transition"); - let mut session = SessionController::new(&path).unwrap(); - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - - std::thread::sleep(Duration::from_millis(1200)); - session - .start_transition( - "Need to inspect failures", - "Return to session controller", - Duration::from_secs(300), - ) - .unwrap(); - std::thread::sleep(Duration::from_millis(2200)); - session.return_from_transition().unwrap(); - - let summary = session.timing_summary().unwrap().unwrap(); - let created_at = session.active_commitment().unwrap().created_at_unix_secs; - let wall_elapsed = unix_secs_now().saturating_sub(created_at); - - assert_eq!(session.runtime_state(), RuntimeState::Active); - assert!(!summary.currently_in_transition); - assert!(wall_elapsed >= 3); - assert!( - summary.active_elapsed_secs <= 2, - "active elapsed should exclude completed transition time, got {}", - summary.active_elapsed_secs - ); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn timing_summary_freezes_elapsed_during_open_transition() { - let path = temp_log_path("timing-open-transition"); - let mut session = SessionController::new(&path).unwrap(); - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - - std::thread::sleep(Duration::from_millis(1200)); - session - .start_transition( - "Need to inspect failures", - "Return to session controller", - Duration::from_secs(300), - ) - .unwrap(); - std::thread::sleep(Duration::from_millis(2200)); - - let summary = session.timing_summary().unwrap().unwrap(); - let created_at = session.active_commitment().unwrap().created_at_unix_secs; - let wall_elapsed = unix_secs_now().saturating_sub(created_at); - - assert_eq!(session.runtime_state(), RuntimeState::Transition); - assert!(summary.currently_in_transition); - assert!(wall_elapsed >= 3); - assert!( - summary.active_elapsed_secs <= 2, - "active elapsed should freeze at transition start, got {}", - summary.active_elapsed_secs - ); - - fs::remove_file(path).unwrap(); - } - - #[test] - fn timing_summary_stops_active_elapsed_during_review() { - let path = temp_log_path("timing-review"); - let mut session = SessionController::new(&path).unwrap(); - session.enter_planning().unwrap(); - session - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - - std::thread::sleep(Duration::from_millis(1200)); - session.complete_for_review().unwrap(); - std::thread::sleep(Duration::from_millis(2200)); - - let summary = session.timing_summary().unwrap().unwrap(); - let created_at = session.active_commitment().unwrap().created_at_unix_secs; - let wall_elapsed = unix_secs_now().saturating_sub(created_at); - - assert_eq!(session.runtime_state(), RuntimeState::Review); - assert!(!summary.currently_in_transition); - assert!(wall_elapsed >= 3); - assert!( - summary.active_elapsed_secs <= 2, - "active elapsed should freeze at review start, got {}", - summary.active_elapsed_secs - ); - - fs::remove_file(path).unwrap(); - } -} diff --git a/legacy/src/state_machine.rs b/legacy/src/state_machine.rs deleted file mode 100644 index eb749ec..0000000 --- a/legacy/src/state_machine.rs +++ /dev/null @@ -1,327 +0,0 @@ -use crate::domain::{CommitmentState, RuntimeState}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RuntimeAction { - EnterPlanning, - CancelOrTimeout, - Activate { policy_accepted: bool }, - StartTransition, - CompleteForReview, - SevereViolation, - ReturnToActive, - SwitchTask, - EndTransitionForReview, - ContinuePlanning, - EndWorkPeriod, - EnterAdminOverride, - ExitAdminOverride(RuntimeState), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CommitmentAction { - Activate, - PauseForTransition, - ReturnFromPause, - Complete, - Abandon, - Violate, - RecoverExplicitly, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum TransitionError { - PolicyRejected, - IllegalRuntimeTransition { - current: RuntimeState, - action: RuntimeAction, - }, - IllegalCommitmentTransition { - current: CommitmentState, - action: CommitmentAction, - }, - InvalidAdminOverrideExitTarget { - target: RuntimeState, - }, -} - -impl std::fmt::Display for TransitionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TransitionError::PolicyRejected => write!(f, "policy was rejected"), - TransitionError::IllegalRuntimeTransition { current, action } => { - write!( - f, - "illegal runtime transition from {:?} via {:?}", - current, action - ) - } - TransitionError::IllegalCommitmentTransition { current, action } => { - write!( - f, - "illegal commitment transition from {:?} via {:?}", - current, action - ) - } - TransitionError::InvalidAdminOverrideExitTarget { target } => { - write!(f, "invalid admin override exit target {:?}", target) - } - } - } -} - -impl std::error::Error for TransitionError {} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CompositeTransition { - pub runtime_state: RuntimeState, - pub commitment_state: CommitmentState, -} - -pub fn transition_runtime( - current: RuntimeState, - action: RuntimeAction, -) -> Result { - use RuntimeAction::*; - use RuntimeState::*; - - match (current, action) { - (Locked, EnterPlanning) => Ok(Planning), - ( - Planning, - Activate { - policy_accepted: true, - }, - ) => Ok(Active), - ( - Planning, - Activate { - policy_accepted: false, - }, - ) => Err(TransitionError::PolicyRejected), - (Planning, CancelOrTimeout) => Ok(Locked), - (Active, StartTransition) => Ok(Transition), - (Active, CompleteForReview) => Ok(Review), - (Active, SevereViolation) => Ok(Locked), - (Transition, ReturnToActive) => Ok(Active), - (Transition, SwitchTask) => Ok(Planning), - (Transition, EndTransitionForReview) => Ok(Review), - (Transition, CancelOrTimeout) => Ok(Locked), - (Review, ContinuePlanning) => Ok(Planning), - (Review, EndWorkPeriod) => Ok(Locked), - (_, EnterAdminOverride) => Ok(AdminOverride), - (AdminOverride, ExitAdminOverride(Locked)) => Ok(Locked), - (AdminOverride, ExitAdminOverride(Planning)) => Ok(Planning), - (AdminOverride, ExitAdminOverride(target)) => { - Err(TransitionError::InvalidAdminOverrideExitTarget { target }) - } - _ => Err(TransitionError::IllegalRuntimeTransition { current, action }), - } -} - -pub fn transition_commitment( - current: CommitmentState, - action: CommitmentAction, -) -> Result { - use CommitmentAction::*; - use CommitmentState::*; - - match (current.clone(), action) { - (Draft, Activate) => Ok(Active), - (Active, PauseForTransition) => Ok(Paused), - (Paused, ReturnFromPause) => Ok(Active), - (Active, Complete) => Ok(Completed), - (Active, Abandon) => Ok(Abandoned), - (Active, Violate) => Ok(Violated), - (Paused, Abandon) => Ok(Abandoned), - (Violated, RecoverExplicitly) => Ok(Active), - (Violated, Abandon) => Ok(Abandoned), - _ => Err(TransitionError::IllegalCommitmentTransition { current, action }), - } -} - -pub fn start_transition( - runtime_state: RuntimeState, - commitment_state: CommitmentState, -) -> Result { - let next_runtime = transition_runtime(runtime_state, RuntimeAction::StartTransition)?; - let next_commitment = - transition_commitment(commitment_state, CommitmentAction::PauseForTransition)?; - - Ok(CompositeTransition { - runtime_state: next_runtime, - commitment_state: next_commitment, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn planning_can_only_activate_with_valid_policy() { - assert_eq!( - transition_runtime( - RuntimeState::Planning, - RuntimeAction::Activate { - policy_accepted: true, - } - ), - Ok(RuntimeState::Active) - ); - assert_eq!( - transition_runtime( - RuntimeState::Planning, - RuntimeAction::Activate { - policy_accepted: false, - } - ), - Err(TransitionError::PolicyRejected) - ); - } - - #[test] - fn active_can_enter_transition_or_review_or_locked() { - assert_eq!( - transition_runtime(RuntimeState::Active, RuntimeAction::StartTransition), - Ok(RuntimeState::Transition) - ); - assert_eq!( - transition_runtime(RuntimeState::Active, RuntimeAction::CompleteForReview), - Ok(RuntimeState::Review) - ); - assert_eq!( - transition_runtime(RuntimeState::Active, RuntimeAction::SevereViolation), - Ok(RuntimeState::Locked) - ); - } - - #[test] - fn locked_cannot_directly_become_active() { - let err = transition_runtime( - RuntimeState::Locked, - RuntimeAction::Activate { - policy_accepted: true, - }, - ) - .unwrap_err(); - - assert_eq!( - err, - TransitionError::IllegalRuntimeTransition { - current: RuntimeState::Locked, - action: RuntimeAction::Activate { - policy_accepted: true, - }, - } - ); - assert_eq!( - err.to_string(), - "illegal runtime transition from Locked via Activate { policy_accepted: true }" - ); - } - - #[test] - fn admin_override_cannot_exit_to_admin_override() { - let err = transition_runtime( - RuntimeState::AdminOverride, - RuntimeAction::ExitAdminOverride(RuntimeState::AdminOverride), - ) - .unwrap_err(); - - assert_eq!( - err, - TransitionError::InvalidAdminOverrideExitTarget { - target: RuntimeState::AdminOverride, - } - ); - } - - #[test] - fn admin_override_cannot_exit_to_active_or_transition() { - for target in [RuntimeState::Active, RuntimeState::Transition] { - let err = transition_runtime( - RuntimeState::AdminOverride, - RuntimeAction::ExitAdminOverride(target), - ) - .unwrap_err(); - - assert_eq!( - err, - TransitionError::InvalidAdminOverrideExitTarget { target } - ); - } - } - - #[test] - fn admin_override_can_exit_to_locked_or_planning() { - assert_eq!( - transition_runtime( - RuntimeState::AdminOverride, - RuntimeAction::ExitAdminOverride(RuntimeState::Locked), - ), - Ok(RuntimeState::Locked) - ); - assert_eq!( - transition_runtime( - RuntimeState::AdminOverride, - RuntimeAction::ExitAdminOverride(RuntimeState::Planning), - ), - Ok(RuntimeState::Planning) - ); - } - - #[test] - fn commitment_violation_recovery_is_explicit() { - assert_eq!( - transition_commitment( - CommitmentState::Violated, - CommitmentAction::RecoverExplicitly - ), - Ok(CommitmentState::Active) - ); - let err = - transition_commitment(CommitmentState::Violated, CommitmentAction::ReturnFromPause) - .unwrap_err(); - - assert_eq!( - err, - TransitionError::IllegalCommitmentTransition { - current: CommitmentState::Violated, - action: CommitmentAction::ReturnFromPause, - } - ); - assert_eq!( - err.to_string(), - "illegal commitment transition from Violated via ReturnFromPause" - ); - } - - #[test] - fn start_transition_moves_runtime_and_commitment_together() { - assert_eq!( - start_transition(RuntimeState::Active, CommitmentState::Active), - Ok(CompositeTransition { - runtime_state: RuntimeState::Transition, - commitment_state: CommitmentState::Paused, - }) - ); - } - - #[test] - fn start_transition_fails_before_returning_partial_state() { - assert_eq!( - start_transition(RuntimeState::Active, CommitmentState::Draft), - Err(TransitionError::IllegalCommitmentTransition { - current: CommitmentState::Draft, - action: CommitmentAction::PauseForTransition, - }) - ); - assert_eq!( - start_transition(RuntimeState::Locked, CommitmentState::Active), - Err(TransitionError::IllegalRuntimeTransition { - current: RuntimeState::Locked, - action: RuntimeAction::StartTransition, - }) - ); - } -} diff --git a/legacy/src/window/linux.rs b/legacy/src/window/linux.rs deleted file mode 100644 index 4a9794e..0000000 --- a/legacy/src/window/linux.rs +++ /dev/null @@ -1,113 +0,0 @@ -use super::WindowSnapshot; -use crate::domain::EvidenceHealth; -use regex::Regex; -use std::{process::Command, process::Output, str}; - -pub fn get_title_clean() -> String { - get_snapshot().title -} - -pub fn get_snapshot() -> WindowSnapshot { - let window_info = get_window_info(); - let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap(); - let title = re.replace_all(&window_info.title, "").to_string(); - let class = (window_info.class != "none").then_some(window_info.class); - let health = if window_info.wid.is_empty() { - EvidenceHealth::Unavailable("xdotool active window unavailable".to_string()) - } else { - EvidenceHealth::Available - }; - - WindowSnapshot { - title, - class, - health, - } -} - -pub fn minimize_other(title: &str) { - let window_info = get_window_info(); - if window_info.wid.is_empty() { - return; - } - - if window_info.title != title { - run_xdotool(&["windowminimize", &window_info.wid]); - } -} - -struct WindowInfo { - title: String, - class: String, - wid: String, -} - -fn is_valid_window_id(wid: &str) -> bool { - !wid.is_empty() && wid.bytes().all(|byte| byte.is_ascii_digit()) -} - -fn run_xdotool(args: &[&str]) -> Option { - let output = Command::new("xdotool").args(args).output(); - - let Ok(Output { - status, - stdout, - stderr: _, - }) = output - else { - return None; - }; - - if status.code() != Some(0) { - return None; - } - - let Ok(output_str) = str::from_utf8(&stdout) else { - return None; - }; - - Some(output_str.trim().to_string()) -} - -fn get_window_info() -> WindowInfo { - let none = WindowInfo { - title: "none".to_string(), - class: "none".to_string(), - wid: "".to_string(), - }; - - let Some(wid) = run_xdotool(&["getactivewindow"]) else { - return none; - }; - - if !is_valid_window_id(&wid) { - return none; - }; - - let Some(class) = run_xdotool(&["getwindowclassname", &wid]) else { - return none; - }; - - let Some(title) = run_xdotool(&["getwindowname", &wid]) else { - return none; - }; - - WindowInfo { title, class, wid } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn valid_window_id_accepts_ascii_digits_only() { - assert!(is_valid_window_id("12345")); - assert!(is_valid_window_id("0")); - - assert!(!is_valid_window_id("")); - assert!(!is_valid_window_id("12abc")); - assert!(!is_valid_window_id(" 123")); - assert!(!is_valid_window_id("123 ")); - assert!(!is_valid_window_id("123")); - } -} diff --git a/legacy/src/window/mod.rs b/legacy/src/window/mod.rs deleted file mode 100644 index f28e785..0000000 --- a/legacy/src/window/mod.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::domain::EvidenceHealth; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct WindowSnapshot { - pub title: String, - pub class: Option, - pub health: EvidenceHealth, -} - -impl WindowSnapshot { - pub fn unavailable(reason: impl Into) -> Self { - Self { - title: "none".to_string(), - class: None, - health: EvidenceHealth::Unavailable(reason.into()), - } - } -} - -#[cfg(target_os = "windows")] -mod windows; -#[cfg(target_os = "windows")] -pub use windows::*; - -#[cfg(target_os = "linux")] -mod linux; -#[cfg(target_os = "linux")] -pub use linux::*; - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn unavailable_snapshot_records_reason_without_evidence() { - assert_eq!( - WindowSnapshot::unavailable("no active window"), - WindowSnapshot { - title: "none".to_string(), - class: None, - health: EvidenceHealth::Unavailable("no active window".to_string()), - } - ); - } -} diff --git a/legacy/src/window/windows.rs b/legacy/src/window/windows.rs deleted file mode 100644 index ac7025d..0000000 --- a/legacy/src/window/windows.rs +++ /dev/null @@ -1,68 +0,0 @@ -use super::WindowSnapshot; -use crate::domain::EvidenceHealth; -use regex::Regex; -use std::{ffi::OsString, os::windows::ffi::OsStringExt}; -use winapi::shared::windef::HWND; -use winapi::um::winuser::{GetForegroundWindow, GetWindowTextW, ShowWindow, SW_MINIMIZE}; - -pub fn get_title_clean() -> String { - get_snapshot().title -} - -pub fn get_snapshot() -> WindowSnapshot { - let window_info = get_window_info(); - if window_info.hwnd.is_null() { - return WindowSnapshot::unavailable("foreground window unavailable"); - } - - let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap(); - let title = re.replace_all(&window_info.title, "").to_string(); - let health = if window_info.title.is_empty() { - EvidenceHealth::Degraded("foreground window title unavailable".to_string()) - } else { - EvidenceHealth::Degraded("window class unavailable on current Windows adapter".to_string()) - }; - - WindowSnapshot { - title, - class: None, - health, - } -} - -pub fn minimize_other(title: &str) { - let window_info = get_window_info(); - if window_info.hwnd.is_null() { - return; - } - - if window_info.title != title { - unsafe { - ShowWindow(window_info.hwnd, SW_MINIMIZE); - } - } -} - -struct WindowInfo { - title: String, - hwnd: HWND, -} - -fn get_window_info() -> WindowInfo { - unsafe { - let hwnd = GetForegroundWindow(); - if hwnd.is_null() { - return WindowInfo { - title: String::new(), - hwnd, - }; - } - - let mut text: [u16; 512] = [0; 512]; - let len = GetWindowTextW(hwnd, text.as_mut_ptr(), text.len() as i32) as usize; - let title = OsString::from_wide(&text[..len]) - .to_string_lossy() - .into_owned(); - WindowInfo { title, hwnd } - } -}