From 9689d599bad48262623d9478bcde8c31099923d6 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 13 Apr 2026 16:27:55 +0300 Subject: [PATCH 1/5] =?UTF-8?q?Add=20SQLite=20persistence=20via=20sqlx=20?= =?UTF-8?q?=E2=80=94=20migrations=20and=20CRUD=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add sqlx 0.8 (sqlite, runtime-tokio-rustls, macros, uuid, chrono) and chrono 0.4 to runner/Cargo.toml - Create three migration files: projects, workspaces, sessions tables with soft-delete (deleted_at), FK indices, and WAL mode via pool opts - Implement runner/src/store.rs: Store struct wrapping SqlitePool with full CRUD for Project, Workspace, Session domain types - Optimistic concurrency on state updates via updated_at WHERE clause - 7 unit tests: WAL mode, CRUD, soft-delete exclusion, optimistic concurrency for both projects and sessions, idempotent soft-delete - All tests pass with SQLX_OFFLINE=true (dynamic queries, no query! macros) - runner/.sqlx/sqlx-data.json committed for CI offline mode Closes #17 --- runner/.sqlx/sqlx-data.json | 4 + runner/Cargo.lock | 639 ++++++++++++- runner/Cargo.toml | 2 + .../migrations/20240001_create_projects.sql | 15 + .../migrations/20240002_create_workspaces.sql | 11 + .../migrations/20240003_create_sessions.sql | 19 + runner/src/main.rs | 1 + runner/src/store.rs | 891 ++++++++++++++++++ 8 files changed, 1577 insertions(+), 5 deletions(-) create mode 100644 runner/.sqlx/sqlx-data.json create mode 100644 runner/migrations/20240001_create_projects.sql create mode 100644 runner/migrations/20240002_create_workspaces.sql create mode 100644 runner/migrations/20240003_create_sessions.sql create mode 100644 runner/src/store.rs diff --git a/runner/.sqlx/sqlx-data.json b/runner/.sqlx/sqlx-data.json new file mode 100644 index 0000000..b1592e9 --- /dev/null +++ b/runner/.sqlx/sqlx-data.json @@ -0,0 +1,4 @@ +{ + "db": "SQLite", + "queries": [] +} diff --git a/runner/Cargo.lock b/runner/Cargo.lock index 3df72b4..2170ed0 100644 --- a/runner/Cargo.lock +++ b/runner/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -125,6 +131,15 @@ dependencies = [ "syn", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -207,6 +222,9 @@ name = "bitflags" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] [[package]] name = "blake2" @@ -276,6 +294,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -311,8 +335,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -325,6 +351,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -340,6 +372,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -376,6 +432,17 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "der-parser" version = "10.0.0" @@ -407,6 +474,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -443,6 +511,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -454,6 +528,9 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "equivalent" @@ -471,6 +548,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -528,6 +627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -536,6 +636,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.32" @@ -566,8 +694,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -643,6 +774,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -652,6 +785,15 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -664,6 +806,33 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hostname" version = "0.4.2" @@ -1001,6 +1170,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -1010,9 +1182,15 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -1020,7 +1198,21 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ + "bitflags 2.11.0", "libc", + "plain", + "redox_syscall 0.7.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", ] [[package]] @@ -1065,6 +1257,16 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "mdns-sd" version = "0.11.5" @@ -1154,6 +1356,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -1169,6 +1387,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1176,6 +1405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1199,6 +1429,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1217,7 +1453,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1243,6 +1479,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1285,6 +1530,39 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "polling" version = "2.8.0" @@ -1464,6 +1742,15 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -1532,6 +1819,7 @@ dependencies = [ "argon2", "bollard", "bytes", + "chrono", "dashmap", "dirs", "hostname", @@ -1543,6 +1831,7 @@ dependencies = [ "rcgen", "serde", "sha2", + "sqlx", "tempfile", "time", "tokio", @@ -1573,6 +1862,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -1597,9 +1906,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "log", "once_cell", @@ -1780,6 +2089,17 @@ dependencies = [ "time", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1816,6 +2136,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "slab" version = "0.4.12" @@ -1827,6 +2157,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -1857,12 +2190,231 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags 2.11.0", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags 2.11.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2000,6 +2552,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.51.1" @@ -2226,6 +2793,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2306,12 +2874,33 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2359,6 +2948,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2408,6 +3003,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -2487,6 +3088,34 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/runner/Cargo.toml b/runner/Cargo.toml index 986b17c..ccc1d7a 100644 --- a/runner/Cargo.toml +++ b/runner/Cargo.toml @@ -25,6 +25,8 @@ dirs = "6" dashmap = "6" arc-swap = "1" uuid = { version = "1", features = ["v4"] } +sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "macros", "uuid", "chrono"] } +chrono = { version = "0.4", features = ["serde"] } tokio-util = { version = "0.7", features = ["rt"] } tokio-stream = { version = "0.1", features = ["net"] } tonic-health = "0.12" diff --git a/runner/migrations/20240001_create_projects.sql b/runner/migrations/20240001_create_projects.sql new file mode 100644 index 0000000..026d281 --- /dev/null +++ b/runner/migrations/20240001_create_projects.sql @@ -0,0 +1,15 @@ +-- Projects table: tracks git repositories cloned by the runner +CREATE TABLE IF NOT EXISTS projects ( + id TEXT NOT NULL PRIMARY KEY, -- UUID v4 + name TEXT NOT NULL, + git_url TEXT NOT NULL, + local_path TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'Cloning', -- Cloning | Ready | Failed + clone_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_projects_state ON projects (state) + WHERE deleted_at IS NULL; diff --git a/runner/migrations/20240002_create_workspaces.sql b/runner/migrations/20240002_create_workspaces.sql new file mode 100644 index 0000000..4195857 --- /dev/null +++ b/runner/migrations/20240002_create_workspaces.sql @@ -0,0 +1,11 @@ +-- Workspaces: git worktrees within a project +CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT NOT NULL PRIMARY KEY, -- UUID v4 + project_id TEXT NOT NULL REFERENCES projects(id), + branch TEXT NOT NULL, + created_at TEXT NOT NULL, + deleted_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_workspaces_project_id ON workspaces (project_id) + WHERE deleted_at IS NULL; diff --git a/runner/migrations/20240003_create_sessions.sql b/runner/migrations/20240003_create_sessions.sql new file mode 100644 index 0000000..d4a443f --- /dev/null +++ b/runner/migrations/20240003_create_sessions.sql @@ -0,0 +1,19 @@ +-- Sessions: running container sessions attached to a workspace +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT NOT NULL PRIMARY KEY, -- UUID v4 + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + container_id TEXT, + state TEXT NOT NULL DEFAULT 'Starting', -- Starting | Running | Stopped | Failed + profile TEXT NOT NULL DEFAULT 'default', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + stopped_at TEXT, + error_reason TEXT, + deleted_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_sessions_workspace_id ON sessions (workspace_id) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions (state) + WHERE deleted_at IS NULL; diff --git a/runner/src/main.rs b/runner/src/main.rs index fd0fcb2..45ee396 100644 --- a/runner/src/main.rs +++ b/runner/src/main.rs @@ -13,6 +13,7 @@ mod pty; mod session; mod session_auth; mod session_manager; +mod store; mod tls; mod vt_parser; diff --git a/runner/src/store.rs b/runner/src/store.rs new file mode 100644 index 0000000..ad83764 --- /dev/null +++ b/runner/src/store.rs @@ -0,0 +1,891 @@ +// store.rs is a standalone infrastructure module — public types will be used +// by future tasks (T-5, T-7a). Suppress dead_code warnings until then. +#![allow(dead_code)] +//! SQLite persistence layer via sqlx. +//! +//! Provides [`Store`] — a connection-pool wrapper with CRUD operations for +//! three domain entities: [`Project`], [`Workspace`], and [`Session`]. +//! +//! WAL mode is enabled at pool-open time so concurrent async readers do not +//! block writers. Foreign-key enforcement is also turned on. +//! +//! Optimistic concurrency: state-update helpers include a WHERE clause that +//! matches the caller's last-known `updated_at` timestamp. A return value of +//! `false` means either the row was not found or a concurrent writer already +//! changed it — the caller should re-read and retry. + +use chrono::{DateTime, Utc}; +use sqlx::{ + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, + SqlitePool, +}; +use std::str::FromStr; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// Domain types +// --------------------------------------------------------------------------- + +/// State of a cloned git project. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProjectState { + Cloning, + Ready, + Failed, +} + +impl ProjectState { + fn as_str(&self) -> &'static str { + match self { + Self::Cloning => "Cloning", + Self::Ready => "Ready", + Self::Failed => "Failed", + } + } +} + +impl FromStr for ProjectState { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "Cloning" => Ok(Self::Cloning), + "Ready" => Ok(Self::Ready), + "Failed" => Ok(Self::Failed), + other => Err(format!("unknown ProjectState: {other}")), + } + } +} + +/// A cloned git repository tracked by the runner. +#[derive(Debug, Clone)] +pub struct Project { + pub id: String, + pub name: String, + pub git_url: String, + pub local_path: String, + pub state: ProjectState, + pub clone_error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +/// Parameters required to create a new project. +#[derive(Debug, Clone)] +pub struct CreateProject { + pub name: String, + pub git_url: String, + pub local_path: String, +} + +// --------------------------------------------------------------------------- + +/// State of a container session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionState { + Starting, + Running, + Stopped, + Failed, +} + +impl SessionState { + fn as_str(&self) -> &'static str { + match self { + Self::Starting => "Starting", + Self::Running => "Running", + Self::Stopped => "Stopped", + Self::Failed => "Failed", + } + } +} + +impl FromStr for SessionState { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "Starting" => Ok(Self::Starting), + "Running" => Ok(Self::Running), + "Stopped" => Ok(Self::Stopped), + "Failed" => Ok(Self::Failed), + other => Err(format!("unknown SessionState: {other}")), + } + } +} + +/// A git worktree inside a project. +#[derive(Debug, Clone)] +pub struct Workspace { + pub id: String, + pub project_id: String, + pub branch: String, + pub created_at: DateTime, + pub deleted_at: Option>, +} + +/// Parameters required to create a new workspace. +#[derive(Debug, Clone)] +pub struct CreateWorkspace { + pub project_id: String, + pub branch: String, +} + +/// A running container session attached to a workspace. +#[derive(Debug, Clone)] +pub struct Session { + pub id: String, + pub workspace_id: String, + pub container_id: Option, + pub state: SessionState, + pub profile: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub stopped_at: Option>, + pub error_reason: Option, + pub deleted_at: Option>, +} + +/// Parameters required to create a new session. +#[derive(Debug, Clone)] +pub struct CreateSession { + pub workspace_id: String, + pub profile: String, +} + +// --------------------------------------------------------------------------- +// Raw row types (sqlx FromRow) +// --------------------------------------------------------------------------- + +#[derive(sqlx::FromRow)] +struct ProjectRow { + id: String, + name: String, + git_url: String, + local_path: String, + state: String, + clone_error: Option, + created_at: String, + updated_at: String, + deleted_at: Option, +} + +impl TryFrom for Project { + type Error = sqlx::Error; + + fn try_from(row: ProjectRow) -> Result { + Ok(Self { + id: row.id, + name: row.name, + git_url: row.git_url, + local_path: row.local_path, + state: row + .state + .parse() + .map_err(|e: String| sqlx::Error::Decode(e.into()))?, + clone_error: row.clone_error, + created_at: parse_dt(&row.created_at)?, + updated_at: parse_dt(&row.updated_at)?, + deleted_at: row.deleted_at.as_deref().map(parse_dt).transpose()?, + }) + } +} + +#[derive(sqlx::FromRow)] +struct WorkspaceRow { + id: String, + project_id: String, + branch: String, + created_at: String, + deleted_at: Option, +} + +impl TryFrom for Workspace { + type Error = sqlx::Error; + + fn try_from(row: WorkspaceRow) -> Result { + Ok(Self { + id: row.id, + project_id: row.project_id, + branch: row.branch, + created_at: parse_dt(&row.created_at)?, + deleted_at: row.deleted_at.as_deref().map(parse_dt).transpose()?, + }) + } +} + +#[derive(sqlx::FromRow)] +struct SessionRow { + id: String, + workspace_id: String, + container_id: Option, + state: String, + profile: String, + created_at: String, + updated_at: String, + stopped_at: Option, + error_reason: Option, + deleted_at: Option, +} + +impl TryFrom for Session { + type Error = sqlx::Error; + + fn try_from(row: SessionRow) -> Result { + Ok(Self { + id: row.id, + workspace_id: row.workspace_id, + container_id: row.container_id, + state: row + .state + .parse() + .map_err(|e: String| sqlx::Error::Decode(e.into()))?, + profile: row.profile, + created_at: parse_dt(&row.created_at)?, + updated_at: parse_dt(&row.updated_at)?, + stopped_at: row.stopped_at.as_deref().map(parse_dt).transpose()?, + error_reason: row.error_reason, + deleted_at: row.deleted_at.as_deref().map(parse_dt).transpose()?, + }) + } +} + +fn parse_dt(s: &str) -> Result, sqlx::Error> { + DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&Utc)) + .map_err(|e| sqlx::Error::Decode(format!("invalid datetime '{s}': {e}").into())) +} + +fn now_str() -> String { + Utc::now().to_rfc3339() +} + +// --------------------------------------------------------------------------- +// Store +// --------------------------------------------------------------------------- + +/// Async SQLite store backed by a connection pool. +/// +/// Create with [`Store::new`]; the pool is shared cheaply via `Clone`. +#[derive(Clone, Debug)] +pub struct Store { + pool: SqlitePool, +} + +impl Store { + /// Open (or create) the SQLite database at `db_url`, enable WAL mode and + /// foreign-key enforcement, then run pending migrations. + /// + /// `db_url` can be `"sqlite::memory:"` for tests or a file path such as + /// `"sqlite:relay.db"`. + pub async fn new(db_url: &str) -> Result { + let opts = SqliteConnectOptions::from_str(db_url)? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .foreign_keys(true); + + let pool = SqlitePoolOptions::new() + .max_connections(16) + .connect_with(opts) + .await?; + + sqlx::migrate!("./migrations").run(&pool).await?; + + Ok(Self { pool }) + } + + // ----------------------------------------------------------------------- + // Projects + // ----------------------------------------------------------------------- + + /// Insert a new project with `Cloning` state. + pub async fn create_project(&self, req: CreateProject) -> Result { + let id = Uuid::new_v4().to_string(); + let now = now_str(); + + sqlx::query( + "INSERT INTO projects (id, name, git_url, local_path, state, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, 'Cloning', ?5, ?5)", + ) + .bind(&id) + .bind(&req.name) + .bind(&req.git_url) + .bind(&req.local_path) + .bind(&now) + .execute(&self.pool) + .await?; + + // Re-read to return the full row (avoids duplicating mapping logic). + self.get_project(&id) + .await? + .ok_or_else(|| sqlx::Error::RowNotFound) + } + + /// Fetch a project by id, including soft-deleted rows. + pub async fn get_project(&self, id: &str) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, ProjectRow>( + "SELECT id, name, git_url, local_path, state, clone_error, + created_at, updated_at, deleted_at + FROM projects WHERE id = ?1", + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + + row.map(Project::try_from).transpose() + } + + /// List all non-deleted projects. + pub async fn list_projects(&self) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, ProjectRow>( + "SELECT id, name, git_url, local_path, state, clone_error, + created_at, updated_at, deleted_at + FROM projects WHERE deleted_at IS NULL + ORDER BY created_at", + ) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(Project::try_from).collect() + } + + /// Update the project's state with optimistic concurrency. + /// + /// `updated_at_check` must equal the caller's last-known `updated_at` + /// value. Returns `true` if the row was updated, `false` if not found or + /// the optimistic check failed (stale read). + pub async fn update_project_state( + &self, + id: &str, + state: ProjectState, + clone_error: Option<&str>, + updated_at_check: &str, + ) -> Result { + let now = now_str(); + let rows_affected = sqlx::query( + "UPDATE projects + SET state = ?1, clone_error = ?2, updated_at = ?3 + WHERE id = ?4 AND updated_at = ?5 AND deleted_at IS NULL", + ) + .bind(state.as_str()) + .bind(clone_error) + .bind(&now) + .bind(id) + .bind(updated_at_check) + .execute(&self.pool) + .await? + .rows_affected(); + + Ok(rows_affected > 0) + } + + /// Soft-delete a project. Returns `true` if the row was marked deleted. + pub async fn soft_delete_project(&self, id: &str) -> Result { + let now = now_str(); + let rows_affected = sqlx::query( + "UPDATE projects SET deleted_at = ?1 + WHERE id = ?2 AND deleted_at IS NULL", + ) + .bind(&now) + .bind(id) + .execute(&self.pool) + .await? + .rows_affected(); + + Ok(rows_affected > 0) + } + + // ----------------------------------------------------------------------- + // Workspaces + // ----------------------------------------------------------------------- + + /// Insert a new workspace for the given project. + pub async fn create_workspace(&self, req: CreateWorkspace) -> Result { + let id = Uuid::new_v4().to_string(); + let now = now_str(); + + sqlx::query( + "INSERT INTO workspaces (id, project_id, branch, created_at) + VALUES (?1, ?2, ?3, ?4)", + ) + .bind(&id) + .bind(&req.project_id) + .bind(&req.branch) + .bind(&now) + .execute(&self.pool) + .await?; + + self.get_workspace(&id) + .await? + .ok_or_else(|| sqlx::Error::RowNotFound) + } + + /// Fetch a workspace by id, including soft-deleted rows. + pub async fn get_workspace(&self, id: &str) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, WorkspaceRow>( + "SELECT id, project_id, branch, created_at, deleted_at + FROM workspaces WHERE id = ?1", + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + + row.map(Workspace::try_from).transpose() + } + + /// List non-deleted workspaces for a project. + pub async fn list_workspaces_for_project( + &self, + project_id: &str, + ) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, WorkspaceRow>( + "SELECT id, project_id, branch, created_at, deleted_at + FROM workspaces + WHERE project_id = ?1 AND deleted_at IS NULL + ORDER BY created_at", + ) + .bind(project_id) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(Workspace::try_from).collect() + } + + /// Soft-delete a workspace. Returns `true` if the row was marked deleted. + pub async fn soft_delete_workspace(&self, id: &str) -> Result { + let now = now_str(); + let rows_affected = sqlx::query( + "UPDATE workspaces SET deleted_at = ?1 + WHERE id = ?2 AND deleted_at IS NULL", + ) + .bind(&now) + .bind(id) + .execute(&self.pool) + .await? + .rows_affected(); + + Ok(rows_affected > 0) + } + + // ----------------------------------------------------------------------- + // Sessions + // ----------------------------------------------------------------------- + + /// Insert a new session with `Starting` state. + pub async fn create_session(&self, req: CreateSession) -> Result { + let id = Uuid::new_v4().to_string(); + let now = now_str(); + + sqlx::query( + "INSERT INTO sessions (id, workspace_id, state, profile, created_at, updated_at) + VALUES (?1, ?2, 'Starting', ?3, ?4, ?4)", + ) + .bind(&id) + .bind(&req.workspace_id) + .bind(&req.profile) + .bind(&now) + .execute(&self.pool) + .await?; + + self.get_session(&id) + .await? + .ok_or_else(|| sqlx::Error::RowNotFound) + } + + /// Fetch a session by id, including soft-deleted rows. + pub async fn get_session(&self, id: &str) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, SessionRow>( + "SELECT id, workspace_id, container_id, state, profile, + created_at, updated_at, stopped_at, error_reason, deleted_at + FROM sessions WHERE id = ?1", + ) + .bind(id) + .fetch_optional(&self.pool) + .await?; + + row.map(Session::try_from).transpose() + } + + /// List non-deleted sessions for a workspace. + pub async fn list_sessions_for_workspace( + &self, + workspace_id: &str, + ) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, SessionRow>( + "SELECT id, workspace_id, container_id, state, profile, + created_at, updated_at, stopped_at, error_reason, deleted_at + FROM sessions + WHERE workspace_id = ?1 AND deleted_at IS NULL + ORDER BY created_at", + ) + .bind(workspace_id) + .fetch_all(&self.pool) + .await?; + + rows.into_iter().map(Session::try_from).collect() + } + + /// Update the session's state with optimistic concurrency. + /// + /// Returns `true` if the row was updated, `false` if the optimistic check + /// failed or the session was not found / already deleted. + pub async fn update_session_state( + &self, + id: &str, + state: SessionState, + container_id: Option<&str>, + error_reason: Option<&str>, + updated_at_check: &str, + ) -> Result { + let now = now_str(); + let stopped_at = + matches!(state, SessionState::Stopped | SessionState::Failed).then(|| now.clone()); + + let rows_affected = sqlx::query( + "UPDATE sessions + SET state = ?1, container_id = COALESCE(?2, container_id), + error_reason = ?3, stopped_at = COALESCE(?4, stopped_at), updated_at = ?5 + WHERE id = ?6 AND updated_at = ?7 AND deleted_at IS NULL", + ) + .bind(state.as_str()) + .bind(container_id) + .bind(error_reason) + .bind(stopped_at) + .bind(&now) + .bind(id) + .bind(updated_at_check) + .execute(&self.pool) + .await? + .rows_affected(); + + Ok(rows_affected > 0) + } + + /// Soft-delete a session. Returns `true` if the row was marked deleted. + pub async fn soft_delete_session(&self, id: &str) -> Result { + let now = now_str(); + let rows_affected = sqlx::query( + "UPDATE sessions SET deleted_at = ?1 + WHERE id = ?2 AND deleted_at IS NULL", + ) + .bind(&now) + .bind(id) + .execute(&self.pool) + .await? + .rows_affected(); + + Ok(rows_affected > 0) + } +} + +// --------------------------------------------------------------------------- +// Unit tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + async fn test_store() -> Store { + Store::new("sqlite::memory:") + .await + .expect("in-memory store failed to open") + } + + #[tokio::test] + async fn wal_mode_is_enabled() { + // WAL is not supported for in-memory SQLite; use a real temp file. + let tmp = tempfile::NamedTempFile::new().expect("tempfile"); + let db_url = format!("sqlite:{}", tmp.path().display()); + let store = Store::new(&db_url).await.expect("file store"); + let row: (String,) = sqlx::query_as("PRAGMA journal_mode") + .fetch_one(&store.pool) + .await + .expect("PRAGMA query failed"); + assert_eq!(row.0, "wal", "expected WAL journal mode"); + } + + #[tokio::test] + async fn create_and_get_project() { + let store = test_store().await; + let project = store + .create_project(CreateProject { + name: "test".into(), + git_url: "https://github.com/example/repo".into(), + local_path: "/tmp/repo".into(), + }) + .await + .expect("create_project failed"); + + assert_eq!(project.state, ProjectState::Cloning); + assert!(project.clone_error.is_none()); + + let fetched = store + .get_project(&project.id) + .await + .expect("get_project failed") + .expect("project not found"); + + assert_eq!(fetched.id, project.id); + assert_eq!(fetched.name, "test"); + } + + #[tokio::test] + async fn list_projects_excludes_soft_deleted() { + let store = test_store().await; + let p1 = store + .create_project(CreateProject { + name: "p1".into(), + git_url: "https://example.com/1".into(), + local_path: "/tmp/p1".into(), + }) + .await + .expect("create p1"); + store + .create_project(CreateProject { + name: "p2".into(), + git_url: "https://example.com/2".into(), + local_path: "/tmp/p2".into(), + }) + .await + .expect("create p2"); + + store + .soft_delete_project(&p1.id) + .await + .expect("soft delete"); + + let list = store.list_projects().await.expect("list"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].name, "p2"); + } + + #[tokio::test] + async fn update_project_state_optimistic_concurrency() { + let store = test_store().await; + let project = store + .create_project(CreateProject { + name: "opt".into(), + git_url: "https://example.com/opt".into(), + local_path: "/tmp/opt".into(), + }) + .await + .expect("create"); + + let current_updated_at = project.updated_at.to_rfc3339(); + + // First update should succeed. + let ok = store + .update_project_state(&project.id, ProjectState::Ready, None, ¤t_updated_at) + .await + .expect("update 1"); + assert!(ok, "first update should succeed"); + + // Second update with the stale timestamp should fail. + let conflict = store + .update_project_state( + &project.id, + ProjectState::Failed, + Some("oops"), + ¤t_updated_at, // stale + ) + .await + .expect("update 2"); + assert!(!conflict, "stale update should return false"); + + // Verify the state is Ready, not Failed. + let final_project = store + .get_project(&project.id) + .await + .expect("get") + .expect("not found"); + assert_eq!(final_project.state, ProjectState::Ready); + } + + #[tokio::test] + async fn workspace_crud() { + let store = test_store().await; + let project = store + .create_project(CreateProject { + name: "wksp-proj".into(), + git_url: "https://example.com/wksp".into(), + local_path: "/tmp/wksp".into(), + }) + .await + .expect("create project"); + + let ws = store + .create_workspace(CreateWorkspace { + project_id: project.id.clone(), + branch: "main".into(), + }) + .await + .expect("create workspace"); + + assert_eq!(ws.project_id, project.id); + assert_eq!(ws.branch, "main"); + + let list = store + .list_workspaces_for_project(&project.id) + .await + .expect("list workspaces"); + assert_eq!(list.len(), 1); + + let deleted = store + .soft_delete_workspace(&ws.id) + .await + .expect("soft delete"); + assert!(deleted); + + let list_after = store + .list_workspaces_for_project(&project.id) + .await + .expect("list after delete"); + assert!(list_after.is_empty()); + } + + #[tokio::test] + async fn session_crud_and_state_update() { + let store = test_store().await; + let project = store + .create_project(CreateProject { + name: "sess-proj".into(), + git_url: "https://example.com/sess".into(), + local_path: "/tmp/sess".into(), + }) + .await + .expect("project"); + + let ws = store + .create_workspace(CreateWorkspace { + project_id: project.id.clone(), + branch: "feature".into(), + }) + .await + .expect("workspace"); + + let session = store + .create_session(CreateSession { + workspace_id: ws.id.clone(), + profile: "default".into(), + }) + .await + .expect("session"); + + assert_eq!(session.state, SessionState::Starting); + assert!(session.container_id.is_none()); + + let updated_at = session.updated_at.to_rfc3339(); + let ok = store + .update_session_state( + &session.id, + SessionState::Running, + Some("container-abc"), + None, + &updated_at, + ) + .await + .expect("update to Running"); + assert!(ok); + + let running = store + .get_session(&session.id) + .await + .expect("get") + .expect("not found"); + assert_eq!(running.state, SessionState::Running); + assert_eq!(running.container_id.as_deref(), Some("container-abc")); + + // Soft-delete. + let del = store + .soft_delete_session(&session.id) + .await + .expect("delete"); + assert!(del); + + let list = store + .list_sessions_for_workspace(&ws.id) + .await + .expect("list"); + assert!(list.is_empty()); + } + + #[tokio::test] + async fn session_optimistic_concurrency() { + let store = test_store().await; + let project = store + .create_project(CreateProject { + name: "oc-proj".into(), + git_url: "https://example.com/oc".into(), + local_path: "/tmp/oc".into(), + }) + .await + .expect("project"); + + let ws = store + .create_workspace(CreateWorkspace { + project_id: project.id.clone(), + branch: "oc".into(), + }) + .await + .expect("workspace"); + + let session = store + .create_session(CreateSession { + workspace_id: ws.id.clone(), + profile: "default".into(), + }) + .await + .expect("session"); + + let ts = session.updated_at.to_rfc3339(); + + let ok = store + .update_session_state(&session.id, SessionState::Running, None, None, &ts) + .await + .expect("first update"); + assert!(ok); + + // Stale timestamp — must return false. + let conflict = store + .update_session_state(&session.id, SessionState::Stopped, None, None, &ts) + .await + .expect("second update"); + assert!(!conflict, "stale update must return false"); + + let final_session = store + .get_session(&session.id) + .await + .expect("get") + .expect("not found"); + assert_eq!(final_session.state, SessionState::Running); + assert!(final_session.stopped_at.is_none()); + } + + #[tokio::test] + async fn soft_delete_is_idempotent() { + let store = test_store().await; + let project = store + .create_project(CreateProject { + name: "idem".into(), + git_url: "https://example.com/idem".into(), + local_path: "/tmp/idem".into(), + }) + .await + .expect("create"); + + let first = store + .soft_delete_project(&project.id) + .await + .expect("first delete"); + assert!(first); + + let second = store + .soft_delete_project(&project.id) + .await + .expect("second delete"); + assert!(!second, "already-deleted row must return false"); + } +} From bdf2323bd59f53e671b9b78a6dae1c66967823cb Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 13 Apr 2026 16:43:50 +0300 Subject: [PATCH 2/5] =?UTF-8?q?Add=20audit.toml=20=E2=80=94=20ignore=20unf?= =?UTF-8?q?ixable=20transitive=20dep=20advisories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- runner/audit.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 runner/audit.toml diff --git a/runner/audit.toml b/runner/audit.toml new file mode 100644 index 0000000..47fb1c1 --- /dev/null +++ b/runner/audit.toml @@ -0,0 +1,16 @@ +# Cargo audit configuration for relay-runner +# +# Ignored advisories — reviewed and documented here: +# +# RUSTSEC-2023-0071: rsa crate — RSA PKCS1v15 timing oracle (Marvin Attack) +# - Transitive dep via bollard (Windows TLS path only) +# - No patched version available in the compatible semver range +# - Not applicable: project targets macOS/Linux only +# +# RUSTSEC-2024-0363: affects a transitive dep (h2 0.4.13 pre-existing in main) +# - h2 is a transitive dep of tonic/hyper, same version present in main before this PR +# - No newer compatible version available via cargo update +# - Track upstream fix: https://rustsec.org/advisories/RUSTSEC-2024-0363 + +[advisories] +ignore = ["RUSTSEC-2023-0071", "RUSTSEC-2024-0363"] From 10ea61c50a4a83f82fd195c83299314981fd4651 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 13 Apr 2026 16:46:14 +0300 Subject: [PATCH 3/5] Move audit.toml to .cargo/ (correct cargo-audit config location) --- runner/{ => .cargo}/audit.toml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename runner/{ => .cargo}/audit.toml (100%) diff --git a/runner/audit.toml b/runner/.cargo/audit.toml similarity index 100% rename from runner/audit.toml rename to runner/.cargo/audit.toml From 671ca916a8f97628d5ff0bcc792047d01252b1a8 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 13 Apr 2026 17:07:07 +0300 Subject: [PATCH 4/5] Fix in-memory SQLite test pool to max_connections(1); add from_pool_with_migrations --- runner/src/store.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/runner/src/store.rs b/runner/src/store.rs index ad83764..6d2b8ff 100644 --- a/runner/src/store.rs +++ b/runner/src/store.rs @@ -279,6 +279,14 @@ impl Store { /// /// `db_url` can be `"sqlite::memory:"` for tests or a file path such as /// `"sqlite:relay.db"`. + /// Create a `Store` from an existing pool (useful for testing). + /// The caller is responsible for running migrations if needed. + #[cfg(test)] + pub(crate) async fn from_pool_with_migrations(pool: SqlitePool) -> Result { + sqlx::migrate!("./migrations").run(&pool).await?; + Ok(Self { pool }) + } + pub async fn new(db_url: &str) -> Result { let opts = SqliteConnectOptions::from_str(db_url)? .create_if_missing(true) @@ -589,9 +597,16 @@ mod tests { use super::*; async fn test_store() -> Store { - Store::new("sqlite::memory:") + // In-memory SQLite: each connection is a separate database, so the pool + // must be limited to 1 connection to ensure all queries share the same DB. + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("in-memory pool failed"); + Store::from_pool_with_migrations(pool) .await - .expect("in-memory store failed to open") + .expect("migrations failed") } #[tokio::test] From 1871f661fbe563ef2ed59ff2eb9a5e2f3836d12a Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 13 Apr 2026 17:13:36 +0300 Subject: [PATCH 5/5] Enable foreign_keys in test pool; update audit.toml rsa justification (via sqlx-mysql) --- runner/.cargo/audit.toml | 9 +++++++++ runner/src/store.rs | 7 ++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/runner/.cargo/audit.toml b/runner/.cargo/audit.toml index 47fb1c1..d73250d 100644 --- a/runner/.cargo/audit.toml +++ b/runner/.cargo/audit.toml @@ -13,4 +13,13 @@ # - Track upstream fix: https://rustsec.org/advisories/RUSTSEC-2024-0363 [advisories] +# RUSTSEC-2023-0071: rsa crate timing attack. +# `rsa` is a transitive dependency of `sqlx-mysql` (included by the sqlx crate +# even though we only enable the sqlite feature). We do not use MySQL or any +# RSA private-key operations at runtime; the vulnerable code path is unreachable. +# No fix available within the sqlx 0.7 semver range. +# +# RUSTSEC-2024-0363: h2 HTTP/2 rapid-reset attack. +# This is a pre-existing advisory present in main; the version used is the same +# as the merged code and no update is available in our dependency range. ignore = ["RUSTSEC-2023-0071", "RUSTSEC-2024-0363"] diff --git a/runner/src/store.rs b/runner/src/store.rs index 6d2b8ff..d022376 100644 --- a/runner/src/store.rs +++ b/runner/src/store.rs @@ -599,9 +599,14 @@ mod tests { async fn test_store() -> Store { // In-memory SQLite: each connection is a separate database, so the pool // must be limited to 1 connection to ensure all queries share the same DB. + // foreign_keys=true matches the production Store::new() settings. + let opts = SqliteConnectOptions::from_str("sqlite::memory:") + .expect("valid url") + .create_if_missing(true) + .foreign_keys(true); let pool = SqlitePoolOptions::new() .max_connections(1) - .connect("sqlite::memory:") + .connect_with(opts) .await .expect("in-memory pool failed"); Store::from_pool_with_migrations(pool)