Compare commits
16 Commits
1ff8ccf27e
...
video-deve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d6fff224e | ||
|
|
a94f2db4ee | ||
|
|
168e03d9e7 | ||
|
|
a9317989a2 | ||
|
|
a516b99015 | ||
|
|
bc55d0516d | ||
|
|
34f5ace545 | ||
|
|
1e3aa25d70 | ||
|
|
fa370ccfa2 | ||
|
|
442451bdfb | ||
|
|
b28befc69d | ||
|
|
320adeea4a | ||
|
|
7ec8c351bc | ||
|
|
fdd58508da | ||
|
|
06fadf6957 | ||
|
|
8017849e24 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -69,4 +69,5 @@ Desktop.ini
|
||||
# Локальные настройки и секреты
|
||||
.env
|
||||
*.local
|
||||
.local/
|
||||
|
||||
|
||||
8
INSTALL_RX_AGENT.cmd
Normal file
8
INSTALL_RX_AGENT.cmd
Normal file
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
setlocal
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\install_rf001_rx_autostart.ps1"
|
||||
set "EXIT_CODE=%ERRORLEVEL%"
|
||||
echo.
|
||||
if not "%EXIT_CODE%"=="0" echo INSTALL_FAILED=%EXIT_CODE%
|
||||
pause
|
||||
exit /b %EXIT_CODE%
|
||||
7
UNINSTALL_RX_AGENT.cmd
Normal file
7
UNINSTALL_RX_AGENT.cmd
Normal file
@@ -0,0 +1,7 @@
|
||||
@echo off
|
||||
setlocal
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\uninstall_rf001_rx_autostart.ps1"
|
||||
set "EXIT_CODE=%ERRORLEVEL%"
|
||||
echo.
|
||||
pause
|
||||
exit /b %EXIT_CODE%
|
||||
28
configs/rf/rf001a_common_clock.json
Normal file
28
configs/rf/rf001a_common_clock.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"test_id": "RF001A_COMMON_CLOCK_001",
|
||||
"legacy_waveform_test_id": "RF001_QPSK_500K_R23_256B_100P_GAP5MS",
|
||||
"center_frequency_hz": 1400000000,
|
||||
"sample_rate_hz": 8000000,
|
||||
"symbol_rate_sps": 500000,
|
||||
"modulation": "QPSK",
|
||||
"inner_fec": "2/3",
|
||||
"application_payload_bytes": 256,
|
||||
"packet_count": 100,
|
||||
"inter_packet_gap_ms": 5,
|
||||
"seed": 20260917,
|
||||
"digital_carrier_offset_hz": 125000,
|
||||
"clock_mode": "COMMON_10_MHZ",
|
||||
"tx_vga_db": 20,
|
||||
"rx_lna_db": 24,
|
||||
"rx_vga_db": 24,
|
||||
"rf_amp_enabled": false,
|
||||
"antenna_bias_enabled": false,
|
||||
"canonical_format": "c64le",
|
||||
"hardware_format": "cs8",
|
||||
"cs8_scale_codes_per_source_unit": 256,
|
||||
"expected_canonical_sha256": "7cd81395efaa88b623b7e4e5a02e3a80b52185007397ea56ceaacc13353974b2",
|
||||
"expected_hardware_sha256": "75f3fa39bfa4ee419f0676c54e4b9c486d7ff64bd0fe2fc06343b20c9d117ecd",
|
||||
"expected_sample_count": 8833828,
|
||||
"rx_capture_seconds": 20,
|
||||
"rx_capture_samples": 160000000
|
||||
}
|
||||
737
configs/rf/rf001a_truth_metadata.json
Normal file
737
configs/rf/rf001a_truth_metadata.json
Normal file
@@ -0,0 +1,737 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"test_id": "RF001_QPSK_500K_R23_256B_100P_GAP5MS",
|
||||
"timestamp_utc": "2026-09-18T12:23:31.127702+00:00",
|
||||
"modulation": "QPSK",
|
||||
"symbol_rate": 500000,
|
||||
"sample_rate": 8000000,
|
||||
"fec_rate": "2/3",
|
||||
"digital_carrier_offset_hz": 125000.0,
|
||||
"application_payload_bytes": 256,
|
||||
"packet_count": 100,
|
||||
"inter_packet_gap_ms": 5,
|
||||
"inter_packet_gap_samples": 40000,
|
||||
"inter_array_zero_samples": 39872,
|
||||
"seed": 20260917,
|
||||
"iq_format": "c64le",
|
||||
"dtype": "<c8",
|
||||
"waveform_sample_count": 8833828,
|
||||
"waveform_duration_s": 1.1042285,
|
||||
"packet_waveform_start_samples": [
|
||||
0,
|
||||
88737,
|
||||
177474,
|
||||
266211,
|
||||
354948,
|
||||
443685,
|
||||
532422,
|
||||
621159,
|
||||
709896,
|
||||
798633,
|
||||
887370,
|
||||
976107,
|
||||
1064844,
|
||||
1153581,
|
||||
1242318,
|
||||
1331055,
|
||||
1419792,
|
||||
1508529,
|
||||
1597266,
|
||||
1686003,
|
||||
1774740,
|
||||
1863477,
|
||||
1952214,
|
||||
2040951,
|
||||
2129688,
|
||||
2218425,
|
||||
2307162,
|
||||
2395899,
|
||||
2484636,
|
||||
2573373,
|
||||
2662110,
|
||||
2750847,
|
||||
2839584,
|
||||
2928321,
|
||||
3017058,
|
||||
3105795,
|
||||
3194532,
|
||||
3283269,
|
||||
3372006,
|
||||
3460743,
|
||||
3549480,
|
||||
3638217,
|
||||
3726954,
|
||||
3815691,
|
||||
3904428,
|
||||
3993165,
|
||||
4081902,
|
||||
4170639,
|
||||
4259376,
|
||||
4348113,
|
||||
4436850,
|
||||
4525587,
|
||||
4614324,
|
||||
4703061,
|
||||
4791798,
|
||||
4880535,
|
||||
4969272,
|
||||
5058009,
|
||||
5146746,
|
||||
5235483,
|
||||
5324220,
|
||||
5412957,
|
||||
5501694,
|
||||
5590431,
|
||||
5679168,
|
||||
5767905,
|
||||
5856642,
|
||||
5945379,
|
||||
6034116,
|
||||
6122853,
|
||||
6211590,
|
||||
6300327,
|
||||
6389064,
|
||||
6477801,
|
||||
6566538,
|
||||
6655275,
|
||||
6744012,
|
||||
6832749,
|
||||
6921486,
|
||||
7010223,
|
||||
7098960,
|
||||
7187697,
|
||||
7276434,
|
||||
7365171,
|
||||
7453908,
|
||||
7542645,
|
||||
7631382,
|
||||
7720119,
|
||||
7808856,
|
||||
7897593,
|
||||
7986330,
|
||||
8075067,
|
||||
8163804,
|
||||
8252541,
|
||||
8341278,
|
||||
8430015,
|
||||
8518752,
|
||||
8607489,
|
||||
8696226,
|
||||
8784963
|
||||
],
|
||||
"expected_packet_hashes": [
|
||||
{
|
||||
"sequence_number": 0,
|
||||
"payload_sha256": "4e95e78189b1156739263a383aa26e568b369a24737b78f169a02d7182b66f0b",
|
||||
"packet_sha256": "e50f93a6389e4911db17eb822d583f06fae05df6708c64019de08c6ec6834960"
|
||||
},
|
||||
{
|
||||
"sequence_number": 1,
|
||||
"payload_sha256": "8d2104b86fe512059111192d7ff0bc089f1931a57756c8b930fa03c238352e75",
|
||||
"packet_sha256": "35c7fbd31f2452da87e40be898553a29d05ae1303ab7e9928d514ca7f94f19e9"
|
||||
},
|
||||
{
|
||||
"sequence_number": 2,
|
||||
"payload_sha256": "a1c20c33df93076fcb95cbfc1e5c6eeeb7c0b35733a4e28a8c7330c994850aca",
|
||||
"packet_sha256": "d672a74d80a3e16265bb4fe531579c907dcff464ae036e4cf129c3e22de8ec80"
|
||||
},
|
||||
{
|
||||
"sequence_number": 3,
|
||||
"payload_sha256": "d3ffdd0789e7bb3f7dba60b96a61175414912253001475d16ec6f837eb8b17a5",
|
||||
"packet_sha256": "7083d4c56b3cda44f9d34be4588809519dd65a4072dbfcc6d563470a2a527547"
|
||||
},
|
||||
{
|
||||
"sequence_number": 4,
|
||||
"payload_sha256": "03f62ee09971f903b3610e9b73a8484021e76d09a5758a4aafc0e951ca1a4399",
|
||||
"packet_sha256": "8e74c8ac395bfca64ad1a2b72f755ff909626735d375bdc75d12750dae79334f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 5,
|
||||
"payload_sha256": "a7915faacc162ae02f5b962eafb6ee221d4320e60a9a29f9aaafaa23358e76b7",
|
||||
"packet_sha256": "46d3e3d8b40beadb6a881c821c460e44a4cdc6e43ae2c94649677f7b26087617"
|
||||
},
|
||||
{
|
||||
"sequence_number": 6,
|
||||
"payload_sha256": "9484962525e16284e0a5efb2d1e3672c5ee688f7edce17c25d0eb4d2884eee5d",
|
||||
"packet_sha256": "3c72b11fc893d6b28ec966f0677897ffdb33da26c820fe29a5528be0535c8638"
|
||||
},
|
||||
{
|
||||
"sequence_number": 7,
|
||||
"payload_sha256": "b9476ffd787f7d88612de8763244274f5b89b0b3744dbac7d5286992de7be12c",
|
||||
"packet_sha256": "ed6a563a919e2ada004520042696bb2b18af80eb81afcb9c35ba0b4ce96b712e"
|
||||
},
|
||||
{
|
||||
"sequence_number": 8,
|
||||
"payload_sha256": "1e8ce60401efc25916c78b94fd5ee1d5ad9f61fd4ca0f79377815bf7e2e8cdc0",
|
||||
"packet_sha256": "cc377708eae5a9da1fd7238f124b1a8658769ec8b19f405b9f3d902eece165f2"
|
||||
},
|
||||
{
|
||||
"sequence_number": 9,
|
||||
"payload_sha256": "1dd003103ed9065ad42ad177c6cdcb6abe95e1b6d85703a4b82a550d9fe1a5f8",
|
||||
"packet_sha256": "a11f702f42659771e907d969c53f6ae953ee8e7576ee0e5d29a1e9a95caeeccc"
|
||||
},
|
||||
{
|
||||
"sequence_number": 10,
|
||||
"payload_sha256": "09106da81d922fef36a31ba4632ec1bce31061f3b6da3322e6be0e1c4806f0c4",
|
||||
"packet_sha256": "cebbd0b3f5ba39659a8f784542f97b68d47ce271a7349e616ee453933bdf5fd0"
|
||||
},
|
||||
{
|
||||
"sequence_number": 11,
|
||||
"payload_sha256": "ff2de9370b5817127586092275a38c8878693962ba91b7ff5836334e835020b8",
|
||||
"packet_sha256": "93d38f1a1f5d1b103e970d606424847838b2e8c3bcedbf538e963e369f6d05f5"
|
||||
},
|
||||
{
|
||||
"sequence_number": 12,
|
||||
"payload_sha256": "194d4eea8b148f8097e18882d97d1089b7e46896fe5bb98befa37ebb3c50aa29",
|
||||
"packet_sha256": "c1043bf2927f0c94afe3e5166e264eb38660dea6f3e62f715bd9eee7dc0f716c"
|
||||
},
|
||||
{
|
||||
"sequence_number": 13,
|
||||
"payload_sha256": "074c679bb903935c6bf5cc52c3ced97408376b1c7a0cc15eb72b514063bf4810",
|
||||
"packet_sha256": "55feddabdea9a8e154e787b587a1f1ce09b0a85d4b0cddd79bb09e721b7d66e1"
|
||||
},
|
||||
{
|
||||
"sequence_number": 14,
|
||||
"payload_sha256": "257814be75560af99a9c4a8f63c01ab7c6ed12a13487cfd1d0f8c1c872ff9bb6",
|
||||
"packet_sha256": "6963cf0fa929c33bbf44fd44044d7cd70ca20ec3ceb5ac17f35b06baf6b290b3"
|
||||
},
|
||||
{
|
||||
"sequence_number": 15,
|
||||
"payload_sha256": "9d2c9736b139bea03b80a21f2b6d7c8055e167ecdbea0e69f812b6cb3dd37fe4",
|
||||
"packet_sha256": "84537c730059fb93ce34ded7ca5b71f74db0055931132568233325fa6710cce8"
|
||||
},
|
||||
{
|
||||
"sequence_number": 16,
|
||||
"payload_sha256": "a6ee2a88458190abfbe8ebdfcc93d9346442ed8041c47d032d75eeae81d1a5e0",
|
||||
"packet_sha256": "2c6781c0fa023af965f994cf246d6138dd878235c96e0f1ed641bb9b631102bf"
|
||||
},
|
||||
{
|
||||
"sequence_number": 17,
|
||||
"payload_sha256": "421f076061c433b5515bc5eb42e06ed42355a46e5099c6caf9f364433f41b140",
|
||||
"packet_sha256": "fac6f86486df9d66931360844789e8d0f350b9ec0140e925b52eaec011252e27"
|
||||
},
|
||||
{
|
||||
"sequence_number": 18,
|
||||
"payload_sha256": "e93c614ff590771cba9d220c81f44c8ebdba3f7ed6406768648872b889050fb6",
|
||||
"packet_sha256": "8c20ec5bd30a1a8091e6b0b173d01ee0018b52017d32c62742434fa032c5fb98"
|
||||
},
|
||||
{
|
||||
"sequence_number": 19,
|
||||
"payload_sha256": "d14f3bfce872f3ae12d5b10c0861ff438c1dedefd652cb7762c94cdef48b052f",
|
||||
"packet_sha256": "f34f48c6a7327425f7fc42d244e65b57fc13dd5caf0079b37a94e76719a5e16f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 20,
|
||||
"payload_sha256": "54fa05ab31402bd66ccb9fd7bf130c167d2d11546bcb34530923fa14103f07f1",
|
||||
"packet_sha256": "4c44637f18540a4bc7eb765e0b331abcfd683ab21136d24522f91298e5329a38"
|
||||
},
|
||||
{
|
||||
"sequence_number": 21,
|
||||
"payload_sha256": "dcf67daa5a735ca45f4609f9981a00bab143f2fd5f7d1d5ea56240e1650ffade",
|
||||
"packet_sha256": "da583f81b26a0035fb829ec1707a33dddcca2662ddae9ca22d22bacaa4580cb6"
|
||||
},
|
||||
{
|
||||
"sequence_number": 22,
|
||||
"payload_sha256": "f0578ad0a65f0f9df231fd508d9f6f27fe0043e2720d1ba20a9927c730f0601f",
|
||||
"packet_sha256": "677b77e784fa91edc4c2896f9ca574bd9f3e71fc18e355082674200a7166d9c5"
|
||||
},
|
||||
{
|
||||
"sequence_number": 23,
|
||||
"payload_sha256": "12246760d0aa098ffe08470487f15d832ef6731dbce3ed9c4b1ba11db94f15c2",
|
||||
"packet_sha256": "c91339dfc311df65afbbb88b0f5f7ca06c1cbffb0e2b330bb3a4b4202586b308"
|
||||
},
|
||||
{
|
||||
"sequence_number": 24,
|
||||
"payload_sha256": "2d7d070265ae85f0014490a9b53046c143df8947f99161b6770619c34f6a69dc",
|
||||
"packet_sha256": "8bc0d37edd1b0e0fceefe84150fa240133221e75b4c7dd632a8211aaab27a2f2"
|
||||
},
|
||||
{
|
||||
"sequence_number": 25,
|
||||
"payload_sha256": "74a152e7fa829dfc448669927bf714c5b610947f7225bbc8bf5d491ed2a300ad",
|
||||
"packet_sha256": "9d2b1fe0c9d1722093944105bfe64d99109a595a6e4ce5504030f23f10f10d09"
|
||||
},
|
||||
{
|
||||
"sequence_number": 26,
|
||||
"payload_sha256": "7fb9a08bbd3fb7c8004bcb54ac76e016c37f5686f23c4f14bd3b6f6db7bfaa85",
|
||||
"packet_sha256": "6461326859169e27329f26c064a87445d33ceb7846368ae3d7b683d9aaec912b"
|
||||
},
|
||||
{
|
||||
"sequence_number": 27,
|
||||
"payload_sha256": "18d0572fc2bb366b04198f0c0f451ea79c06f93e8b729a6194db3ce950e35d0f",
|
||||
"packet_sha256": "627b69cd08eb2c539cee8b470ba7c007d78cfa9eb5464b91274ef6749a3bf24e"
|
||||
},
|
||||
{
|
||||
"sequence_number": 28,
|
||||
"payload_sha256": "6ae0006e493efe63ba7f482832b32e59c1556eae2e845402e36685ea9d64f2eb",
|
||||
"packet_sha256": "64511c3982b1c77ec76d75e9036fbfb91fc378ea3275e420c66de5acba93a0c0"
|
||||
},
|
||||
{
|
||||
"sequence_number": 29,
|
||||
"payload_sha256": "c8810ded095de421272cb2ac06d03a4dae07603c92aa54ed6f95c9e92b94aa4d",
|
||||
"packet_sha256": "5c363e0729272ef745715362825755ac4d5bf64e9ce6245a116226ab7a3860fb"
|
||||
},
|
||||
{
|
||||
"sequence_number": 30,
|
||||
"payload_sha256": "b738ff110f5bc52700094b698f2f814b59f62edd1553073634818cf41634a7b9",
|
||||
"packet_sha256": "69f0ce4e421548ffe79b5262eec9cf8c1f0e01e338e4a5c47c344c095203cc7e"
|
||||
},
|
||||
{
|
||||
"sequence_number": 31,
|
||||
"payload_sha256": "f70565bb9395029c857ea85ebb9fbeca8c55f37cff2de52a43aa5dcaee49fce4",
|
||||
"packet_sha256": "826f2108ecd64fe1c4f7b750fcd6f0c0d586d9092e7a608b050ec9d298039291"
|
||||
},
|
||||
{
|
||||
"sequence_number": 32,
|
||||
"payload_sha256": "a6110fb9975df074ec37b653a288fbcc3356eb60460e9b7c77fab99cf519d54e",
|
||||
"packet_sha256": "35a4f770aeb60a0b269fd70b48c0856230a2661fc6f85ace9e3fe7ffeaaedd1b"
|
||||
},
|
||||
{
|
||||
"sequence_number": 33,
|
||||
"payload_sha256": "e6ee539b6824c69e0cfac0f166d8ca08bdfe1b72a4dc47bbdaf3c1bfdfd20af4",
|
||||
"packet_sha256": "7530f6f6480163162bb5c799f114b068ee3fa6e840d837b5d45f307dc78842b2"
|
||||
},
|
||||
{
|
||||
"sequence_number": 34,
|
||||
"payload_sha256": "3320f4036487dba8c882d2b2a3b0fc03375f803e73ee8ca4a170699bb2b49c73",
|
||||
"packet_sha256": "6a990db6b516d4c264b965d54851dbde6782a56b5b810996ffd04a6f8b58a200"
|
||||
},
|
||||
{
|
||||
"sequence_number": 35,
|
||||
"payload_sha256": "049538d4d23c057af2aa77989ff2260b72fd67920e864f6e7c7761500a777ae5",
|
||||
"packet_sha256": "4f247fe687e7a91ca3a7362e47098ffee42e351e0e346bed46c9c684ceb8935d"
|
||||
},
|
||||
{
|
||||
"sequence_number": 36,
|
||||
"payload_sha256": "72102a33749d838277ea1e8243ebdf4ae30b6dbda739c334fdf11cf20d408724",
|
||||
"packet_sha256": "60d3b3d234ac875609f83497e1137c05579663a2d9d1ea0911309f5cfdfca12a"
|
||||
},
|
||||
{
|
||||
"sequence_number": 37,
|
||||
"payload_sha256": "fb8457ec7bcda3c70b61181c95c2d8a844e4b341f8d6d4f9533f594344952bd7",
|
||||
"packet_sha256": "2e5e79705c683ed6cec2b0705124a3a51d7c918cd5477334173378eed5ad72c9"
|
||||
},
|
||||
{
|
||||
"sequence_number": 38,
|
||||
"payload_sha256": "e21bd69eaa967ab61212dc57e211d76733099b2534d76591710eef5c4bbb68c0",
|
||||
"packet_sha256": "deb2bdd76ae1bdaab5cf45693cc913bf860720b8a6369ea6dab984398635c142"
|
||||
},
|
||||
{
|
||||
"sequence_number": 39,
|
||||
"payload_sha256": "e440830eb1dcb5e952ffd38a6c4a8aa7a455c73f483fd96487ca3b1b5814f74a",
|
||||
"packet_sha256": "9377bc52bb374a1f148e30b8a583b2504f5cd9754d79a488219ae6594add0d49"
|
||||
},
|
||||
{
|
||||
"sequence_number": 40,
|
||||
"payload_sha256": "ab22ef291546afad3c096faf4c11a917ba530b71063a74899e748e291ddc1888",
|
||||
"packet_sha256": "50a5d4f1af820acc6611ad05cca7e2a58743ffb75e2e21c5e537bcb4f01d1ca1"
|
||||
},
|
||||
{
|
||||
"sequence_number": 41,
|
||||
"payload_sha256": "3a77bddd7867d943b8c70673f63e0b7dc45af645d82f27779ca3b716d4688772",
|
||||
"packet_sha256": "21192e0633a549a6863c1412050379a37f784c2850fe3b0fd9a6189e6fa04716"
|
||||
},
|
||||
{
|
||||
"sequence_number": 42,
|
||||
"payload_sha256": "349255af17303f3ad7de96079f17001db1120bdc1d015414ab1189450ca23867",
|
||||
"packet_sha256": "799d9e8af80186dd693bea85869ae8e05bbdbacb188fdbdcd355ee568563152e"
|
||||
},
|
||||
{
|
||||
"sequence_number": 43,
|
||||
"payload_sha256": "70eb1217481f5a17dad4ba973f99ae149bf7d591b2739a2fe7a44b33946fc2de",
|
||||
"packet_sha256": "ece74d595de7a24e04d7c17afbdeef079337f316d6da6f9f0a7936a3a0ed2a51"
|
||||
},
|
||||
{
|
||||
"sequence_number": 44,
|
||||
"payload_sha256": "64127d2496375c8c508b39c0a80bff78eb26ed00c57d462de27b75dfb852f23f",
|
||||
"packet_sha256": "8ffcae35363d9f1ca2f6bfc02c0833cff9fad1e75312d587db44db6d290a316e"
|
||||
},
|
||||
{
|
||||
"sequence_number": 45,
|
||||
"payload_sha256": "94e697b1bb63efb563fe8f23a7ac717bcbe55ec12b612b5946fa95a34804d2ad",
|
||||
"packet_sha256": "9a893918e5441504c77c49107975c34671e6ab572dedfa64b52d554b3b220057"
|
||||
},
|
||||
{
|
||||
"sequence_number": 46,
|
||||
"payload_sha256": "f0b9ffe40da709a93f71477c7e7ef7e5c0c7960a81249e73032e45b389b23a37",
|
||||
"packet_sha256": "ba04e7b50f0a2c3dce78694aa977d6e4f6a17842761c94b90395aa8dc136b57b"
|
||||
},
|
||||
{
|
||||
"sequence_number": 47,
|
||||
"payload_sha256": "2177b36f9aac5bfbef359e662f9c947c98f800526ffe9f99665912c303f22abb",
|
||||
"packet_sha256": "3da0bafcd46fc6a917505c5615c830dd27e4d3c58e97603d089c79282fc9f9c0"
|
||||
},
|
||||
{
|
||||
"sequence_number": 48,
|
||||
"payload_sha256": "3aa5a3818488ea9cbf9fdcf655aaaa22dcf01d7b00ffcc5e127a9a2fd9332cd8",
|
||||
"packet_sha256": "2d2424d3024c65353da8065ba8a32e8f8a6a18633267a8b01e32cbe734e2d9dc"
|
||||
},
|
||||
{
|
||||
"sequence_number": 49,
|
||||
"payload_sha256": "365bef881aa8d57ddce1849485d33938f6ea85b9f75383c99eaf4fd95b4233b9",
|
||||
"packet_sha256": "471f68e3c998b8a65917b5e5ea978aa9e1fb1a8ba9586c3e10ba69ef67f5f373"
|
||||
},
|
||||
{
|
||||
"sequence_number": 50,
|
||||
"payload_sha256": "47043687b94a7c4abbe6fd405bcf92cc843cd0185bd1246b50e3678ded1331c3",
|
||||
"packet_sha256": "58610a2248c28fb815df0acba42d2de1d474f5bba54fb634572771e22bb841a5"
|
||||
},
|
||||
{
|
||||
"sequence_number": 51,
|
||||
"payload_sha256": "1cc173ed5a928de1a49017d88f233ed64c4212edeffb618dd99b03c7a06f9e69",
|
||||
"packet_sha256": "e91154fb19ca789296e09a1884243dda26a20ff6e964a99350b63a56f9900d6d"
|
||||
},
|
||||
{
|
||||
"sequence_number": 52,
|
||||
"payload_sha256": "d073a047015bc48b7249996ac967cbe573e70d0a383685ecb5fd65ae0fa7f5d0",
|
||||
"packet_sha256": "4fabd2ece3ba7b35ce77ab185e5689d9a69ec25e97d25431ba06c4fe41ce3ca1"
|
||||
},
|
||||
{
|
||||
"sequence_number": 53,
|
||||
"payload_sha256": "9bd5a56c09781c5358e2e0aad364a470667f519550f18eb38c7de982c1080280",
|
||||
"packet_sha256": "5a9fff30607546c10719fc8b2373751936d85c9cbb825351d40ff192c35e67c3"
|
||||
},
|
||||
{
|
||||
"sequence_number": 54,
|
||||
"payload_sha256": "80673128b291980917d288d4909f2024f331bada7884dedcb38a3b25e115a4e2",
|
||||
"packet_sha256": "291d6753b048005cc93ff7916df1c702ceffe6ad34dd20824f9fc93c802839cc"
|
||||
},
|
||||
{
|
||||
"sequence_number": 55,
|
||||
"payload_sha256": "86a03ba0f472f0627123e22e11014f42b2f318cee7055b439e5ebca516061370",
|
||||
"packet_sha256": "038b17c9da0844d003d4780cd61217e904a51e113bf0705862c30285bf4c216d"
|
||||
},
|
||||
{
|
||||
"sequence_number": 56,
|
||||
"payload_sha256": "1497fa9c6aae68d2a41256f9257c13264d50bd32dcc79b76c3be0fbff53d14f8",
|
||||
"packet_sha256": "203e9373cc6fa3d52ffcc4a9e3ce9675bf86f2e154bcba9217a302355819c6b1"
|
||||
},
|
||||
{
|
||||
"sequence_number": 57,
|
||||
"payload_sha256": "acd390d05a2a51bdb14c74c3a26588e544af4167d3907b49a86897e8c5d82f22",
|
||||
"packet_sha256": "1af6cc447cc333df9af40721d7c126efb1fcb065bef62ba42f00ba2e812b320d"
|
||||
},
|
||||
{
|
||||
"sequence_number": 58,
|
||||
"payload_sha256": "5366d7d4670582403cbd0e2f6fa12974384278b03ad2899b2c5e4b92ab32a564",
|
||||
"packet_sha256": "8c500c11f4f412bd2eaa86862398c4ccf9fc499c32a8717e5931df5b8c9e7836"
|
||||
},
|
||||
{
|
||||
"sequence_number": 59,
|
||||
"payload_sha256": "99a4565ae1db6cb094cbddc0adaa442bd9b0fa04e02115ac5f82b1ba0cb021f3",
|
||||
"packet_sha256": "1ae3de9c1f2a4ea0a97dce9df11929f82caf63e17e404498cb08b678acfaafaa"
|
||||
},
|
||||
{
|
||||
"sequence_number": 60,
|
||||
"payload_sha256": "1785424f2d9427a9c13bbdcaca2975f4ea2893daf6f3642deac0b7929acd7d4d",
|
||||
"packet_sha256": "662b48a8e61ada3671dfff3af459c5ee979849856be2602b4b5f85828b87e3f8"
|
||||
},
|
||||
{
|
||||
"sequence_number": 61,
|
||||
"payload_sha256": "1fb826298448fd4183522f7ccd3f45e9889ab78314ef78749cf9d679501d2efb",
|
||||
"packet_sha256": "73e2f50f4cf77ef91e0b374b68aa07bb7a7cc9601cfa5d0e19475bedda4a9847"
|
||||
},
|
||||
{
|
||||
"sequence_number": 62,
|
||||
"payload_sha256": "86b46d9e32a85309e34183b5fadb8e64030dc804c6ba54d685e32b2e367ec362",
|
||||
"packet_sha256": "32de517b56537ead8cb46eabdfcfa41bb3924528c5bc31dd1f04b9c8865246ee"
|
||||
},
|
||||
{
|
||||
"sequence_number": 63,
|
||||
"payload_sha256": "19cb46c3f3fbf8d804aa64f66031f4cb9c551dd198083394568ba12e7f4140e1",
|
||||
"packet_sha256": "964a64272e72c8377f10baf43845d1023e2f66363d10ea14861005e3496201d0"
|
||||
},
|
||||
{
|
||||
"sequence_number": 64,
|
||||
"payload_sha256": "cce7d5957bf9573b07cd537ad1882e4f9ca8e40a06e7ee651b45eb1083ac062e",
|
||||
"packet_sha256": "2a9f1cd3eb0a6fd78a79ea5b7ee20eb90266967ad60519ffd52d379f27968cc5"
|
||||
},
|
||||
{
|
||||
"sequence_number": 65,
|
||||
"payload_sha256": "a3022b2ccdddfb3ae31666802fc50862d328b90fa3f1fd919a7ec282a803d01b",
|
||||
"packet_sha256": "9fb0676a2a05c48791b14dcac0fe694462123d07aa1f0539a25ae2c85edcb467"
|
||||
},
|
||||
{
|
||||
"sequence_number": 66,
|
||||
"payload_sha256": "d700f2d05d13f0397b8314a413b3bdeefdb29b19fe266ed6fadd7847c8d370a8",
|
||||
"packet_sha256": "4db24f6fcccde301f1a7f3c7f93a22f809b6304ecada8ff08964bf47f97c7d44"
|
||||
},
|
||||
{
|
||||
"sequence_number": 67,
|
||||
"payload_sha256": "a7c6780dfa088e82142e99839a3794d068e7dbed9c1c984ab89e1c18341aa165",
|
||||
"packet_sha256": "47eb548881c5c2bb7f4568ec803d62744041363a8df836ac24f212dd043b194d"
|
||||
},
|
||||
{
|
||||
"sequence_number": 68,
|
||||
"payload_sha256": "4e94ad2174f8379eb311155969ef7619b862d6db93c0ce2b6b0b8222641869e0",
|
||||
"packet_sha256": "16d123eac8487f32dfbef2836b9ca28bf0bbbc9ec9cbde9473be3ad49dad8561"
|
||||
},
|
||||
{
|
||||
"sequence_number": 69,
|
||||
"payload_sha256": "19375b8ab0a37f5eff619977273c547c0698ed9264d91b725568bb4dd7ccb6c4",
|
||||
"packet_sha256": "e806ca6e9be15f5e5d3e9ab21ddfe5a81cba66c5dc40283e3543bbbb1de8ad23"
|
||||
},
|
||||
{
|
||||
"sequence_number": 70,
|
||||
"payload_sha256": "d60d45967a1dcf5188222ed928adb9afa9599b1c036db00c3b7c1ff2290edf30",
|
||||
"packet_sha256": "c542d6426d41d9245715b262c3353c05c3e2459f6e57b92b197d0e325b6f1a39"
|
||||
},
|
||||
{
|
||||
"sequence_number": 71,
|
||||
"payload_sha256": "408ab0b95a563333d7705a01201fba72079d78a840ac2c0aebc85a807fafc760",
|
||||
"packet_sha256": "1a40abde7d1ca18c86ccb8cad98005adb259a7262d04f655fb6b59f713339a90"
|
||||
},
|
||||
{
|
||||
"sequence_number": 72,
|
||||
"payload_sha256": "9960aee518000cf96d71300a043bb7826e611314dbfb41b544c64aa62eae8de1",
|
||||
"packet_sha256": "b96ec2a35c3f04a1eb1d11416b6bba6c464eb9223823bc90286e93496995aef2"
|
||||
},
|
||||
{
|
||||
"sequence_number": 73,
|
||||
"payload_sha256": "a0b867b3f9d3e09113a663acc1310c9245d49c985d624d6407f8553cc0f0a8eb",
|
||||
"packet_sha256": "4cd1b3313cd7f2270e083c16fe77f4ce5b843cc57834382e30d8fb3350eb6ac8"
|
||||
},
|
||||
{
|
||||
"sequence_number": 74,
|
||||
"payload_sha256": "6d8f21d468de22c1939ae0f72374cee7e505528e49c052fb5c2295574195c595",
|
||||
"packet_sha256": "1d3512901cb78072a773198320330ac528009c6aff7fb4f42fe10eeb2134a498"
|
||||
},
|
||||
{
|
||||
"sequence_number": 75,
|
||||
"payload_sha256": "918f46919ed53f70bd031a739886dc8b93b212e054388076076bb81624d9a4cb",
|
||||
"packet_sha256": "d423db049f9e9ef07dd9c358c3315239e745ab0f8bf6dee1c93ee027b63550f4"
|
||||
},
|
||||
{
|
||||
"sequence_number": 76,
|
||||
"payload_sha256": "c898050384f25ea8e8a06121ca9c870b21ffdf5d7d66941ea80f1f43cc7d6c48",
|
||||
"packet_sha256": "2ed7dbda8de3ad2b55fb5631aa0d6adb52e0e7a2e87b3ccbf4c4d14b93737224"
|
||||
},
|
||||
{
|
||||
"sequence_number": 77,
|
||||
"payload_sha256": "420551db636aa35fa85dae39b8166056a028338da82fdaeab3d2e2f90fb692f9",
|
||||
"packet_sha256": "ea584a2d123b4969df4bc52dd721f9983fa5a34c9e2a98a0b857a99ee7e7bae3"
|
||||
},
|
||||
{
|
||||
"sequence_number": 78,
|
||||
"payload_sha256": "ff37d42b153f57cd46808c831894c180ec8710827cbe5ddd84891a349e78584e",
|
||||
"packet_sha256": "44f213f517d5ecf2b419426c14eaae5c03176485db73b78b29a145ad5c952a66"
|
||||
},
|
||||
{
|
||||
"sequence_number": 79,
|
||||
"payload_sha256": "795aa97056eb0c8b5e4134ba9204581c06a3f73eb72a26cfd52cdfba5c2d3ebd",
|
||||
"packet_sha256": "a7a4fff4bf520ebe57db314995036ce76eeb09a778ccedd5eec4fa4179782fc0"
|
||||
},
|
||||
{
|
||||
"sequence_number": 80,
|
||||
"payload_sha256": "772225bc6d1c9d7da39b90cf545409d21b8a267ec3881abb8b4cf737dc8b5586",
|
||||
"packet_sha256": "c04ae816948e9f645dde14c688bf7a881fd761c12666169bf3cf944397d2ae1f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 81,
|
||||
"payload_sha256": "5d7b128857e4eea0290eb5748b26da0ff30136e2d2b0cbf6527b08cb84b576ee",
|
||||
"packet_sha256": "19d35293afa0ff3705dd9fe7dff134aec57655a2cfacfc10b3b56792488cc379"
|
||||
},
|
||||
{
|
||||
"sequence_number": 82,
|
||||
"payload_sha256": "6f13fedc3925d328f7aa448715f4f88bf7f8397430f015474d1ec0b8c2524f19",
|
||||
"packet_sha256": "7f5da368cdb8d93f01a743046842a94b779408bf03be46ea749b7d3d875efb68"
|
||||
},
|
||||
{
|
||||
"sequence_number": 83,
|
||||
"payload_sha256": "db4fae9f025383ee9fcb272bf591391be9a7511480a6155bd16368b2a7526da8",
|
||||
"packet_sha256": "bc5c2b1a99bb0f03281a6a8977fb91497c1fcb08f824fe37f3a5dc58272e135f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 84,
|
||||
"payload_sha256": "c7263f589c0b9a8bfed09664ba80c2c98bef86e4e190ecadf5a4764be50af600",
|
||||
"packet_sha256": "19e7ac7b9331adb5b9e58a91612203767498150463300b360e0ba603b20ca104"
|
||||
},
|
||||
{
|
||||
"sequence_number": 85,
|
||||
"payload_sha256": "8813e4ed36a33b415487386e94ee7e07bc33825b7516e662cf51f5b43f2f25a6",
|
||||
"packet_sha256": "9714f48963007f0802169f73e765a3d19d958d130ce82f022919aa1f29cf97e3"
|
||||
},
|
||||
{
|
||||
"sequence_number": 86,
|
||||
"payload_sha256": "ae35ed2b855402a457dc0ebf13e9c6915a1964a87a9a6f58b678ada2549e36a0",
|
||||
"packet_sha256": "a76f078847dc4afb6bb6f7c55ae2c7d04770e2ea8d5ecf11cb9a1a72e2374efc"
|
||||
},
|
||||
{
|
||||
"sequence_number": 87,
|
||||
"payload_sha256": "1e508aacf860bfa4fa124c366386be546cebd62a375f14e771be08999506fe85",
|
||||
"packet_sha256": "5531ebb6a8138ee6b9f549be25ee2cf47c0c321bfe05eebb7232e611b6124fff"
|
||||
},
|
||||
{
|
||||
"sequence_number": 88,
|
||||
"payload_sha256": "96942711e476e7bdb2aa57c3845c4594825f4dd8b8fd8f3203c87acccdc6d527",
|
||||
"packet_sha256": "a23c2b138284b533fbbc7ff2350056c82878d92572e7b96ccdc60e28977858de"
|
||||
},
|
||||
{
|
||||
"sequence_number": 89,
|
||||
"payload_sha256": "e356c8ef03d32d28983c088d6c0bad625ca577351dd6e067763b212e817d75c4",
|
||||
"packet_sha256": "6f003c9766914ee11f6d4bd5cb8f80f05451e06f4ba21e589e7ee288ee0ec87f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 90,
|
||||
"payload_sha256": "bd6da495c09b3b533bd4e8c6f0ac5751c0b26174172fc5085e22b48f3b53a577",
|
||||
"packet_sha256": "5b2a1a43ebf039b998af09037edf1054d913dfa4d364fa502ac5e89279055fa7"
|
||||
},
|
||||
{
|
||||
"sequence_number": 91,
|
||||
"payload_sha256": "648b2da1112dcfeb229519fde8ef8034449937e7b42602588c223855382925be",
|
||||
"packet_sha256": "90a075a8c1804a32a42c5d0ebce27bc7dc3e23feb7e9d8cdffe3f3486ec5cf0f"
|
||||
},
|
||||
{
|
||||
"sequence_number": 92,
|
||||
"payload_sha256": "669afb8bb74b9dbdf96708d2a48ee1fce635ae22294e06ed4500586e57213762",
|
||||
"packet_sha256": "2567a20ec8033be6286594ce3a542566866544bc083ad278f9340ca2b48aae24"
|
||||
},
|
||||
{
|
||||
"sequence_number": 93,
|
||||
"payload_sha256": "a2f6f9d77355b3a04c32d069af37031a06f2c484d58b51849bb55b3cdec572e5",
|
||||
"packet_sha256": "9192777c9fecb9f5ed4f065b7d5bfafcc41f6f76ac5ce742ccedc5b2a6f61de3"
|
||||
},
|
||||
{
|
||||
"sequence_number": 94,
|
||||
"payload_sha256": "055d60647878f18901ec2a1e009363e3d5517fccbbe92fa2cd6b6feeadf58391",
|
||||
"packet_sha256": "b54786e36fd680a81066bddf3289f17aa95b57d46bf006cf08c1e07a7aaa5a27"
|
||||
},
|
||||
{
|
||||
"sequence_number": 95,
|
||||
"payload_sha256": "d10f469fbad708685b16c1ddcceca68a84f77a802b3ad04b0d394e45f754d0a2",
|
||||
"packet_sha256": "f37eb95b8e0344bedc4ad85436cbe4db62593dde31fdb3daf0284e49a2ac32c1"
|
||||
},
|
||||
{
|
||||
"sequence_number": 96,
|
||||
"payload_sha256": "12a224b34c176ef7fed560197a39ad3bc448eee1467733f84250b29f3a06ce03",
|
||||
"packet_sha256": "fe3ce30a3d7649061f208a86c8ede5aac568803d079d8da1a0a5a11a962dd358"
|
||||
},
|
||||
{
|
||||
"sequence_number": 97,
|
||||
"payload_sha256": "db488bd0b7c4c38e5fce4a309d323955bd870a5b9947c753fd9065a0ce42b210",
|
||||
"packet_sha256": "a60f4e0dfe9b40ae18e03deb331022e53292368803bed492bc3069dbc564bb38"
|
||||
},
|
||||
{
|
||||
"sequence_number": 98,
|
||||
"payload_sha256": "3f12c8bc119739279484078bc5e17c39a7c36e4078ae55fc6a25aba0b7457348",
|
||||
"packet_sha256": "88e79568d2845aa646c91d1e211ee97e10c3a7cb43696607c6d56c7da0c5bb8b"
|
||||
},
|
||||
{
|
||||
"sequence_number": 99,
|
||||
"payload_sha256": "c91c2b855495e75e3751b1e4c3abc5525db4d7500b1845d0e9907d8f60ccb2dc",
|
||||
"packet_sha256": "ea7ce09526a21d432e7ae7a9007a9e69d69197f1a6b2c2668cd66c95c1e42688"
|
||||
}
|
||||
],
|
||||
"rf_tx": 0,
|
||||
"sdr_hardware": "NOT_ACCESSED",
|
||||
"status": "PREPARED_NOT_RUN",
|
||||
"iq_sha256": "7cd81395efaa88b623b7e4e5a02e3a80b52185007397ea56ceaacc13353974b2",
|
||||
"shared_spec_test_id": "RF001A_COMMON_CLOCK_001",
|
||||
"payload_size": 256,
|
||||
"expected_sequence_numbers": [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24,
|
||||
25,
|
||||
26,
|
||||
27,
|
||||
28,
|
||||
29,
|
||||
30,
|
||||
31,
|
||||
32,
|
||||
33,
|
||||
34,
|
||||
35,
|
||||
36,
|
||||
37,
|
||||
38,
|
||||
39,
|
||||
40,
|
||||
41,
|
||||
42,
|
||||
43,
|
||||
44,
|
||||
45,
|
||||
46,
|
||||
47,
|
||||
48,
|
||||
49,
|
||||
50,
|
||||
51,
|
||||
52,
|
||||
53,
|
||||
54,
|
||||
55,
|
||||
56,
|
||||
57,
|
||||
58,
|
||||
59,
|
||||
60,
|
||||
61,
|
||||
62,
|
||||
63,
|
||||
64,
|
||||
65,
|
||||
66,
|
||||
67,
|
||||
68,
|
||||
69,
|
||||
70,
|
||||
71,
|
||||
72,
|
||||
73,
|
||||
74,
|
||||
75,
|
||||
76,
|
||||
77,
|
||||
78,
|
||||
79,
|
||||
80,
|
||||
81,
|
||||
82,
|
||||
83,
|
||||
84,
|
||||
85,
|
||||
86,
|
||||
87,
|
||||
88,
|
||||
89,
|
||||
90,
|
||||
91,
|
||||
92,
|
||||
93,
|
||||
94,
|
||||
95,
|
||||
96,
|
||||
97,
|
||||
98,
|
||||
99
|
||||
],
|
||||
"tx_waveform_sha256": "75f3fa39bfa4ee419f0676c54e4b9c486d7ff64bd0fe2fc06343b20c9d117ecd",
|
||||
"tx_waveform_format": "cs8",
|
||||
"fec": "2/3",
|
||||
"gap_ms": 5,
|
||||
"source_provenance": "Generated from deterministic RF-001 seed and verified against configs/rf/rf001a_common_clock.json"
|
||||
}
|
||||
204
docs/handoff/NEXT_RX_TASK.md
Normal file
204
docs/handoff/NEXT_RX_TASK.md
Normal file
@@ -0,0 +1,204 @@
|
||||
TASK_ID = RF001A_OTA_CAPTURE_004
|
||||
TASK_TITLE = Start hardened RF-001A common-clock OTA capture
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = 168e03d9e778ab2ec63502552d385ec7be3ef677
|
||||
REQUIRES_APPROVAL = YES
|
||||
HARDWARE_ACCESS = YES
|
||||
RF_RX = YES
|
||||
RF_TX = NO
|
||||
ALLOW_COMMIT = NO
|
||||
ALLOW_PUSH = RX_RESULT_ONLY
|
||||
RISK_LEVEL = YELLOW
|
||||
|
||||
---
|
||||
|
||||
# RF-001A RX capture 004
|
||||
|
||||
This file is the authoritative current task for the RX laptop. Read
|
||||
`RX_NODE_STATE.md` first. If chat history conflicts with these files or the
|
||||
current Git HEAD, stop and report the conflict.
|
||||
|
||||
This task is prepared only. A previous approval does not apply to this new
|
||||
TASK_ID. Do not access HackRF until the user approves this exact YELLOW task.
|
||||
|
||||
## 1. Software and Git gates
|
||||
|
||||
1. Run `git pull --ff-only origin video-development`.
|
||||
2. Require branch `video-development` and a clean tracked worktree.
|
||||
3. Require local HEAD to equal `origin/video-development`; `EXPECTED_HEAD`
|
||||
must be its ancestor.
|
||||
4. Confirm the historical channel-test READY and COMPLETE acknowledgements
|
||||
from `origin/tx-node` through the RX watcher.
|
||||
5. Before opening HackRF, run the authenticated no-change publication check:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --check-result-write --result-branch rx-node
|
||||
|
||||
Require `RX-NODE WRITE PREFLIGHT = PASS`. This command fetches the branch,
|
||||
performs `git push --dry-run` of its existing HEAD to the same ref, and
|
||||
confirms that the remote HEAD did not change. It creates no commit and no
|
||||
production READY event.
|
||||
6. Run the targeted tests, Ruff, Pyright and `git diff --check` according to
|
||||
the tracked project instructions.
|
||||
7. Validate `configs/rf/rf001a_truth_metadata.json`: waveform SHA256, seed,
|
||||
PHY settings, 100 sequence numbers and 100 packet hashes must agree with
|
||||
`configs/rf/rf001a_common_clock.json`.
|
||||
|
||||
If any gate fails, do not open HackRF and stop. In particular, a failed Git
|
||||
write preflight blocks the hardware task.
|
||||
|
||||
## 2. Optional offline check of capture 003
|
||||
|
||||
If the existing `RF001A_OTA_CAPTURE_003` file is present locally, analyze it
|
||||
read-only with the streaming cs8 decoder before starting new hardware work:
|
||||
|
||||
python -B -X utf8 tools/qpsk_rf_rx.py --iq-file <capture-003-path>\rf001a_ota_capture.cs8 --iq-format cs8 --truth-metadata configs/rf/rf001a_truth_metadata.json --report <new-path-outside-git>\capture003_streaming_report.json
|
||||
|
||||
No RF-001A transmission occurred during capture 003. The expected exact
|
||||
packet count is 0. CRC-valid background candidates must not be reported as
|
||||
exact unless their complete packet SHA256 matches the tracked truth metadata.
|
||||
The analysis must use memmap/chunks and must not materialize the full 1.92 GB
|
||||
capture as complex64.
|
||||
|
||||
If capture 003 is unavailable, report `CAPTURE_003 ANALYSIS = NOT_RUN` and
|
||||
continue only after all mandatory gates pass.
|
||||
|
||||
## 3. Hardware preflight after YELLOW approval
|
||||
|
||||
1. Run `hackrf_info` and require RX serial
|
||||
`000000000000000097c063dc2e5327d3`.
|
||||
2. With `MASTER CLKOUT -> SLAVE CLKIN` connected, run:
|
||||
|
||||
hackrf_clock.exe -d 000000000000000097c063dc2e5327d3 -i
|
||||
|
||||
3. Require `CLKIN status: clock signal detected`.
|
||||
4. Repeat the Git write preflight immediately before the finite RX command.
|
||||
5. Require antennas connected, approximately 2 m distance, clear LOS, and
|
||||
video disabled.
|
||||
6. Require at least 1500000000 free bytes on the capture volume and record the
|
||||
measured value.
|
||||
|
||||
Do not reconfigure clocks. If CLKIN or the second Git preflight fails, stop
|
||||
before capture.
|
||||
|
||||
## 4. Finite RX capture
|
||||
|
||||
Use a unique directory outside Git. Never overwrite an earlier capture.
|
||||
|
||||
RX serial = 000000000000000097c063dc2e5327d3
|
||||
center frequency = 1400000000 Hz
|
||||
sample rate = 8000000 samples/s
|
||||
RX LNA = 24 dB
|
||||
RX VGA = 24 dB
|
||||
RF amp = OFF
|
||||
antenna bias = OFF
|
||||
duration = 60 s
|
||||
sample count = 480000000 complex samples
|
||||
hardware format = cs8
|
||||
expected file size = 960000000 bytes
|
||||
|
||||
Use these fixed `hackrf_transfer.exe` arguments:
|
||||
|
||||
-d 000000000000000097c063dc2e5327d3
|
||||
-r <unique_capture_path>\rf001a_ota_capture.cs8
|
||||
-f 1400000000
|
||||
-s 8000000
|
||||
-n 480000000
|
||||
-l 24
|
||||
-g 24
|
||||
-a 0
|
||||
-p 0
|
||||
-B
|
||||
|
||||
Use `tools/capture_process_runner.ps1` as a controlled background process.
|
||||
Preserve its complete stdout, stderr, start JSON and result JSON outside Git.
|
||||
The runner must start exactly one finite child, record the actual child PID and
|
||||
UTC start time, set the expected end to start plus 60 seconds, wait for that
|
||||
exact process, record its integer exit code, size and duration, and calculate
|
||||
SHA256 after the output file closes. Never retry the capture automatically.
|
||||
|
||||
Within five seconds require the runner and child to be alive and the file to
|
||||
exist and grow. Otherwise publish no READY and stop.
|
||||
|
||||
## 5. READY publication
|
||||
|
||||
Create the strict schema-version-1 READY JSON outside Git for TASK_ID
|
||||
`RF001A_OTA_CAPTURE_004`. Use the actual child PID and UTC timestamps,
|
||||
`expected_capture_end = capture_started_at + 60 s`, current source HEAD,
|
||||
`common_clock_verified = true`, `rf_tx = 0`, and null final result fields.
|
||||
|
||||
Publish it once with:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --publish-result <ready-json> --result-branch rx-node
|
||||
|
||||
The publisher creates one immutable READY commit. On a recognized transient
|
||||
network or authentication failure it checks the remote branch and may retry
|
||||
the exact same commit once. It never creates a second READY commit or changes
|
||||
the payload between attempts. If the remote already contains that commit, the
|
||||
publication is successful. A second failure means:
|
||||
|
||||
READY publication = FAIL
|
||||
RX READY FOR TX = NO
|
||||
RF TX = 0
|
||||
|
||||
Do not publish READY after the capture window has ended. After the process
|
||||
exits, publish only a `CAPTURE_COMPLETE` result with the actual exit code,
|
||||
size, duration and error counters, including nonzero failure values.
|
||||
|
||||
After the remote HEAD confirms the READY commit, immediately print:
|
||||
|
||||
TASK_ID = RF001A_OTA_CAPTURE_004
|
||||
RX CAPTURE STARTED = YES
|
||||
RX READY FOR TX = YES
|
||||
CAPTURE PID =
|
||||
CAPTURE PATH =
|
||||
CAPTURE START TIME =
|
||||
EXPECTED CAPTURE END =
|
||||
RX RESULT HEAD =
|
||||
RF TX = 0
|
||||
|
||||
Return control without waiting for capture completion.
|
||||
|
||||
## 6. Completion and offline decode
|
||||
|
||||
After the child exits, record exact exit code, actual size, duration, overruns,
|
||||
USB errors, file-write errors and SHA256. A complete capture is exactly
|
||||
960000000 bytes; a smaller file is incomplete. Run the CLKIN check again and
|
||||
require detected status.
|
||||
|
||||
Publish an honest schema-version-1 COMPLETE result with final values. Keep IQ,
|
||||
stdout, stderr and large logs outside Git.
|
||||
|
||||
After `origin/tx-node` contains `TX_STARTED` and `TX_COMPLETE` for
|
||||
`RF001A_OTA_TX_004`, run the streaming decoder with RX/TX timestamps:
|
||||
|
||||
python -B -X utf8 tools/qpsk_rf_rx.py --iq-file <capture-path>\rf001a_ota_capture.cs8 --iq-format cs8 --truth-metadata configs/rf/rf001a_truth_metadata.json --capture-started-at <CAPTURE_STARTED_AT> --tx-started-at <TX_STARTED_AT> --tx-finished-at <TX_FINISHED_AT> --report <new-path-outside-git>\rf001a_streaming_report.json
|
||||
|
||||
The decoder uses a two-second margin before and after the timestamped burst.
|
||||
Without complete timestamps it uses the streaming power locator. Chunk overlap
|
||||
is derived from the real 256-byte RF-001 waveform and duplicate detections from
|
||||
overlap are suppressed.
|
||||
|
||||
## 7. Prohibited actions
|
||||
|
||||
RF TX, firmware update, CPLD update, Zadig, USB driver changes, clock
|
||||
reconfiguration, commit or push to `video-development`, repeated capture, raw
|
||||
IQ/video/waveform publication and large-log publication are forbidden.
|
||||
|
||||
Final report:
|
||||
|
||||
TASK_ID = RF001A_OTA_CAPTURE_004
|
||||
RX-NODE WRITE PREFLIGHT = PASS / FAIL
|
||||
COMMON CLOCK = VERIFIED / NOT_VERIFIED
|
||||
RX CAPTURE COMPLETE = YES / NO
|
||||
CAPTURE SIZE =
|
||||
CAPTURE DURATION =
|
||||
RX OVERRUNS =
|
||||
USB ERRORS =
|
||||
FILE WRITE ERRORS =
|
||||
RAW IQ SHA256 =
|
||||
STREAMING DECODE = PASS / FAIL / NOT_RUN
|
||||
EXACT RF001A PACKETS =
|
||||
PEAK ANALYSIS MEMORY =
|
||||
RF TX = 0
|
||||
|
||||
STOP.
|
||||
150
docs/handoff/NEXT_TX_TASK.md
Normal file
150
docs/handoff/NEXT_TX_TASK.md
Normal file
@@ -0,0 +1,150 @@
|
||||
TASK_ID = RF001A_OTA_TX_004
|
||||
TASK_TITLE = Send one finite RF-001A burst into RX capture 004
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = 168e03d9e778ab2ec63502552d385ec7be3ef677
|
||||
REQUIRES_APPROVAL = YES
|
||||
HARDWARE_ACCESS = YES
|
||||
RF_RX = NO
|
||||
RF_TX = YES
|
||||
ALLOW_COMMIT = NO
|
||||
ALLOW_PUSH = TX_RESULT_ONLY
|
||||
RISK_LEVEL = RED
|
||||
|
||||
---
|
||||
|
||||
# RF-001A TX 004
|
||||
|
||||
This task is prepared only. Do not execute it now. Old approval for any earlier
|
||||
TASK_ID is invalid. The MAIN watcher waits for a fresh
|
||||
`RF001A_OTA_CAPTURE_004` READY event from `origin/rx-node`.
|
||||
|
||||
## 1. Watcher gate
|
||||
|
||||
The watcher must require all of the following:
|
||||
|
||||
- RX TASK_ID = `RF001A_OTA_CAPTURE_004`;
|
||||
- state = `RX_READY_FOR_TX`;
|
||||
- source HEAD is the current `video-development` HEAD;
|
||||
- COMMON_CLOCK_VERIFIED = YES;
|
||||
- live child and growing capture are both true;
|
||||
- planned RX duration is 59–61 seconds;
|
||||
- READY is no more than 30 seconds old;
|
||||
- at least 10 seconds remain in the capture window;
|
||||
- the observed commit is the current valid `origin/rx-node` HEAD.
|
||||
|
||||
The watcher then requests exactly:
|
||||
|
||||
AUTHORIZE RF TX
|
||||
|
||||
Any other response blocks TX. After the exact phrase, fetch `origin/rx-node`
|
||||
again and repeat every commit, digest, task, source HEAD, freshness, common
|
||||
clock and remaining-time check. A changed or stale result requires a new RX
|
||||
capture and a new exact RED approval.
|
||||
|
||||
On success publish a schema-version-2 `TX_AUTHORIZED` event to `origin/tx-node`
|
||||
with the exact source RX commit and digest, TX TASK_ID `RF001A_OTA_TX_004`,
|
||||
`RF_TX_AUTHORIZED = YES`, `RF_TX_STARTED = NO` and `RF_TX = 0`. Also write the
|
||||
local authorization record. This event is permission evidence, not proof of a
|
||||
transmission.
|
||||
|
||||
## 2. Read-only TX preflight
|
||||
|
||||
Before opening TX hardware:
|
||||
|
||||
1. Run `git pull --ff-only origin video-development`.
|
||||
2. Require branch `video-development`, a clean tracked worktree and local HEAD
|
||||
equal to `origin/video-development`; `EXPECTED_HEAD` must be its ancestor.
|
||||
3. Run `hackrf_info`; require TX serial
|
||||
`000000000000000097c063dc323b97d3`.
|
||||
4. Require the recorded MASTER CLKOUT command = PASS, both antennas connected,
|
||||
approximately 2 m distance, clear LOS, and video disabled.
|
||||
5. Verify the waveform without radio access:
|
||||
|
||||
python -B -X utf8 tools/rf001a_prepare.py --verify-dir data/raw/rf001/rf001a_common_clock_001
|
||||
|
||||
6. Require:
|
||||
|
||||
waveform path = data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8
|
||||
waveform size = 17667656 bytes
|
||||
waveform SHA256 = 75f3fa39bfa4ee419f0676c54e4b9c486d7ff64bd0fe2fc06343b20c9d117ecd
|
||||
sample count = 8833828 complex samples
|
||||
duration = 1.1042285 s
|
||||
offline exact packets = 100/100
|
||||
|
||||
If any value differs, do not transmit.
|
||||
|
||||
## 3. Final executor gate
|
||||
|
||||
Immediately before hardware access, the controlled executor must read the
|
||||
local authorization and re-fetch `origin/rx-node`. Require:
|
||||
|
||||
- TX TASK_ID = `RF001A_OTA_TX_004`;
|
||||
- RX TASK_ID = `RF001A_OTA_CAPTURE_004`;
|
||||
- authorization phrase = `AUTHORIZE RF TX`;
|
||||
- authorization binds the current READY commit and digest;
|
||||
- `rf_tx_started = false` and `rf_tx = 0` in the authorization record;
|
||||
- at least 10 seconds remain in the same READY window.
|
||||
|
||||
If any check fails, do not open HackRF.
|
||||
|
||||
## 4. One finite TX burst
|
||||
|
||||
Only after all gates pass may the executor create a unique evidence directory
|
||||
outside Git and run exactly one finite command:
|
||||
|
||||
hackrf_transfer.exe
|
||||
-d 000000000000000097c063dc323b97d3
|
||||
-t data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8
|
||||
-f 1400000000
|
||||
-s 8000000
|
||||
-n 8833828
|
||||
-x 20
|
||||
-a 0
|
||||
-p 0
|
||||
-B
|
||||
|
||||
Never add repeat mode and never retry automatically.
|
||||
|
||||
At the exact process start, publish `TX_STARTED` to `origin/tx-node`, preserving
|
||||
the authorization binding and recording `TX_STARTED_AT` as accurately as the
|
||||
host permits. Set `RF_TX_STARTED = YES` and `RF_TX = 1` because this event is
|
||||
evidence of an actual started process.
|
||||
|
||||
Write the strict schema-version-2 event JSON outside Git and publish it with:
|
||||
|
||||
python -B tools/tx_agent_watcher.py --publish-tx-event <started-json> --acknowledgement-branch tx-node
|
||||
|
||||
After that exact process exits, publish `TX_COMPLETE` with `TX_FINISHED_AT`,
|
||||
the integer process exit code, underrun count and error count. Preserve the
|
||||
complete stdout and stderr outside Git. The event reports what happened; it
|
||||
does not by itself prove that packets were received. Captured IQ remains the
|
||||
source for exact packet verification.
|
||||
|
||||
Publish the completion JSON with the same `--publish-tx-event` command. The
|
||||
publisher requires the matching `TX_AUTHORIZED` and `TX_STARTED` history.
|
||||
|
||||
The only files permitted on `tx-node` are:
|
||||
|
||||
docs/handoff/results/TX_RESULT.json
|
||||
docs/handoff/results/TX_RESULT.md
|
||||
|
||||
Do not publish waveform, IQ, video, source code, local state or large logs.
|
||||
|
||||
## 5. Final report
|
||||
|
||||
TASK_ID = RF001A_OTA_TX_004
|
||||
RX READY FOR TX = YES / NO
|
||||
RX RESULT HEAD =
|
||||
RX RESULT DIGEST =
|
||||
TX AUTHORIZED EVENT = PASS / FAIL
|
||||
TX STARTED AT =
|
||||
TX FINISHED AT =
|
||||
TX PROCESS EXIT CODE =
|
||||
TX UNDERRUNS =
|
||||
TX ERRORS =
|
||||
TX COMPLETE EVENT = PASS / FAIL
|
||||
RF TX STOPPED = YES / NO
|
||||
|
||||
STOP after the one finite burst and event publication. Firmware, CPLD, Zadig,
|
||||
USB driver changes, clock reconfiguration, a second burst, commit or push to
|
||||
`video-development` are forbidden.
|
||||
223
docs/handoff/RF001_BOOTSTRAP_20260929_5_ATTEMPT.md
Normal file
223
docs/handoff/RF001_BOOTSTRAP_20260929_5_ATTEMPT.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# RF001 bootstrap 2026.09.29.5
|
||||
|
||||
Дата: 29 сентября 2026 года.
|
||||
|
||||
## Production fix
|
||||
|
||||
Launcher теперь проверяет два допустимых варианта process identity:
|
||||
|
||||
- `DIRECT`: health PID совпадает с `Popen.pid`;
|
||||
- `WINDOWS_VENV_WRAPPER`: health PID принадлежит единственному непосредственному потомку проверенного venv wrapper.
|
||||
|
||||
В обоих вариантах проверяются живой процесс, PID, PPID, creation time, полный путь executable, точная командная строка, release ID, agent version, одноразовый launch nonce и `state=RUNNING`. Перед принятием health process identity проверяется второй раз. Failure cleanup завершает только доказанную lineage и не выбирает процессы по имени.
|
||||
|
||||
Health schema и `rf001_rx_http_agent.py` не изменялись.
|
||||
|
||||
Локальные проверки: `102 passed`. Python compile и PowerShell parse прошли. Ruff и Pyright отсутствуют, поэтому не запускались.
|
||||
|
||||
## Windows integration
|
||||
|
||||
Изолированный запуск на RX использовал настоящий Windows venv, исправленный production launcher, canonical release A и настоящий agent health writer. Scheduled Task и legacy agent не изменялись.
|
||||
|
||||
- `Popen.pid`: 11000;
|
||||
- health PID: 13248;
|
||||
- topology: wrapper 11000 -> direct child 13248;
|
||||
- production `start_release()`: PASS;
|
||||
- strict target identity: PASS;
|
||||
- cleanup: PASS;
|
||||
- orphan: 0;
|
||||
- HTTP task polling: false;
|
||||
- hardware accessed: false;
|
||||
- RF TX: 0.
|
||||
|
||||
Evidence:
|
||||
|
||||
```text
|
||||
C:\Users\Professional\Downloads\SDR_Rover_Diagnostics\launcher_identity_fix_20260929T135321Z\evidence\diagnostic_summary.json
|
||||
```
|
||||
|
||||
## Bootstrap
|
||||
|
||||
- ID: `rf001-bootstrap-2026.09.29.5`;
|
||||
- SHA256: `4833cd22f3f1bd41ae1a847c66f258ccf9d4c6fb4eec5feb2d36e688324c6554`;
|
||||
- размер: 1 506 140 байт;
|
||||
- HMAC: PASS;
|
||||
- manifest-файлы: 17, расхождений 0;
|
||||
- release A: 10 файлов из 10 побайтно совпадают с `.4`, включая `READY.json`;
|
||||
- signed `agent_release_bootstrap_id`: `rf001-bootstrap-2026.09.29.4`;
|
||||
- `.1`, `.2`, `.3` и `.4` не изменялись;
|
||||
- SOURCE/RX SHA256: MATCH.
|
||||
|
||||
## Единственная попытка установки
|
||||
|
||||
`INSTALL_RX_AGENT.cmd` был запущен один раз. Результат: `exit code 1`, fail-closed.
|
||||
|
||||
Фактическая ошибка:
|
||||
|
||||
```text
|
||||
Unexpected file in the bootstrap package: install.stderr.txt
|
||||
```
|
||||
|
||||
Причина доказана. Оркестратор заранее создал `install.stdout.txt` и `install.stderr.txt` внутри распакованного bootstrap-каталога, чтобы перенаправить вывод `cmd.exe`. Installer затем проверил строгий allowlist всех файлов каталога и отверг созданный оркестратором `install.stderr.txt` как посторонний файл. Ошибка возникла до копирования launcher, изменения Scheduled Task и остановки legacy agent.
|
||||
|
||||
Это не ошибка HMAC, manifest, immutable release A или process identity fix. Bootstrap `.5` остался подписанным и прошёл preflight. Повторная установка запрещена текущим STOP-условием. `.6` не создавался.
|
||||
|
||||
Для следующей отдельно разрешённой попытки вывод установщика должен сохраняться вне распакованного bootstrap-каталога, а запуск должен использовать новый чистый каталог распаковки. Текущий загрязнённый каталог не следует использовать повторно.
|
||||
|
||||
## Безопасное состояние после отказа
|
||||
|
||||
- Scheduled Task: `Running`, прежнее legacy action;
|
||||
- active agent: `2026.09.25.1`;
|
||||
- MAIN heartbeat: свежий;
|
||||
- unfinished tasks: 0;
|
||||
- installed launcher SHA256: `fb99882351ee96802d1edd9de947634f2a559e849399c8153a1290688030ed79`, прежний `.4` launcher;
|
||||
- production agent topology: PID 7980 -> PID 17568;
|
||||
- `hackrf_transfer`: 0;
|
||||
- RX capture: 0;
|
||||
- B update: NOT_RUN;
|
||||
- staircase search: NOT_RUN;
|
||||
- new RX: 0;
|
||||
- new RF TX: 0.
|
||||
|
||||
## Отдельно разрешённая попытка 2
|
||||
|
||||
После нового разрешения тот же неизменный ZIP `.5` повторно проверен на MAIN и
|
||||
RX. SHA256 в обоих случаях:
|
||||
|
||||
```text
|
||||
4833cd22f3f1bd41ae1a847c66f258ccf9d4c6fb4eec5feb2d36e688324c6554
|
||||
```
|
||||
|
||||
Первая распаковка с `install.stdout.txt` и `install.stderr.txt` сохранена без
|
||||
изменений: 21 файл до и после второй попытки. Для второй попытки создан новый
|
||||
каталог:
|
||||
|
||||
```text
|
||||
C:\Users\Professional\Downloads\SDR_Rover_Bootstrap\rf001-bootstrap-2026.09.29.5_attempt2_clean
|
||||
```
|
||||
|
||||
До запуска установщика получены следующие результаты:
|
||||
|
||||
- HMAC: PASS;
|
||||
- manifest: 17/17;
|
||||
- файлов в пакете с двумя файлами аутентификации: 19/19;
|
||||
- unexpected: 0;
|
||||
- missing: 0;
|
||||
- mismatch: 0;
|
||||
- release A: 10/10;
|
||||
- `INSTALL_RX_AGENT.cmd`: присутствует;
|
||||
- canonical release A: 10/10, расхождений 0;
|
||||
- active task: отсутствует;
|
||||
- unfinished tasks: 0;
|
||||
- unfinished hardware tasks: 0;
|
||||
- `hackrf_transfer`: 0;
|
||||
- RX capture process: 0.
|
||||
|
||||
Все runtime-файлы второй попытки сохранены вне bootstrap-каталога:
|
||||
|
||||
```text
|
||||
C:\Users\Professional\AppData\Local\SDR_Rover_RX_Agent\install_logs\bootstrap_2026.09.29.5_attempt2_20260929T150351603Z
|
||||
```
|
||||
|
||||
Установщик запущен ровно один раз из чистого каталога с явным рабочим
|
||||
каталогом. Результат: `exit code 0`, stderr пуст. Дерево пакета сразу после
|
||||
старта и после завершения побайтно не изменилось; timestamps также совпали.
|
||||
Карантин из 11 файлов и первая загрязнённая распаковка не изменились.
|
||||
|
||||
После установки Scheduled Task запускает versioned launcher. Release A
|
||||
`2026.09.29.1` сообщил свежий MAIN heartbeat, launcher status перешёл в
|
||||
`RUNNING / TARGET_HEALTHY`, active task отсутствовал. Legacy-процессов: 0.
|
||||
Наблюдаемая цепочка Windows состояла из launcher wrapper, его Python-потомка,
|
||||
agent wrapper и единственного agent child с одним launch nonce. Это одна
|
||||
production lineage, а не два экземпляра агента.
|
||||
|
||||
## Обновление A -> B
|
||||
|
||||
Пакет `rf001-agent-2026.09.29.2.zip` проверен до постановки задачи:
|
||||
|
||||
- SHA256: `225a032a927b672343f4ccf7a0ef0662f462e1ec45782c0437ba50ab73bac203`;
|
||||
- release ID: `rf001-agent-2026.09.29.2`;
|
||||
- agent version: `2026.09.29.2`;
|
||||
- внутренний manifest: 9 файлов, расхождений 0;
|
||||
- зарегистрированный HMAC: PASS.
|
||||
|
||||
Обновление выполнено только через подписанный HTTP-канал:
|
||||
|
||||
```text
|
||||
UPDATE_20260929T151002Z_8B82A943
|
||||
```
|
||||
|
||||
Задача завершилась со статусом `COMPLETE`, self-test вернул 0. Launcher
|
||||
переключился на B, active release и свежий MAIN heartbeat показывают
|
||||
`2026.09.29.2`, launcher status: `RUNNING / TARGET_HEALTHY`. Rollback не
|
||||
использовался. SCP/SSH-установка B не выполнялась.
|
||||
|
||||
## Полный поиск лестницы
|
||||
|
||||
После подтверждения B поставлена одна задача:
|
||||
|
||||
```text
|
||||
STAIRCASE_20260929T151048Z_EF3AA9DE
|
||||
```
|
||||
|
||||
Итог:
|
||||
|
||||
- session: `RF001A_20260925T125829Z_227EADB4`;
|
||||
- coverage: 0...2 400 000 000 комплексных отсчётов, 300,0 с;
|
||||
- RX SHA256: подтверждён;
|
||||
- reference SHA256: подтверждён;
|
||||
- result: `NOT_DETECTED`;
|
||||
- лучший кандидат: sample 474 212 320, 59,27654 с;
|
||||
- CFO: -3,0 Гц;
|
||||
- candidate score: 0,009649134828942169;
|
||||
- contrast to background p99: 4,052909297771804 дБ, ниже порога;
|
||||
- spectral contrast: 3,6405801238037765 дБ;
|
||||
- step pattern: FAIL;
|
||||
- наблюдаемые ступени: +2,879031, -2,205885 и +15,541302 дБ при ожидаемых
|
||||
+6,0 дБ;
|
||||
- maximum absolute step error: 9,54130207648743 дБ;
|
||||
- analysis time: 263,3271906 с;
|
||||
- peak additional memory: 496 549 888 байт;
|
||||
- peak process memory: 536 199 168 байт;
|
||||
- hardware accessed: false;
|
||||
- RF TX: 0.
|
||||
|
||||
Причины вердикта: корреляция ниже порога, контраст пика к фону ниже порога,
|
||||
структура ступеней или спектральное подтверждение не прошли. Пороги и алгоритм
|
||||
не менялись, повтор задачи не выполнялся.
|
||||
|
||||
Отчёт на RX:
|
||||
|
||||
```text
|
||||
C:\Users\Professional\Documents\SDR_Rover_Runtime\rx_capture\RF001A_20260925T125829Z_227EADB4\staircase_search_report.json
|
||||
SHA256=b34ad21ae70a4290a0394a3ee3317bdd560e43dbe266aa3d7e1ce1c967c11dd7
|
||||
```
|
||||
|
||||
Результат принят MAIN и сохранён в
|
||||
`.local/rf001_http_server_state.json` под указанным task ID.
|
||||
|
||||
## Исправление MAIN-оркестрации
|
||||
|
||||
Подписанный `.5` не изменялся. На MAIN добавлена отдельная утилита
|
||||
`tools/rf001_bootstrap_install_orchestrator.py`. Она до запуска проверяет HMAC,
|
||||
manifest, SHA256 и точный allowlist, запрещает каталог доказательств внутри
|
||||
package tree, перенаправляет stdout/stderr только наружу и сравнивает снимки
|
||||
пакета сразу после старта и после завершения.
|
||||
|
||||
Регрессии в `tests/test_rf001_bootstrap_install_orchestrator.py` проверяют:
|
||||
|
||||
- чистый пакет до запуска;
|
||||
- внешние stdout/stderr;
|
||||
- неизменность package tree;
|
||||
- успешный точный allowlist;
|
||||
- fail-closed для пакета с посторонним файлом;
|
||||
- отказ от evidence-каталога внутри пакета.
|
||||
|
||||
Итоговый RF001-набор: `122 passed`. Полный набор репозитория: `1296 passed`,
|
||||
`4 failed`, `2 skipped`. Четыре сбоя находятся вне изменённого контура: два
|
||||
старых теста `capture_process_runner.ps1` получили `sha256=null`, один тест
|
||||
требует отсутствующий `data/raw/lab064/tx_metadata.json`, ещё один требует
|
||||
недоступный Numba backend. Новая утилита компилируется; её отдельные регрессии:
|
||||
`4 passed`.
|
||||
|
||||
Новый bootstrap `.6` для этого исправления не требуется.
|
||||
310
docs/handoff/RF001_HTTP_CONTROL.md
Normal file
310
docs/handoff/RF001_HTTP_CONTROL.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# HTTP-канал управления RF-001
|
||||
|
||||
Текущее состояние: **HTTP-КАНАЛ РАБОТАЕТ; ВЕРСИОННЫЙ МЕХАНИЗМ ОБНОВЛЕНИЯ
|
||||
И НЕБЛОКИРУЮЩИЙ `STAIRCASE_SEARCH` ПРОВЕРЕНЫ БЕЗ НОВОГО ДОСТУПА К РАДИО**.
|
||||
|
||||
Канал заменяет Git как транспорт между работающими компьютерами. Git остаётся
|
||||
для исходного кода и истории. Старые watcher-ы сохранены как резервный вариант.
|
||||
|
||||
## Граница этапа 1
|
||||
|
||||
Реализованы:
|
||||
|
||||
- JSON-сервер на основном ПК;
|
||||
- исходящее подключение RX-агента с ноутбука к основному ПК;
|
||||
- подпись каждого запроса HMAC-SHA256;
|
||||
- защита от повторного использования запроса по времени и nonce;
|
||||
- ограничение адреса RX-агента на сервере;
|
||||
- фиксированные задачи `INVENTORY`, `OFFLINE_VERIFY`, `RX_CAPTURE_START`,
|
||||
`RX_CAPTURE_STATUS`, `CAPTURE_DECODE`, `CAPTURE_DIAGNOSTICS` и
|
||||
`STAIRCASE_SEARCH` и `UPDATE_AGENT`;
|
||||
- чтение `hostname`, `whoami`, состояния Git и `hackrf_info`;
|
||||
- сохранение результата на основном ПК;
|
||||
- обязательное поле `rf_tx = 0` во всех сообщениях.
|
||||
|
||||
На RX-ноутбуке 23 сентября 2026 года автономная проверка завершилась так:
|
||||
|
||||
- ветка `video-development`;
|
||||
- HEAD `a94f2db4ee8377201a97263166cb09431df5f5d6`;
|
||||
- 100 CRC-корректных пакетов из 100;
|
||||
- 100 точных пакетов из 100;
|
||||
- `hardware_accessed = false`;
|
||||
- ошибок нет;
|
||||
- `rf_tx = 0`.
|
||||
|
||||
В сеансе `RF001A_20260925T125829Z_227EADB4` уже были выполнены:
|
||||
|
||||
- один конечный RX-захват с фиксированным профилем;
|
||||
- проверка READY по фактическому PID, обнаруженному CLKIN и растущему файлу;
|
||||
- чтение итогового состояния захвата;
|
||||
- потоковое декодирование принятого IQ;
|
||||
- полный потоковый поиск эталонной лестницы без обращения к SDR.
|
||||
|
||||
Новые захваты и передачи не входят в текущее состояние и требуют отдельного
|
||||
явного разрешения. Завершённый поиск лестницы сохраняет исходный результат
|
||||
`NOT_DETECTED`; последующая диагностика должна записываться отдельно и не
|
||||
подменять этот результат.
|
||||
|
||||
Не входят в управляющий канал:
|
||||
|
||||
- запуск `hackrf_transfer` в режиме TX;
|
||||
- команда или сетевой метод RF TX;
|
||||
- изменение firmware, CPLD, USB-драйвера или Zadig;
|
||||
- commit и push.
|
||||
|
||||
Для сервера и RX-агента подготовлен пользовательский автозапуск через
|
||||
Планировщик заданий Windows. Службы Windows и права администратора для этого не
|
||||
нужны. Сервер и агент работают постоянно после входа соответствующего
|
||||
пользователя в Windows. Агент не содержит произвольной shell-команды.
|
||||
|
||||
## Схема
|
||||
|
||||
```text
|
||||
ОСНОВНОЙ ПК 192.168.255.185:8765
|
||||
HTTP JSON server
|
||||
очередь только фиксированных типов заданий
|
||||
^
|
||||
| исходящее соединение с подписью запроса
|
||||
|
|
||||
RX-НОУТБУК 192.168.255.56
|
||||
polling agent
|
||||
hostname / whoami / Git / hackrf_info / offline 100/100
|
||||
RF TX = 0
|
||||
```
|
||||
|
||||
Сервер не раздаёт каталоги и произвольные пути. Он может выдать только заранее
|
||||
зарегистрированный архив агента с точным `release_id` через ограниченный маршрут
|
||||
обновлений. Произвольной shell-команды в протоколе нет. Неизвестный тип задания
|
||||
и любое значение `rf_tx`, отличное от нуля, отклоняются.
|
||||
|
||||
## Файлы
|
||||
|
||||
- `tools/rf001_http_control.py`: сервер, локальная очередь и клиент управления;
|
||||
- `tools/rf001_rx_http_agent.py`: RX-агент с фиксированной инвентаризацией;
|
||||
- `tools/rf001_offline_decode.py`: фиксированный программный `legacy`-декодер;
|
||||
- `tools/rf001_staircase_search.py`: потоковый поиск эталонной лестницы без
|
||||
обращения к SDR;
|
||||
- `tools/rf001_staircase_job.py`: фиксированный дочерний процесс длительного
|
||||
поиска и сохранение результата до сетевого подтверждения;
|
||||
- `tools/rf001_agent_update.py`: строгая проверка состава и распаковка
|
||||
версионного архива;
|
||||
- `tools/rf001_agent_launcher.py`: стабильный запускатель, переключение версии
|
||||
и локальный откат при неудачном запуске;
|
||||
- `tools/rf001_build_agent_packages.py`: воспроизводимая сборка выпусков и
|
||||
первоначального пакета без включения секрета;
|
||||
- `tools/rf001_rx_capture.py`: жёсткие RX-параметры и проверки READY/COMPLETE;
|
||||
- `tools/rf001_capture_runner.py`: один конечный фоновый RX-процесс и журнал;
|
||||
- `.local/rf001_http_token.txt`: общий секрет вне Git;
|
||||
- `.local/rf001_http_server_state.json`: локальное состояние вне Git.
|
||||
|
||||
## Подготовка общего секрета
|
||||
|
||||
На основном ПК из корня проекта один раз выполнить:
|
||||
|
||||
```powershell
|
||||
python -B tools\rf001_http_control.py init-token
|
||||
```
|
||||
|
||||
Команда не перезаписывает существующий секрет. Файл
|
||||
`.local\rf001_http_token.txt` надо скопировать на RX-ноутбук по доверенному
|
||||
каналу и не добавлять в Git. Сервер и агент должны читать одинаковое содержимое.
|
||||
|
||||
## Запуск сервера на основном ПК
|
||||
|
||||
```powershell
|
||||
python -B tools\rf001_http_control.py serve `
|
||||
--bind 192.168.255.185 `
|
||||
--port 8765 `
|
||||
--allowed-agent-ip 192.168.255.56
|
||||
```
|
||||
|
||||
Ожидаемый стартовый вывод:
|
||||
|
||||
```text
|
||||
SERVER = http://192.168.255.185:8765
|
||||
ALLOWED AGENT IP = 192.168.255.56
|
||||
TASK KINDS = INVENTORY, OFFLINE_VERIFY, RX_CAPTURE_START, RX_CAPTURE_STATUS, CAPTURE_DECODE, CAPTURE_DIAGNOSTICS, STAIRCASE_SEARCH, UPDATE_AGENT
|
||||
RF TX = 0
|
||||
```
|
||||
|
||||
Действующее правило Windows Firewall должно оставаться ограниченным программой
|
||||
Python, TCP-портом `8765`, локальным адресом `192.168.255.185`, удалённым
|
||||
адресом `192.168.255.56` и беспроводным интерфейсом.
|
||||
|
||||
## Постановка задания на основном ПК
|
||||
|
||||
```powershell
|
||||
python -B tools\rf001_http_control.py enqueue-inventory --agent-id user-pc
|
||||
```
|
||||
|
||||
Это локальная операция. Сервер принимает её только от собственного адреса и с
|
||||
правильной подписью. Задание не содержит произвольной команды.
|
||||
|
||||
## Автозапуск агента на RX-ноутбуке
|
||||
|
||||
Первоначальный пакет содержит `INSTALL_RX_AGENT.cmd`. После распаковки его надо
|
||||
запустить один раз. Установщик проверяет HMAC-манифест существующим секретом,
|
||||
размеры и SHA256 всех файлов, сохраняет прежнюю установку и XML задания, а
|
||||
затем меняет только действие существующего пользовательского задания
|
||||
`SDR Rover RX Agent`. Пользователь, триггеры, параметры и уровень прав задания
|
||||
сохраняются. Ожидаемый итог:
|
||||
|
||||
```text
|
||||
BOOTSTRAP_INSTALL=COMPLETE
|
||||
TASK_STATE=Running
|
||||
AGENT_VERSION=2026.09.29.1
|
||||
ARBITRARY_SHELL=DISABLED
|
||||
RF_TX=0
|
||||
```
|
||||
|
||||
После этого ручной запуск на ноутбуке для штатных обновлений не требуется.
|
||||
Запускатель хранит выпуски в отдельных каталогах, проверяет запуск по PID,
|
||||
версии и одноразовой метке и при сбое возвращается к предыдущему локальному
|
||||
выпуску. Очередь MAIN, IQ, эталоны, результаты, конфигурация и секрет находятся
|
||||
вне каталога конкретной версии.
|
||||
|
||||
## Модель доверия обновлений
|
||||
|
||||
Манифест каждого выпуска подписывается `HMAC-SHA256` из стандартной библиотеки
|
||||
Python тем же общим секретом, которым уже подписываются HTTP-запросы. Подпись
|
||||
охватывает отдельный домен `RF001-UPDATE-MANIFEST-V1`, `release_id`, версию
|
||||
агента, совместимую версию протокола, размер и SHA256 архива и хеш внутреннего
|
||||
манифеста. Разрешение MAIN дополнительно привязано к `agent_id` и сроку
|
||||
действия. Сам секрет в ZIP не включается и при установке не заменяется.
|
||||
|
||||
Ограничение модели: общий симметричный секрет находится и на MAIN, и на
|
||||
RX-ноутбуке. Компрометация одного из этих узлов позволяет создавать корректные
|
||||
HMAC-подписи. Аппаратного корня доверия и независимой издательской подписи у
|
||||
этого контура нет.
|
||||
|
||||
Перед переключением агент скачивает архив не более двух раз за одну
|
||||
сохранённую попытку обновления, проверяет подпись, размер, SHA256, точный список
|
||||
файлов, хеш каждого файла, допустимость путей, предел распакованного объёма,
|
||||
совместимость и свободное место. Затем выполняется программная самопроверка без
|
||||
SDR. Если агент занят локальной задачей, обновление остаётся в состоянии
|
||||
`WAITING_FOR_IDLE`. Наличие файлов само по себе не считается установкой:
|
||||
фактическую версию MAIN получает из следующего сообщения работающего агента.
|
||||
|
||||
## Поиск эталонной лестницы
|
||||
|
||||
`STAIRCASE_SEARCH` принимает только идентификатор существующего сеанса и
|
||||
ожидаемые SHA256 RX-файла и эталона. Агент сам строит пути внутри
|
||||
`%USERPROFILE%\Documents\SDR_Rover_Runtime`: запись берётся из каталога
|
||||
`rx_capture`, а эталон — из отдельного каталога `references`. Сетевой запрос не
|
||||
может передать путь или команду.
|
||||
|
||||
Перед запуском агент проверяет оба SHA256. Отчёт сохраняется рядом с записью
|
||||
под именем `staircase_search_report.json`. Если подходящий отчёт уже существует,
|
||||
он проверяется и возвращается без повторного анализа и без перезаписи. Для
|
||||
задачи всегда заданы `hardware_accessed = false`, `rf_tx = 0` и
|
||||
`automatic_retry = false`.
|
||||
|
||||
Поиск выполняется отдельным фиксированным процессом. Агент продолжает опрос
|
||||
сервера и сообщает фактическую фазу, число обработанных комплексных отсчётов и
|
||||
общий объём. Эти значения берутся из потокового анализатора, а не оцениваются
|
||||
по времени. Активная аренда продлевается даже после истечения обычного короткого
|
||||
тайм-аута. Указатель задачи записывается до запуска процесса, поэтому перезапуск
|
||||
агента не создаёт второй анализатор. Готовый результат остаётся на диске до
|
||||
подтверждения MAIN и после обрыва сети отправляется без повторного вычисления.
|
||||
|
||||
## Диагностический однократный запуск агента
|
||||
|
||||
Команду выполнять из фактического клона `SDR_Rover_Video` на ноутбуке. Пути к
|
||||
репозиторию, секрету и `hackrf_info.exe` надо подставить по результатам
|
||||
инвентаризации ноутбука:
|
||||
|
||||
```powershell
|
||||
python -B tools\rf001_rx_http_agent.py `
|
||||
--server http://192.168.255.185:8765 `
|
||||
--token-file <путь-к-rf001_http_token.txt> `
|
||||
--agent-id user-pc `
|
||||
--repository <путь-к-SDR_Rover_Video> `
|
||||
--hackrf-info <путь-к-hackrf_info.exe> `
|
||||
--hackrf-clock <путь-к-hackrf_clock.exe> `
|
||||
--hackrf-transfer <путь-к-hackrf_transfer.exe> `
|
||||
--once
|
||||
```
|
||||
|
||||
Без `--once` агент работает постоянно. Этот ручной режим нужен только для
|
||||
диагностики; при установленном автозапуске его не используют.
|
||||
|
||||
## Просмотр результата на основном ПК
|
||||
|
||||
```powershell
|
||||
python -B tools\rf001_http_control.py status
|
||||
```
|
||||
|
||||
Для завершённой инвентаризации сервер показывает состояние задания
|
||||
`COMPLETE` или `FAILED`, фактические результаты команд и `rf_tx = 0`.
|
||||
|
||||
## Автономная проверка 100/100
|
||||
|
||||
После подтверждения одинакового HEAD на обоих компьютерах основной ПК ставит
|
||||
фиксированное программное задание:
|
||||
|
||||
```powershell
|
||||
python -B tools\rf001_http_control.py enqueue-offline-verify `
|
||||
--agent-id user-pc `
|
||||
--expected-head a94f2db4ee8377201a97263166cb09431df5f5d6
|
||||
```
|
||||
|
||||
Агент сверяет ветку и HEAD чтением `.git`, поэтому `git.exe` для этой проверки
|
||||
не нужен. Затем он запускает только два заранее заданных проектных сценария:
|
||||
создание детерминированной RF-001A волны и её программное декодирование через
|
||||
встроенный `legacy`-декодер, не требующий Numba. Рабочие
|
||||
файлы сохраняются вне Git в `Documents\SDR_Rover_Runtime\offline_verify`.
|
||||
Успех требует ровно 100 CRC-корректных и 100 точных пакетов. HackRF при этом не
|
||||
открывается, `hardware_accessed = false`, `rf_tx = 0`.
|
||||
|
||||
## Подготовленный RX-захват
|
||||
|
||||
Сервер не примет постановку RX-захвата без точной локальной фразы
|
||||
`AUTHORIZE RF RX`. До отдельного разрешения эту команду не выполнять:
|
||||
|
||||
```powershell
|
||||
python -B tools\rf001_http_control.py enqueue-rx-capture `
|
||||
--agent-id user-pc `
|
||||
--expected-head a94f2db4ee8377201a97263166cb09431df5f5d6 `
|
||||
--authorize-rx "AUTHORIZE RF RX"
|
||||
```
|
||||
|
||||
Агент перед захватом проверяет:
|
||||
|
||||
- ветку и HEAD без обязательного наличия `git.exe`;
|
||||
- отсутствие конфликтующих SDR-процессов;
|
||||
- серийный номер RX `000000000000000097c063dc2e5327d3`;
|
||||
- строку `clock signal detected` от `hackrf_clock.exe`;
|
||||
- не менее `6000000000` свободных байт;
|
||||
- неизменность частоты `1400000000` Гц, частоты дискретизации `8000000`
|
||||
отсчётов/с, LNA/VGA `24/24` дБ, выключенных RF amp и antenna bias.
|
||||
|
||||
Для ручного барьера TX захват длится 300 секунд: `2400000000` комплексных
|
||||
отсчётов, ожидаемый файл `4800000000` байт. Значение 20 секунд в исходном
|
||||
профиле проверяется как часть исходных данных, но управляющий контур расширяет
|
||||
окно до 300 секунд. Это зафиксировано в журнале сеанса. Автоматического повтора
|
||||
нет.
|
||||
|
||||
Фоновый процесс запускает фиксированный Python-runner. Старый
|
||||
`capture_process_runner.ps1` и Git watcher-ы не изменены и остаются резервом.
|
||||
|
||||
Состояние конкретного сеанса запрашивается отдельной фиксированной задачей:
|
||||
|
||||
```powershell
|
||||
python -B tools\rf001_http_control.py enqueue-rx-status `
|
||||
--agent-id user-pc `
|
||||
--session-id <SESSION_ID>
|
||||
```
|
||||
|
||||
TX разрешён только при свежем результате `READY`, открытом окне захвата и после
|
||||
точной отдельной фразы `AUTHORIZE RF TX`. В сетевом протоколе операции TX нет.
|
||||
|
||||
После завершения захвата сервер может поставить потоковое декодирование с тремя
|
||||
фактическими временами. Отчёт и IQ остаются на RX-ноутбуке вне Git.
|
||||
|
||||
## Текущее продолжение
|
||||
|
||||
На RX-ноутбуке работает версия агента `2026.09.29.2`; штатное обновление через
|
||||
подписанный HTTP-канал и полный `STAIRCASE_SEARCH` завершены. Дальнейшая работа
|
||||
с существующим сеансом является только потоковой офлайн-диагностикой сохранённой
|
||||
IQ-записи. Новый RX-захват и любой RF TX требуют нового отдельного разрешения;
|
||||
разрешения, использованные в завершённом сеансе, повторно не применяются.
|
||||
157
docs/handoff/RF001_LAUNCHER_HEALTH_DIAGNOSTIC.md
Normal file
157
docs/handoff/RF001_LAUNCHER_HEALTH_DIAGNOSTIC.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# RF001 launcher/health diagnostic
|
||||
|
||||
Дата проверки: 29 сентября 2026 года.
|
||||
|
||||
## Ограничения проверки
|
||||
|
||||
- Производственный RX-агент не останавливался и не изменялся.
|
||||
- Задание Планировщика Windows не изменялось.
|
||||
- Диагностический процесс не обращался к HTTP-очереди.
|
||||
- Доступ к SDR не выполнялся: `hardware_accessed=false`, `RF RX=0`, `RF TX=0`.
|
||||
- Bootstrap `rf001-bootstrap-2026.09.29.5` не создавался; `.4` повторно не запускался.
|
||||
- Clean release A `rf001-agent-2026.09.29.1` использовался только для чтения кода health writer и проверки `READY.json`/manifest.
|
||||
|
||||
## Безопасное состояние после проверки
|
||||
|
||||
- Активный производственный агент: `2026.09.25.1`; heartbeat на MAIN свежий, active task отсутствует.
|
||||
- Незавершённых задач: 0. Незавершённых аппаратных задач: 0.
|
||||
- Задание `SDR Rover RX Agent`: `Running`; его действие по-прежнему запускает legacy agent, а не release A.
|
||||
- Производственная цепочка legacy agent на RX: venv wrapper PID 7980 -> базовый Python PID 17568.
|
||||
- `hackrf_transfer.exe`: 0 процессов. Процессов RX capture: 0.
|
||||
- Диагностических процессов: 0.
|
||||
- Clean release A: 10 файлов из 10 совпадают с `versions/rf001-agent-2026.09.29.1` внутри подписанного bootstrap `.4` по относительному пути, размеру и SHA256.
|
||||
- Conflict quarantine сохранён. SHA256 его diagnostic manifest: `b92f15dd4b7cffef375fd108f454a41623ed7328220c73c3f2b847dc59e956bc`.
|
||||
- `rf001-bootstrap-2026.09.29.5.zip` отсутствует.
|
||||
|
||||
## Доказанная первопричина
|
||||
|
||||
Два последовательных изолированных запуска через настоящий `start_release()` дали одинаковый результат. Поля `release_id`, `agent_version`, `launch_nonce` и `state` совпали. Единственным несовпавшим полем стало `health.pid == Popen.pid`.
|
||||
|
||||
На проверенном Windows/venv способе запуска `Popen.pid` принадлежит процессу `venv\\Scripts\\pythonw.exe`, а фактический интерпретатор, который исполняет health writer, является его единственным прямым дочерним процессом с образом базового `Python313\\python.exe`. Связь установлена не по именам: сверены PID, PPID, время создания, полный путь образа, командная строка и одноразовый `launch_nonce`.
|
||||
|
||||
### Запуск 1
|
||||
|
||||
- Launcher процесса диагностики: PID 7848.
|
||||
- `Popen.pid`: 10284, образ `C:\\Users\\Professional\\Documents\\Codex\\SDR_Rover_Video\\.venv\\Scripts\\pythonw.exe`, PPID 7848, создан `2026-09-29T13:04:44.696889Z`.
|
||||
- Health writer: PID 8452, образ `C:\\Users\\Professional\\AppData\\Local\\Programs\\Python\\Python313\\python.exe`, PPID 10284, создан `2026-09-29T13:04:44.702070Z`.
|
||||
- Health обновлён `2026-09-29T16:04:44.866156+03:00`.
|
||||
- Wrapper уже наблюдался 0,167845 с и отсутствовал к завершению прогона через 2,589228 с после создания.
|
||||
- Agent child уже наблюдался 0,164086 с и отсутствовал к завершению прогона через 2,585469 с после создания.
|
||||
- Точный момент завершения каждого процесса не опрашивался, поэтому приведены проверенные нижняя и верхняя границы, а не предполагаемая длительность.
|
||||
|
||||
### Запуск 2
|
||||
|
||||
- Launcher процесса диагностики: PID 7848.
|
||||
- `Popen.pid`: 3504, образ `C:\\Users\\Professional\\Documents\\Codex\\SDR_Rover_Video\\.venv\\Scripts\\pythonw.exe`, PPID 7848, создан `2026-09-29T13:04:47.290751Z`.
|
||||
- Health writer: PID 14616, образ `C:\\Users\\Professional\\AppData\\Local\\Programs\\Python\\Python313\\python.exe`, PPID 3504, создан `2026-09-29T13:04:47.295668Z`.
|
||||
- Health обновлён `2026-09-29T16:04:47.461326+03:00`.
|
||||
- Wrapper уже наблюдался 0,169277 с и отсутствовал к завершению прогона через 2,574777 с после создания.
|
||||
- Agent child уже наблюдался 0,165658 с и отсутствовал к завершению прогона через 2,571158 с после создания.
|
||||
- Точный момент завершения каждого процесса не опрашивался.
|
||||
|
||||
Оба `launch_nonce` различались:
|
||||
|
||||
- `96acc3af17150f42d393238bcbdad3ff`;
|
||||
- `e4a618547e4bcda4b6914bf81bbd7963`.
|
||||
|
||||
В обоих случаях штатный `start_release()` завершился fail-closed:
|
||||
|
||||
```text
|
||||
RuntimeError: health file does not match the launched process
|
||||
```
|
||||
|
||||
После каждого прогона процессов, принадлежащих диагностическому запуску, не осталось. Исходные health-файлы и снимки процессов сохранены на RX в:
|
||||
|
||||
```text
|
||||
C:\Users\Professional\Downloads\SDR_Rover_Diagnostics\launcher_health_20260929T130000Z\evidence
|
||||
```
|
||||
|
||||
## Сравнение полей health
|
||||
|
||||
| Поле | Запуск 1 | Запуск 2 |
|
||||
|---|---:|---:|
|
||||
| `release_id == rf001-agent-2026.09.29.1` | PASS | PASS |
|
||||
| `agent_version == 2026.09.29.1` | PASS | PASS |
|
||||
| `launch_nonce` | PASS | PASS |
|
||||
| `state == RUNNING` | PASS | PASS |
|
||||
| `health.pid == Popen.pid` | FAIL: 8452 != 10284 | FAIL: 14616 != 3504 |
|
||||
|
||||
Следовательно, `Popen.pid` соответствует варианту C: это родитель фактического процесса агента. Он не является произвольным или посторонним процессом; это venv-обёртка с ожидаемой командной строкой, которая существует одновременно с прямым дочерним health writer.
|
||||
|
||||
## Строгая модель идентичности
|
||||
|
||||
Основным вариантом по-прежнему должен быть запуск, при котором `Popen.pid` принадлежит фактическому долгоживущему агенту. Для проверенной Windows/venv топологии, где это условие не выполняется, допустима только следующая строгая альтернатива:
|
||||
|
||||
1. Сохранить точные проверки `release_id`, `agent_version`, одноразового `launch_nonce` и `state=RUNNING`.
|
||||
2. Снять идентичность процесса `Popen` сразу после запуска: PID, путь образа, командную строку и время создания.
|
||||
3. Если health PID отличается, принять только один живой прямой дочерний процесс этого конкретного wrapper.
|
||||
4. Для дочернего процесса проверить время создания после начала данного запуска, точную команду с тем же agent entrypoint и nonce, ожидаемый путь интерпретатора и наличие процесса в момент принятия health.
|
||||
5. Не принимать внука, произвольного потомка, самый новый `python.exe`, совпадение только по имени или один числовой PID.
|
||||
6. При завершении или откате повторно сверить PID и время создания перед остановкой wrapper и строго связанных с ним потомков. Это закрывает повторное использование PID.
|
||||
|
||||
Минимальное производственное исправление можно ограничить `rf001_agent_launcher.py`: добавить проверку уникального прямого потомка и такую же строгую очистку потомков при ошибке. Формат health менять не требуется, поскольку в нём уже есть nonce и собственный PID. Если получение надёжного времени создания и пути образа без дополнительного компонента окажется невозможно, health writer следует расширить полями process identity; отключать проверку PID нельзя.
|
||||
|
||||
## Конечный автомат launcher
|
||||
|
||||
Названия ниже описывают фактические фазы кода. Не все они сейчас записываются как отдельное значение в `launcher_status.json`.
|
||||
|
||||
| Фаза | Допустимый вход | Процессы | Canonical release / active pointer | Scheduled Task | Health | Действие при ошибке |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `LEGACY_ACTIVE` | Действующий агент здоров и idle | Legacy agent запущен | Указатель на legacy | Launcher не меняет определение задания | Legacy `RUNNING` | Запрос обновления принимается только при `safe_to_switch=true`, совпадающем `from_release_id` и idle health |
|
||||
| `TARGET_VERIFIED` | Подписанный target прошёл `validate_ready_release()` | Новый процесс ещё не создан | Target уже существует в canonical releases; указатель ещё legacy | Без изменений | Legacy health | Отказ до остановки legacy |
|
||||
| `STARTING_TARGET` | Target проверен | Legacy останавливается; создаётся wrapper target | Указатель атомарно меняется на target; status `STARTING_UPDATE` | Без изменений | Старый health больше не считается подтверждением | Переход в `TARGET_FAILED` |
|
||||
| `WAITING_FOR_HEALTH` | `Popen` создан, nonce уникален | Wrapper и возможный прямой child | Указатель target | Без изменений | Ожидаются свежие точные поля и строгая process identity | Timeout, exit или mismatch завершают target fail-closed |
|
||||
| `TARGET_HEALTHY` | Все health-поля и identity прошли | Target продолжает работу | Указатель target; status `RUNNING` | Без изменений | Target `RUNNING` | Дальнейший выход ведёт в `RESTARTING` |
|
||||
| `TARGET_FAILED` | Startup exit, timeout или mismatch | Target должен быть остановлен без orphan | Target ещё был активным указателем | Без изменений | Ошибочный health не принимается | Начать rollback |
|
||||
| `ROLLING_BACK` | Target не прошёл health gate | Target очищается, запускается legacy | Указатель возвращается на legacy | Без изменений | Для legacy требуется новый nonce и новый health | Ошибка rollback должна оставаться явной, а не считаться успехом |
|
||||
| `LEGACY_RESTORED` | Legacy снова прошёл health gate | Legacy работает | Указатель legacy; status `ROLLED_BACK` | Без изменений | Свежий legacy `RUNNING` | Архивировать запрос и продолжить legacy |
|
||||
|
||||
Существенный пробел текущего production-кода: `TARGET_FAILED` останавливает только объект `Popen`; общая строгая очистка потомка в launcher пока не реализована. В двух диагностических запусках orphan не осталось, но это наблюдение не заменяет будущую явную очистку по identity.
|
||||
|
||||
## Проверенные гонки и регрессии
|
||||
|
||||
Локальный набор `pytest` проверяет:
|
||||
|
||||
- точное совпадение процесса: PASS;
|
||||
- неверные nonce, release, version и state: FAIL;
|
||||
- stale health: FAIL;
|
||||
- посторонний PID: FAIL;
|
||||
- единственный ожидаемый прямой child: PASS только со строгой lineage, nonce, командой и временем создания;
|
||||
- неожиданный grandchild: FAIL;
|
||||
- повторное использование PID с другим creation time: FAIL;
|
||||
- target умер до health: FAIL;
|
||||
- target записал health, но умер: FAIL;
|
||||
- health в состоянии `STARTING`, до полной готовности: FAIL;
|
||||
- rollback не принимает health с nonce прошлого запуска: FAIL;
|
||||
- выбор процессов для cleanup не затрагивает посторонний процесс;
|
||||
- cleanup останавливает только строго принадлежащие wrapper и child;
|
||||
- два успешных логических запуска детерминированы и имеют разные nonce;
|
||||
- health JSON заменяется атомарно.
|
||||
|
||||
Команда проверки:
|
||||
|
||||
```text
|
||||
python -m pytest tests/test_rf001_launcher_health_diagnostic.py tests/test_rf001_agent_launcher.py tests/test_rf001_rx_http_agent.py tests/test_rf001_agent_update.py -q
|
||||
```
|
||||
|
||||
Результат: `48 passed`, предупреждение только о недоступности `.pytest_cache` в текущей песочнице.
|
||||
|
||||
Windows integration test использовал реальный RX venv, установленный launcher source, canonical release A и настоящий agent health writer. Выполнено два последовательных изолированных запуска. HTTP polling, SDR и аппаратные команды в этом режиме отсутствовали.
|
||||
|
||||
## Файлы и следующий этап
|
||||
|
||||
В этой диагностической фазе добавлены только:
|
||||
|
||||
- `tools/rf001_launcher_health_diagnostic.py`;
|
||||
- `tests/test_rf001_launcher_health_diagnostic.py`;
|
||||
- этот отчёт.
|
||||
|
||||
Для будущего производственного исправления потребуются как минимум:
|
||||
|
||||
- `tools/rf001_agent_launcher.py`;
|
||||
- `tests/test_rf001_agent_launcher.py`;
|
||||
- тесты упаковки, подтверждающие неизменность подписанного состава и fail-closed rollback.
|
||||
|
||||
`tools/rf001_rx_http_agent.py` потребуется менять только при выборе расширенного health process identity; доказанная минимальная схема может обойтись без изменения агента.
|
||||
|
||||
После отдельного согласования исправления понадобится новый уникальный bootstrap, поскольку launcher на RX должен быть заменён проверенным пакетом. Bootstrap `.5` в рамках этой фазы не создавался.
|
||||
43
docs/handoff/RF_HANDOFF_CHANNEL_TEST.md
Normal file
43
docs/handoff/RF_HANDOFF_CHANNEL_TEST.md
Normal file
@@ -0,0 +1,43 @@
|
||||
TASK_ID = RF_HANDOFF_CHANNEL_TEST_001
|
||||
TASK_TITLE = Verify the bidirectional Git handoff channel without SDR
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = bc55d0516dc1ee36cefa1ff096e7d86f60645303
|
||||
REQUIRES_APPROVAL = NO
|
||||
HARDWARE_ACCESS = NO
|
||||
RF_RX = NO
|
||||
RF_TX = NO
|
||||
ALLOW_COMMIT = NO
|
||||
ALLOW_PUSH = RX_RESULT_ONLY
|
||||
RISK_LEVEL = GREEN
|
||||
|
||||
# Model-free handoff channel test
|
||||
|
||||
The real-origin gate was completed before RF001A_OTA_CAPTURE_003 with:
|
||||
|
||||
python -B tools/rf_handoff_smoke.py --hold-seconds 7
|
||||
|
||||
The script uses the real origin configured in the main RX checkout. It publishes a synthetic READY result to the existing origin/rx-node branch, waits seven seconds, then publishes a synthetic COMPLETE result. Both publications may change only:
|
||||
|
||||
docs/handoff/results/RX_RESULT.json
|
||||
docs/handoff/results/RX_RESULT.md
|
||||
|
||||
The verified READY commit is `4cb6c8cad1ab5194755c2740c2a5a83933d099d8`; its canonical digest is `23fc54f1f788f068fd4a842daf0a445bd4ec192866b4d89406c74f1797a9952b`. The verified COMPLETE commit is `83d91ff5309777ab639a24f774e9d0b8825a11e9`; its canonical digest is `7d98ed2e70e98a087b968c0d65fb1db4277b81f251e075d20583de2e5002b2d6`.
|
||||
|
||||
The MAIN watcher observed both states without requesting RED approval. The RX watcher must now verify corresponding CHANNEL_READY_OBSERVED and CHANNEL_COMPLETE_OBSERVED records in the full origin/tx-node history. Do not rerun this channel test only to recreate MAIN confirmation.
|
||||
|
||||
The script and watcher do not access SDR hardware, do not run hackrf_transfer and do not authorize any RF transmission.
|
||||
|
||||
Required report:
|
||||
|
||||
TASK_ID = RF_HANDOFF_CHANNEL_TEST_001
|
||||
READY PUBLISH = PASS / FAIL
|
||||
READY REMOTE HEAD =
|
||||
MAIN WATCHER RECEIVES READY = PASS / FAIL
|
||||
COMPLETE PUBLISH = PASS / FAIL
|
||||
COMPLETE REMOTE HEAD =
|
||||
MAIN WATCHER RECEIVES COMPLETE = PASS / FAIL
|
||||
FINAL REMOTE STATE = CAPTURE_COMPLETE / OTHER
|
||||
REAL RX = NOT_RUN
|
||||
RF TX = 0
|
||||
|
||||
If any field fails, do not start the hardware RX task. STOP.
|
||||
170
docs/handoff/RX_AGENT_PROTOCOL.md
Normal file
170
docs/handoff/RX_AGENT_PROTOCOL.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# RX agent handoff protocol
|
||||
|
||||
## Purpose
|
||||
|
||||
The RX laptop watches origin/video-development with ordinary local Python code. Git polling does not call an LLM, ChatGPT or an agent. A detected task is not prepared until the user approves it.
|
||||
|
||||
The RX-to-MAIN result channel uses the separate origin/rx-node branch. The RX laptop publishes small machine-readable readiness and completion results. The MAIN PC reads those results without merging rx-node into video-development.
|
||||
|
||||
The MAIN-to-RX acknowledgement channel uses the independent origin/tx-node branch. The MAIN watcher publishes evidence that it observed one exact rx-node commit and digest. The RX laptop scans the acknowledgement history without merging tx-node into video-development.
|
||||
|
||||
## Task header
|
||||
|
||||
docs/handoff/NEXT_RX_TASK.md starts with these plain key-value fields:
|
||||
|
||||
TASK_ID = RF001A_OTA_CAPTURE_004
|
||||
TASK_TITLE = Start coordinated RF-001A common-clock OTA capture
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = <full 40-character base commit hash>
|
||||
REQUIRES_APPROVAL = YES
|
||||
HARDWARE_ACCESS = YES
|
||||
RF_RX = YES
|
||||
RF_TX = NO
|
||||
ALLOW_COMMIT = NO
|
||||
ALLOW_PUSH = RX_RESULT_ONLY
|
||||
RISK_LEVEL = YELLOW
|
||||
|
||||
TASK_ID is immutable and unique. A revised task receives a new ID. EXPECTED_HEAD identifies the known base commit against which the task was written; the watcher requires it to be an ancestor of the fetched task commit.
|
||||
|
||||
ALLOW_PUSH = RX_RESULT_ONLY authorizes publication of the two allowlisted result files to an existing rx-node branch after the task passes its local approval gate. It never permits a push to video-development and never permits branch creation.
|
||||
|
||||
The watcher rejects missing, duplicate or invalid fields. It reads the task with git show origin/video-development:docs/handoff/NEXT_RX_TASK.md and does not need to change the local checkout.
|
||||
|
||||
## Safety levels and approval
|
||||
|
||||
| Level | Operations | Approval rule |
|
||||
|---|---|---|
|
||||
| GREEN | Software-only checks, parsing and local result preparation | The task's normal approval applies |
|
||||
| YELLOW | Hardware inventory, clock checks, bounded RF RX and result-only publication | The user must choose [A] Approve for the exact TASK_ID |
|
||||
| RED | RF TX, firmware, CPLD, Zadig, USB driver changes, destructive Git or filesystem operations | A normal approval is insufficient; RF TX requires the exact separate phrase AUTHORIZE RF TX |
|
||||
|
||||
The watcher derives RED whenever RF_TX = YES and rejects a lower declared level. A non-RF RED task requires AUTHORIZE <TASK_ID>. An RF TX task requires exactly AUTHORIZE RF TX.
|
||||
|
||||
At the RX prompt the choices are:
|
||||
|
||||
[A] Approve
|
||||
[D] Defer
|
||||
[S] Stop watcher
|
||||
|
||||
The watcher records last_approved_task_id only after approval succeeds. Result publication requires the result TASK_ID to match that approved ID.
|
||||
|
||||
## Local state and executor boundary
|
||||
|
||||
Runtime state is stored in .local/rx_agent_state.json. Prepared prompts and result skeletons are stored in .local/rx_agent_outbox/<TASK_ID>/. The .local directory is ignored by Git.
|
||||
|
||||
State includes:
|
||||
|
||||
- last_seen_task_id;
|
||||
- last_completed_task_id;
|
||||
- last_approved_task_id;
|
||||
- deferred_task_id;
|
||||
- running;
|
||||
- watcher PID;
|
||||
- confirmed channel-test READY and COMPLETE digests;
|
||||
- matching tx-node acknowledgement commits.
|
||||
|
||||
ManualExecutor writes TASK.md, RX_RESULT.md and RX_RESULT.json after approval. It does not invoke an agent, access SDR hardware, commit or push.
|
||||
|
||||
After the task actually finishes, mark the local ID complete:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --mark-completed <TASK_ID>
|
||||
|
||||
## Strict result schema
|
||||
|
||||
The coordinated RF-001A result is a strict JSON object. Unknown or missing keys are rejected. READY requires:
|
||||
|
||||
- task_id = RF001A_OTA_CAPTURE_004;
|
||||
- state = RX_READY_FOR_TX;
|
||||
- rx_ready_for_tx, process_alive, file_growing and common_clock_verified all true;
|
||||
- a positive capture_pid;
|
||||
- 60-second planned duration;
|
||||
- rf_tx = 0;
|
||||
- final capture fields set to null.
|
||||
|
||||
COMPLETE requires:
|
||||
|
||||
- state = CAPTURE_COMPLETE;
|
||||
- rx_ready_for_tx, process_alive and file_growing all false;
|
||||
- exact exit code, size, duration and SHA256;
|
||||
- error counters as non-negative integers or null when the tool did not report them;
|
||||
- clkin_after = DETECTED or NOT_DETECTED;
|
||||
- rf_tx_observed = EXPECTED_EXTERNAL_TX or UNKNOWN;
|
||||
- rf_tx = 0.
|
||||
|
||||
Timestamps use timezone-aware ISO-8601. source_head is the actual local 40-character Git HEAD.
|
||||
|
||||
## Publishing to rx-node
|
||||
|
||||
Before any production hardware access, run
|
||||
`python -B tools/rx_agent_watcher.py --check-result-write`. It performs an
|
||||
authenticated fetch, a no-change `git push --dry-run` of the existing rx-node
|
||||
HEAD to the same ref, and a second remote HEAD check. Failure blocks capture.
|
||||
The check creates no commit and no READY event.
|
||||
|
||||
Publish a prepared local JSON file with:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --publish-result <result-json> --result-branch rx-node
|
||||
|
||||
The publisher checks:
|
||||
|
||||
1. the current task and result TASK_ID agree;
|
||||
2. ALLOW_PUSH = RX_RESULT_ONLY;
|
||||
3. RF_TX = NO;
|
||||
4. last_approved_task_id matches;
|
||||
5. result source_head equals the current checkout HEAD;
|
||||
6. origin/rx-node already exists;
|
||||
7. the JSON schema is valid.
|
||||
|
||||
It checks the branch with git fetch and git ls-remote through the real origin configured in the main checkout. It creates a temporary worktree from that repository and writes exactly:
|
||||
|
||||
docs/handoff/results/RX_RESULT.json
|
||||
docs/handoff/results/RX_RESULT.md
|
||||
|
||||
It checks the staged path set, creates one immutable commit, pushes that exact
|
||||
commit hash to rx-node, and confirms that `git ls-remote` returns it. After a
|
||||
recognized transient network or authentication error, it first checks whether
|
||||
the commit already reached the remote. If not, it fetches and may retry that
|
||||
same commit once. It never creates a second READY commit or changes the payload
|
||||
between attempts. It then removes the temporary worktree. It never switches
|
||||
the source checkout. It rejects any additional staged path and enforces a
|
||||
64 KiB limit per result file.
|
||||
|
||||
Raw IQ, video, waveform files, large logs, secrets and machine-local state remain outside Git. A missing result branch or failed push is reported and never authorizes TX.
|
||||
|
||||
## MAIN PC watcher
|
||||
|
||||
tools/tx_agent_watcher.py polls origin/rx-node every 5 seconds by default. It does not call an LLM or open SDR hardware. Its versioned local state stores `last_processed_rx_node_commit`. Each poll processes every commit in `last_processed_rx_node_commit..origin/rx-node` from oldest to newest and reads the result from that exact commit instead of reading only the branch tip.
|
||||
|
||||
For RF_HANDOFF_CHANNEL_TEST_001 the watcher records synthetic READY and COMPLETE states without requesting RED approval. For a new valid production READY result it checks task ID, source HEAD, current result branch commit, state, live-process flags, common clock, publication age, planned duration and remaining capture time. It then requests the exact phrase AUTHORIZE RF TX.
|
||||
|
||||
After the phrase, it fetches rx-node again and repeats every check. A changed result or commit, stale publication or fewer than 10 seconds remaining blocks authorization. Historical READY is still observed in order, but an expired capture window never authorizes TX.
|
||||
|
||||
The history cursor advances only after successful schema validation, handling and required acknowledgement publication. Duplicate content advances the commit cursor without repeating an action. A malformed intermediate result blocks later commits, and a non-fast-forward branch rewrite is rejected.
|
||||
|
||||
For each successfully handled channel-test event, the MAIN watcher publishes a strict TX_RESULT to origin/tx-node. The object contains task ID, acknowledgement state, exact source RX commit and digest, source HEAD, actual observation timestamp, watcher PID and RF safety fields. A channel-test acknowledgement always has RF_TX_AUTHORIZED false, RF_TX_STARTED false and RF_TX 0.
|
||||
|
||||
The tx-node publisher stages exactly `docs/handoff/results/TX_RESULT.json` and `docs/handoff/results/TX_RESULT.md`, checks the staged path allowlist and enforces 64 KiB per file. A push failure prevents the rx-node cursor from advancing. Duplicate acknowledgements are suppressed by their source binding. No local state, code, IQ, video, waveform or large log is published.
|
||||
|
||||
A successful gate publishes a schema-version-2 `TX_AUTHORIZED` event and writes
|
||||
one local authorization record under `.local/tx_authorizations/`. The record
|
||||
has `rf_tx_started = false` and `rf_tx = 0`. The separately controlled TX
|
||||
executor must re-fetch and compare the result commit and digest immediately
|
||||
before opening hardware. It then publishes `TX_STARTED` at process start and
|
||||
`TX_COMPLETE` after exit with UTC timestamps, exit code, underruns and errors.
|
||||
|
||||
Duplicate result digests are not shown repeatedly.
|
||||
|
||||
## Token and model behavior
|
||||
|
||||
- RX task polling does not call an LLM.
|
||||
- MAIN result polling does not call an LLM.
|
||||
- No model runs while there is no new task or READY result.
|
||||
- ManualExecutor prepares local files only.
|
||||
- Neither watcher starts RF TX.
|
||||
- The TX executor remains a separate RED-controlled action.
|
||||
|
||||
## Bidirectional channel and dry-run contracts
|
||||
|
||||
The completed preflight channel command published synthetic READY and COMPLETE results with TASK_ID RF_HANDOFF_CHANNEL_TEST_001 through origin/rx-node. The MAIN watcher observed both without a RED prompt. The RX watcher now requires exact matching acknowledgements in origin/tx-node before RF001A_OTA_CAPTURE_004 can pass its software gate.
|
||||
|
||||
Automated coordination tests use a temporary local bare Git repository and independent rx-node and tx-node branches. They cover exact commit and digest binding, duplicate suppression, two commits between polls, stale acknowledgement rejection, missing tx-node, push failure, non-fast-forward protection and a full fake two-way flow without accessing the real remote or SDR hardware and without transmitting RF.
|
||||
104
docs/handoff/RX_AGENT_USAGE.md
Normal file
104
docs/handoff/RX_AGENT_USAGE.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# RX watcher usage
|
||||
|
||||
## Start
|
||||
|
||||
On the RX laptop, pull video-development and double-click:
|
||||
|
||||
tools\start_rx_watcher.cmd
|
||||
|
||||
The watcher stays in a visible console and polls origin/video-development every 30 seconds. It does not start ChatGPT, Codex or another agent.
|
||||
|
||||
## Idle
|
||||
|
||||
While no new TASK_ID exists, the watcher performs only git fetch, reads the remote task file and compares IDs with .local/rx_agent_state.json. There is no model execution or token use. For RF001A_OTA_CAPTURE_004 it also verifies the required historical acknowledgements on origin/tx-node and performs the authenticated no-change rx-node write preflight before offering the task for approval.
|
||||
|
||||
## New task
|
||||
|
||||
The console shows:
|
||||
|
||||
NEW TASK DETECTED
|
||||
|
||||
TASK ID =
|
||||
TITLE =
|
||||
SAFETY LEVEL =
|
||||
HARDWARE ACCESS =
|
||||
RF RX =
|
||||
RF TX =
|
||||
|
||||
Choose [A] Approve, [D] Defer or [S] Stop watcher. RF TX also requires the exact phrase AUTHORIZE RF TX. Another RED action requires AUTHORIZE <TASK_ID>.
|
||||
|
||||
## Approve and execute
|
||||
|
||||
After approval, open:
|
||||
|
||||
.local\rx_agent_outbox\<TASK_ID>\TASK.md
|
||||
|
||||
Transfer that prepared task to the chosen executor. The watcher records last_approved_task_id, which is required for result publication. Preparing the outbox is not evidence that the hardware task ran.
|
||||
|
||||
When the task is actually complete, run:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --mark-completed <TASK_ID>
|
||||
|
||||
## Publish READY
|
||||
|
||||
For RF001A_OTA_CAPTURE_004, first require the watcher to print:
|
||||
|
||||
RX-NODE WRITE PREFLIGHT = PASS
|
||||
|
||||
MAIN READY RECEIVED = CONFIRMED
|
||||
MAIN COMPLETE RECEIVED = CONFIRMED
|
||||
|
||||
These confirmations come from the full origin/tx-node history. They must bind the known channel-test RX commits and canonical digests. The RX watcher stores the two digests and the matching tx-node commit hashes in its local state. A missing branch, wrong digest, wrong RX commit, wrong source HEAD, malformed intermediate acknowledgement or rewritten history blocks the task.
|
||||
|
||||
Do not rerun RF_HANDOFF_CHANNEL_TEST_001 merely to recreate these confirmations. After this gate and the task's YELLOW approval, create the strict READY JSON outside Git only after CLKIN is detected, the capture child is alive and the file is growing. Run:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --publish-result <ready-json-path> --result-branch rx-node
|
||||
|
||||
On success, the watcher prints RX RESULT PUBLISH = PUBLISHED or UNCHANGED, the result commit, the independently confirmed RX RESULT REMOTE HEAD, and RF TX = 0. Report RX READY FOR TX = YES only after the remote HEAD equals the result commit.
|
||||
|
||||
The command fails if:
|
||||
|
||||
- the task was not approved locally;
|
||||
- the TASK_ID differs;
|
||||
- source_head differs from the checkout HEAD;
|
||||
- origin/rx-node does not exist;
|
||||
- the schema is invalid;
|
||||
- Git commit or push fails.
|
||||
|
||||
A failed publication is not READY evidence and cannot authorize TX.
|
||||
|
||||
Immediately before hardware access, repeat the same non-mutating check:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --check-result-write --result-branch rx-node
|
||||
|
||||
The READY publisher retries only one recognized transient failure. It checks
|
||||
the remote before retrying and pushes the same immutable commit; it does not
|
||||
create a replacement READY commit.
|
||||
|
||||
## Verified model-free channel evidence
|
||||
|
||||
The completed RF_HANDOFF_CHANNEL_TEST_001 evidence is bound to READY commit `4cb6c8cad1ab5194755c2740c2a5a83933d099d8` and COMPLETE commit `83d91ff5309777ab639a24f774e9d0b8825a11e9`. The RX watcher accepts the gate only after origin/tx-node contains the matching MAIN acknowledgements. This read-only check performs no capture and keeps RF TX at 0.
|
||||
|
||||
## Publish COMPLETE
|
||||
|
||||
After capture completion, build the strict final JSON with state CAPTURE_COMPLETE and publish it with the same command:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --publish-result <final-json-path> --result-branch rx-node
|
||||
|
||||
Only the two fixed result files are committed. Raw IQ, video, waveform, stdout, stderr and large logs stay outside Git.
|
||||
|
||||
## Stop
|
||||
|
||||
Double-click:
|
||||
|
||||
tools\stop_rx_watcher.cmd
|
||||
|
||||
The script creates a local stop request and waits for the watcher PID to clear. A successful stop prints WATCHER STOPPED.
|
||||
|
||||
## Dry run
|
||||
|
||||
From the repository root:
|
||||
|
||||
python -B tools/rx_agent_watcher.py --dry-run --once --approval approve --task-file tests/fixtures/watcher_test_task.md
|
||||
|
||||
The fake task is written under .local/rx_agent_outbox/WATCHER_TEST_001/. No Git fetch, SDR access, agent call, RF RX, RF TX, commit or push occurs.
|
||||
47
docs/handoff/RX_NODE_STATE.md
Normal file
47
docs/handoff/RX_NODE_STATE.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# RX node state
|
||||
|
||||
ROLE =
|
||||
RX node for SDR_Rover_Video
|
||||
|
||||
LAPTOP PROJECT PATH =
|
||||
C:\Users\Professional\Documents\Codex\SDR_Rover_Video
|
||||
|
||||
BRANCH =
|
||||
video-development
|
||||
|
||||
EXPECTED CURRENT HEAD =
|
||||
1ff8ccf27ec81b3b06aeb8af12793d1516a67a16
|
||||
|
||||
This HEAD is the baseline before the handoff commit. Record the new HEAD after pulling the branch.
|
||||
|
||||
HARDWARE =
|
||||
Auron HackRF Plus
|
||||
|
||||
hackrf_info identifies device as =
|
||||
HackRF One
|
||||
|
||||
PYTHON =
|
||||
3.13.0 in .venv
|
||||
|
||||
KNOWN SOFTWARE STATUS =
|
||||
offline RF-001: 100/100 exact packets
|
||||
pip check: PASS
|
||||
ruff targeted paths: PASS
|
||||
pyright: PASS
|
||||
|
||||
These are previously reported results. Verify the current laptop state in NEXT_RX_TASK.md.
|
||||
|
||||
ROLE POLICY =
|
||||
The laptop is primarily the RX node. The main PC owns development and pushes video-development. The RX laptop pulls the branch and executes the current task.
|
||||
|
||||
DO NOT WITHOUT EXPLICIT TASK AUTHORIZATION:
|
||||
- RF TX
|
||||
- firmware update
|
||||
- CPLD update
|
||||
- hackrf_spiflash
|
||||
- Zadig
|
||||
- USB driver changes
|
||||
- commit to video-development
|
||||
- push to video-development
|
||||
|
||||
Large raw IQ and video files must not be added to Git unless explicitly required.
|
||||
55
docs/handoff/RX_RESULT_TEMPLATE.md
Normal file
55
docs/handoff/RX_RESULT_TEMPLATE.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# RX result schema
|
||||
|
||||
The publisher creates both docs/handoff/results/RX_RESULT.json and docs/handoff/results/RX_RESULT.md on the existing rx-node branch. JSON is authoritative. Markdown is generated from the same validated object.
|
||||
|
||||
## READY JSON
|
||||
|
||||
{
|
||||
"schema_version": 1,
|
||||
"task_id": "RF001A_OTA_CAPTURE_004",
|
||||
"state": "RX_READY_FOR_TX",
|
||||
"rx_ready_for_tx": true,
|
||||
"process_alive": true,
|
||||
"file_growing": true,
|
||||
"capture_pid": 1234,
|
||||
"published_at": "2026-09-21T12:00:02+00:00",
|
||||
"capture_started_at": "2026-09-21T12:00:00+00:00",
|
||||
"expected_capture_end": "2026-09-21T12:01:00+00:00",
|
||||
"capture_path": "C:\\Users\\Professional\\Documents\\SDR_Rover_Captures\\<run>\\rf001a_ota_capture.cs8",
|
||||
"common_clock_verified": true,
|
||||
"source_head": "<40-character lowercase Git HEAD>",
|
||||
"rf_tx": 0,
|
||||
"exit_code": null,
|
||||
"file_size": null,
|
||||
"duration_seconds": null,
|
||||
"overruns": null,
|
||||
"usb_errors": null,
|
||||
"file_errors": null,
|
||||
"sha256": null,
|
||||
"clkin_after": "NOT_RUN",
|
||||
"rf_tx_observed": "UNKNOWN"
|
||||
}
|
||||
|
||||
## COMPLETE JSON
|
||||
|
||||
The final object keeps the same identity, timing, path and PID. It changes these fields:
|
||||
|
||||
state = CAPTURE_COMPLETE
|
||||
rx_ready_for_tx = false
|
||||
process_alive = false
|
||||
file_growing = false
|
||||
published_at = <final publication UTC time>
|
||||
exit_code = <integer>
|
||||
file_size = <integer bytes>
|
||||
duration_seconds = <number>
|
||||
overruns = <non-negative integer or null>
|
||||
usb_errors = <non-negative integer or null>
|
||||
file_errors = <non-negative integer or null>
|
||||
sha256 = <lowercase 64-character SHA256>
|
||||
clkin_after = DETECTED or NOT_DETECTED
|
||||
rf_tx_observed = EXPECTED_EXTERNAL_TX or UNKNOWN
|
||||
rf_tx = 0
|
||||
|
||||
Use null when the capture tool did not report an error counter. Do not infer zero.
|
||||
|
||||
Raw IQ, video, waveform files and large logs must stay outside Git.
|
||||
95
docs/handoff/TX_AGENT_USAGE.md
Normal file
95
docs/handoff/TX_AGENT_USAGE.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# MAIN TX watcher usage
|
||||
|
||||
## Purpose
|
||||
|
||||
tools/tx_agent_watcher.py is an ordinary local Python process. It polls the small RX result on origin/rx-node and publishes small acknowledgements to origin/tx-node. It does not call an LLM and does not open HackRF hardware.
|
||||
|
||||
## Start
|
||||
|
||||
From the repository root, double-click:
|
||||
|
||||
tools\start_tx_watcher.cmd
|
||||
|
||||
The visible console polls every 5 seconds. A missing rx-node or tx-node branch, an invalid result or an acknowledgement push failure is reported. No model runs while polling or publishing.
|
||||
|
||||
## Result history cursor
|
||||
|
||||
The watcher stores `last_processed_rx_node_commit` in `.local\tx_agent_watcher_state.json`. After each fetch it enumerates every commit in:
|
||||
|
||||
last_processed_rx_node_commit..origin/rx-node
|
||||
|
||||
It reads `RX_RESULT.json` from each exact commit and processes the commits from oldest to newest. READY therefore remains observable when READY and COMPLETE are both published between two polling cycles.
|
||||
|
||||
The cursor advances only after that commit has been read, validated, handled and, when required, acknowledged successfully. A failed acknowledgement push leaves the RX commit unprocessed so that the next poll retries it. A malformed intermediate commit blocks later commits until the defect is corrected. A non-fast-forward rewrite of `rx-node` is rejected. Duplicate result content is suppressed, but its commit is still marked as processed.
|
||||
|
||||
State format version 2 retains the existing digest and authorization fields and adds `last_seen_commit` and `last_processed_rx_node_commit`. A version-1 digest-only state is mapped to the newest matching commit in the existing result history. Back up the local state before manual recovery; do not delete it blindly.
|
||||
|
||||
## MAIN acknowledgements
|
||||
|
||||
After it handles a channel-test READY or COMPLETE event, the watcher publishes a strict acknowledgement to origin/tx-node. Each acknowledgement binds the exact `SOURCE_RX_COMMIT`, canonical `SOURCE_RX_DIGEST` and RX result `SOURCE_HEAD`. The channel-test states are:
|
||||
|
||||
CHANNEL_READY_OBSERVED
|
||||
CHANNEL_COMPLETE_OBSERVED
|
||||
|
||||
The tx-node branch is independent of video-development. Every commit tree may contain only:
|
||||
|
||||
docs/handoff/results/TX_RESULT.json
|
||||
docs/handoff/results/TX_RESULT.md
|
||||
|
||||
Each file is limited to 64 KiB. Before committing, the publisher compares the complete staged path set with this allowlist. It suppresses an acknowledgement that already exists for the same task, state, RX commit, digest, source HEAD and RF safety values. The RX laptop scans the full tx-node history from oldest to newest, so two acknowledgements published between polls remain observable.
|
||||
|
||||
The acknowledgement operation is ordinary Python and Git. It does not invoke ChatGPT, Codex or another model. It stores no IQ, video, waveform, source code, large logs or local state on tx-node.
|
||||
|
||||
## READY and RED approval
|
||||
|
||||
RF_HANDOFF_CHANNEL_TEST_001 READY and COMPLETE states are recorded and acknowledged without a RED prompt and never open the production gate.
|
||||
|
||||
For a fresh valid RF001A_OTA_CAPTURE_004 READY result, the watcher checks the expected source HEAD, current origin/rx-node commit, common clock and capture window. It then prints the task ID, capture start and expected end and asks for:
|
||||
|
||||
AUTHORIZE RF TX
|
||||
|
||||
Any other input blocks authorization.
|
||||
|
||||
After the exact phrase, the watcher fetches rx-node again. It requires the same result digest and commit, the expected source HEAD, a fresh publication, an active 60-second capture window, verified common clock and at least 10 seconds remaining. An expired historical READY is recorded as observed but cannot create an authorization.
|
||||
|
||||
On success it writes:
|
||||
|
||||
.local\tx_authorizations\RF001A_OTA_TX_004_authorized.json
|
||||
|
||||
The watcher also publishes `TX_AUTHORIZED` to origin/tx-node. This record opens
|
||||
the gate for the separately controlled TX executor. The watcher itself does
|
||||
not run hackrf_transfer. The record explicitly says `rf_tx_started = false`
|
||||
and `rf_tx = 0`.
|
||||
|
||||
At actual process start the executor publishes `TX_STARTED` with the most
|
||||
accurate available UTC timestamp. After process exit it publishes
|
||||
`TX_COMPLETE` with finish time, integer exit code, underruns and errors. These
|
||||
events preserve the exact RX READY commit and digest binding. They do not
|
||||
replace verification of packets from captured IQ.
|
||||
|
||||
The executor writes each strict schema-version-2 event JSON outside Git, then
|
||||
uses the model-free publisher:
|
||||
|
||||
python -B tools/tx_agent_watcher.py --publish-tx-event <event-json> --acknowledgement-branch tx-node
|
||||
|
||||
The publisher requires a clean, current `video-development` checkout. A
|
||||
`TX_STARTED` event requires a matching `TX_AUTHORIZED` in tx-node. A
|
||||
`TX_COMPLETE` event also requires the matching `TX_STARTED` timestamp.
|
||||
|
||||
The TX executor must re-fetch rx-node and compare the result commit and digest immediately before hardware access.
|
||||
|
||||
## Stop
|
||||
|
||||
Double-click:
|
||||
|
||||
tools\stop_tx_watcher.cmd
|
||||
|
||||
The stop command writes a local stop request and waits for the watcher PID to clear.
|
||||
|
||||
## Fake mode
|
||||
|
||||
Automated tests use a local bare Git repository. For a single local JSON fixture, the CLI dry-run form is:
|
||||
|
||||
python -B tools/tx_agent_watcher.py --dry-run --once --result-file <ready-json> --approval authorize
|
||||
|
||||
The --approval option is rejected outside --dry-run. Fake mode writes a local fake authorization and never fetches the production remote or accesses SDR hardware.
|
||||
27
docs/handoff/TX_NODE_STATE.md
Normal file
27
docs/handoff/TX_NODE_STATE.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# TX node state
|
||||
|
||||
ROLE =
|
||||
MAIN PC TX NODE
|
||||
|
||||
PROJECT PATH =
|
||||
C:\Users\user\Desktop\projects\SDR_Rover_Video
|
||||
|
||||
BRANCH =
|
||||
video-development
|
||||
|
||||
HARDWARE =
|
||||
Auron HackRF Plus; hackrf_info reports HackRF One r9
|
||||
|
||||
RF-001A TEST SPEC =
|
||||
configs/rf/rf001a_common_clock.json
|
||||
|
||||
Main PC owns development and pushes video-development. Current RF TX authorization = 0.
|
||||
|
||||
DO NOT WITHOUT EXPLICIT TASK AUTHORIZATION:
|
||||
- RF TX or continuous TX
|
||||
- firmware or CPLD update
|
||||
- hackrf_spiflash
|
||||
- Zadig or USB driver changes
|
||||
- RF parameter changes
|
||||
|
||||
Large IQ/video files and local device logs stay out of Git.
|
||||
75
docs/rf/RF_001A_PREPARATION.md
Normal file
75
docs/rf/RF_001A_PREPARATION.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# RF-001A: подготовка опыта с общей опорой 10 МГц
|
||||
|
||||
Текущее состояние: **PREPARED_NOT_RUN**. Передача в эфир, общий такт и двухузловой захват в этой задаче не проверялись. Единый источник параметров: [rf001a_common_clock.json](../../configs/rf/rf001a_common_clock.json). Перед каждым опытом оба узла сверяют с ним настройки. Проверка `tools/rf001a_prepare.py` отвергает расхождение spec с действующими константами TX и VIDEO-005 RX. Новый PHY не добавлен.
|
||||
|
||||
## Подготовленные файлы на основном ПК
|
||||
|
||||
`python -B -X utf8 tools/rf001a_prepare.py --output-dir data/raw/rf001/<новый_уникальный_каталог>` создаёт исходный `tx_waveform.c64le`, прежнюю TX-ведомость `tx_metadata.json` с seed, порядком 0…99 и SHA каждого пакета, `tx_waveform.cs8` и `rf001a_metadata.json`. Каталог должен быть новым. Файлы лежат в игнорируемом Git `data/raw/`; их нельзя добавлять в коммит. Текущий локальный экземпляр: `data/raw/rf001/rf001a_common_clock_001/`.
|
||||
|
||||
| Параметр | Проверенное значение |
|
||||
|---|---:|
|
||||
| TEST ID | `RF001A_COMMON_CLOCK_001` |
|
||||
| Идентификатор прежнего TX/RX формата metadata | `RF001_QPSK_500K_R23_256B_100P_GAP5MS` |
|
||||
| QPSK, FEC, payload, packets, gap | 500000 симв/с, 2/3, 256 байт, 100, 5 мс |
|
||||
| Seed | 20260917 |
|
||||
| Центр RF / частота отсчётов | 1400000000 Гц / 8000000 отсчётов/с |
|
||||
| Цифровое смещение | +125000 Гц: `RF_001_PLAN.md` и `protocol/video005_iq_waveform.py` |
|
||||
| Длина TX | 8833828 комплексных отсчётов; 1,1042285 с |
|
||||
| Исходный c64le | 70670624 байта; SHA256 `7cd81395efaa88b623b7e4e5a02e3a80b52185007397ea56ceaacc13353974b2` |
|
||||
| Компонентный peak / комплексный RMS | 0,3949069381 / 0,1854172647 в исходных единицах |
|
||||
| HackRF cs8 | 17667656 байт; SHA256 `75f3fa39bfa4ee419f0676c54e4b9c486d7ff64bd0fe2fc06343b20c9d117ecd` |
|
||||
| cs8 | signed int8, чередование I/Q, множитель 256 кодов на единицу источника |
|
||||
| Проверка cs8 | 0 кодов -128/127; обратное чтение совпало с ожидаемыми квантованными отсчётами; максимальная ошибка компоненты 0,001953125 |
|
||||
| Офлайн-приём именно cs8 | 100/100 CRC-valid, 100/100 exact, существующий `qpsk_rf_rx.py` → VIDEO-005 receiver |
|
||||
|
||||
Проверить файлы повторно: `python -B -X utf8 tools/rf001a_prepare.py --verify-dir data/raw/rf001/rf001a_common_clock_001`. Затем `python -B -X utf8 tools/qpsk_rf_rx.py --iq-file data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8 --iq-format cs8 --truth-metadata data/raw/rf001/rf001a_common_clock_001/tx_metadata.json --report data/raw/rf001/<новый_отчёт>.json`. Отчёт нельзя перезаписывать. Метаданные TX сохраняют прежний идентификатор, который проверяет существующий RX; `rf001a_metadata.json` связывает их с новым ID опыта. Истина применяется после работы приёмника.
|
||||
|
||||
## Настройки оборудования для следующего, отдельно разрешённого сеанса
|
||||
|
||||
Предусмотрены TX VGA 20 дБ (`-x 20`), RX LNA 24 дБ (`-l 24`), RX VGA 24 дБ (`-g 24`), RF amplifier OFF (`-a 0`), питание антенны OFF (`-p 0`). Это baseline Lab066; назначения флагов и шаги проверены по установленному `hackrf_transfer -h`. На основном ПК `hackrf_info` обнаружил HackRF One r9, serial `000000000000000097c063dc323b97d3`, libhackrf/firmware 2024.02.1, API 1.08. Название устройства в задаче: Auron HackRF Plus. RX serial надо заново снять на ноутбуке; не переносить serial TX на RX.
|
||||
|
||||
Общая опора: 10 МГц reference → MASTER → MASTER CLKOUT → SLAVE CLKIN. Основной критерий RF-001A: штатная команда `hackrf_clock -o 1` на MASTER завершилась успешно; SLAVE через `hackrf_clock -i` выдал точную строку `CLKIN status: clock signal detected`; после короткого конечного RX-start SLAVE продолжает видеть этот сигнал. Только при выполнении всех трёх условий допустима отметка `COMMON CLOCK VERIFIED = YES`. Вывод `hackrf_clock -r 3` и register 29 служат диагностикой и не являются обязательным PASS/FAIL-критерием физического CLKOUT. Старые результаты прошлых Lab не заменяют свежую проверку на SLAVE. Пользователь подтвердил подключённые антенны, оба USB, кабель MASTER CLKOUT → SLAVE CLKIN, около 2 м LOS и одинаковую поляризацию. Прямой кабель TX RF → RX RF допустим лишь с нужным ослаблением. Геометрию A/B не менять.
|
||||
|
||||
**Будущая команда TX, только для подготовки, не запускать сейчас:**
|
||||
|
||||
```powershell
|
||||
& 'C:\Users\user\Tools\HackRF\Library\bin\hackrf_transfer.exe' -d 000000000000000097c063dc323b97d3 -t data/raw/rf001/rf001a_common_clock_001/tx_waveform.cs8 -f 1400000000 -s 8000000 -n 8833828 -x 20 -a 0 -p 0 -B
|
||||
```
|
||||
|
||||
Она однократная, без `-R`. Прежде чем дать отдельное разрешение на TX, сверить SHA cs8, RX READY, физический checkpoint и общую опору. Пока `RF TX = 0`.
|
||||
|
||||
RX готовит новый каталог и конечный захват 20 с: 160000000 комплексных отсчётов cs8, ровно 320000000 байт (около 320 MB или 305,2 MiB). До захвата проверить свободное место с запасом. RX запускается первым, пользователь видит активный захват, затем отдельно разрешённый TX делает одну передачу; оба процесса завершаются, raw IQ и полные stdout/stderr сохраняются, тот же raw IQ анализируется офлайн. Не принимать неизвестные промежуточные или конечные underrun/overrun за ноль.
|
||||
|
||||
## Форма отчёта будущего опыта
|
||||
|
||||
```text
|
||||
TEST ID =
|
||||
CLOCK MODE = COMMON 10 MHz
|
||||
CENTER FREQUENCY =
|
||||
SAMPLE RATE =
|
||||
MODULATION = QPSK
|
||||
SYMBOL RATE = 500 ksymbol/s
|
||||
INNER FEC = 2/3
|
||||
PAYLOAD = 256 bytes
|
||||
GAP = 5 ms
|
||||
TX PACKETS = 100
|
||||
DETECTED PREAMBLES =
|
||||
CRC VALID PACKETS =
|
||||
EXACT PACKETS =
|
||||
PACKET SUCCESS RATE =
|
||||
RAW BER =
|
||||
POST-FEC ERROR RATE =
|
||||
MISSING PACKETS =
|
||||
DUPLICATES =
|
||||
CFO MEDIAN =
|
||||
CFO P95 =
|
||||
TIMING OFFSET =
|
||||
TIMING DRIFT =
|
||||
TX UNDERRUNS =
|
||||
RX OVERRUNS =
|
||||
RX CLIPPING =
|
||||
RAW IQ SHA256 =
|
||||
RESULT = GOOD / PARTIAL / FAIL
|
||||
```
|
||||
|
||||
Для первого smoke test 99–100 exact = GOOD, 95–98 = PARTIAL/DIAGNOSTIC, <95 = FAIL FOR VIDEO PROGRESSION. Один прогон не подтверждает статистическую надёжность. Неизмеренное поле помечать `NOT_MEASURED`, отсутствующий счётчик `NOT_REPORTED`. RF-001B сейчас только план: повторить опыт без общей опоры при тех же частотах, sample/symbol rate, FEC, payload, gap, gain, антеннах и геометрии. RF-001B не запускать.
|
||||
@@ -22,10 +22,25 @@
|
||||
"tools/qpsk_rf_common.py",
|
||||
"tools/qpsk_rf_tx.py",
|
||||
"tools/qpsk_rf_rx.py",
|
||||
"tools/rf001a_streaming.py",
|
||||
"tools/rf001a_prepare.py",
|
||||
"tools/rx_agent_watcher.py",
|
||||
"tools/rf_handoff_channel.py",
|
||||
"tools/rf_handoff_smoke.py",
|
||||
"tools/tx_agent_watcher.py",
|
||||
"tools/tx_handoff_channel.py",
|
||||
"tools/clock001.py",
|
||||
"tests/test_video005_iq.py",
|
||||
"tests/test_video005_video.py",
|
||||
"tests/test_rf001_preparation.py",
|
||||
"tests/test_rf001a_prepare.py",
|
||||
"tests/test_rx_agent_watcher.py",
|
||||
"tests/test_rf_handoff_coordination.py",
|
||||
"tests/test_rf_handoff_reliability.py",
|
||||
"tests/test_rf001a_streaming.py",
|
||||
"tests/test_tx_handoff_acknowledgement.py",
|
||||
"tests/test_capture_process_runner.py",
|
||||
"tests/test_rf001a_truth_metadata.py",
|
||||
"tests/test_clock001.py"
|
||||
],
|
||||
"typeCheckingMode": "basic",
|
||||
|
||||
15
tests/fixtures/watcher_test_task.md
vendored
Normal file
15
tests/fixtures/watcher_test_task.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
TASK_ID = WATCHER_TEST_001
|
||||
TASK_TITLE = Watcher dry-run test
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = b28befc69df8e1493db3ed2b122696b44ec27221
|
||||
REQUIRES_APPROVAL = YES
|
||||
HARDWARE_ACCESS = NO
|
||||
RF_RX = NO
|
||||
RF_TX = NO
|
||||
ALLOW_COMMIT = NO
|
||||
ALLOW_PUSH = NO
|
||||
RISK_LEVEL = GREEN
|
||||
|
||||
# Fake task
|
||||
|
||||
Create a dry-run result only. Do not access hardware, start an agent, or change Git.
|
||||
87
tests/test_capture_process_runner.py
Normal file
87
tests/test_capture_process_runner.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Software-only checks for the durable capture process runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNNER = ROOT / "tools" / "capture_process_runner.ps1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expected_exit_code", [0, 7])
|
||||
def test_runner_persists_exact_child_exit_code(
|
||||
tmp_path: Path,
|
||||
expected_exit_code: int,
|
||||
) -> None:
|
||||
child = tmp_path / f"child_{expected_exit_code}.cmd"
|
||||
child.write_text(
|
||||
f"@echo off\r\nexit /b {expected_exit_code}\r\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
capture = tmp_path / "fake_capture.cs8"
|
||||
capture_bytes = b"software-only-capture-evidence\x00\x01"
|
||||
capture.write_bytes(capture_bytes)
|
||||
arguments_path = tmp_path / "arguments.json"
|
||||
arguments_path.write_text(
|
||||
json.dumps(["/d", "/c", str(child)]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
stdout_path = tmp_path / "child.stdout.txt"
|
||||
stderr_path = tmp_path / "child.stderr.txt"
|
||||
started_path = tmp_path / "capture_started.json"
|
||||
result_path = tmp_path / "capture_result.json"
|
||||
|
||||
completed = subprocess.run(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(RUNNER),
|
||||
"-Executable",
|
||||
str(Path(os.environ["COMSPEC"])),
|
||||
"-ArgumentsJsonPath",
|
||||
str(arguments_path),
|
||||
"-CapturePath",
|
||||
str(capture),
|
||||
"-StdoutPath",
|
||||
str(stdout_path),
|
||||
"-StderrPath",
|
||||
str(stderr_path),
|
||||
"-StartedStatusPath",
|
||||
str(started_path),
|
||||
"-ResultStatusPath",
|
||||
str(result_path),
|
||||
],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert completed.returncode == expected_exit_code
|
||||
started = json.loads(started_path.read_text(encoding="utf-8-sig"))
|
||||
result = json.loads(result_path.read_text(encoding="utf-8-sig"))
|
||||
assert started["state"] == "RUNNING"
|
||||
assert isinstance(started["pid"], int) and started["pid"] > 0
|
||||
assert result["pid"] == started["pid"]
|
||||
assert result["started_at"] == started["started_at"]
|
||||
assert result["finished_at"]
|
||||
assert result["duration"] >= 0
|
||||
assert result["exit_code"] == expected_exit_code
|
||||
assert result["capture_path"] == str(capture)
|
||||
assert result["capture_size"] == len(capture_bytes)
|
||||
assert result["sha256"] == hashlib.sha256(capture_bytes).hexdigest()
|
||||
assert result["runner_error"] is None
|
||||
625
tests/test_rf001_agent_launcher.py
Normal file
625
tests/test_rf001_agent_launcher.py
Normal file
@@ -0,0 +1,625 @@
|
||||
"""Software-only tests for the stable versioned RX-agent launcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.rf001_agent_launcher as launcher
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self, process_id: int, returncode: int | None = None) -> None:
|
||||
self.pid = process_id
|
||||
self.returncode = returncode
|
||||
|
||||
def poll(self) -> int | None:
|
||||
return self.returncode
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.returncode = 0
|
||||
|
||||
def kill(self) -> None:
|
||||
self.returncode = -9
|
||||
|
||||
def wait(self, timeout: float | None = None) -> int:
|
||||
del timeout
|
||||
if self.returncode is None:
|
||||
self.returncode = 0
|
||||
return self.returncode
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 29, 13, 0, tzinfo=timezone.utc)
|
||||
WRAPPER = r"C:\runtime\.venv\Scripts\pythonw.exe"
|
||||
CHILD = r"C:\runtime\Python313\python.exe"
|
||||
AGENT = r"C:\release\app\tools\rf001_rx_http_agent.py"
|
||||
|
||||
|
||||
def _attempt(
|
||||
*,
|
||||
process_id: int = 200,
|
||||
nonce: str = "current-launch-nonce",
|
||||
returncode: int | None = None,
|
||||
) -> launcher.LaunchAttempt:
|
||||
command = (
|
||||
WRAPPER,
|
||||
"-u",
|
||||
"-B",
|
||||
"-X",
|
||||
"utf8",
|
||||
AGENT,
|
||||
"--launch-nonce",
|
||||
nonce,
|
||||
)
|
||||
return launcher.LaunchAttempt(
|
||||
process=FakeProcess(process_id, returncode), # type: ignore[arg-type]
|
||||
command=command,
|
||||
wrapper_pid=process_id,
|
||||
launch_boundary=NOW.isoformat(),
|
||||
wrapper_executable=WRAPPER,
|
||||
child_executable=CHILD,
|
||||
launch_nonce=nonce,
|
||||
)
|
||||
|
||||
|
||||
def _identity(
|
||||
pid: int,
|
||||
parent_pid: int,
|
||||
*,
|
||||
executable: str,
|
||||
command: tuple[str, ...] | None = None,
|
||||
seconds: float = 1.0,
|
||||
) -> launcher.ProcessIdentity:
|
||||
return launcher.ProcessIdentity(
|
||||
pid=pid,
|
||||
parent_pid=parent_pid,
|
||||
executable=executable,
|
||||
command_line=subprocess.list2cmdline(command or _attempt().command),
|
||||
created_at=(NOW + timedelta(seconds=seconds)).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _wrapper(attempt: launcher.LaunchAttempt | None = None) -> launcher.ProcessIdentity:
|
||||
value = attempt or _attempt()
|
||||
return _identity(
|
||||
value.wrapper_pid,
|
||||
100,
|
||||
executable=value.wrapper_executable,
|
||||
command=value.command,
|
||||
)
|
||||
|
||||
|
||||
def _child(attempt: launcher.LaunchAttempt | None = None) -> launcher.ProcessIdentity:
|
||||
value = attempt or _attempt()
|
||||
return _identity(
|
||||
value.wrapper_pid + 1,
|
||||
value.wrapper_pid,
|
||||
executable=value.child_executable,
|
||||
command=value.command,
|
||||
)
|
||||
|
||||
|
||||
def _health(
|
||||
*,
|
||||
pid: int = 200,
|
||||
release_id: str = "rf001-agent-2026.09.29.1",
|
||||
version: str = "2026.09.29.1",
|
||||
nonce: str = "current-launch-nonce",
|
||||
state: str = "RUNNING",
|
||||
updated_at: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"release_id": release_id,
|
||||
"agent_version": version,
|
||||
"launch_nonce": nonce,
|
||||
"state": state,
|
||||
"pid": pid,
|
||||
"updated_at": updated_at or (NOW + timedelta(seconds=2)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _config(tmp_path: Path) -> dict[str, object]:
|
||||
install = tmp_path / "install"
|
||||
config: dict[str, object] = {
|
||||
"install_root": str(install),
|
||||
"releases_root": str(install / "versions"),
|
||||
"active_release_file": str(install / "active_release.json"),
|
||||
"launcher_status": str(install / "launcher_status.json"),
|
||||
"launcher_request": str(install / "launcher_request.json"),
|
||||
"health_file": str(install / "agent_health.json"),
|
||||
"agent_log": str(install / "logs" / "agent.log"),
|
||||
"agent_arguments": [],
|
||||
}
|
||||
return config
|
||||
|
||||
|
||||
def _ready(config: dict[str, object], release_id: str, version: str) -> None:
|
||||
root = Path(str(config["releases_root"])) / release_id
|
||||
(root / "app" / "tools").mkdir(parents=True)
|
||||
(root / "app" / "tools" / "rf001_rx_http_agent.py").write_text(
|
||||
"# fixture\n", encoding="utf-8"
|
||||
)
|
||||
(root / "READY.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "READY",
|
||||
"release_id": release_id,
|
||||
"agent_version": version,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _request(
|
||||
config: dict[str, object],
|
||||
current: str,
|
||||
target: str,
|
||||
version: str,
|
||||
) -> dict[str, object]:
|
||||
value = {
|
||||
"task_id": "UPDATE_TEST_001",
|
||||
"from_release_id": current,
|
||||
"release_id": target,
|
||||
"agent_version": version,
|
||||
"safe_to_switch": True,
|
||||
}
|
||||
path = Path(str(config["launcher_request"]))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
Path(str(config["health_file"])).write_text(
|
||||
json.dumps({"busy": False, "active_task_id": None}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def test_launcher_switches_only_to_ready_release(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config = _config(tmp_path)
|
||||
old = "rf001-agent-2026.09.29.1"
|
||||
new = "rf001-agent-2026.09.29.2"
|
||||
_ready(config, old, "2026.09.29.1")
|
||||
_ready(config, new, "2026.09.29.2")
|
||||
request = _request(config, old, new, "2026.09.29.2")
|
||||
terminated: list[FakeProcess] = []
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"terminate_process",
|
||||
lambda process: terminated.append(process),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"start_release",
|
||||
lambda *_args, **_kwargs: (FakeProcess(2), "2026.09.29.2"),
|
||||
)
|
||||
process, release_id, version = launcher.switch_release(
|
||||
config,
|
||||
FakeProcess(1),
|
||||
old,
|
||||
"2026.09.29.1",
|
||||
request,
|
||||
)
|
||||
assert process.pid == 2
|
||||
assert release_id == new
|
||||
assert version == "2026.09.29.2"
|
||||
assert len(terminated) == 1
|
||||
active = json.loads(Path(str(config["active_release_file"])).read_text())
|
||||
assert active["release_id"] == new
|
||||
status = json.loads(Path(str(config["launcher_status"])).read_text())
|
||||
assert status["state"] == "RUNNING"
|
||||
assert status["phase"] == "TARGET_HEALTHY"
|
||||
assert status["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_launcher_rolls_back_after_new_version_start_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config = _config(tmp_path)
|
||||
old = "rf001-agent-2026.09.29.1"
|
||||
new = "rf001-agent-2026.09.29.2"
|
||||
_ready(config, old, "2026.09.29.1")
|
||||
_ready(config, new, "2026.09.29.2")
|
||||
request = _request(config, old, new, "2026.09.29.2")
|
||||
calls: list[str] = []
|
||||
|
||||
def fake_start(
|
||||
_config_value: object,
|
||||
release_id: str,
|
||||
*,
|
||||
expected_version: str | None = None,
|
||||
) -> tuple[FakeProcess, str]:
|
||||
del expected_version
|
||||
calls.append(release_id)
|
||||
if release_id == new:
|
||||
raise RuntimeError("fixed startup failure")
|
||||
return FakeProcess(3), "2026.09.29.1"
|
||||
|
||||
monkeypatch.setattr(launcher, "terminate_process", lambda _process: None)
|
||||
monkeypatch.setattr(launcher, "start_release", fake_start)
|
||||
process, release_id, version = launcher.switch_release(
|
||||
config,
|
||||
FakeProcess(1),
|
||||
old,
|
||||
"2026.09.29.1",
|
||||
request,
|
||||
)
|
||||
assert process.pid == 3
|
||||
assert release_id == old
|
||||
assert version == "2026.09.29.1"
|
||||
assert calls == [new, old]
|
||||
active = json.loads(Path(str(config["active_release_file"])).read_text())
|
||||
assert active["release_id"] == old
|
||||
status = json.loads(Path(str(config["launcher_status"])).read_text())
|
||||
assert status["state"] == "ROLLED_BACK"
|
||||
assert status["phase"] == "LEGACY_RESTORED"
|
||||
assert "fixed startup failure" in status["error"]
|
||||
|
||||
|
||||
def test_launcher_rejects_busy_agent_without_stopping_it(tmp_path: Path) -> None:
|
||||
config = _config(tmp_path)
|
||||
old = "rf001-agent-2026.09.29.1"
|
||||
new = "rf001-agent-2026.09.29.2"
|
||||
_ready(config, old, "2026.09.29.1")
|
||||
_ready(config, new, "2026.09.29.2")
|
||||
request = _request(config, old, new, "2026.09.29.2")
|
||||
Path(str(config["health_file"])).write_text(
|
||||
json.dumps({"busy": True, "active_task_id": "STAIRCASE_TEST"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="not idle"):
|
||||
launcher.switch_release(
|
||||
config,
|
||||
FakeProcess(1),
|
||||
old,
|
||||
"2026.09.29.1",
|
||||
request,
|
||||
)
|
||||
|
||||
|
||||
def test_direct_exact_identity_passes() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
result = launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
attempt.wrapper_pid,
|
||||
[wrapper],
|
||||
)
|
||||
assert result.mode == "DIRECT"
|
||||
assert result.target == wrapper
|
||||
|
||||
|
||||
def test_unique_expected_wrapper_child_passes() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
result = launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
child.pid,
|
||||
[wrapper, child],
|
||||
)
|
||||
assert result.mode == "WINDOWS_VENV_WRAPPER"
|
||||
assert result.target == child
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("launch_nonce", "wrong"),
|
||||
("release_id", "rf001-agent-2026.09.29.9"),
|
||||
("agent_version", "2026.09.29.9"),
|
||||
("state", "STARTING"),
|
||||
],
|
||||
)
|
||||
def test_wrong_health_field_fails(field: str, value: str) -> None:
|
||||
health = _health()
|
||||
health[field] = value
|
||||
assert not launcher._health_fields_match(
|
||||
health,
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
launch_nonce="current-launch-nonce",
|
||||
launch_boundary=NOW.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def test_stale_health_fails() -> None:
|
||||
health = _health(updated_at=(NOW - timedelta(seconds=1)).isoformat())
|
||||
assert not launcher._health_fields_match(
|
||||
health,
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
launch_nonce="current-launch-nonce",
|
||||
launch_boundary=NOW.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def test_unrelated_pid_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
unrelated = _identity(300, 999, executable=CHILD)
|
||||
with pytest.raises(RuntimeError, match="not a direct wrapper child"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
unrelated.pid,
|
||||
[wrapper, unrelated],
|
||||
)
|
||||
|
||||
|
||||
def test_unrelated_child_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
unrelated = replace(
|
||||
_child(attempt),
|
||||
command_line="python.exe unrelated.py",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="absent or ambiguous"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
unrelated.pid,
|
||||
[wrapper, unrelated],
|
||||
)
|
||||
|
||||
|
||||
def test_unexpected_grandchild_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
grandchild = replace(child, pid=202, parent_pid=child.pid)
|
||||
with pytest.raises(RuntimeError, match="not a direct wrapper child"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
grandchild.pid,
|
||||
[wrapper, child, grandchild],
|
||||
)
|
||||
|
||||
|
||||
def test_two_matching_children_fail() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
first = _child(attempt)
|
||||
second = replace(first, pid=first.pid + 1)
|
||||
with pytest.raises(RuntimeError, match="absent or ambiguous"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
first.pid,
|
||||
[wrapper, first, second],
|
||||
)
|
||||
|
||||
|
||||
def test_creation_time_mismatch_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
stale_child = replace(
|
||||
_child(attempt),
|
||||
created_at=(NOW - timedelta(seconds=30)).isoformat(),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="absent or ambiguous"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
stale_child.pid,
|
||||
[wrapper, stale_child],
|
||||
)
|
||||
|
||||
|
||||
def test_pid_reuse_between_validations_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
reused = replace(
|
||||
child,
|
||||
created_at=(NOW + timedelta(seconds=3)).isoformat(),
|
||||
)
|
||||
snapshots = iter(([wrapper, child], [wrapper, reused]))
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"_query_process_identities",
|
||||
lambda *_args, **_kwargs: next(snapshots),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="changed during validation"):
|
||||
launcher._validate_target_twice(
|
||||
attempt,
|
||||
wrapper,
|
||||
child.pid,
|
||||
)
|
||||
|
||||
|
||||
def test_wrapper_dies_too_early() -> None:
|
||||
attempt = _attempt(returncode=1)
|
||||
wrapper = _wrapper(attempt)
|
||||
with pytest.raises(RuntimeError, match="wrapper died"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
wrapper.pid,
|
||||
[wrapper],
|
||||
)
|
||||
|
||||
|
||||
def test_child_dies_before_final_validation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
snapshots = iter(([wrapper, child], [wrapper]))
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"_query_process_identities",
|
||||
lambda *_args, **_kwargs: next(snapshots),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="health process is absent"):
|
||||
launcher._validate_target_twice(
|
||||
attempt,
|
||||
wrapper,
|
||||
child.pid,
|
||||
)
|
||||
|
||||
|
||||
def test_wrong_executable_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = replace(_child(attempt), executable=r"C:\Other\python.exe")
|
||||
with pytest.raises(RuntimeError, match="absent or ambiguous"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
child.pid,
|
||||
[wrapper, child],
|
||||
)
|
||||
|
||||
|
||||
def test_wrong_command_line_fails() -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = replace(_child(attempt), command_line="python.exe other_agent.py")
|
||||
with pytest.raises(RuntimeError, match="absent or ambiguous"):
|
||||
launcher._validate_target_identity(
|
||||
attempt,
|
||||
wrapper,
|
||||
child.pid,
|
||||
[wrapper, child],
|
||||
)
|
||||
|
||||
|
||||
def test_cleanup_terminates_verified_child_and_wrapper_only(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
unrelated = _identity(300, 999, executable=CHILD)
|
||||
alive = {wrapper.pid: wrapper, child.pid: child, unrelated.pid: unrelated}
|
||||
terminated: list[int] = []
|
||||
|
||||
def query(wrapper_pid: int, health_pid: int | None = None) -> list[launcher.ProcessIdentity]:
|
||||
del health_pid
|
||||
return [
|
||||
identity
|
||||
for identity in alive.values()
|
||||
if identity.pid == wrapper_pid or identity.parent_pid == wrapper_pid
|
||||
]
|
||||
|
||||
def terminate_identity(identity: launcher.ProcessIdentity) -> str:
|
||||
terminated.append(identity.pid)
|
||||
alive.pop(identity.pid)
|
||||
return f"PID {identity.pid}: terminated"
|
||||
|
||||
def terminate_wrapper(process: FakeProcess) -> None:
|
||||
terminated.append(process.pid)
|
||||
alive.pop(process.pid)
|
||||
process.returncode = 0
|
||||
|
||||
monkeypatch.setattr(launcher, "_query_process_identities", query)
|
||||
monkeypatch.setattr(launcher, "_terminate_verified_identity", terminate_identity)
|
||||
monkeypatch.setattr(launcher, "terminate_process", terminate_wrapper)
|
||||
|
||||
result = launcher.strict_failure_cleanup(attempt, wrapper)
|
||||
|
||||
assert result.state == "COMPLETE"
|
||||
assert result.orphan_verified_target_processes == ()
|
||||
assert terminated == [child.pid, wrapper.pid]
|
||||
assert unrelated.pid in alive
|
||||
|
||||
|
||||
def test_cleanup_unknown_identity_kills_nothing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
attempt = _attempt()
|
||||
wrapper = _wrapper(attempt)
|
||||
child = _child(attempt)
|
||||
killed: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"_query_process_identities",
|
||||
lambda *_args, **_kwargs: [wrapper, child],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"_terminate_verified_identity",
|
||||
lambda identity: killed.append(identity.pid),
|
||||
)
|
||||
|
||||
result = launcher.strict_failure_cleanup(attempt, None)
|
||||
|
||||
assert result.state == "CLEANUP_INCOMPLETE"
|
||||
assert killed == []
|
||||
|
||||
|
||||
def test_two_sequential_successful_starts_use_distinct_nonces(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
config = _config(tmp_path)
|
||||
release_id = "rf001-agent-2026.09.29.1"
|
||||
_ready(config, release_id, "2026.09.29.1")
|
||||
nonces = iter(("first-nonce", "second-nonce"))
|
||||
process_ids = iter((200, 300))
|
||||
observed: list[tuple[int, str]] = []
|
||||
|
||||
monkeypatch.setattr(launcher.secrets, "token_hex", lambda _length: next(nonces))
|
||||
monkeypatch.setattr(
|
||||
launcher,
|
||||
"_runtime_executables",
|
||||
lambda _value: (WRAPPER, CHILD),
|
||||
)
|
||||
|
||||
def spawn(
|
||||
command: list[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
log_stream: object,
|
||||
) -> FakeProcess:
|
||||
del cwd, log_stream
|
||||
process = FakeProcess(next(process_ids))
|
||||
nonce = command[command.index("--launch-nonce") + 1]
|
||||
Path(str(config["health_file"])).write_text(
|
||||
json.dumps(_health(pid=process.pid, nonce=nonce, updated_at="2099-01-01T00:00:00+00:00")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return process
|
||||
|
||||
def capture(attempt: launcher.LaunchAttempt, *, timeout_seconds: float) -> launcher.ProcessIdentity:
|
||||
del timeout_seconds
|
||||
return _identity(
|
||||
attempt.wrapper_pid,
|
||||
100,
|
||||
executable=WRAPPER,
|
||||
command=attempt.command,
|
||||
)
|
||||
|
||||
def validate(
|
||||
attempt: launcher.LaunchAttempt,
|
||||
wrapper: launcher.ProcessIdentity,
|
||||
health_pid: int,
|
||||
) -> launcher.ValidatedTarget:
|
||||
observed.append((health_pid, attempt.launch_nonce))
|
||||
return launcher.ValidatedTarget("DIRECT", wrapper, wrapper)
|
||||
|
||||
monkeypatch.setattr(launcher, "_spawn_agent", spawn)
|
||||
monkeypatch.setattr(launcher, "_capture_wrapper_identity", capture)
|
||||
monkeypatch.setattr(launcher, "_validate_target_twice", validate)
|
||||
|
||||
first, _ = launcher.start_release(config, release_id)
|
||||
second, _ = launcher.start_release(config, release_id)
|
||||
|
||||
assert first.pid == 200
|
||||
assert second.pid == 300
|
||||
assert observed == [(200, "first-nonce"), (300, "second-nonce")]
|
||||
172
tests/test_rf001_agent_update.py
Normal file
172
tests/test_rf001_agent_update.py
Normal file
@@ -0,0 +1,172 @@
|
||||
"""Software-only tests for fixed RF-001 agent release archives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rf001_agent_update import (
|
||||
CONTENT_MANIFEST_NAME,
|
||||
RELEASE_SOURCE_FILES,
|
||||
ReleaseValidationError,
|
||||
build_release_archive,
|
||||
canonical_json_bytes,
|
||||
inspect_release_archive,
|
||||
sign_update_manifest,
|
||||
verify_update_manifest_signature,
|
||||
)
|
||||
|
||||
|
||||
def _source_tree(root: Path, *, agent_version: str = "2026.09.29.1") -> None:
|
||||
for index, relative in enumerate(RELEASE_SOURCE_FILES.values()):
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"# fixed fixture {index}\n"
|
||||
if relative == RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]:
|
||||
content += f'AGENT_VERSION = "{agent_version}"\n'
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def test_release_roundtrip_uses_exact_allowlist(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
_source_tree(source)
|
||||
package = tmp_path / "rf001-agent-2026.09.29.1.zip"
|
||||
built = build_release_archive(
|
||||
source,
|
||||
package,
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
)
|
||||
extraction = tmp_path / "extracted"
|
||||
checked = inspect_release_archive(
|
||||
package,
|
||||
expected_package_sha256=built.package_sha256,
|
||||
expected_package_bytes=built.package_bytes,
|
||||
expected_content_manifest_sha256=built.content_manifest_sha256,
|
||||
extraction_root=extraction,
|
||||
)
|
||||
assert checked == built
|
||||
assert {
|
||||
path.relative_to(extraction).as_posix()
|
||||
for path in extraction.rglob("*")
|
||||
if path.is_file()
|
||||
} == set(RELEASE_SOURCE_FILES) | {CONTENT_MANIFEST_NAME}
|
||||
|
||||
|
||||
def test_update_manifest_rejects_bad_signature() -> None:
|
||||
token = b"fixed-test-token-0123456789abcdef"
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"release_id": "rf001-agent-2026.09.29.1",
|
||||
"package_sha256": "a" * 64,
|
||||
}
|
||||
signature = sign_update_manifest(token, manifest)
|
||||
verify_update_manifest_signature(token, manifest, signature)
|
||||
with pytest.raises(ReleaseValidationError, match="mismatch"):
|
||||
verify_update_manifest_signature(token, manifest, "0" * 64)
|
||||
changed = dict(manifest, package_sha256="b" * 64)
|
||||
with pytest.raises(ReleaseValidationError, match="mismatch"):
|
||||
verify_update_manifest_signature(token, changed, signature)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe_name",
|
||||
(
|
||||
"../outside.py",
|
||||
"/absolute.py",
|
||||
"C:/absolute.py",
|
||||
"app\\tools\\backslash.py",
|
||||
),
|
||||
)
|
||||
def test_release_rejects_unsafe_paths(tmp_path: Path, unsafe_name: str) -> None:
|
||||
package = tmp_path / "unsafe.zip"
|
||||
with zipfile.ZipFile(package, "w") as archive:
|
||||
archive.writestr(unsafe_name, b"x")
|
||||
# zipfile normalizes a backslash to a slash before it can be inspected;
|
||||
# that spelling still cannot enter the fixed release allowlist.
|
||||
with pytest.raises(
|
||||
ReleaseValidationError,
|
||||
match="unsafe|drive-like|not allowlisted|manifest is absent",
|
||||
):
|
||||
inspect_release_archive(package)
|
||||
|
||||
|
||||
def test_release_rejects_symlink_and_duplicate_names(tmp_path: Path) -> None:
|
||||
symlink_package = tmp_path / "symlink.zip"
|
||||
with zipfile.ZipFile(symlink_package, "w") as archive:
|
||||
info = zipfile.ZipInfo("app/tools/link.py")
|
||||
info.create_system = 3
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
archive.writestr(info, b"target")
|
||||
with pytest.raises(ReleaseValidationError, match="links"):
|
||||
inspect_release_archive(symlink_package)
|
||||
|
||||
duplicate_package = tmp_path / "duplicate.zip"
|
||||
with zipfile.ZipFile(duplicate_package, "w") as archive:
|
||||
archive.writestr("app/tools/a.py", b"a")
|
||||
archive.writestr("APP/TOOLS/A.PY", b"b")
|
||||
with pytest.raises(ReleaseValidationError, match="duplicate"):
|
||||
inspect_release_archive(duplicate_package)
|
||||
|
||||
|
||||
def test_release_rejects_unsupported_protocol_and_sha(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
_source_tree(source)
|
||||
package = tmp_path / "release.zip"
|
||||
built = build_release_archive(
|
||||
source,
|
||||
package,
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=2,
|
||||
)
|
||||
assert built.protocol_version == 2
|
||||
with pytest.raises(ReleaseValidationError, match="package SHA256"):
|
||||
inspect_release_archive(package, expected_package_sha256="0" * 64)
|
||||
|
||||
|
||||
def test_content_manifest_must_be_canonical(tmp_path: Path) -> None:
|
||||
package = tmp_path / "noncanonical.zip"
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"release_id": "rf001-agent-2026.09.29.1",
|
||||
"agent_version": "2026.09.29.1",
|
||||
"protocol_version": 1,
|
||||
"files": [],
|
||||
}
|
||||
assert canonical_json_bytes(manifest) != json.dumps(manifest).encode("utf-8")
|
||||
with zipfile.ZipFile(package, "w") as archive:
|
||||
archive.writestr(CONTENT_MANIFEST_NAME, json.dumps(manifest))
|
||||
with pytest.raises(ReleaseValidationError, match="canonical"):
|
||||
inspect_release_archive(package)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_version", "requested_version"),
|
||||
(
|
||||
("2026.09.29.2", "2026.09.29.1"),
|
||||
("2026.09.29.1", "2026.09.29.2"),
|
||||
),
|
||||
)
|
||||
def test_release_builder_rejects_version_mismatch_before_artifact(
|
||||
tmp_path: Path,
|
||||
source_version: str,
|
||||
requested_version: str,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
_source_tree(source, agent_version=source_version)
|
||||
package = tmp_path / "must-not-exist.zip"
|
||||
with pytest.raises(ReleaseValidationError, match="AGENT_VERSION|release_id"):
|
||||
build_release_archive(
|
||||
source,
|
||||
package,
|
||||
release_id=f"rf001-agent-{requested_version}",
|
||||
agent_version=requested_version,
|
||||
protocol_version=1,
|
||||
)
|
||||
assert not package.exists()
|
||||
133
tests/test_rf001_bootstrap_install_orchestrator.py
Normal file
133
tests/test_rf001_bootstrap_install_orchestrator.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Software-only regressions for external bootstrap install evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rf001_bootstrap_install_orchestrator import (
|
||||
EvidencePathError,
|
||||
PackageValidationError,
|
||||
SIGNATURE_DOMAIN,
|
||||
run_installer,
|
||||
snapshot_tree,
|
||||
validate_pristine_package,
|
||||
)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _signed_package(tmp_path: Path) -> tuple[Path, Path]:
|
||||
package = tmp_path / "clean-bootstrap"
|
||||
package.mkdir()
|
||||
(package / "INSTALL_RX_AGENT.cmd").write_text("@echo off\n", encoding="utf-8")
|
||||
payload = package / "payload.bin"
|
||||
payload.write_bytes(b"fixed bootstrap payload")
|
||||
rows = []
|
||||
for path in sorted(item for item in package.rglob("*") if item.is_file()):
|
||||
rows.append(
|
||||
{
|
||||
"path": path.relative_to(package).as_posix(),
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
)
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"package_kind": "BOOTSTRAP",
|
||||
"bootstrap_id": "rf001-bootstrap-2099.01.01.1",
|
||||
"agent_release_id": "rf001-agent-2099.01.01.1",
|
||||
"agent_version": "2099.01.01.1",
|
||||
"files": rows,
|
||||
}
|
||||
manifest_bytes = (
|
||||
json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n"
|
||||
).encode("utf-8")
|
||||
(package / "BOOTSTRAP_MANIFEST.json").write_bytes(manifest_bytes)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
signature = hmac.new(
|
||||
token.read_text(encoding="utf-8").encode("utf-8"),
|
||||
SIGNATURE_DOMAIN + manifest_bytes,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
(package / "BOOTSTRAP_MANIFEST.hmac").write_text(
|
||||
signature + "\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
return package, token
|
||||
|
||||
|
||||
def test_pristine_package_uses_external_logs_and_remains_unchanged(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
package, token = _signed_package(tmp_path)
|
||||
evidence = tmp_path / "external-evidence"
|
||||
before = snapshot_tree(package)
|
||||
command = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; print('installer stdout'); print('installer stderr', file=sys.stderr)",
|
||||
]
|
||||
|
||||
result = run_installer(package, evidence, token, command=command)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
assert result["package_content_unchanged_immediate"] is True
|
||||
assert result["package_content_unchanged_after"] is True
|
||||
assert result["package_metadata_unchanged_after"] is True
|
||||
assert snapshot_tree(package) == before
|
||||
assert (evidence / "install.stdout.txt").read_text().strip() == "installer stdout"
|
||||
assert (evidence / "install.stderr.txt").read_text().strip() == "installer stderr"
|
||||
assert not (package / "install.stdout.txt").exists()
|
||||
assert not (package / "install.stderr.txt").exists()
|
||||
preflight = json.loads((evidence / "package_preflight.json").read_text())
|
||||
assert preflight["hmac"] == "PASS"
|
||||
assert preflight["unexpected"] == 0
|
||||
assert preflight["missing"] == 0
|
||||
|
||||
|
||||
def test_contaminated_package_fails_closed_before_command(tmp_path: Path) -> None:
|
||||
package, token = _signed_package(tmp_path)
|
||||
(package / "install.stdout.txt").write_text("orchestration leak\n", encoding="utf-8")
|
||||
evidence = tmp_path / "external-evidence"
|
||||
command_marker = tmp_path / "command-ran.txt"
|
||||
command = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
f"from pathlib import Path; Path({str(command_marker)!r}).write_text('ran')",
|
||||
]
|
||||
|
||||
with pytest.raises(PackageValidationError, match="allowlist mismatch"):
|
||||
run_installer(package, evidence, token, command=command)
|
||||
|
||||
assert not command_marker.exists()
|
||||
assert not (evidence / "install.stdout.txt").exists()
|
||||
assert not (evidence / "install.stderr.txt").exists()
|
||||
|
||||
|
||||
def test_evidence_directory_inside_package_is_rejected(tmp_path: Path) -> None:
|
||||
package, token = _signed_package(tmp_path)
|
||||
evidence = package / "runtime-evidence"
|
||||
|
||||
with pytest.raises(EvidencePathError, match="outside the package"):
|
||||
run_installer(package, evidence, token, command=[sys.executable, "-c", "pass"])
|
||||
|
||||
|
||||
def test_pristine_validation_reports_exact_allowlist(tmp_path: Path) -> None:
|
||||
package, token = _signed_package(tmp_path)
|
||||
|
||||
result = validate_pristine_package(package, token)
|
||||
|
||||
assert result["hmac"] == "PASS"
|
||||
assert result["manifest_file_count"] == 2
|
||||
assert result["package_file_count"] == 4
|
||||
assert result["unexpected"] == 0
|
||||
assert result["missing"] == 0
|
||||
319
tests/test_rf001_build_agent_packages.py
Normal file
319
tests/test_rf001_build_agent_packages.py
Normal file
@@ -0,0 +1,319 @@
|
||||
"""Software-only tests for the authenticated bootstrap package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from pathlib import PurePosixPath
|
||||
import shutil
|
||||
import subprocess
|
||||
import unicodedata
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.rf001_build_agent_packages as packages
|
||||
from tools.rf001_agent_update import (
|
||||
RELEASE_SOURCE_FILES,
|
||||
ReleaseValidationError,
|
||||
source_agent_version,
|
||||
)
|
||||
|
||||
|
||||
def _bootstrap_source(
|
||||
root: Path,
|
||||
*,
|
||||
agent_version: str = "2026.09.29.1",
|
||||
) -> Path:
|
||||
fixed = {
|
||||
"INSTALL_RX_AGENT.cmd": "@echo off\n",
|
||||
"UNINSTALL_RX_AGENT.cmd": "@echo off\n",
|
||||
"tools/rf001_bootstrap_readme.txt": "fixed bootstrap readme\n",
|
||||
"tools/rf001_agent_launcher.py": "# fixed launcher\n",
|
||||
"tools/install_rf001_rx_autostart.ps1": "# fixed installer\n",
|
||||
"tools/uninstall_rf001_rx_autostart.ps1": "# fixed uninstaller\n",
|
||||
}
|
||||
for name, content in fixed.items():
|
||||
path = root / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
for index, relative in enumerate(RELEASE_SOURCE_FILES.values()):
|
||||
path = root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"# fixed release source {index}\n"
|
||||
if relative == RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]:
|
||||
content += f'AGENT_VERSION = "{agent_version}"\n'
|
||||
path.write_text(content, encoding="utf-8")
|
||||
reference = root / ".local/rf001_probe/lab065_reference_staircase.cs8"
|
||||
reference.parent.mkdir(parents=True, exist_ok=True)
|
||||
reference.write_bytes(b"fixed exact staircase fixture")
|
||||
return reference
|
||||
|
||||
|
||||
def _formed_installer_allowlist(release_id: str) -> list[str]:
|
||||
powershell = shutil.which("powershell.exe")
|
||||
if powershell is None:
|
||||
pytest.skip("Windows PowerShell is required to evaluate the installer allowlist")
|
||||
installer = Path(packages.__file__).with_name("install_rf001_rx_autostart.ps1")
|
||||
environment = os.environ.copy()
|
||||
environment["SDR_ROVER_INSTALLER_PATH"] = str(installer)
|
||||
environment["SDR_ROVER_RELEASE_ID"] = release_id
|
||||
command = r"""
|
||||
$ErrorActionPreference = "Stop"
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$syntax = [Management.Automation.Language.Parser]::ParseFile(
|
||||
$env:SDR_ROVER_INSTALLER_PATH,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors
|
||||
)
|
||||
if ($parseErrors.Count -ne 0) {
|
||||
throw "Installer contains PowerShell parse errors"
|
||||
}
|
||||
$assignments = @($syntax.FindAll({
|
||||
param($node)
|
||||
$node -is [Management.Automation.Language.AssignmentStatementAst] -and
|
||||
$node.Left -is [Management.Automation.Language.VariableExpressionAst] -and
|
||||
$node.Left.VariablePath.UserPath -eq "expectedPaths"
|
||||
}, $true))
|
||||
if ($assignments.Count -ne 1) {
|
||||
throw "Installer expectedPaths assignment is absent or ambiguous"
|
||||
}
|
||||
$releasePrefix = "versions/$($env:SDR_ROVER_RELEASE_ID)/"
|
||||
. ([scriptblock]::Create($assignments[0].Extent.Text))
|
||||
ConvertTo-Json -Compress -InputObject @($expectedPaths)
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
[powershell, "-NoProfile", "-NonInteractive", "-Command", command],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
env=environment,
|
||||
)
|
||||
value = json.loads(completed.stdout.lstrip("\ufeff"))
|
||||
assert isinstance(value, list)
|
||||
assert all(isinstance(path, str) for path in value)
|
||||
return value
|
||||
|
||||
|
||||
def test_bootstrap_is_authenticated_and_excludes_secret(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "bootstrap.zip"
|
||||
built = packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.1",
|
||||
agent_release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
checked = packages.inspect_bootstrap_archive(package, token_path=token)
|
||||
assert checked == built
|
||||
assert checked.file_count == len(RELEASE_SOURCE_FILES) + 8
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
names = archive.namelist()
|
||||
manifest = json.loads(archive.read(packages.BOOTSTRAP_MANIFEST_NAME))
|
||||
ready = json.loads(
|
||||
archive.read("versions/rf001-agent-2026.09.29.1/READY.json")
|
||||
)
|
||||
assert not any("token" in name.casefold() for name in names)
|
||||
assert not any(
|
||||
b"fixed-test-token" in archive.read(name)
|
||||
for name in names
|
||||
)
|
||||
actual_version = source_agent_version(
|
||||
source / RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]
|
||||
)
|
||||
assert manifest["agent_version"] == actual_version
|
||||
assert ready["agent_version"] == actual_version
|
||||
|
||||
|
||||
def test_new_bootstrap_preserves_existing_release_bytes(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
base_package = tmp_path / "bootstrap-4.zip"
|
||||
next_package = tmp_path / "bootstrap-5.zip"
|
||||
release_id = "rf001-agent-2026.09.29.1"
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
base_package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.4",
|
||||
agent_release_id=release_id,
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
(source / "tools/rf001_agent_launcher.py").write_text(
|
||||
"# corrected launcher\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
next_package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.5",
|
||||
agent_release_id=release_id,
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
agent_release_bootstrap_id="rf001-bootstrap-2026.09.29.4",
|
||||
)
|
||||
|
||||
release_prefix = f"versions/{release_id}/"
|
||||
with zipfile.ZipFile(base_package) as base, zipfile.ZipFile(next_package) as new:
|
||||
release_names = sorted(
|
||||
name for name in base.namelist() if name.startswith(release_prefix)
|
||||
)
|
||||
assert release_names
|
||||
assert all(base.read(name) == new.read(name) for name in release_names)
|
||||
assert base.read("launcher/rf001_agent_launcher.py") != new.read(
|
||||
"launcher/rf001_agent_launcher.py"
|
||||
)
|
||||
manifest = json.loads(new.read(packages.BOOTSTRAP_MANIFEST_NAME))
|
||||
ready = json.loads(new.read(f"{release_prefix}READY.json"))
|
||||
assert manifest["bootstrap_id"] == "rf001-bootstrap-2026.09.29.5"
|
||||
assert (
|
||||
manifest["agent_release_bootstrap_id"]
|
||||
== "rf001-bootstrap-2026.09.29.4"
|
||||
)
|
||||
assert ready["bootstrap_id"] == "rf001-bootstrap-2026.09.29.4"
|
||||
|
||||
|
||||
def test_bootstrap_rejects_the_wrong_existing_secret(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "bootstrap.zip"
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.1",
|
||||
agent_release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
wrong = tmp_path / "wrong.txt"
|
||||
wrong.write_text("another-fixed-token-0123456789abcdef", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="signature mismatch"):
|
||||
packages.inspect_bootstrap_archive(package, token_path=wrong)
|
||||
|
||||
|
||||
def test_installer_allowlist_matches_signed_manifest_and_has_safe_paths(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "bootstrap.zip"
|
||||
release_id = "rf001-agent-2026.09.29.1"
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.2",
|
||||
agent_release_id=release_id,
|
||||
agent_version="2026.09.29.1",
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
manifest = json.loads(archive.read(packages.BOOTSTRAP_MANIFEST_NAME))
|
||||
manifest_paths = {row["path"] for row in manifest["files"]}
|
||||
formed_paths = _formed_installer_allowlist(release_id)
|
||||
|
||||
assert set(formed_paths) == manifest_paths
|
||||
assert len(formed_paths) == len(manifest_paths)
|
||||
assert f"versions/{release_id}/READY.json" in formed_paths
|
||||
assert (
|
||||
f"versions/{release_id}/app/tools/hackrf_process_check.py" in formed_paths
|
||||
)
|
||||
assert (
|
||||
f"versions/{release_id}/app/tools/rf001_staircase_search.py" in formed_paths
|
||||
)
|
||||
for path in formed_paths:
|
||||
assert not any(unicodedata.category(character) == "Cc" for character in path)
|
||||
parsed = PurePosixPath(path)
|
||||
assert not parsed.is_absolute()
|
||||
assert all(part not in ("", ".", "..") for part in parsed.parts)
|
||||
assert "\\" not in path
|
||||
assert not any(":" in part for part in parsed.parts)
|
||||
|
||||
with zipfile.ZipFile(package, "a") as archive:
|
||||
archive.writestr("unexpected/extra.txt", b"prohibited")
|
||||
with pytest.raises(ValueError, match="unexpected files"):
|
||||
packages.inspect_bootstrap_archive(package, token_path=token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source_version", "requested_version"),
|
||||
(
|
||||
("2026.09.29.2", "2026.09.29.1"),
|
||||
("2026.09.29.1", "2026.09.29.2"),
|
||||
),
|
||||
)
|
||||
def test_bootstrap_rejects_agent_version_mismatch_before_creating_artifact(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
source_version: str,
|
||||
requested_version: str,
|
||||
) -> None:
|
||||
source = tmp_path / "source"
|
||||
reference = _bootstrap_source(source, agent_version=source_version)
|
||||
monkeypatch.setattr(
|
||||
packages,
|
||||
"REFERENCE_SHA256",
|
||||
hashlib.sha256(reference.read_bytes()).hexdigest(),
|
||||
)
|
||||
token = tmp_path / "token.txt"
|
||||
token.write_text("fixed-test-token-0123456789abcdef", encoding="utf-8")
|
||||
package = tmp_path / "must-not-exist.zip"
|
||||
with pytest.raises(ReleaseValidationError, match="AGENT_VERSION|release_id"):
|
||||
packages.build_bootstrap_archive(
|
||||
source,
|
||||
package,
|
||||
bootstrap_id="rf001-bootstrap-2026.09.29.3",
|
||||
agent_release_id=f"rf001-agent-{requested_version}",
|
||||
agent_version=requested_version,
|
||||
protocol_version=1,
|
||||
token_path=token,
|
||||
)
|
||||
assert not package.exists()
|
||||
48
tests/test_rf001_capture_runner.py
Normal file
48
tests/test_rf001_capture_runner.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Software-only process evidence tests for the Python RX runner."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from tools.rf001_capture_runner import run_capture
|
||||
|
||||
|
||||
def test_python_runner_persists_child_exit_and_hash(tmp_path: Path) -> None:
|
||||
child = tmp_path / "child.py"
|
||||
capture = tmp_path / "capture.cs8"
|
||||
capture_bytes = b"software-only-capture-evidence\x00\x01"
|
||||
child.write_text(
|
||||
"from pathlib import Path\n"
|
||||
f"Path({str(capture)!r}).write_bytes({capture_bytes!r})\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
arguments = tmp_path / "arguments.json"
|
||||
arguments.write_text(json.dumps(["-B", str(child)]), encoding="utf-8")
|
||||
stdout = tmp_path / "stdout.txt"
|
||||
stderr = tmp_path / "stderr.txt"
|
||||
started = tmp_path / "started.json"
|
||||
result = tmp_path / "result.json"
|
||||
|
||||
exit_code = run_capture(
|
||||
Path(sys.executable),
|
||||
arguments,
|
||||
capture,
|
||||
stdout,
|
||||
stderr,
|
||||
started,
|
||||
result,
|
||||
)
|
||||
|
||||
start_record = json.loads(started.read_text(encoding="utf-8"))
|
||||
final_record = json.loads(result.read_text(encoding="utf-8"))
|
||||
assert exit_code == 0
|
||||
assert start_record["state"] == "RUNNING"
|
||||
assert start_record["rf_tx"] == 0
|
||||
assert final_record["exit_code"] == 0
|
||||
assert final_record["capture_size"] == len(capture_bytes)
|
||||
assert final_record["sha256"] == hashlib.sha256(capture_bytes).hexdigest()
|
||||
assert final_record["runner_error"] is None
|
||||
assert final_record["rf_tx"] == 0
|
||||
537
tests/test_rf001_http_control.py
Normal file
537
tests/test_rf001_http_control.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""Software-only tests for the RF-001 HTTP control channel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.rf001_http_control as control_module
|
||||
from tools.rf001_http_control import (
|
||||
AuthenticationError,
|
||||
ControlHTTPServer,
|
||||
ControlState,
|
||||
PROTOCOL_VERSION,
|
||||
ProtocolError,
|
||||
RequestAuthenticator,
|
||||
canonical_json,
|
||||
request_json,
|
||||
signed_headers,
|
||||
)
|
||||
|
||||
|
||||
TOKEN = b"software-only-test-token-0123456789abcdef0123456789abcdef"
|
||||
|
||||
|
||||
def test_bootstrap_registration_uses_distinct_fixed_identifier(
|
||||
tmp_path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
releases = tmp_path / "releases"
|
||||
releases.mkdir()
|
||||
package = releases / "rf001-bootstrap-2026.09.29.1.zip"
|
||||
package.write_bytes(b"fixed bootstrap package")
|
||||
monkeypatch.setattr(control_module, "DEFAULT_RELEASES_ROOT", releases)
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
registered = state.register_bootstrap_release(
|
||||
str(package),
|
||||
release_id="rf001-bootstrap-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
content_manifest_sha256="a" * 64,
|
||||
token=TOKEN,
|
||||
)
|
||||
assert registered["package_kind"] == "BOOTSTRAP"
|
||||
assert registered["rf_tx"] == 0
|
||||
replacement = releases / "replacement.zip"
|
||||
replacement.write_bytes(b"different bootstrap package")
|
||||
with pytest.raises(ProtocolError, match="already registered differently"):
|
||||
state.register_bootstrap_release(
|
||||
str(replacement),
|
||||
release_id="rf001-bootstrap-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
content_manifest_sha256="b" * 64,
|
||||
token=TOKEN,
|
||||
)
|
||||
with pytest.raises(ProtocolError, match="release_id"):
|
||||
state.register_bootstrap_release(
|
||||
str(package),
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
content_manifest_sha256="a" * 64,
|
||||
token=TOKEN,
|
||||
)
|
||||
|
||||
|
||||
def test_request_authentication_rejects_replay_and_tampering() -> None:
|
||||
authenticator = RequestAuthenticator(TOKEN, window_seconds=60)
|
||||
body = canonical_json({"agent_id": "rx-user-pc", "rf_tx": 0})
|
||||
target = "/api/v1/agent/check-in"
|
||||
headers = signed_headers(
|
||||
TOKEN,
|
||||
"POST",
|
||||
target,
|
||||
body,
|
||||
timestamp=1_000,
|
||||
nonce="a" * 32,
|
||||
)
|
||||
|
||||
authenticator.verify("POST", target, body, headers, now=1_000)
|
||||
with pytest.raises(AuthenticationError, match="already used"):
|
||||
authenticator.verify("POST", target, body, headers, now=1_000)
|
||||
|
||||
changed_headers = signed_headers(
|
||||
TOKEN,
|
||||
"POST",
|
||||
target,
|
||||
body,
|
||||
timestamp=1_000,
|
||||
nonce="b" * 32,
|
||||
)
|
||||
with pytest.raises(AuthenticationError, match="mismatch"):
|
||||
authenticator.verify(
|
||||
"POST",
|
||||
target,
|
||||
body + b" ",
|
||||
changed_headers,
|
||||
now=1_000,
|
||||
)
|
||||
|
||||
|
||||
def test_state_leases_inventory_and_records_idempotent_result(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
task = state.enqueue_inventory("rx-user-pc", include_hackrf_info=True)
|
||||
response = state.check_in(
|
||||
{
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
)
|
||||
assert response["task"] == task
|
||||
assert response["rf_tx"] == 0
|
||||
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": "rx-user-pc",
|
||||
"status": "COMPLETE",
|
||||
"started_at": "2026-09-23T00:00:00+00:00",
|
||||
"finished_at": "2026-09-23T00:00:01+00:00",
|
||||
"result": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"errors": [],
|
||||
"rf_tx": 0,
|
||||
}
|
||||
accepted = state.record_result(result)
|
||||
assert accepted == {"accepted": True, "duplicate": False, "rf_tx": 0}
|
||||
duplicate = state.record_result(result)
|
||||
assert duplicate == {"accepted": True, "duplicate": True, "rf_tx": 0}
|
||||
|
||||
conflicting = dict(result)
|
||||
conflicting["status"] = "FAILED"
|
||||
with pytest.raises(ProtocolError, match="conflicting"):
|
||||
state.record_result(conflicting)
|
||||
|
||||
|
||||
def test_state_leases_fixed_offline_verify_task(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
expected_head = "a" * 40
|
||||
task = state.enqueue_offline_verify(
|
||||
"rx-user-pc",
|
||||
expected_head=expected_head,
|
||||
)
|
||||
response = state.check_in(
|
||||
{
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
)
|
||||
assert response["task"] == task
|
||||
assert task["kind"] == "OFFLINE_VERIFY"
|
||||
assert task["expected_head"] == expected_head
|
||||
assert task["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_offline_verify_rejects_invalid_expected_head(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
with pytest.raises(ProtocolError, match="expected_head"):
|
||||
state.enqueue_offline_verify("rx-user-pc", expected_head="main")
|
||||
|
||||
|
||||
def test_rx_capture_requires_exact_local_authorization(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
with pytest.raises(ProtocolError, match="exact RX authorization"):
|
||||
state.enqueue_rx_capture(
|
||||
"rx-user-pc",
|
||||
expected_head="a" * 40,
|
||||
authorization="yes",
|
||||
)
|
||||
|
||||
task = state.enqueue_rx_capture(
|
||||
"rx-user-pc",
|
||||
expected_head="a" * 40,
|
||||
authorization="AUTHORIZE RF RX",
|
||||
)
|
||||
assert task["kind"] == "RX_CAPTURE_START"
|
||||
assert task["rx_serial"] == "000000000000000097c063dc2e5327d3"
|
||||
assert task["rx_authorized"] is True
|
||||
assert task["automatic_retry"] is False
|
||||
assert task["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_expired_rx_capture_lease_is_not_retried(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
task = state.enqueue_rx_capture(
|
||||
"rx-user-pc",
|
||||
expected_head="a" * 40,
|
||||
authorization="AUTHORIZE RF RX",
|
||||
)
|
||||
check_in = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
|
||||
first = state.check_in(check_in)
|
||||
assert first["task"] == task
|
||||
|
||||
state._data["tasks"][0]["leased_at_epoch"] = int(time.time()) - 31
|
||||
second = state.check_in(check_in)
|
||||
|
||||
assert second["task"] is None
|
||||
stored = state.snapshot()["tasks"][0]
|
||||
assert stored["state"] == "LEASE_EXPIRED"
|
||||
assert "lease_expired_at" in stored
|
||||
assert stored["task_id"] == task["task_id"]
|
||||
|
||||
|
||||
def test_status_and_decode_tasks_use_fixed_session(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
session_id = "RF001A_20260923T150000Z_1234ABCD"
|
||||
status = state.enqueue_rx_status("rx-user-pc", session_id=session_id)
|
||||
decode = state.enqueue_capture_decode(
|
||||
"rx-user-pc",
|
||||
session_id=session_id,
|
||||
tx_started_at="2026-09-23T15:00:05+00:00",
|
||||
tx_finished_at="2026-09-23T15:00:07+00:00",
|
||||
)
|
||||
diagnostics = state.enqueue_capture_diagnostics(
|
||||
"rx-user-pc",
|
||||
session_id=session_id,
|
||||
tx_started_at="2026-09-23T15:00:05+00:00",
|
||||
tx_finished_at="2026-09-23T15:00:07+00:00",
|
||||
)
|
||||
assert status["kind"] == "RX_CAPTURE_STATUS"
|
||||
assert decode["kind"] == "CAPTURE_DECODE"
|
||||
assert diagnostics["kind"] == "CAPTURE_DIAGNOSTICS"
|
||||
assert status["session_id"] == decode["session_id"] == session_id
|
||||
assert diagnostics["session_id"] == session_id
|
||||
assert status["rf_tx"] == decode["rf_tx"] == diagnostics["rf_tx"] == 0
|
||||
|
||||
with pytest.raises(ProtocolError, match="invalid session_id"):
|
||||
state.enqueue_rx_status("rx-user-pc", session_id="..\\outside")
|
||||
|
||||
|
||||
def test_staircase_task_has_only_fixed_hashes_and_no_retry(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
session_id = "RF001A_20260925T125829Z_227EADB4"
|
||||
task = state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id=session_id,
|
||||
expected_rx_sha256="2" * 64,
|
||||
expected_reference_sha256="f" * 64,
|
||||
)
|
||||
assert task["kind"] == "STAIRCASE_SEARCH"
|
||||
assert task["session_id"] == session_id
|
||||
assert task["expected_rx_sha256"] == "2" * 64
|
||||
assert task["expected_reference_sha256"] == "f" * 64
|
||||
assert task["hardware_accessed"] is False
|
||||
assert task["automatic_retry"] is False
|
||||
assert task["rf_tx"] == 0
|
||||
assert "capture_path" not in task
|
||||
assert "reference_path" not in task
|
||||
assert "command" not in task
|
||||
|
||||
with pytest.raises(ProtocolError, match="invalid session_id"):
|
||||
state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id="..\\outside",
|
||||
expected_rx_sha256="2" * 64,
|
||||
expected_reference_sha256="f" * 64,
|
||||
)
|
||||
with pytest.raises(ProtocolError, match="expected_rx_sha256"):
|
||||
state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id=session_id,
|
||||
expected_rx_sha256="bad",
|
||||
expected_reference_sha256="f" * 64,
|
||||
)
|
||||
with pytest.raises(ProtocolError, match="expected_reference_sha256"):
|
||||
state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id=session_id,
|
||||
expected_rx_sha256="2" * 64,
|
||||
expected_reference_sha256="bad",
|
||||
)
|
||||
|
||||
|
||||
def test_long_task_heartbeat_renews_expired_lease_without_reissue(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
task = state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id="RF001A_20260925T125829Z_227EADB4",
|
||||
expected_rx_sha256="2" * 64,
|
||||
expected_reference_sha256="f" * 64,
|
||||
)
|
||||
check_in = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"agent_version": "test", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
assert state.check_in(check_in)["task"] == task
|
||||
state._data["tasks"][0]["leased_at_epoch"] = int(time.time()) - 300
|
||||
active = {
|
||||
**check_in,
|
||||
"active_task": {
|
||||
"task_id": task["task_id"],
|
||||
"kind": "STAIRCASE_SEARCH",
|
||||
"state": "RUNNING",
|
||||
"phase": "SEARCHING",
|
||||
"processed_samples": 400,
|
||||
"total_samples": 1000,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
},
|
||||
}
|
||||
response = state.check_in(active)
|
||||
assert response["task"] is None
|
||||
stored = state.snapshot()["tasks"][0]
|
||||
assert stored["state"] == "LEASED"
|
||||
assert stored["progress"]["processed_samples"] == 400
|
||||
|
||||
|
||||
def test_update_approval_is_bound_to_registered_release_and_not_repeated(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
release_id = "rf001-agent-2026.09.29.2"
|
||||
state._data["releases"][release_id] = {
|
||||
"manifest": {
|
||||
"schema_version": 1,
|
||||
"release_id": release_id,
|
||||
"agent_version": "2026.09.29.2",
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"package_kind": "AGENT_RELEASE",
|
||||
"package_sha256": "a" * 64,
|
||||
"package_bytes": 1234,
|
||||
"content_manifest_sha256": "b" * 64,
|
||||
},
|
||||
"manifest_signature": "c" * 64,
|
||||
"package_path": str(tmp_path / "release.zip"),
|
||||
}
|
||||
task = state.enqueue_agent_update(
|
||||
"rx-user-pc",
|
||||
release_id=release_id,
|
||||
validity_seconds=3600,
|
||||
)
|
||||
assert task["kind"] == "UPDATE_AGENT"
|
||||
assert task["release_id"] == release_id
|
||||
assert task["package_sha256"] == "a" * 64
|
||||
assert task["automatic_retry"] is False
|
||||
assert task["hardware_accessed"] is False
|
||||
assert task["rf_tx"] == 0
|
||||
with pytest.raises(ProtocolError, match="already has an update task"):
|
||||
state.enqueue_agent_update(
|
||||
"rx-user-pc",
|
||||
release_id=release_id,
|
||||
validity_seconds=3600,
|
||||
)
|
||||
|
||||
|
||||
def test_queued_update_waits_for_active_software_task(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
running = state.enqueue_staircase_search(
|
||||
"rx-user-pc",
|
||||
session_id="RF001A_20260925T125829Z_227EADB4",
|
||||
expected_rx_sha256="2" * 64,
|
||||
expected_reference_sha256="f" * 64,
|
||||
)
|
||||
check_in = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"agent_version": "test", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
assert state.check_in(check_in)["task"] == running
|
||||
release_id = "rf001-agent-2026.09.29.2"
|
||||
state._data["releases"][release_id] = {
|
||||
"manifest": {
|
||||
"schema_version": 1,
|
||||
"release_id": release_id,
|
||||
"agent_version": "2026.09.29.2",
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"package_kind": "AGENT_RELEASE",
|
||||
"package_sha256": "a" * 64,
|
||||
"package_bytes": 1234,
|
||||
"content_manifest_sha256": "b" * 64,
|
||||
},
|
||||
"manifest_signature": "c" * 64,
|
||||
"package_path": str(tmp_path / "release.zip"),
|
||||
}
|
||||
update = state.enqueue_agent_update(
|
||||
"rx-user-pc", release_id=release_id, validity_seconds=3600
|
||||
)
|
||||
response = state.check_in(
|
||||
{
|
||||
**check_in,
|
||||
"active_task": {
|
||||
"task_id": running["task_id"],
|
||||
"kind": "STAIRCASE_SEARCH",
|
||||
"state": "RUNNING",
|
||||
"phase": "SEARCHING",
|
||||
"processed_samples": 1,
|
||||
"total_samples": 10,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
},
|
||||
}
|
||||
)
|
||||
assert response["task"] is None
|
||||
stored = next(
|
||||
task
|
||||
for task in state.snapshot()["tasks"]
|
||||
if task["task_id"] == update["task_id"]
|
||||
)
|
||||
assert stored["state"] == "QUEUED"
|
||||
assert stored["update_status"] == "WAITING_FOR_IDLE"
|
||||
assert stored["blocked_by_task_id"] == running["task_id"]
|
||||
|
||||
|
||||
def test_http_server_end_to_end_inventory_without_rf(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
server = ControlHTTPServer(
|
||||
("127.0.0.1", 0),
|
||||
state=state,
|
||||
token=TOKEN,
|
||||
allowed_agent_ip="127.0.0.1",
|
||||
)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base_url = f"http://127.0.0.1:{server.server_address[1]}"
|
||||
try:
|
||||
task = request_json(
|
||||
base_url,
|
||||
TOKEN,
|
||||
"POST",
|
||||
"/api/v1/local/tasks/inventory",
|
||||
{"agent_id": "rx-user-pc", "include_hackrf_info": True},
|
||||
)
|
||||
assert task["kind"] == "INVENTORY"
|
||||
assert task["rf_tx"] == 0
|
||||
|
||||
check_in = request_json(
|
||||
base_url,
|
||||
TOKEN,
|
||||
"POST",
|
||||
"/api/v1/agent/check-in",
|
||||
{
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
},
|
||||
)
|
||||
assert check_in["task"] == task
|
||||
assert check_in["rf_tx"] == 0
|
||||
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": "rx-user-pc",
|
||||
"status": "COMPLETE",
|
||||
"started_at": "2026-09-23T00:00:00+00:00",
|
||||
"finished_at": "2026-09-23T00:00:01+00:00",
|
||||
"result": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"errors": [],
|
||||
"rf_tx": 0,
|
||||
}
|
||||
acknowledgement = request_json(
|
||||
base_url,
|
||||
TOKEN,
|
||||
"POST",
|
||||
"/api/v1/agent/result",
|
||||
result,
|
||||
)
|
||||
assert acknowledgement["accepted"] is True
|
||||
assert acknowledgement["rf_tx"] == 0
|
||||
|
||||
status = request_json(
|
||||
base_url,
|
||||
TOKEN,
|
||||
"GET",
|
||||
"/api/v1/local/status",
|
||||
)
|
||||
assert status["results"][task["task_id"]] == result
|
||||
assert status["tasks"][0]["state"] == "COMPLETE"
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_result_cannot_claim_rf_tx(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
task = state.enqueue_inventory("rx-user-pc", include_hackrf_info=False)
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": "rx-user-pc",
|
||||
"status": "COMPLETE",
|
||||
"result": {},
|
||||
"errors": [],
|
||||
"rf_tx": 1,
|
||||
}
|
||||
with pytest.raises(ProtocolError, match="rf_tx = 0"):
|
||||
state.record_result(result)
|
||||
|
||||
|
||||
def test_cancelled_task_is_not_released_or_completed(tmp_path) -> None:
|
||||
state = ControlState(tmp_path / "state.json")
|
||||
task = state.enqueue_rx_capture(
|
||||
"rx-user-pc",
|
||||
expected_head="a" * 40,
|
||||
authorization="AUTHORIZE RF RX",
|
||||
)
|
||||
cancellation = state.cancel_task(
|
||||
task["task_id"],
|
||||
reason="software packaging failure before hardware access",
|
||||
)
|
||||
assert cancellation["state"] == "CANCELLED"
|
||||
assert cancellation["rf_tx"] == 0
|
||||
|
||||
response = state.check_in(
|
||||
{
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"agent_id": "rx-user-pc",
|
||||
"inventory": {"hostname": "User-PC", "rf_tx": 0},
|
||||
"rf_tx": 0,
|
||||
}
|
||||
)
|
||||
assert response["task"] is None
|
||||
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": "rx-user-pc",
|
||||
"status": "FAILED",
|
||||
"result": {"hardware_accessed": False, "rf_tx": 0},
|
||||
"errors": ["packaging failure"],
|
||||
"rf_tx": 0,
|
||||
}
|
||||
with pytest.raises(ProtocolError, match="cancelled"):
|
||||
state.record_result(result)
|
||||
429
tests/test_rf001_installer_transaction.py
Normal file
429
tests/test_rf001_installer_transaction.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""Software-only checks for transactional bootstrap target deployment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
BOOTSTRAP_ID = "rf001-bootstrap-2026.09.29.4"
|
||||
RELEASE_ID = "rf001-agent-2026.09.29.1"
|
||||
AGENT_VERSION = "2026.09.29.1"
|
||||
RELEASE_PREFIX = f"versions/{RELEASE_ID}/"
|
||||
FUNCTIONS = (
|
||||
"Get-LowerSha256",
|
||||
"Assert-ExactReleasePath",
|
||||
"Get-ReleaseDirectoryState",
|
||||
"Assert-ReleaseMetadata",
|
||||
"New-UniqueQuarantineDestination",
|
||||
"Write-CreateNewUtf8Json",
|
||||
"Move-ConflictingTargetToQuarantine",
|
||||
"Move-FailedReleasePathToQuarantine",
|
||||
"Install-CleanRelease",
|
||||
"Prepare-TargetRelease",
|
||||
)
|
||||
|
||||
|
||||
def _powershell() -> str:
|
||||
executable = shutil.which("powershell.exe")
|
||||
if executable is None:
|
||||
pytest.skip("Windows PowerShell is required for installer transaction tests")
|
||||
return executable
|
||||
|
||||
|
||||
def _installer() -> Path:
|
||||
return Path(__file__).parents[1] / "tools" / "install_rf001_rx_autostart.ps1"
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _release_fixture(tmp_path: Path) -> tuple[Path, Path, Path, Path]:
|
||||
install_root = tmp_path / "install"
|
||||
versions_root = install_root / "versions"
|
||||
target = versions_root / RELEASE_ID
|
||||
source = tmp_path / "signed-bootstrap" / "versions" / RELEASE_ID
|
||||
tools = source / "app" / "tools"
|
||||
tools.mkdir(parents=True)
|
||||
(tools / "rf001_rx_http_agent.py").write_text(
|
||||
f'AGENT_VERSION = "{AGENT_VERSION}"\n', encoding="utf-8"
|
||||
)
|
||||
(tools / "rf001_agent_update.py").write_text(
|
||||
"# fixed signed updater fixture\n", encoding="utf-8"
|
||||
)
|
||||
ready = {
|
||||
"agent_version": AGENT_VERSION,
|
||||
"bootstrap_id": BOOTSTRAP_ID,
|
||||
"hardware_accessed": False,
|
||||
"protocol_version": 1,
|
||||
"release_id": RELEASE_ID,
|
||||
"rf_tx": 0,
|
||||
"schema_version": 1,
|
||||
"status": "READY",
|
||||
}
|
||||
(source / "READY.json").write_text(
|
||||
json.dumps(ready, sort_keys=True, separators=(",", ":")) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
rows = []
|
||||
for path in sorted(item for item in source.rglob("*") if item.is_file()):
|
||||
relative = path.relative_to(source).as_posix()
|
||||
rows.append(
|
||||
{
|
||||
"path": RELEASE_PREFIX + relative,
|
||||
"bytes": path.stat().st_size,
|
||||
"sha256": _sha256(path),
|
||||
}
|
||||
)
|
||||
manifest = tmp_path / "release_rows.json"
|
||||
manifest.write_text(json.dumps(rows), encoding="utf-8")
|
||||
versions_root.mkdir(parents=True)
|
||||
return source, install_root, target, manifest
|
||||
|
||||
|
||||
def _function_loader() -> str:
|
||||
names = ",".join(f'"{name}"' for name in FUNCTIONS)
|
||||
return rf"""
|
||||
$ErrorActionPreference = "Stop"
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$syntax = [Management.Automation.Language.Parser]::ParseFile(
|
||||
$env:RF001_INSTALLER,
|
||||
[ref]$tokens,
|
||||
[ref]$parseErrors
|
||||
)
|
||||
if ($parseErrors.Count -ne 0) {{
|
||||
throw "Installer contains PowerShell parse errors"
|
||||
}}
|
||||
foreach ($name in @({names})) {{
|
||||
$matches = @($syntax.FindAll({{
|
||||
param($node)
|
||||
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -eq $name
|
||||
}}, $true))
|
||||
if ($matches.Count -ne 1) {{
|
||||
throw "Installer function is absent or ambiguous: $name"
|
||||
}}
|
||||
. ([scriptblock]::Create($matches[0].Extent.Text))
|
||||
}}
|
||||
$releaseRows = @(Get-Content -LiteralPath $env:RF001_ROWS -Raw -Encoding UTF8 | ConvertFrom-Json)
|
||||
$installRoot = $env:RF001_INSTALL_ROOT
|
||||
$versionsRoot = Join-Path $installRoot "versions"
|
||||
$sourceRelease = $env:RF001_SOURCE_RELEASE
|
||||
$targetRelease = Join-Path $versionsRoot "{RELEASE_ID}"
|
||||
"""
|
||||
|
||||
|
||||
def _run_powershell(
|
||||
body: str,
|
||||
*,
|
||||
source: Path,
|
||||
install_root: Path,
|
||||
manifest: Path,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"RF001_INSTALLER": str(_installer()),
|
||||
"RF001_ROWS": str(manifest),
|
||||
"RF001_INSTALL_ROOT": str(install_root),
|
||||
"RF001_SOURCE_RELEASE": str(source),
|
||||
}
|
||||
)
|
||||
completed = subprocess.run(
|
||||
[_powershell(), "-NoProfile", "-NonInteractive", "-Command", _function_loader() + body],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
env=environment,
|
||||
)
|
||||
if check and completed.returncode != 0:
|
||||
raise AssertionError(
|
||||
f"PowerShell transaction harness failed ({completed.returncode}):\n"
|
||||
f"STDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}"
|
||||
)
|
||||
return completed
|
||||
|
||||
|
||||
def _prepare(
|
||||
source: Path,
|
||||
install_root: Path,
|
||||
manifest: Path,
|
||||
) -> dict[str, object]:
|
||||
body = rf"""
|
||||
$result = Prepare-TargetRelease `
|
||||
-InstallRoot $installRoot `
|
||||
-VersionsRoot $versionsRoot `
|
||||
-SourceRelease $sourceRelease `
|
||||
-TargetRelease $targetRelease `
|
||||
-ReleaseId "{RELEASE_ID}" `
|
||||
-BootstrapId "{BOOTSTRAP_ID}" `
|
||||
-AgentVersion "{AGENT_VERSION}" `
|
||||
-ProtocolVersion 1 `
|
||||
-ReleaseRows $releaseRows `
|
||||
-ReleasePrefix "{RELEASE_PREFIX}"
|
||||
$final = Get-ReleaseDirectoryState `
|
||||
-TargetRelease $targetRelease `
|
||||
-ReleaseRows $releaseRows `
|
||||
-ReleasePrefix "{RELEASE_PREFIX}"
|
||||
[ordered]@{{
|
||||
result = $result
|
||||
final_state = $final.state
|
||||
}} | ConvertTo-Json -Depth 10 -Compress
|
||||
"""
|
||||
completed = _run_powershell(
|
||||
body,
|
||||
source=source,
|
||||
install_root=install_root,
|
||||
manifest=manifest,
|
||||
)
|
||||
value = json.loads(completed.stdout.lstrip("\ufeff"))
|
||||
assert isinstance(value, dict)
|
||||
return value
|
||||
|
||||
|
||||
def _snapshot(root: Path) -> dict[str, tuple[int, int, str]]:
|
||||
return {
|
||||
path.relative_to(root).as_posix(): (
|
||||
path.stat().st_size,
|
||||
path.stat().st_mtime_ns,
|
||||
_sha256(path),
|
||||
)
|
||||
for path in root.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
|
||||
def _assert_matches_manifest(target: Path, manifest: Path) -> None:
|
||||
rows = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
expected = {
|
||||
str(row["path"])[len(RELEASE_PREFIX) :]: (
|
||||
int(row["bytes"]),
|
||||
str(row["sha256"]),
|
||||
)
|
||||
for row in rows
|
||||
}
|
||||
actual = {
|
||||
path.relative_to(target).as_posix(): (path.stat().st_size, _sha256(path))
|
||||
for path in target.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_absent_target_is_deployed_from_signed_manifest(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
report = _prepare(source, install_root, manifest)
|
||||
assert report["result"]["initial_state"] == "TARGET_ABSENT"
|
||||
assert report["result"]["reused"] is False
|
||||
assert report["final_state"] == "TARGET_VERIFIED"
|
||||
_assert_matches_manifest(target, manifest)
|
||||
|
||||
|
||||
def test_exact_target_is_reused_without_writing_bytes(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
before = _snapshot(target)
|
||||
report = _prepare(source, install_root, manifest)
|
||||
assert report["result"]["initial_state"] == "TARGET_VERIFIED"
|
||||
assert report["result"]["reused"] is True
|
||||
assert _snapshot(target) == before
|
||||
|
||||
|
||||
def test_mixed_release_is_quarantined_before_clean_deployment(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
agent = target / "app" / "tools" / "rf001_rx_http_agent.py"
|
||||
agent.write_text('AGENT_VERSION = "2026.09.29.2"\n', encoding="utf-8")
|
||||
ready = json.loads((target / "READY.json").read_text(encoding="utf-8"))
|
||||
ready["bootstrap_id"] = "rf001-bootstrap-2026.09.29.2"
|
||||
(target / "READY.json").write_text(json.dumps(ready), encoding="utf-8")
|
||||
conflict_hash = _sha256(agent)
|
||||
|
||||
report = _prepare(source, install_root, manifest)
|
||||
result = report["result"]
|
||||
assert result["initial_state"] == "TARGET_CONFLICT"
|
||||
quarantine = Path(str(result["quarantine_path"]))
|
||||
diagnostic_path = Path(str(result["quarantine_diagnostic"]))
|
||||
assert quarantine.is_dir()
|
||||
assert _sha256(quarantine / "app" / "tools" / agent.name) == conflict_hash
|
||||
diagnostic = json.loads(diagnostic_path.read_text(encoding="utf-8-sig"))
|
||||
assert diagnostic["bootstrap_id"] == BOOTSTRAP_ID
|
||||
assert diagnostic["expected_release_id"] == RELEASE_ID
|
||||
assert {row["kind"] for row in diagnostic["mismatches"]} >= {
|
||||
"SHA256_MISMATCH"
|
||||
}
|
||||
assert any(row["sha256"] == conflict_hash for row in diagnostic["conflicting_files"])
|
||||
_assert_matches_manifest(target, manifest)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("case", "expected_kind"),
|
||||
(
|
||||
("ready_mismatch", "SHA256_MISMATCH"),
|
||||
("python_mismatch", "SHA256_MISMATCH"),
|
||||
("missing", "MISSING"),
|
||||
("unexpected", "UNEXPECTED"),
|
||||
),
|
||||
)
|
||||
def test_each_file_set_conflict_is_quarantined(
|
||||
tmp_path: Path,
|
||||
case: str,
|
||||
expected_kind: str,
|
||||
) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
if case == "ready_mismatch":
|
||||
(target / "READY.json").write_bytes(b"{}\n")
|
||||
elif case == "python_mismatch":
|
||||
(target / "app" / "tools" / "rf001_agent_update.py").write_bytes(b"changed\n")
|
||||
elif case == "missing":
|
||||
(target / "app" / "tools" / "rf001_agent_update.py").unlink()
|
||||
else:
|
||||
(target / "app" / "tools" / "unexpected.py").write_bytes(b"unexpected\n")
|
||||
|
||||
result = _prepare(source, install_root, manifest)["result"]
|
||||
diagnostic = json.loads(
|
||||
Path(str(result["quarantine_diagnostic"])).read_text(encoding="utf-8-sig")
|
||||
)
|
||||
assert expected_kind in {row["kind"] for row in diagnostic["mismatches"]}
|
||||
_assert_matches_manifest(target, manifest)
|
||||
|
||||
|
||||
def test_failure_after_quarantine_leaves_canonical_target_absent(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
(target / "READY.json").write_bytes(b"conflict\n")
|
||||
body = rf"""
|
||||
$state = Get-ReleaseDirectoryState `
|
||||
-TargetRelease $targetRelease `
|
||||
-ReleaseRows $releaseRows `
|
||||
-ReleasePrefix "{RELEASE_PREFIX}"
|
||||
$quarantine = Move-ConflictingTargetToQuarantine `
|
||||
-InstallRoot $installRoot `
|
||||
-VersionsRoot $versionsRoot `
|
||||
-TargetRelease $targetRelease `
|
||||
-ReleaseId "{RELEASE_ID}" `
|
||||
-BootstrapId "{BOOTSTRAP_ID}" `
|
||||
-State $state
|
||||
try {{
|
||||
throw "fixed failure after quarantine before target creation"
|
||||
}}
|
||||
catch {{
|
||||
[ordered]@{{
|
||||
error = $_.Exception.Message
|
||||
canonical_exists = Test-Path -LiteralPath $targetRelease
|
||||
quarantine_exists = Test-Path -LiteralPath $quarantine.path -PathType Container
|
||||
diagnostic_exists = Test-Path -LiteralPath $quarantine.diagnostic_path -PathType Leaf
|
||||
}} | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
completed = _run_powershell(
|
||||
body, source=source, install_root=install_root, manifest=manifest
|
||||
)
|
||||
report = json.loads(completed.stdout.lstrip("\ufeff"))
|
||||
assert report == {
|
||||
"error": "fixed failure after quarantine before target creation",
|
||||
"canonical_exists": False,
|
||||
"quarantine_exists": True,
|
||||
"diagnostic_exists": True,
|
||||
}
|
||||
|
||||
|
||||
def test_failure_during_deployment_quarantines_partial_staging(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
(source / "READY.json").unlink()
|
||||
body = rf"""
|
||||
try {{
|
||||
Install-CleanRelease `
|
||||
-InstallRoot $installRoot `
|
||||
-VersionsRoot $versionsRoot `
|
||||
-SourceRelease $sourceRelease `
|
||||
-TargetRelease $targetRelease `
|
||||
-ReleaseId "{RELEASE_ID}" `
|
||||
-BootstrapId "{BOOTSTRAP_ID}" `
|
||||
-AgentVersion "{AGENT_VERSION}" `
|
||||
-ProtocolVersion 1 `
|
||||
-ReleaseRows $releaseRows `
|
||||
-ReleasePrefix "{RELEASE_PREFIX}" | Out-Null
|
||||
throw "deployment unexpectedly succeeded"
|
||||
}}
|
||||
catch {{
|
||||
$failed = @(Get-ChildItem -LiteralPath (Join-Path $installRoot "quarantine") -Directory |
|
||||
Where-Object {{ $_.Name -like "{RELEASE_ID}_failed_staging_*" }})
|
||||
[ordered]@{{
|
||||
error = $_.Exception.Message
|
||||
canonical_exists = Test-Path -LiteralPath $targetRelease
|
||||
failed_staging_count = $failed.Count
|
||||
}} | ConvertTo-Json -Compress
|
||||
}}
|
||||
"""
|
||||
completed = _run_powershell(
|
||||
body, source=source, install_root=install_root, manifest=manifest
|
||||
)
|
||||
report = json.loads(completed.stdout.lstrip("\ufeff"))
|
||||
assert "Clean release deployment failed" in report["error"]
|
||||
assert report["canonical_exists"] is False
|
||||
assert report["failed_staging_count"] == 1
|
||||
|
||||
|
||||
def test_installer_switch_failure_restores_saved_task_and_launcher_state() -> None:
|
||||
text = _installer().read_text(encoding="utf-8-sig")
|
||||
switch = text.index("Set-ScheduledTask -TaskName $taskName -Action $action")
|
||||
catch = text.index("catch {", switch)
|
||||
restore_task = text.index("Register-ScheduledTask -TaskName $taskName -Xml $xml -Force", catch)
|
||||
restore_files = text.index('foreach ($name in @("launcher"', catch)
|
||||
restart_old = text.index("if ($wasRunning)", catch)
|
||||
assert switch < catch < restore_task < restore_files < restart_old
|
||||
|
||||
|
||||
def test_successful_reinstall_is_idempotent(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
first = _prepare(source, install_root, manifest)
|
||||
before = _snapshot(target)
|
||||
second = _prepare(source, install_root, manifest)
|
||||
assert first["result"]["initial_state"] == "TARGET_ABSENT"
|
||||
assert second["result"]["initial_state"] == "TARGET_VERIFIED"
|
||||
assert second["result"]["reused"] is True
|
||||
assert _snapshot(target) == before
|
||||
quarantine = install_root / "quarantine"
|
||||
assert not quarantine.exists() or not any(quarantine.iterdir())
|
||||
|
||||
|
||||
def test_existing_quarantine_is_never_overwritten(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
(target / "READY.json").write_bytes(b"conflict\n")
|
||||
sentinel = install_root / "quarantine" / "existing-audit" / "sentinel.bin"
|
||||
sentinel.parent.mkdir(parents=True)
|
||||
sentinel.write_bytes(b"must remain unchanged")
|
||||
before = _snapshot(sentinel.parent)
|
||||
result = _prepare(source, install_root, manifest)["result"]
|
||||
assert _snapshot(sentinel.parent) == before
|
||||
assert Path(str(result["quarantine_path"])) != sentinel.parent
|
||||
|
||||
|
||||
def test_matching_version_strings_do_not_validate_changed_bytes(tmp_path: Path) -> None:
|
||||
source, install_root, target, manifest = _release_fixture(tmp_path)
|
||||
shutil.copytree(source, target)
|
||||
agent = target / "app" / "tools" / "rf001_rx_http_agent.py"
|
||||
agent.write_text(
|
||||
f'# changed bytes, same identity strings\nAGENT_VERSION = "{AGENT_VERSION}"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = _prepare(source, install_root, manifest)["result"]
|
||||
assert result["initial_state"] == "TARGET_CONFLICT"
|
||||
assert result["reused"] is False
|
||||
_assert_matches_manifest(target, manifest)
|
||||
235
tests/test_rf001_launcher_health_diagnostic.py
Normal file
235
tests/test_rf001_launcher_health_diagnostic.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Regression tests for strict launcher/health process identity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from tools.rf001_launcher_health_diagnostic import (
|
||||
HandshakeExpectation,
|
||||
ProcessRecord,
|
||||
_terminate_owned_processes,
|
||||
atomic_write_json,
|
||||
evaluate_identity,
|
||||
strict_owned_process_ids,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 29, 13, 0, tzinfo=timezone.utc)
|
||||
SCRIPT = r"C:\diagnostic\rf001_launcher_health_diagnostic.py"
|
||||
|
||||
|
||||
def _expectation(*, popen_pid: int = 200) -> HandshakeExpectation:
|
||||
return HandshakeExpectation(
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
agent_version="2026.09.29.1",
|
||||
launch_nonce="fixed-unique-nonce",
|
||||
state="RUNNING",
|
||||
launcher_pid=100,
|
||||
popen_pid=popen_pid,
|
||||
launched_at=NOW.isoformat(),
|
||||
diagnostic_script=SCRIPT,
|
||||
)
|
||||
|
||||
|
||||
def _record(pid: int, parent: int, *, seconds: float = 1.0) -> ProcessRecord:
|
||||
return ProcessRecord(
|
||||
pid=pid,
|
||||
parent_pid=parent,
|
||||
name="python.exe",
|
||||
executable=r"C:\Python313\python.exe",
|
||||
command_line=f'python "{SCRIPT}" --launch-nonce fixed-unique-nonce',
|
||||
created_at=(NOW + timedelta(seconds=seconds)).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _health(*, pid: int = 200) -> dict[str, object]:
|
||||
return {
|
||||
"release_id": "rf001-agent-2026.09.29.1",
|
||||
"agent_version": "2026.09.29.1",
|
||||
"launch_nonce": "fixed-unique-nonce",
|
||||
"state": "RUNNING",
|
||||
"pid": pid,
|
||||
"updated_at": (NOW + timedelta(seconds=2)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _evaluate(
|
||||
health: dict[str, object] | None,
|
||||
records: list[ProcessRecord],
|
||||
*,
|
||||
expectation: HandshakeExpectation | None = None,
|
||||
popen_alive: bool = True,
|
||||
health_process_alive: bool = True,
|
||||
) -> dict[str, object]:
|
||||
return evaluate_identity(
|
||||
health,
|
||||
expectation or _expectation(),
|
||||
records,
|
||||
popen_alive=popen_alive,
|
||||
health_process_alive=health_process_alive,
|
||||
)
|
||||
|
||||
|
||||
def test_exact_process_identity_passes() -> None:
|
||||
result = _evaluate(_health(), [_record(200, 100)])
|
||||
assert result["identity_valid"] is True
|
||||
assert result["identity_model"] == "EXACT_POPEN_PROCESS"
|
||||
|
||||
|
||||
def test_wrong_nonce_fails() -> None:
|
||||
health = _health()
|
||||
health["launch_nonce"] = "wrong"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_wrong_release_fails() -> None:
|
||||
health = _health()
|
||||
health["release_id"] = "rf001-agent-elsewhere"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_wrong_version_fails() -> None:
|
||||
health = _health()
|
||||
health["agent_version"] = "2026.09.29.2"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_health_before_running_state_fails() -> None:
|
||||
health = _health()
|
||||
health["state"] = "STARTING"
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "HEALTH_FIELDS_MISMATCH"
|
||||
|
||||
|
||||
def test_stale_health_fails() -> None:
|
||||
health = _health()
|
||||
health["updated_at"] = (NOW - timedelta(seconds=1)).isoformat()
|
||||
assert _evaluate(health, [_record(200, 100)])["reason"] == "STALE_HEALTH"
|
||||
|
||||
|
||||
def test_rollback_does_not_accept_previous_launch_health() -> None:
|
||||
previous_health = _health()
|
||||
previous_health["launch_nonce"] = "previous-launch-nonce"
|
||||
assert (
|
||||
_evaluate(previous_health, [_record(200, 100)])["reason"]
|
||||
== "HEALTH_FIELDS_MISMATCH"
|
||||
)
|
||||
|
||||
|
||||
def test_unrelated_pid_fails() -> None:
|
||||
result = _evaluate(
|
||||
_health(pid=300),
|
||||
[_record(200, 100), _record(300, 999)],
|
||||
)
|
||||
assert result["reason"] == "UNRELATED_PID"
|
||||
|
||||
|
||||
def test_unique_expected_wrapper_child_passes_strict_lineage() -> None:
|
||||
result = _evaluate(
|
||||
_health(pid=201),
|
||||
[_record(200, 100), _record(201, 200)],
|
||||
)
|
||||
assert result["identity_valid"] is True
|
||||
assert (
|
||||
result["identity_model"]
|
||||
== "UNIQUE_DIRECT_CHILD_WITH_NONCE_AND_CREATION_TIME"
|
||||
)
|
||||
assert result["pid_match"] is False
|
||||
|
||||
|
||||
def test_unexpected_grandchild_fails() -> None:
|
||||
result = _evaluate(
|
||||
_health(pid=202),
|
||||
[_record(200, 100), _record(201, 200), _record(202, 201)],
|
||||
)
|
||||
assert result["reason"] == "UNEXPECTED_GRANDCHILD"
|
||||
|
||||
|
||||
def test_pid_reuse_creation_time_mismatch_fails() -> None:
|
||||
reused = _record(200, 100, seconds=-30)
|
||||
assert _evaluate(_health(), [reused])["reason"] == "PROCESS_CREATION_TIME_MISMATCH"
|
||||
|
||||
|
||||
def test_target_dies_before_health_fails() -> None:
|
||||
result = _evaluate(None, [_record(200, 100)], popen_alive=False)
|
||||
assert result["reason"] == "HEALTH_ABSENT"
|
||||
|
||||
|
||||
def test_target_writes_health_then_dies_immediately_fails() -> None:
|
||||
result = _evaluate(
|
||||
_health(),
|
||||
[_record(200, 100)],
|
||||
health_process_alive=False,
|
||||
)
|
||||
assert result["reason"] == "HEALTH_PROCESS_DIED"
|
||||
|
||||
|
||||
def test_orphan_selection_never_includes_unrelated_process() -> None:
|
||||
expectation = _expectation()
|
||||
related = _record(201, 200)
|
||||
unrelated = replace(_record(300, 999), command_line="python unrelated.py")
|
||||
assert strict_owned_process_ids(
|
||||
expectation,
|
||||
[_record(200, 100), related, unrelated],
|
||||
) == [201, 200]
|
||||
|
||||
|
||||
def test_orphan_cleanup_terminates_only_strictly_owned_processes(monkeypatch) -> None:
|
||||
expectation = _expectation()
|
||||
wrapper = _record(200, 100)
|
||||
child = _record(201, 200)
|
||||
unrelated = replace(_record(300, 999), command_line="python unrelated.py")
|
||||
records = [wrapper, child, unrelated]
|
||||
terminated: list[int] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.rf001_launcher_health_diagnostic.capture_windows_processes",
|
||||
lambda: records,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"tools.rf001_launcher_health_diagnostic.os.kill",
|
||||
lambda pid, _signal: terminated.append(pid),
|
||||
)
|
||||
|
||||
actions = _terminate_owned_processes(expectation, records)
|
||||
|
||||
assert terminated == [201, 200]
|
||||
assert actions == [
|
||||
{"pid": 201, "status": "TERMINATED"},
|
||||
{"pid": 200, "status": "TERMINATED"},
|
||||
]
|
||||
|
||||
|
||||
def test_repeated_success_is_deterministic_with_distinct_identity() -> None:
|
||||
first = _expectation(popen_pid=200)
|
||||
second = replace(
|
||||
first,
|
||||
popen_pid=300,
|
||||
launch_nonce="second-unique-nonce",
|
||||
launched_at=(NOW + timedelta(seconds=10)).isoformat(),
|
||||
)
|
||||
first_result = _evaluate(_health(pid=200), [_record(200, 100)], expectation=first)
|
||||
second_record = replace(
|
||||
_record(300, 100, seconds=11),
|
||||
command_line=f'python "{SCRIPT}" --launch-nonce second-unique-nonce',
|
||||
)
|
||||
second_health = {
|
||||
**_health(pid=300),
|
||||
"launch_nonce": "second-unique-nonce",
|
||||
"updated_at": (NOW + timedelta(seconds=12)).isoformat(),
|
||||
}
|
||||
second_result = _evaluate(second_health, [second_record], expectation=second)
|
||||
assert first_result["identity_valid"] is True
|
||||
assert second_result["identity_valid"] is True
|
||||
assert first.launch_nonce != second.launch_nonce
|
||||
assert first.popen_pid != second.popen_pid
|
||||
|
||||
|
||||
def test_diagnostic_json_replacement_is_atomic(tmp_path: Path) -> None:
|
||||
path = tmp_path / "health.json"
|
||||
for sequence in range(20):
|
||||
atomic_write_json(path, {"sequence": sequence})
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"sequence": sequence}
|
||||
assert not path.with_suffix(".json.tmp").exists()
|
||||
55
tests/test_rf001_offline_decode.py
Normal file
55
tests/test_rf001_offline_decode.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Software-only checks for the fixed HTTP-agent offline decoder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rf001_offline_decode import decode_offline
|
||||
|
||||
|
||||
def test_decode_offline_forces_legacy_and_refuses_overwrite(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
iq_path = tmp_path / "waveform.cs8"
|
||||
truth_path = tmp_path / "truth.json"
|
||||
report_path = tmp_path / "report.json"
|
||||
iq_path.write_bytes(b"iq")
|
||||
truth_path.write_text("{}", encoding="utf-8")
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
def fake_decoder(iq_file: Path, **options: object) -> dict[str, object]:
|
||||
calls.append({"iq_file": iq_file, **options})
|
||||
return {"crc_valid_packets": 100, "exact_packets": 100}
|
||||
|
||||
result = decode_offline(
|
||||
repository,
|
||||
iq_path,
|
||||
truth_path,
|
||||
report_path,
|
||||
decoder=fake_decoder,
|
||||
)
|
||||
|
||||
assert result == {"crc_valid_packets": 100, "exact_packets": 100}
|
||||
assert calls == [
|
||||
{
|
||||
"iq_file": iq_path,
|
||||
"iq_format": "cs8",
|
||||
"truth_metadata_path": truth_path,
|
||||
"decoder_backend": "legacy",
|
||||
"capture_started_at": None,
|
||||
"tx_started_at": None,
|
||||
"tx_finished_at": None,
|
||||
}
|
||||
]
|
||||
assert json.loads(report_path.read_text(encoding="utf-8")) == result
|
||||
with pytest.raises(FileExistsError, match="already exists"):
|
||||
decode_offline(
|
||||
repository,
|
||||
iq_path,
|
||||
truth_path,
|
||||
report_path,
|
||||
decoder=fake_decoder,
|
||||
)
|
||||
356
tests/test_rf001_rx_capture.py
Normal file
356
tests/test_rf001_rx_capture.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""Software-only tests for the fixed RF-001A RX capture operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Sequence
|
||||
|
||||
from tools.rf001_rx_capture import (
|
||||
CAPTURE_FILE_NAME,
|
||||
FIXED_RX_SERIAL,
|
||||
MANIFEST_FILE_NAME,
|
||||
REPORT_FILE_NAME,
|
||||
RESULT_FILE_NAME,
|
||||
read_capture_diagnostics,
|
||||
read_capture_status,
|
||||
start_rx_capture,
|
||||
)
|
||||
|
||||
|
||||
class FakeResult:
|
||||
def __init__(self, arguments: Sequence[str], stdout: str) -> None:
|
||||
self.arguments = list(arguments)
|
||||
self.returncode = 0
|
||||
self.stdout = stdout
|
||||
self.stderr = ""
|
||||
|
||||
|
||||
def _write_repository(repository: Path) -> None:
|
||||
(repository / "configs" / "rf").mkdir(parents=True)
|
||||
(repository / "tools").mkdir()
|
||||
profile = {
|
||||
"test_id": "RF001A_COMMON_CLOCK_001",
|
||||
"center_frequency_hz": 1_400_000_000,
|
||||
"sample_rate_hz": 8_000_000,
|
||||
"symbol_rate_sps": 500_000,
|
||||
"modulation": "QPSK",
|
||||
"inner_fec": "2/3",
|
||||
"application_payload_bytes": 256,
|
||||
"packet_count": 100,
|
||||
"inter_packet_gap_ms": 5,
|
||||
"clock_mode": "COMMON_10_MHZ",
|
||||
"rx_lna_db": 24,
|
||||
"rx_vga_db": 24,
|
||||
"rf_amp_enabled": False,
|
||||
"antenna_bias_enabled": False,
|
||||
"hardware_format": "cs8",
|
||||
"rx_capture_seconds": 20,
|
||||
"rx_capture_samples": 160_000_000,
|
||||
}
|
||||
(repository / "configs" / "rf" / "rf001a_common_clock.json").write_text(
|
||||
json.dumps(profile),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_capture_start_uses_fixed_receive_only_command(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
_write_repository(repository)
|
||||
tool_dir = tmp_path / "hackrf"
|
||||
tool_dir.mkdir()
|
||||
info = tool_dir / "hackrf_info.exe"
|
||||
clock = tool_dir / "hackrf_clock.exe"
|
||||
transfer = tool_dir / "hackrf_transfer.exe"
|
||||
for path in (info, clock, transfer):
|
||||
path.write_bytes(b"")
|
||||
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(arguments: Sequence[str], timeout: float) -> FakeResult:
|
||||
del timeout
|
||||
command = list(arguments)
|
||||
commands.append(command)
|
||||
if command == [str(info)]:
|
||||
return FakeResult(command, f"Serial number: {FIXED_RX_SERIAL}")
|
||||
if command == [str(clock), "-d", FIXED_RX_SERIAL, "-i"]:
|
||||
return FakeResult(command, "CLKIN status: clock signal detected")
|
||||
raise AssertionError(command)
|
||||
|
||||
launched: list[list[str]] = []
|
||||
capture_path: Path | None = None
|
||||
|
||||
def launcher(arguments: Sequence[str]) -> int:
|
||||
nonlocal capture_path
|
||||
command = list(arguments)
|
||||
launched.append(command)
|
||||
capture_path = Path(command[command.index("--capture-path") + 1])
|
||||
started_path = Path(command[command.index("--started-status") + 1])
|
||||
started_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"state": "RUNNING",
|
||||
"pid": 4321,
|
||||
"started_at": "2026-09-23T15:00:00+00:00",
|
||||
"capture_path": str(capture_path),
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
capture_path.write_bytes(b"12")
|
||||
return 1234
|
||||
|
||||
def grow(_: float) -> None:
|
||||
assert capture_path is not None
|
||||
with capture_path.open("ab") as stream:
|
||||
stream.write(b"34")
|
||||
|
||||
result, errors = start_rx_capture(
|
||||
repository,
|
||||
tmp_path / "runtime",
|
||||
"RF001A_20260923T150000Z_1234ABCD",
|
||||
FIXED_RX_SERIAL,
|
||||
hackrf_info_path=info,
|
||||
hackrf_clock_path=clock,
|
||||
hackrf_transfer_path=transfer,
|
||||
command_runner=runner,
|
||||
process_gate=lambda: (True, {"state": "CLEAR"}),
|
||||
background_launcher=launcher,
|
||||
process_stopper=lambda pid: None,
|
||||
sleep=grow,
|
||||
)
|
||||
|
||||
assert errors == []
|
||||
assert result["state"] == "READY"
|
||||
assert result["rf_rx"] == 1
|
||||
assert result["rf_tx"] == 0
|
||||
assert result["automatic_retry"] is False
|
||||
assert result["common_clock_verified"] is True
|
||||
assert len(launched) == 1
|
||||
arguments_path = Path(
|
||||
launched[0][launched[0].index("--arguments-json") + 1]
|
||||
)
|
||||
transfer_arguments = json.loads(arguments_path.read_text(encoding="utf-8"))
|
||||
assert transfer_arguments == [
|
||||
"-d", FIXED_RX_SERIAL,
|
||||
"-r", str(result["capture_path"]),
|
||||
"-f", "1400000000",
|
||||
"-s", "8000000",
|
||||
"-n", "2400000000",
|
||||
"-l", "24",
|
||||
"-g", "24",
|
||||
"-a", "0",
|
||||
"-p", "0",
|
||||
"-B",
|
||||
]
|
||||
assert "-t" not in transfer_arguments
|
||||
assert commands == [
|
||||
[str(info)],
|
||||
[str(clock), "-d", FIXED_RX_SERIAL, "-i"],
|
||||
]
|
||||
|
||||
|
||||
def test_capture_start_rejects_explicit_no_clock_status(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
_write_repository(repository)
|
||||
tool_dir = tmp_path / "hackrf"
|
||||
tool_dir.mkdir()
|
||||
info = tool_dir / "hackrf_info.exe"
|
||||
clock = tool_dir / "hackrf_clock.exe"
|
||||
transfer = tool_dir / "hackrf_transfer.exe"
|
||||
for path in (info, clock, transfer):
|
||||
path.write_bytes(b"")
|
||||
|
||||
def runner(arguments: Sequence[str], timeout: float) -> FakeResult:
|
||||
del timeout
|
||||
command = list(arguments)
|
||||
if command == [str(info)]:
|
||||
return FakeResult(command, f"Serial number: {FIXED_RX_SERIAL}")
|
||||
if command == [str(clock), "-d", FIXED_RX_SERIAL, "-i"]:
|
||||
return FakeResult(command, "CLKIN status: no clock signal detected")
|
||||
raise AssertionError(command)
|
||||
|
||||
launched: list[list[str]] = []
|
||||
result, errors = start_rx_capture(
|
||||
repository,
|
||||
tmp_path / "runtime",
|
||||
"RF001A_20260925T120000Z_1234ABCD",
|
||||
FIXED_RX_SERIAL,
|
||||
hackrf_info_path=info,
|
||||
hackrf_clock_path=clock,
|
||||
hackrf_transfer_path=transfer,
|
||||
command_runner=runner,
|
||||
process_gate=lambda: (True, {"state": "CLEAR"}),
|
||||
background_launcher=lambda arguments: launched.append(list(arguments)) or 1234,
|
||||
process_stopper=lambda pid: None,
|
||||
)
|
||||
|
||||
assert result["hardware_accessed"] is True
|
||||
assert result["rf_rx"] == 0
|
||||
assert result["rf_tx"] == 0
|
||||
assert result["common_clock_verified"] is False
|
||||
assert errors == ["RuntimeError: CLKIN clock signal was not detected"]
|
||||
assert launched == []
|
||||
|
||||
|
||||
def test_capture_status_requires_exact_complete_file(tmp_path: Path) -> None:
|
||||
session_id = "RF001A_20260923T150000Z_1234ABCD"
|
||||
session = tmp_path / "rx_capture" / session_id
|
||||
session.mkdir(parents=True)
|
||||
capture = session / CAPTURE_FILE_NAME
|
||||
capture.write_bytes(b"1234")
|
||||
result_path = session / RESULT_FILE_NAME
|
||||
result_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"exit_code": 0,
|
||||
"runner_error": None,
|
||||
"capture_size": 4,
|
||||
"sha256": "fixture",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(session / MANIFEST_FILE_NAME).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": session_id,
|
||||
"capture_path": str(capture),
|
||||
"capture_started_at": "2026-09-23T15:00:00+00:00",
|
||||
"expected_capture_end": "2026-09-23T15:00:20+00:00",
|
||||
"expected_capture_bytes": 4,
|
||||
"result_status_path": str(result_path),
|
||||
"common_clock_verified": True,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result, errors = read_capture_status(tmp_path, session_id)
|
||||
|
||||
assert errors == []
|
||||
assert result["state"] == "CAPTURE_COMPLETE"
|
||||
assert result["capture_bytes"] == 4
|
||||
assert result["hardware_accessed"] is False
|
||||
assert result["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_capture_diagnostics_returns_report_and_signal_power(tmp_path: Path) -> None:
|
||||
session_id = "RF001A_20260925T104504Z_5FA2C83B"
|
||||
session = tmp_path / "rx_capture" / session_id
|
||||
session.mkdir(parents=True)
|
||||
capture = session / CAPTURE_FILE_NAME
|
||||
samples = bytearray()
|
||||
for index in range(200):
|
||||
code = 10 if 80 <= index < 120 else 1
|
||||
samples.extend((code, code))
|
||||
capture.write_bytes(samples)
|
||||
capture_sha256 = hashlib.sha256(samples).hexdigest()
|
||||
result_path = session / RESULT_FILE_NAME
|
||||
result_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"exit_code": 0,
|
||||
"runner_error": None,
|
||||
"capture_size": len(samples),
|
||||
"sha256": capture_sha256,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(session / MANIFEST_FILE_NAME).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": session_id,
|
||||
"capture_path": str(capture),
|
||||
"capture_started_at": "2026-09-25T10:00:00+00:00",
|
||||
"expected_capture_end": "2026-09-25T10:00:01+00:00",
|
||||
"expected_capture_bytes": len(samples),
|
||||
"result_status_path": str(result_path),
|
||||
"common_clock_verified": True,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
report = {
|
||||
"rf_tx": 0,
|
||||
"iq_sha256": capture_sha256,
|
||||
"detected_preambles": 0,
|
||||
"rejected_candidates": 0,
|
||||
"crc_valid_packets": 0,
|
||||
"exact_packets": 0,
|
||||
"rx_clipping_rail_codes": 0,
|
||||
"analysis_windows": [{"start_sample": 64, "stop_sample": 136}],
|
||||
}
|
||||
(session / REPORT_FILE_NAME).write_text(
|
||||
json.dumps(report),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
diagnostics, errors = read_capture_diagnostics(
|
||||
tmp_path,
|
||||
session_id,
|
||||
"2026-09-25T10:00:00.000010+00:00",
|
||||
"2026-09-25T10:00:00.000015+00:00",
|
||||
)
|
||||
|
||||
assert errors == []
|
||||
assert diagnostics["hardware_accessed"] is False
|
||||
assert diagnostics["rf_tx"] == 0
|
||||
assert diagnostics["report"] == report
|
||||
signal = diagnostics["signal_metrics"]
|
||||
assert signal["tx_window"]["start_sample"] == 80
|
||||
assert signal["tx_window"]["stop_sample"] == 120
|
||||
assert signal["tx_to_baseline_power_ratio"] == 100.0
|
||||
assert signal["tx_to_baseline_db"] == 20.0
|
||||
|
||||
|
||||
def test_missing_process_gate_dependency_reports_failure_without_hardware(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
_write_repository(repository)
|
||||
tool_dir = tmp_path / "hackrf"
|
||||
tool_dir.mkdir()
|
||||
info = tool_dir / "hackrf_info.exe"
|
||||
clock = tool_dir / "hackrf_clock.exe"
|
||||
transfer = tool_dir / "hackrf_transfer.exe"
|
||||
for path in (info, clock, transfer):
|
||||
path.write_bytes(b"")
|
||||
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def runner(arguments: Sequence[str], timeout: float) -> FakeResult:
|
||||
del timeout
|
||||
commands.append(list(arguments))
|
||||
return FakeResult(arguments, "unexpected")
|
||||
|
||||
def missing_gate() -> tuple[bool, dict[str, object]]:
|
||||
raise ModuleNotFoundError("tools.hackrf_process_check")
|
||||
|
||||
result, errors = start_rx_capture(
|
||||
repository,
|
||||
tmp_path / "runtime",
|
||||
"RF001A_20260924T090807Z_B8E6794D",
|
||||
FIXED_RX_SERIAL,
|
||||
hackrf_info_path=info,
|
||||
hackrf_clock_path=clock,
|
||||
hackrf_transfer_path=transfer,
|
||||
command_runner=runner,
|
||||
process_gate=missing_gate,
|
||||
process_stopper=lambda pid: None,
|
||||
)
|
||||
|
||||
assert result["hardware_accessed"] is False
|
||||
assert result["rf_rx"] == 0
|
||||
assert result["rf_tx"] == 0
|
||||
assert errors == [
|
||||
"ModuleNotFoundError: tools.hackrf_process_check"
|
||||
]
|
||||
assert commands == []
|
||||
824
tests/test_rf001_rx_http_agent.py
Normal file
824
tests/test_rf001_rx_http_agent.py
Normal file
@@ -0,0 +1,824 @@
|
||||
"""Software-only tests for the fixed-command RX HTTP agent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.rf001_agent_update as update_module
|
||||
import tools.rf001_rx_http_agent as agent_module
|
||||
from tools.rf001_http_control import PROTOCOL_VERSION, ProtocolError
|
||||
from tools.rf001_rx_http_agent import (
|
||||
CommandResult,
|
||||
collect_inventory,
|
||||
execute_task,
|
||||
normalized_agent_id,
|
||||
read_git_metadata,
|
||||
)
|
||||
|
||||
|
||||
def fake_runner(arguments: Sequence[str], timeout: float) -> CommandResult:
|
||||
del timeout
|
||||
command = list(arguments)
|
||||
if command == ["hostname"]:
|
||||
stdout = "User-PC"
|
||||
elif command == ["whoami"]:
|
||||
stdout = "user-pc\\professional"
|
||||
elif command[0] == "git":
|
||||
stdout = "fake-git-read-only-result"
|
||||
elif command[0].lower().endswith("hackrf_info.exe"):
|
||||
stdout = "hackrf_info version: 2024.02.1"
|
||||
else: # pragma: no cover - protects the test allowlist
|
||||
raise AssertionError(f"unexpected command: {command}")
|
||||
return CommandResult(command, 0, stdout, "")
|
||||
|
||||
|
||||
def test_agent_id_is_normalized_without_shell_content() -> None:
|
||||
assert normalized_agent_id("User-PC") == "user-pc"
|
||||
assert normalized_agent_id(" RX Laptop! ") == "rx-laptop"
|
||||
|
||||
|
||||
def test_inventory_runs_only_fixed_read_only_commands(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
hackrf_info = tmp_path / "hackrf_info.exe"
|
||||
hackrf_info.write_bytes(b"")
|
||||
|
||||
inventory, errors = collect_inventory(
|
||||
repository,
|
||||
include_hackrf_info=True,
|
||||
hackrf_info_path=hackrf_info,
|
||||
command_runner=fake_runner,
|
||||
)
|
||||
|
||||
assert errors == []
|
||||
assert inventory["rf_tx"] == 0
|
||||
assert inventory["hostname_command"]["stdout"] == "User-PC"
|
||||
assert inventory["whoami_command"]["stdout"] == "user-pc\\professional"
|
||||
assert inventory["hackrf_info"]["executed"] is True
|
||||
assert inventory["hackrf_info"]["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_agent_rejects_unknown_task_and_rf_tx(tmp_path: Path) -> None:
|
||||
base_task: dict[str, object] = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": "INVENTORY_TEST_001",
|
||||
"kind": "INVENTORY",
|
||||
"agent_id": "rx-user-pc",
|
||||
"created_at": "2026-09-23T00:00:00+00:00",
|
||||
"include_hackrf_info": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
unknown = dict(base_task)
|
||||
unknown["kind"] = "SHELL"
|
||||
with pytest.raises(ProtocolError, match="unsupported task kind"):
|
||||
execute_task(
|
||||
unknown,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
command_runner=fake_runner,
|
||||
)
|
||||
|
||||
transmit = dict(base_task)
|
||||
transmit["rf_tx"] = 1
|
||||
with pytest.raises(ProtocolError, match="rf_tx = 0"):
|
||||
execute_task(
|
||||
transmit,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
command_runner=fake_runner,
|
||||
)
|
||||
|
||||
|
||||
def test_agent_dispatches_fixed_capture_diagnostics_without_hardware(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[tuple[Path, str, str, str]] = []
|
||||
|
||||
def diagnostics(
|
||||
work_root: Path,
|
||||
session_id: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str,
|
||||
) -> tuple[dict[str, object], list[str]]:
|
||||
calls.append((work_root, session_id, tx_started_at, tx_finished_at))
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
"report": {"detected_preambles": 0},
|
||||
}, []
|
||||
|
||||
monkeypatch.setattr(agent_module, "read_capture_diagnostics", diagnostics)
|
||||
session_id = "RF001A_20260925T104504Z_5FA2C83B"
|
||||
task = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": "DIAG_TEST_001",
|
||||
"kind": "CAPTURE_DIAGNOSTICS",
|
||||
"agent_id": "rx-user-pc",
|
||||
"created_at": "2026-09-25T10:51:00+00:00",
|
||||
"session_id": session_id,
|
||||
"tx_started_at": "2026-09-25T10:46:54+00:00",
|
||||
"tx_finished_at": "2026-09-25T10:46:56+00:00",
|
||||
"rf_tx": 0,
|
||||
}
|
||||
|
||||
result = execute_task(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=tmp_path / "runtime",
|
||||
command_runner=fake_runner,
|
||||
)
|
||||
|
||||
assert result["status"] == "COMPLETE"
|
||||
assert result["result"]["hardware_accessed"] is False
|
||||
assert result["rf_tx"] == 0
|
||||
assert calls == [
|
||||
(
|
||||
tmp_path / "runtime",
|
||||
session_id,
|
||||
"2026-09-25T10:46:54+00:00",
|
||||
"2026-09-25T10:46:56+00:00",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _write_git_metadata(repository: Path, head: str) -> None:
|
||||
git_dir = repository / ".git"
|
||||
ref = git_dir / "refs" / "heads" / "video-development"
|
||||
ref.parent.mkdir(parents=True)
|
||||
(git_dir / "HEAD").write_text(
|
||||
"ref: refs/heads/video-development\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
ref.write_text(head + "\n", encoding="ascii")
|
||||
|
||||
|
||||
def test_read_git_metadata_without_git_executable(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
head = "a94f2db4ee8377201a97263166cb09431df5f5d6"
|
||||
_write_git_metadata(repository, head)
|
||||
|
||||
metadata = read_git_metadata(repository)
|
||||
|
||||
assert metadata["branch"] == "video-development"
|
||||
assert metadata["head"] == head
|
||||
assert metadata["status"] == "NOT_RUN_NO_GIT_EXECUTABLE"
|
||||
|
||||
|
||||
def test_read_git_metadata_from_linked_worktree(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "worktree"
|
||||
repository.mkdir()
|
||||
common = tmp_path / "main.git"
|
||||
worktree_git = common / "worktrees" / "worktree"
|
||||
worktree_git.mkdir(parents=True)
|
||||
(repository / ".git").write_text(
|
||||
f"gitdir: {worktree_git}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(worktree_git / "commondir").write_text("../..\n", encoding="ascii")
|
||||
(worktree_git / "HEAD").write_text(
|
||||
"ref: refs/heads/video-development\n",
|
||||
encoding="ascii",
|
||||
)
|
||||
head = "a94f2db4ee8377201a97263166cb09431df5f5d6"
|
||||
ref = common / "refs" / "heads" / "video-development"
|
||||
ref.parent.mkdir(parents=True)
|
||||
ref.write_text(head + "\n", encoding="ascii")
|
||||
|
||||
metadata = read_git_metadata(repository)
|
||||
|
||||
assert metadata["git_dir"] == str(worktree_git.resolve())
|
||||
assert metadata["common_dir"] == str(common.resolve())
|
||||
assert metadata["branch"] == "video-development"
|
||||
assert metadata["head"] == head
|
||||
|
||||
|
||||
def test_offline_verify_is_fixed_software_only_task(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
repository.mkdir()
|
||||
head = "a94f2db4ee8377201a97263166cb09431df5f5d6"
|
||||
_write_git_metadata(repository, head)
|
||||
(repository / "tools").mkdir()
|
||||
(repository / "configs" / "rf").mkdir(parents=True)
|
||||
for relative in (
|
||||
Path("tools/rf001a_prepare.py"),
|
||||
Path("tools/qpsk_rf_rx.py"),
|
||||
Path("configs/rf/rf001a_truth_metadata.json"),
|
||||
):
|
||||
(repository / relative).write_text("fixture\n", encoding="utf-8")
|
||||
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def offline_runner(arguments: Sequence[str], timeout: float) -> CommandResult:
|
||||
del timeout
|
||||
command = list(arguments)
|
||||
commands.append(command)
|
||||
if "rf001a_prepare.py" in command[4]:
|
||||
output = Path(command[command.index("--output-dir") + 1])
|
||||
output.mkdir()
|
||||
(output / "tx_waveform.cs8").write_bytes(b"fixture")
|
||||
(output / "tx_metadata.json").write_text("{}", encoding="utf-8")
|
||||
elif "rf001_offline_decode.py" in command[4]:
|
||||
report = Path(command[command.index("--report") + 1])
|
||||
report.write_text(
|
||||
'{"crc_valid_packets": 100, "exact_packets": 100}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
else: # pragma: no cover - protects the fixed command contract
|
||||
raise AssertionError(command)
|
||||
return CommandResult(command, 0, "PASS", "")
|
||||
|
||||
task = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": "OFFLINE_TEST_001",
|
||||
"kind": "OFFLINE_VERIFY",
|
||||
"agent_id": "rx-user-pc",
|
||||
"created_at": "2026-09-23T00:00:00+00:00",
|
||||
"expected_head": head,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
result = execute_task(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
repository=repository,
|
||||
hackrf_info_path=None,
|
||||
work_root=tmp_path / "runtime",
|
||||
command_runner=offline_runner,
|
||||
)
|
||||
|
||||
assert result["status"] == "COMPLETE"
|
||||
assert result["result"]["hardware_accessed"] is False
|
||||
assert result["result"]["crc_valid_packets"] == 100
|
||||
assert result["result"]["exact_packets"] == 100
|
||||
assert "--repository" in commands[1]
|
||||
assert all("hackrf" not in Path(command[0]).name.lower() for command in commands)
|
||||
assert all("-t" not in command for command in commands)
|
||||
assert result["rf_tx"] == 0
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _staircase_fixture(
|
||||
tmp_path: Path,
|
||||
) -> tuple[Path, dict[str, object], Path, Path]:
|
||||
work_root = tmp_path / "runtime"
|
||||
session_id = "RF001A_20260925T125829Z_227EADB4"
|
||||
session_directory = work_root / "rx_capture" / session_id
|
||||
session_directory.mkdir(parents=True)
|
||||
capture = session_directory / "rf001a_ota_capture.cs8"
|
||||
capture.write_bytes(b"fixed-rx-capture")
|
||||
reference = work_root / "references" / "lab065_reference_staircase.cs8"
|
||||
reference.parent.mkdir(parents=True)
|
||||
reference.write_bytes(b"fixed-staircase-reference")
|
||||
task: dict[str, object] = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": "STAIRCASE_TEST_001",
|
||||
"kind": "STAIRCASE_SEARCH",
|
||||
"agent_id": "rx-user-pc",
|
||||
"created_at": "2026-09-28T12:00:00+00:00",
|
||||
"session_id": session_id,
|
||||
"expected_rx_sha256": _sha256(capture),
|
||||
"expected_reference_sha256": _sha256(reference),
|
||||
"hardware_accessed": False,
|
||||
"automatic_retry": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
return work_root, task, capture, reference
|
||||
|
||||
|
||||
def _write_staircase_report(arguments: Sequence[str]) -> Path:
|
||||
command = list(arguments)
|
||||
report_path = Path(command[command.index("--report") + 1])
|
||||
report = {
|
||||
"analyzer_version": "test",
|
||||
"mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH",
|
||||
"capture_sha256": command[command.index("--capture-sha256") + 1],
|
||||
"reference_sha256": _sha256(
|
||||
Path(command[command.index("--reference") + 1])
|
||||
),
|
||||
"search": {
|
||||
"coverage_start_sample": 0,
|
||||
"coverage_stop_sample": 8,
|
||||
"coverage_seconds": 0.000001,
|
||||
"coverage_complete": True,
|
||||
"source_samples_processed": 8,
|
||||
"frequency_grid_hz": [-3.0, 0.0, 3.0],
|
||||
"background_score_p99": 0.01,
|
||||
"candidates": [],
|
||||
},
|
||||
"verifications": [],
|
||||
"verdict": "NOT_DETECTED",
|
||||
"verdict_reasons": ["NO_CANDIDATES"],
|
||||
"analysis_seconds": 1.25,
|
||||
"peak_process_memory_bytes": 1234,
|
||||
"peak_additional_memory_bytes": 456,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
report_path.write_text(json.dumps(report), encoding="utf-8")
|
||||
return report_path
|
||||
|
||||
|
||||
def test_staircase_task_rejects_invalid_session_and_hashes(tmp_path: Path) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
invalid_session = dict(task, session_id="..\\outside")
|
||||
with pytest.raises(ProtocolError, match="session_id"):
|
||||
execute_task(
|
||||
invalid_session,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
)
|
||||
|
||||
for field in ("expected_rx_sha256", "expected_reference_sha256"):
|
||||
invalid_hash = dict(task)
|
||||
invalid_hash[field] = "invalid"
|
||||
with pytest.raises(ProtocolError, match=field):
|
||||
execute_task(
|
||||
invalid_hash,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
)
|
||||
|
||||
|
||||
def test_staircase_hash_mismatch_does_not_start_child(tmp_path: Path) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def forbidden_runner(arguments: Sequence[str], timeout: float) -> CommandResult:
|
||||
del timeout
|
||||
calls.append(list(arguments))
|
||||
raise AssertionError("child process must not run after a hash mismatch")
|
||||
|
||||
for field in ("expected_rx_sha256", "expected_reference_sha256"):
|
||||
changed = dict(task)
|
||||
changed[field] = "0" * 64
|
||||
result = execute_task(
|
||||
changed,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
command_runner=forbidden_runner,
|
||||
)
|
||||
assert result["status"] == "FAILED"
|
||||
assert result["result"]["reference_detection"] == "EXECUTION_FAILED"
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_staircase_repeat_reuses_existing_report_without_child(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def analyzer_runner(arguments: Sequence[str], timeout: float) -> CommandResult:
|
||||
assert timeout == 43_200.0
|
||||
calls.append(list(arguments))
|
||||
_write_staircase_report(arguments)
|
||||
assert "hackrf" not in " ".join(arguments).lower()
|
||||
return CommandResult(list(arguments), 0, "REFERENCE STAIRCASE = NOT_DETECTED", "")
|
||||
|
||||
first = execute_task(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
command_runner=analyzer_runner,
|
||||
)
|
||||
second = execute_task(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
command_runner=lambda *_args: (_ for _ in ()).throw(
|
||||
AssertionError("existing report must not start another child")
|
||||
),
|
||||
)
|
||||
assert first["status"] == "COMPLETE"
|
||||
assert first["result"]["hardware_accessed"] is False
|
||||
assert first["result"]["rf_tx"] == 0
|
||||
assert first["result"]["reused_existing_report"] is False
|
||||
assert second["status"] == "COMPLETE"
|
||||
assert second["result"]["reused_existing_report"] is True
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_staircase_child_failure_is_execution_failed(tmp_path: Path) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
|
||||
def failed_runner(arguments: Sequence[str], timeout: float) -> CommandResult:
|
||||
del timeout
|
||||
return CommandResult(list(arguments), 9, "", "fixed failure")
|
||||
|
||||
result = execute_task(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
repository=tmp_path,
|
||||
hackrf_info_path=None,
|
||||
work_root=work_root,
|
||||
command_runner=failed_runner,
|
||||
)
|
||||
assert result["status"] == "FAILED"
|
||||
assert result["result"]["reference_detection"] == "EXECUTION_FAILED"
|
||||
assert result["result"]["hardware_accessed"] is False
|
||||
assert result["result"]["rf_tx"] == 0
|
||||
assert "exited with 9" in result["errors"][0]
|
||||
|
||||
|
||||
def _update_fixture(
|
||||
tmp_path: Path,
|
||||
) -> tuple[
|
||||
Path,
|
||||
bytes,
|
||||
dict[str, object],
|
||||
dict[str, object],
|
||||
argparse.Namespace,
|
||||
]:
|
||||
source = tmp_path / "source"
|
||||
for index, relative in enumerate(update_module.RELEASE_SOURCE_FILES.values()):
|
||||
path = source / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = f"# fixed update fixture {index}\n"
|
||||
if relative == update_module.RELEASE_SOURCE_FILES[
|
||||
"app/tools/rf001_rx_http_agent.py"
|
||||
]:
|
||||
content += 'AGENT_VERSION = "2026.09.29.2"\n'
|
||||
path.write_text(content, encoding="utf-8")
|
||||
release_id = "rf001-agent-2026.09.29.2"
|
||||
package = tmp_path / f"{release_id}.zip"
|
||||
inspection = update_module.build_release_archive(
|
||||
source,
|
||||
package,
|
||||
release_id=release_id,
|
||||
agent_version="2026.09.29.2",
|
||||
protocol_version=PROTOCOL_VERSION,
|
||||
)
|
||||
manifest: dict[str, object] = {
|
||||
**inspection.descriptor(),
|
||||
"package_kind": "AGENT_RELEASE",
|
||||
}
|
||||
token = b"fixed-test-token-0123456789abcdef"
|
||||
task: dict[str, object] = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": "UPDATE_TEST_001",
|
||||
"kind": "UPDATE_AGENT",
|
||||
"agent_id": "rx-user-pc",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"release_id": release_id,
|
||||
"agent_version": "2026.09.29.2",
|
||||
"package_sha256": inspection.package_sha256,
|
||||
"package_bytes": inspection.package_bytes,
|
||||
"content_manifest_sha256": inspection.content_manifest_sha256,
|
||||
"compatible_protocol_version": PROTOCOL_VERSION,
|
||||
"approval_expires_at": (
|
||||
datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
).isoformat(),
|
||||
"automatic_retry": False,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
install_root = tmp_path / "install"
|
||||
install_root.mkdir()
|
||||
launcher_status = install_root / "launcher_status.json"
|
||||
launcher_status.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"active_release_id": "rf001-agent-2026.09.29.1",
|
||||
"active_agent_version": "2026.09.29.1",
|
||||
"rf_tx": 0,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
arguments = argparse.Namespace(
|
||||
agent_id="rx-user-pc",
|
||||
server="http://127.0.0.1:8765",
|
||||
timeout=1.0,
|
||||
install_root=install_root,
|
||||
launcher_request=install_root / "launcher_request.json",
|
||||
launcher_status=launcher_status,
|
||||
release_id="rf001-agent-2026.09.29.1",
|
||||
)
|
||||
return package, token, task, manifest, arguments
|
||||
|
||||
|
||||
def _successful_self_test(
|
||||
arguments: Sequence[str], timeout: float
|
||||
) -> CommandResult:
|
||||
assert timeout == 120.0
|
||||
assert "hackrf" not in " ".join(arguments).lower()
|
||||
return CommandResult(list(arguments), 0, "SELF TEST = PASS", "")
|
||||
|
||||
|
||||
def test_agent_stages_authenticated_update_and_reuses_it(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
package, token, task, manifest, arguments = _update_fixture(tmp_path)
|
||||
downloads: list[Path] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {
|
||||
"manifest": manifest,
|
||||
"manifest_signature": update_module.sign_update_manifest(token, manifest),
|
||||
},
|
||||
)
|
||||
|
||||
def download(
|
||||
_server: str,
|
||||
_token: bytes,
|
||||
_target: str,
|
||||
destination: Path,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
downloads.append(destination)
|
||||
shutil.copyfile(package, destination)
|
||||
|
||||
monkeypatch.setattr(agent_module, "request_file", download)
|
||||
result, errors, request = agent_module.stage_agent_update(
|
||||
task,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert errors == []
|
||||
assert result["update_status"] == "STAGED"
|
||||
assert result["hardware_accessed"] is False
|
||||
assert result["rf_tx"] == 0
|
||||
assert request is not None
|
||||
assert len(downloads) == 1
|
||||
|
||||
result, errors, repeated_request = agent_module.stage_agent_update(
|
||||
task,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=lambda *_args: (_ for _ in ()).throw(
|
||||
AssertionError("an already staged release must not run again")
|
||||
),
|
||||
)
|
||||
assert errors == []
|
||||
assert result["update_status"] == "ALREADY_STAGED"
|
||||
assert repeated_request is not None
|
||||
assert {
|
||||
key: value
|
||||
for key, value in repeated_request.items()
|
||||
if key != "requested_at"
|
||||
} == {key: value for key, value in request.items() if key != "requested_at"}
|
||||
assert len(downloads) == 1
|
||||
|
||||
|
||||
def test_agent_rejects_bad_update_signature_sha_and_protocol(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_package, token, task, manifest, arguments = _update_fixture(tmp_path)
|
||||
network_downloads: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {
|
||||
"manifest": manifest,
|
||||
"manifest_signature": "0" * 64,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_file",
|
||||
lambda *_args, **_kwargs: network_downloads.append("called"),
|
||||
)
|
||||
result, errors, request = agent_module.stage_agent_update(
|
||||
task,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert result["update_status"] == "EXECUTION_FAILED"
|
||||
assert any("signature mismatch" in error for error in errors)
|
||||
assert request is None
|
||||
assert network_downloads == []
|
||||
|
||||
incompatible = dict(task, compatible_protocol_version=PROTOCOL_VERSION + 1)
|
||||
_result, errors, _request = agent_module.stage_agent_update(
|
||||
incompatible,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert any("incompatible" in error for error in errors)
|
||||
|
||||
changed_sha = dict(task, package_sha256="0" * 64)
|
||||
signed = update_module.sign_update_manifest(token, manifest)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {
|
||||
"manifest": manifest,
|
||||
"manifest_signature": signed,
|
||||
},
|
||||
)
|
||||
_result, errors, _request = agent_module.stage_agent_update(
|
||||
changed_sha,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert any("field mismatch: package_sha256" in error for error in errors)
|
||||
assert network_downloads == []
|
||||
|
||||
|
||||
def test_agent_retries_one_interrupted_download_then_stages(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
package, token, task, manifest, arguments = _update_fixture(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {
|
||||
"manifest": manifest,
|
||||
"manifest_signature": update_module.sign_update_manifest(token, manifest),
|
||||
},
|
||||
)
|
||||
calls = 0
|
||||
|
||||
def interrupted_then_complete(
|
||||
_server: str,
|
||||
_token: bytes,
|
||||
_target: str,
|
||||
destination: Path,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 1:
|
||||
destination.write_bytes(b"partial")
|
||||
raise OSError("fixed interrupted transfer")
|
||||
shutil.copyfile(package, destination)
|
||||
|
||||
monkeypatch.setattr(agent_module, "request_file", interrupted_then_complete)
|
||||
result, errors, request = agent_module.stage_agent_update(
|
||||
task,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert errors == []
|
||||
assert result["update_status"] == "STAGED"
|
||||
assert request is not None
|
||||
assert calls == 2
|
||||
state = json.loads(
|
||||
(
|
||||
arguments.install_root
|
||||
/ "staging"
|
||||
/ str(task["task_id"])
|
||||
/ "download_state.json"
|
||||
).read_text(encoding="utf-8")
|
||||
)
|
||||
assert state["network_attempts"] == 2
|
||||
|
||||
|
||||
def test_agent_does_not_exceed_persisted_download_attempt_limit(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_package, token, task, manifest, arguments = _update_fixture(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {
|
||||
"manifest": manifest,
|
||||
"manifest_signature": update_module.sign_update_manifest(token, manifest),
|
||||
},
|
||||
)
|
||||
calls = 0
|
||||
|
||||
def always_interrupted(*_args: object, **_kwargs: object) -> None:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise OSError("fixed interrupted transfer")
|
||||
|
||||
monkeypatch.setattr(agent_module, "request_file", always_interrupted)
|
||||
for _ in range(2):
|
||||
result, errors, request = agent_module.stage_agent_update(
|
||||
task,
|
||||
arguments=arguments,
|
||||
token=token,
|
||||
command_runner=_successful_self_test,
|
||||
)
|
||||
assert result["update_status"] == "EXECUTION_FAILED"
|
||||
assert errors
|
||||
assert request is None
|
||||
assert calls == 2
|
||||
|
||||
|
||||
def test_agent_restart_does_not_duplicate_background_analyzer(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
starts: list[list[str]] = []
|
||||
|
||||
class FakeProcess:
|
||||
pid = 4242
|
||||
|
||||
def fake_popen(arguments: Sequence[str], **_kwargs: object) -> FakeProcess:
|
||||
starts.append(list(arguments))
|
||||
return FakeProcess()
|
||||
|
||||
monkeypatch.setattr(agent_module.subprocess, "Popen", fake_popen)
|
||||
pointer = agent_module.start_staircase_background_job(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
work_root=work_root,
|
||||
)
|
||||
assert pointer["pid"] == 4242
|
||||
with pytest.raises(RuntimeError, match="already active"):
|
||||
agent_module.start_staircase_background_job(
|
||||
task,
|
||||
agent_id="rx-user-pc",
|
||||
work_root=work_root,
|
||||
)
|
||||
assert len(starts) == 1
|
||||
assert pointer["hardware_accessed"] is False
|
||||
assert pointer["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_completed_background_result_survives_network_loss(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
work_root, task, _capture, _reference = _staircase_fixture(tmp_path)
|
||||
directory = work_root / "agent_jobs" / str(task["task_id"])
|
||||
directory.mkdir(parents=True)
|
||||
pointer = {
|
||||
"schema_version": 1,
|
||||
"task": task,
|
||||
"pid": 999999,
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
"automatic_retry": False,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
agent_module.atomic_write_json(
|
||||
work_root / "agent_jobs" / "active_job.json", pointer
|
||||
)
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": task["task_id"],
|
||||
"agent_id": "rx-user-pc",
|
||||
"status": "COMPLETE",
|
||||
"result": {"hardware_accessed": False, "rf_tx": 0},
|
||||
"errors": [],
|
||||
"rf_tx": 0,
|
||||
}
|
||||
agent_module.atomic_write_json(directory / "result.json", result)
|
||||
arguments = argparse.Namespace(
|
||||
work_root=work_root,
|
||||
server="http://127.0.0.1:8765",
|
||||
timeout=1.0,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
OSError("fixed network loss")
|
||||
),
|
||||
)
|
||||
with pytest.raises(OSError, match="network loss"):
|
||||
agent_module.deliver_background_result(arguments, b"token", pointer)
|
||||
assert (work_root / "agent_jobs" / "active_job.json").is_file()
|
||||
assert (directory / "result.json").is_file()
|
||||
|
||||
monkeypatch.setattr(
|
||||
agent_module,
|
||||
"request_json",
|
||||
lambda *_args, **_kwargs: {"accepted": True, "rf_tx": 0},
|
||||
)
|
||||
assert agent_module.deliver_background_result(arguments, b"token", pointer)
|
||||
assert not (work_root / "agent_jobs" / "active_job.json").exists()
|
||||
assert (directory / "delivered_job.json").is_file()
|
||||
assert (directory / "result.json").is_file()
|
||||
354
tests/test_rf001_staircase_search.py
Normal file
354
tests/test_rf001_staircase_search.py
Normal file
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from scipy import signal
|
||||
|
||||
from tools.rf001_staircase_search import (
|
||||
DEFAULT_CRITERIA,
|
||||
DEFAULT_LAYOUT,
|
||||
EXPECTED_REFERENCE_SHA256,
|
||||
ReferenceLayout,
|
||||
SearchCriteria,
|
||||
analyze,
|
||||
design_antialias_filter,
|
||||
locate_reference_streaming,
|
||||
load_reference,
|
||||
read_filtered_decimated_window,
|
||||
verify_candidate,
|
||||
)
|
||||
|
||||
|
||||
LAYOUT = ReferenceLayout(
|
||||
sample_rate_hz=8_000,
|
||||
symbol_rate=200,
|
||||
carrier_hz=500.0,
|
||||
leading_samples=800,
|
||||
segment_samples=800,
|
||||
gap_samples=400,
|
||||
trailing_samples=800,
|
||||
)
|
||||
CRITERIA = SearchCriteria(
|
||||
decimation=1,
|
||||
input_block_samples=50_000,
|
||||
cfo_min_hz=-3.0,
|
||||
cfo_max_hz=3.0,
|
||||
cfo_step_hz=0.5,
|
||||
histogram_stride=8,
|
||||
)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
EXACT_REFERENCE_PATH = (
|
||||
PROJECT_ROOT / ".local" / "rf001_probe" / "lab065_reference_staircase.cs8"
|
||||
)
|
||||
|
||||
|
||||
def _reference() -> np.ndarray:
|
||||
random_generator = np.random.default_rng(20260925)
|
||||
symbols = random_generator.choice((-1.0, 1.0), LAYOUT.segment_samples // 40)
|
||||
base = np.repeat(symbols, 40).astype(np.complex64)
|
||||
time_axis = np.arange(LAYOUT.segment_samples) / LAYOUT.sample_rate_hz
|
||||
base *= np.exp(2j * np.pi * LAYOUT.carrier_hz * time_axis)
|
||||
base *= 60.0 / np.sqrt(np.mean(np.abs(base) ** 2))
|
||||
values = np.zeros(LAYOUT.total_samples, dtype=np.complex64)
|
||||
for level, (start, stop) in zip(LAYOUT.levels_db, LAYOUT.active_windows()):
|
||||
values[start:stop] = base * (10 ** (level / 20))
|
||||
return values
|
||||
|
||||
|
||||
def _write_cs8(path: Path, values: np.ndarray) -> str:
|
||||
interleaved = np.empty(2 * values.size, dtype=np.int8)
|
||||
interleaved[0::2] = np.clip(np.rint(values.real), -128, 127).astype(np.int8)
|
||||
interleaved[1::2] = np.clip(np.rint(values.imag), -128, 127).astype(np.int8)
|
||||
path.write_bytes(interleaved.tobytes())
|
||||
return hashlib.sha256(interleaved.tobytes()).hexdigest()
|
||||
|
||||
|
||||
def _capture(reference: np.ndarray, *, gain: float, cfo_hz: float, start: int) -> np.ndarray:
|
||||
random_generator = np.random.default_rng(417)
|
||||
values = (
|
||||
random_generator.normal(0.0, 1.5, 1_000_000)
|
||||
+ 1j * random_generator.normal(0.0, 1.5, 1_000_000)
|
||||
).astype(np.complex64)
|
||||
time_axis = np.arange(reference.size) / LAYOUT.sample_rate_hz
|
||||
values[start:start + reference.size] += (
|
||||
gain
|
||||
* reference
|
||||
* np.exp(1j * (0.73 + 2 * np.pi * cfo_hz * time_axis))
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("gain", "cfo_hz", "start"),
|
||||
((0.25, -2.5, 49_777), (0.75, 0.0, 420_201), (1.5, 2.5, 749_999)),
|
||||
)
|
||||
def test_unknown_position_gain_cfo_and_block_boundary(
|
||||
tmp_path: Path,
|
||||
gain: float,
|
||||
cfo_hz: float,
|
||||
start: int,
|
||||
) -> None:
|
||||
reference_path = tmp_path / "reference.cs8"
|
||||
capture_path = tmp_path / "capture.cs8"
|
||||
reference_hash = _write_cs8(reference_path, _reference())
|
||||
_write_cs8(capture_path, _capture(_reference(), gain=gain, cfo_hz=cfo_hz, start=start))
|
||||
reference, _ = load_reference(
|
||||
reference_path,
|
||||
LAYOUT,
|
||||
CRITERIA,
|
||||
expected_sha256=reference_hash,
|
||||
)
|
||||
search = locate_reference_streaming(capture_path, reference, LAYOUT, CRITERIA)
|
||||
candidate = search["candidates"][0]
|
||||
verification = verify_candidate(capture_path, reference, candidate, LAYOUT, CRITERIA)
|
||||
assert abs(int(candidate["start_sample"]) - start) <= 1
|
||||
assert abs(float(candidate["cfo_hz"]) - cfo_hz) <= CRITERIA.cfo_step_hz
|
||||
assert float(candidate["score"]) >= CRITERIA.normalized_correlation_minimum
|
||||
assert verification["qualified"] is True
|
||||
|
||||
|
||||
def test_negative_control_without_staircase_is_not_detected(tmp_path: Path) -> None:
|
||||
reference_path = tmp_path / "reference.cs8"
|
||||
capture_path = tmp_path / "noise.cs8"
|
||||
report_path = tmp_path / "report.json"
|
||||
reference_hash = _write_cs8(reference_path, _reference())
|
||||
random_generator = np.random.default_rng(818)
|
||||
noise = (
|
||||
random_generator.normal(0.0, 3.0, 1_000_000)
|
||||
+ 1j * random_generator.normal(0.0, 3.0, 1_000_000)
|
||||
).astype(np.complex64)
|
||||
capture_hash = _write_cs8(capture_path, noise)
|
||||
result = analyze(
|
||||
reference_path,
|
||||
capture_path,
|
||||
report_path,
|
||||
expected_capture_sha256=capture_hash,
|
||||
layout=LAYOUT,
|
||||
criteria=CRITERIA,
|
||||
expected_reference_sha256=reference_hash,
|
||||
)
|
||||
assert result["verdict"] == "NOT_DETECTED"
|
||||
assert report_path.is_file()
|
||||
assert result["hardware_accessed"] is False
|
||||
assert result["rf_tx"] == 0
|
||||
|
||||
|
||||
def test_report_is_not_overwritten(tmp_path: Path) -> None:
|
||||
reference_path = tmp_path / "reference.cs8"
|
||||
capture_path = tmp_path / "capture.cs8"
|
||||
report_path = tmp_path / "report.json"
|
||||
reference_hash = _write_cs8(reference_path, _reference())
|
||||
capture_hash = _write_cs8(
|
||||
capture_path,
|
||||
_capture(_reference(), gain=1.0, cfo_hz=0.0, start=4_000),
|
||||
)
|
||||
result = analyze(
|
||||
reference_path,
|
||||
capture_path,
|
||||
report_path,
|
||||
expected_capture_sha256=capture_hash,
|
||||
layout=LAYOUT,
|
||||
criteria=CRITERIA,
|
||||
expected_reference_sha256=reference_hash,
|
||||
)
|
||||
assert result["verdict"] == "DETECTED"
|
||||
with pytest.raises(FileExistsError):
|
||||
analyze(
|
||||
reference_path,
|
||||
capture_path,
|
||||
report_path,
|
||||
expected_capture_sha256=capture_hash,
|
||||
layout=LAYOUT,
|
||||
criteria=CRITERIA,
|
||||
expected_reference_sha256=reference_hash,
|
||||
)
|
||||
|
||||
|
||||
def _require_exact_reference() -> Path:
|
||||
if not EXACT_REFERENCE_PATH.is_file():
|
||||
pytest.skip("exact Lab065 reference is not available in this checkout")
|
||||
assert EXACT_REFERENCE_PATH.stat().st_size == 12_000_000
|
||||
assert hashlib.sha256(EXACT_REFERENCE_PATH.read_bytes()).hexdigest() == (
|
||||
EXPECTED_REFERENCE_SHA256
|
||||
)
|
||||
return EXACT_REFERENCE_PATH
|
||||
|
||||
|
||||
def _read_exact_reference_values(path: Path) -> np.ndarray:
|
||||
raw = np.memmap(path, dtype=np.int8, mode="r")
|
||||
return (
|
||||
np.asarray(raw[0::2], dtype=np.float32)
|
||||
+ 1j * np.asarray(raw[1::2], dtype=np.float32)
|
||||
).astype(np.complex64)
|
||||
|
||||
|
||||
def _write_noise_cs8(path: Path, samples: int, seed: int) -> None:
|
||||
random_generator = np.random.default_rng(seed)
|
||||
raw = np.memmap(path, dtype=np.int8, mode="w+", shape=(2 * samples,))
|
||||
block = 500_000
|
||||
for start in range(0, samples, block):
|
||||
stop = min(samples, start + block)
|
||||
count = stop - start
|
||||
raw[2 * start:2 * stop:2] = np.clip(
|
||||
np.rint(random_generator.normal(0.0, 0.75, count)),
|
||||
-128,
|
||||
127,
|
||||
).astype(np.int8)
|
||||
raw[2 * start + 1:2 * stop + 1:2] = np.clip(
|
||||
np.rint(random_generator.normal(0.0, 0.75, count)),
|
||||
-128,
|
||||
127,
|
||||
).astype(np.int8)
|
||||
raw.flush()
|
||||
del raw
|
||||
|
||||
|
||||
def _inject_exact_reference(
|
||||
capture_path: Path,
|
||||
reference: np.ndarray,
|
||||
*,
|
||||
start: int,
|
||||
gain: float,
|
||||
phase: float,
|
||||
cfo_hz: float,
|
||||
) -> None:
|
||||
raw = np.memmap(capture_path, dtype=np.int8, mode="r+")
|
||||
existing = (
|
||||
np.asarray(raw[2 * start:2 * (start + reference.size):2], dtype=np.float32)
|
||||
+ 1j
|
||||
* np.asarray(
|
||||
raw[2 * start + 1:2 * (start + reference.size) + 1:2],
|
||||
dtype=np.float32,
|
||||
)
|
||||
)
|
||||
time_axis = np.arange(reference.size, dtype=np.float64) / DEFAULT_LAYOUT.sample_rate_hz
|
||||
injected = existing + gain * reference * np.exp(
|
||||
1j * (phase + 2 * np.pi * cfo_hz * time_axis)
|
||||
)
|
||||
raw[2 * start:2 * (start + reference.size):2] = np.clip(
|
||||
np.rint(injected.real), -128, 127
|
||||
).astype(np.int8)
|
||||
raw[2 * start + 1:2 * (start + reference.size) + 1:2] = np.clip(
|
||||
np.rint(injected.imag), -128, 127
|
||||
).astype(np.int8)
|
||||
raw.flush()
|
||||
del raw
|
||||
|
||||
|
||||
def test_antialias_filter_and_block_overlap_are_fixed() -> None:
|
||||
reference_path = _require_exact_reference()
|
||||
taps = design_antialias_filter(DEFAULT_LAYOUT, DEFAULT_CRITERIA)
|
||||
assert taps.size == 895
|
||||
assert (taps.size - 1) // 2 == 447
|
||||
frequencies, response = signal.freqz(
|
||||
taps,
|
||||
worN=262_144,
|
||||
fs=DEFAULT_LAYOUT.sample_rate_hz,
|
||||
)
|
||||
assert np.max(np.abs(response[frequencies >= 120_000])) <= 10 ** (-79 / 20)
|
||||
|
||||
total = math.ceil(DEFAULT_LAYOUT.total_samples / DEFAULT_CRITERIA.decimation)
|
||||
split = 73_219
|
||||
whole = read_filtered_decimated_window(
|
||||
reference_path,
|
||||
0,
|
||||
total,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
taps,
|
||||
)
|
||||
blocked = np.concatenate(
|
||||
(
|
||||
read_filtered_decimated_window(
|
||||
reference_path,
|
||||
0,
|
||||
split,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
taps,
|
||||
),
|
||||
read_filtered_decimated_window(
|
||||
reference_path,
|
||||
split,
|
||||
total,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
taps,
|
||||
),
|
||||
)
|
||||
)
|
||||
np.testing.assert_allclose(blocked, whole, rtol=0, atol=2e-5)
|
||||
|
||||
|
||||
def test_exact_reference_unknown_positions_gain_phase_noise_and_cfo(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
reference_path = _require_exact_reference()
|
||||
reference_values = _read_exact_reference_values(reference_path)
|
||||
cases = (
|
||||
(123_457, 0.45, 0.73, -2.5),
|
||||
(7_999_987, 0.70, -1.17, 0.0),
|
||||
(15_999_983, 1.00, 2.21, 2.5),
|
||||
)
|
||||
capture_path = tmp_path / "exact_reference_cases.cs8"
|
||||
_write_noise_cs8(capture_path, 22_500_000, seed=2026092801)
|
||||
for start, gain, phase, cfo_hz in cases:
|
||||
_inject_exact_reference(
|
||||
capture_path,
|
||||
reference_values,
|
||||
start=start,
|
||||
gain=gain,
|
||||
phase=phase,
|
||||
cfo_hz=cfo_hz,
|
||||
)
|
||||
reference, _ = load_reference(
|
||||
reference_path,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
expected_sha256=EXPECTED_REFERENCE_SHA256,
|
||||
)
|
||||
search = locate_reference_streaming(
|
||||
capture_path,
|
||||
reference,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
)
|
||||
assert search["coverage_complete"] is True
|
||||
assert search["source_samples_processed"] == 22_500_000
|
||||
candidates = list(search["candidates"])
|
||||
for expected_start, _gain, _phase, expected_cfo in cases:
|
||||
candidate = min(
|
||||
candidates,
|
||||
key=lambda row: abs(int(row["start_sample"]) - expected_start),
|
||||
)
|
||||
assert abs(int(candidate["start_sample"]) - expected_start) <= 32
|
||||
assert abs(float(candidate["cfo_hz"]) - expected_cfo) <= 0.25
|
||||
assert float(candidate["score"]) >= DEFAULT_CRITERIA.normalized_correlation_minimum
|
||||
verification = verify_candidate(
|
||||
capture_path,
|
||||
reference,
|
||||
candidate,
|
||||
DEFAULT_LAYOUT,
|
||||
DEFAULT_CRITERIA,
|
||||
)
|
||||
assert verification["qualified"] is True
|
||||
|
||||
|
||||
def test_exact_reference_negative_control(tmp_path: Path) -> None:
|
||||
reference_path = _require_exact_reference()
|
||||
capture_path = tmp_path / "exact_reference_negative.cs8"
|
||||
report_path = tmp_path / "negative_report.json"
|
||||
_write_noise_cs8(capture_path, 8_000_000, seed=2026092802)
|
||||
capture_hash = hashlib.sha256(capture_path.read_bytes()).hexdigest()
|
||||
result = analyze(
|
||||
reference_path,
|
||||
capture_path,
|
||||
report_path,
|
||||
expected_capture_sha256=capture_hash,
|
||||
)
|
||||
assert result["verdict"] == "NOT_DETECTED"
|
||||
assert result["search"]["coverage_complete"] is True
|
||||
39
tests/test_rf001a_prepare.py
Normal file
39
tests/test_rf001a_prepare.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Consistency and cs8 layout checks for the shared RF-001A definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.rf001a_prepare import SPEC_PATH, load_spec, quantize_cs8
|
||||
|
||||
|
||||
def test_shared_spec_matches_existing_tx_rx_constants() -> None:
|
||||
spec = load_spec()
|
||||
assert spec["test_id"] == "RF001A_COMMON_CLOCK_001"
|
||||
assert spec["expected_sample_count"] * 2 == 17_667_656
|
||||
assert spec["rx_capture_samples"] * 2 == 320_000_000
|
||||
assert spec["expected_hardware_sha256"] == (
|
||||
"75f3fa39bfa4ee419f0676c54e4b9c486d7ff64bd0fe2fc06343b20c9d117ecd"
|
||||
)
|
||||
|
||||
|
||||
def test_spec_mismatch_fails_closed(tmp_path: Path) -> None:
|
||||
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8-sig"))
|
||||
spec["digital_carrier_offset_hz"] = 0
|
||||
path = tmp_path / "wrong.json"
|
||||
path.write_text(json.dumps(spec), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="digital_carrier_offset_hz"):
|
||||
load_spec(path)
|
||||
|
||||
|
||||
def test_cs8_signed_iq_order_and_rail_rejection() -> None:
|
||||
source = np.array([0.25 - 0.125j, -0.25 + 0.125j, 0j], dtype=np.complex64)
|
||||
raw = quantize_cs8(source, 256)
|
||||
assert raw.dtype == np.int8
|
||||
assert raw.tolist() == [64, -32, -64, 32, 0, 0]
|
||||
with pytest.raises(ValueError, match="rail"):
|
||||
quantize_cs8(np.array([0.5 + 0j], dtype=np.complex64), 256)
|
||||
109
tests/test_rf001a_streaming.py
Normal file
109
tests/test_rf001a_streaming.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Проверки оконного cs8-декодера RF-001A без SDR и RF TX."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from protocol.video003_modem import build_frame
|
||||
from protocol.video005_iq_waveform import shape_packet
|
||||
from tools.qpsk_rf_common import SEED, payload_for_sequence
|
||||
from tools.qpsk_rf_rx import decode_file
|
||||
from tools.rf001a_streaming import (
|
||||
MemoryPeakSampler,
|
||||
SampleWindow,
|
||||
count_cs8_clipping,
|
||||
cs8_sample_count,
|
||||
decode_cs8_streaming,
|
||||
read_cs8_window,
|
||||
receive_cs8_windows,
|
||||
required_overlap_samples,
|
||||
rf001_packet_waveform_samples,
|
||||
timestamp_sample_window,
|
||||
)
|
||||
|
||||
|
||||
def _write_cs8(path: Path, iq: np.ndarray) -> None:
|
||||
peak = float(np.max(np.abs(iq))) if iq.size else 1.0
|
||||
normalized = iq / max(peak, 1e-12) * 0.75
|
||||
interleaved = np.empty(iq.size * 2, dtype=np.int8)
|
||||
interleaved[0::2] = np.rint(normalized.real * 127).astype(np.int8)
|
||||
interleaved[1::2] = np.rint(normalized.imag * 127).astype(np.int8)
|
||||
interleaved.tofile(path)
|
||||
|
||||
|
||||
def test_timestamp_to_sample_window_uses_safe_margins() -> None:
|
||||
capture = datetime(2026, 9, 22, 12, 0, tzinfo=timezone.utc)
|
||||
window = timestamp_sample_window(
|
||||
capture_started_at=capture.isoformat(),
|
||||
tx_started_at=(capture + timedelta(seconds=10)).isoformat(),
|
||||
tx_finished_at=(capture + timedelta(seconds=11.1042285)).isoformat(),
|
||||
total_samples=160_000_000,
|
||||
margin_seconds=2.0,
|
||||
)
|
||||
assert window.start == 64_000_000
|
||||
assert window.stop == 104_833_824
|
||||
|
||||
|
||||
def test_chunked_cs8_reading_from_large_sparse_capture(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "large_sparse.cs8"
|
||||
with capture.open("wb") as stream:
|
||||
stream.seek(1_920_000_000 - 1)
|
||||
stream.write(b"\0")
|
||||
raw = np.memmap(capture, dtype=np.int8, mode="r")
|
||||
window = read_cs8_window(raw, 900_000_000, 900_001_000)
|
||||
assert capture.stat().st_size == 1_920_000_000
|
||||
assert cs8_sample_count(capture) == 960_000_000
|
||||
assert window.dtype == np.complex64
|
||||
assert window.size == 1_000
|
||||
assert window.nbytes == 8_000
|
||||
with MemoryPeakSampler() as memory:
|
||||
assert count_cs8_clipping(capture) == 0
|
||||
assert memory.additional_peak_bytes < 512_000_000
|
||||
|
||||
|
||||
def test_packet_across_chunk_boundary_is_decoded_once(tmp_path: Path) -> None:
|
||||
payload = payload_for_sequence(SEED, 7)
|
||||
frame = build_frame(payload, "QPSK", "2/3", sequence_number=7)
|
||||
waveform = shape_packet(frame)
|
||||
chunk_samples = 120_000
|
||||
packet_start = chunk_samples - waveform.size // 2
|
||||
iq = np.zeros(chunk_samples * 2, dtype=np.complex64)
|
||||
iq[packet_start:packet_start + waveform.size] = waveform
|
||||
capture = tmp_path / "boundary.cs8"
|
||||
_write_cs8(capture, iq)
|
||||
|
||||
result = receive_cs8_windows(
|
||||
capture,
|
||||
(SampleWindow(0, iq.size),),
|
||||
decoder_backend="legacy",
|
||||
chunk_samples=chunk_samples,
|
||||
)
|
||||
|
||||
assert required_overlap_samples() == rf001_packet_waveform_samples() == 48_865
|
||||
assert [packet.packet for packet in result.reception.packets] == [frame.packet]
|
||||
assert result.maximum_loaded_samples < iq.size
|
||||
|
||||
|
||||
def test_capture_without_rf001a_signal_has_zero_packets(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "noise_only.cs8"
|
||||
np.zeros(400_000, dtype=np.int8).tofile(capture)
|
||||
result = decode_cs8_streaming(capture, decoder_backend="legacy")
|
||||
assert result.window_source == "STREAMING_POWER_LOCATOR"
|
||||
assert result.windows == ()
|
||||
assert result.reception.packets == ()
|
||||
assert result.maximum_loaded_samples == 0
|
||||
|
||||
truth = Path(__file__).resolve().parents[1] / "configs/rf/rf001a_truth_metadata.json"
|
||||
report = decode_file(
|
||||
capture,
|
||||
iq_format="cs8",
|
||||
truth_metadata_path=truth,
|
||||
decoder_backend="legacy",
|
||||
)
|
||||
assert report["exact_packets"] == 0
|
||||
assert report["crc_valid_packets"] == 0
|
||||
assert report["streaming_decoder"] is True
|
||||
assert report["peak_additional_ram_bytes"] < 1_000_000_000
|
||||
49
tests/test_rf001a_truth_metadata.py
Normal file
49
tests/test_rf001a_truth_metadata.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Checks for the tracked RF-001A decoder truth artifact."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC_PATH = ROOT / "configs" / "rf" / "rf001a_common_clock.json"
|
||||
TRUTH_PATH = ROOT / "configs" / "rf" / "rf001a_truth_metadata.json"
|
||||
|
||||
|
||||
def test_truth_metadata_is_small_complete_and_spec_aligned() -> None:
|
||||
spec = json.loads(SPEC_PATH.read_text(encoding="utf-8"))
|
||||
truth = json.loads(TRUTH_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
assert TRUTH_PATH.stat().st_size < 64 * 1024
|
||||
assert truth["shared_spec_test_id"] == spec["test_id"]
|
||||
assert truth["test_id"] == spec["legacy_waveform_test_id"]
|
||||
assert truth["seed"] == spec["seed"]
|
||||
assert truth["packet_count"] == spec["packet_count"] == 100
|
||||
assert truth["payload_size"] == spec["application_payload_bytes"] == 256
|
||||
assert truth["application_payload_bytes"] == truth["payload_size"]
|
||||
assert truth["expected_sequence_numbers"] == list(range(100))
|
||||
assert len(truth["expected_packet_hashes"]) == 100
|
||||
assert [
|
||||
row["sequence_number"] for row in truth["expected_packet_hashes"]
|
||||
] == list(range(100))
|
||||
assert all(
|
||||
len(row["payload_sha256"]) == 64 and len(row["packet_sha256"]) == 64
|
||||
for row in truth["expected_packet_hashes"]
|
||||
)
|
||||
assert truth["tx_waveform_sha256"] == spec["expected_hardware_sha256"]
|
||||
assert truth["iq_sha256"] == spec["expected_canonical_sha256"]
|
||||
assert truth["modulation"] == spec["modulation"]
|
||||
assert truth["symbol_rate"] == spec["symbol_rate_sps"]
|
||||
assert truth["sample_rate"] == spec["sample_rate_hz"]
|
||||
assert truth["fec"] == spec["inner_fec"]
|
||||
assert truth["fec_rate"] == truth["fec"]
|
||||
assert truth["gap_ms"] == spec["inter_packet_gap_ms"]
|
||||
assert truth["inter_packet_gap_ms"] == truth["gap_ms"]
|
||||
assert (
|
||||
truth["digital_carrier_offset_hz"]
|
||||
== spec["digital_carrier_offset_hz"]
|
||||
)
|
||||
assert truth["tx_waveform_format"] == spec["hardware_format"]
|
||||
assert "waveform" not in truth
|
||||
assert "raw_iq" not in truth
|
||||
843
tests/test_rf_handoff_coordination.py
Normal file
843
tests/test_rf_handoff_coordination.py
Normal file
@@ -0,0 +1,843 @@
|
||||
"""Tests for the RX-to-MAIN Git coordination channel without SDR or RF TX."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from argparse import Namespace
|
||||
from dataclasses import asdict, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rf_handoff_channel import (
|
||||
CHANNEL_TEST_TASK_ID,
|
||||
COMPLETE_STATE,
|
||||
EXPECTED_RX_TASK_ID,
|
||||
GitResultChannel,
|
||||
READY_STATE,
|
||||
RESULT_JSON_PATH,
|
||||
RESULT_MARKDOWN_PATH,
|
||||
ResultBranchMissing,
|
||||
ResultChannelError,
|
||||
ResultHistoryRewritten,
|
||||
RxHandoffResult,
|
||||
canonical_result_json,
|
||||
evaluate_ready,
|
||||
result_digest,
|
||||
result_from_mapping,
|
||||
)
|
||||
from tools.rf_handoff_smoke import run_smoke
|
||||
from tools.rx_agent_watcher import (
|
||||
WatcherState as RxWatcherState,
|
||||
publish_result,
|
||||
require_live_ready_window,
|
||||
save_state as save_rx_state,
|
||||
)
|
||||
from tools.tx_agent_watcher import (
|
||||
TxWatcherState,
|
||||
handle_result,
|
||||
load_state as load_tx_state,
|
||||
migrate_state_cursor,
|
||||
process_history_cycle,
|
||||
request_stop,
|
||||
save_state as save_tx_state,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 21, 12, 0, tzinfo=timezone.utc)
|
||||
HEAD = "a" * 40
|
||||
|
||||
|
||||
def ready_result(**changes: object) -> RxHandoffResult:
|
||||
result = RxHandoffResult(
|
||||
schema_version=1,
|
||||
task_id=EXPECTED_RX_TASK_ID,
|
||||
state=READY_STATE,
|
||||
rx_ready_for_tx=True,
|
||||
process_alive=True,
|
||||
file_growing=True,
|
||||
capture_pid=1234,
|
||||
published_at=NOW.isoformat(),
|
||||
capture_started_at=NOW.isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=60)).isoformat(),
|
||||
capture_path=r"C:\captures\rf001a_ota_capture.cs8",
|
||||
common_clock_verified=True,
|
||||
source_head=HEAD,
|
||||
rf_tx=0,
|
||||
exit_code=None,
|
||||
file_size=None,
|
||||
duration_seconds=None,
|
||||
overruns=None,
|
||||
usb_errors=None,
|
||||
file_errors=None,
|
||||
sha256=None,
|
||||
clkin_after="NOT_RUN",
|
||||
rf_tx_observed="UNKNOWN",
|
||||
)
|
||||
return replace(result, **changes)
|
||||
|
||||
|
||||
def channel_ready_result(**changes: object) -> RxHandoffResult:
|
||||
return ready_result(
|
||||
task_id=CHANNEL_TEST_TASK_ID,
|
||||
capture_path="MODEL_FREE_FAKE_CAPTURE",
|
||||
common_clock_verified=False,
|
||||
**changes,
|
||||
)
|
||||
|
||||
|
||||
def complete_result(
|
||||
result: RxHandoffResult | None = None,
|
||||
**changes: object,
|
||||
) -> RxHandoffResult:
|
||||
source = result or ready_result()
|
||||
complete = replace(
|
||||
source,
|
||||
state=COMPLETE_STATE,
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
published_at=(NOW + timedelta(seconds=61)).isoformat(),
|
||||
exit_code=0,
|
||||
file_size=960_000_000,
|
||||
duration_seconds=60.01,
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
sha256="b" * 64,
|
||||
clkin_after="DETECTED" if source.common_clock_verified else "NOT_RUN",
|
||||
rf_tx_observed="UNKNOWN",
|
||||
)
|
||||
return replace(complete, **changes)
|
||||
|
||||
|
||||
def _git(arguments: list[str], cwd: Path) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def git_fixture(tmp_path: Path) -> tuple[Path, Path]:
|
||||
remote = tmp_path / "remote.git"
|
||||
work = tmp_path / "work"
|
||||
_git(["init", "--bare", str(remote)], tmp_path)
|
||||
work.mkdir()
|
||||
_git(["init"], work)
|
||||
_git(["config", "user.name", "Test"], work)
|
||||
_git(["config", "user.email", "test@example.invalid"], work)
|
||||
(work / "README.md").write_text(
|
||||
"result branch fixture\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_git(["add", "README.md"], work)
|
||||
_git(["commit", "-m", "Seed result branch"], work)
|
||||
_git(["remote", "add", "origin", str(remote)], work)
|
||||
_git(["push", "origin", "HEAD:refs/heads/rx-node"], work)
|
||||
return remote, work
|
||||
|
||||
|
||||
def commit_on_result_branch(
|
||||
work: Path,
|
||||
temporary_root: Path,
|
||||
message: str,
|
||||
*,
|
||||
json_text: str | None = None,
|
||||
allow_empty: bool = False,
|
||||
) -> str:
|
||||
"""Create one test-only result branch commit from the main repository."""
|
||||
|
||||
channel = GitResultChannel(work)
|
||||
channel.fetch()
|
||||
checkout = temporary_root / f"result-{message.replace(' ', '-')}"
|
||||
_git(["worktree", "add", "--detach", str(checkout), "origin/rx-node"], work)
|
||||
try:
|
||||
if json_text is not None:
|
||||
result_path = checkout / RESULT_JSON_PATH
|
||||
result_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
result_path.write_text(json_text, encoding="utf-8")
|
||||
_git(["add", "--", RESULT_JSON_PATH], checkout)
|
||||
commit_arguments = [
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.invalid",
|
||||
"commit",
|
||||
]
|
||||
if allow_empty:
|
||||
commit_arguments.append("--allow-empty")
|
||||
commit_arguments.extend(["-m", message])
|
||||
_git(commit_arguments, checkout)
|
||||
commit = _git(["rev-parse", "HEAD"], checkout)
|
||||
_git(["push", "origin", "HEAD:refs/heads/rx-node"], checkout)
|
||||
return commit
|
||||
finally:
|
||||
_git(["worktree", "remove", "--force", str(checkout)], work)
|
||||
|
||||
|
||||
class FakeSource:
|
||||
def __init__(self, result: RxHandoffResult, digest: str, head: str = HEAD) -> None:
|
||||
self.result = result
|
||||
self.digest = digest
|
||||
self.head = head
|
||||
self.fetch_count = 0
|
||||
|
||||
def fetch(self) -> None:
|
||||
self.fetch_count += 1
|
||||
return None
|
||||
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||
return self.result, self.digest, self.head
|
||||
|
||||
def read_commit(self, commit: str) -> tuple[RxHandoffResult, str, str]:
|
||||
assert commit == self.head
|
||||
return self.read()
|
||||
|
||||
def commits_since(
|
||||
self,
|
||||
last_processed_commit: str | None,
|
||||
) -> tuple[list[str], str]:
|
||||
return ([] if last_processed_commit == self.head else [self.head], self.head)
|
||||
|
||||
def find_commit_by_digest(self, digest: str) -> str | None:
|
||||
return self.head if digest == self.digest else None
|
||||
|
||||
|
||||
class FakeExecutor:
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
self.calls = 0
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
result: RxHandoffResult,
|
||||
digest: str,
|
||||
result_head: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> Path:
|
||||
assert result.task_id == EXPECTED_RX_TASK_ID
|
||||
assert digest == result_digest(result)
|
||||
assert result_head
|
||||
assert dry_run
|
||||
self.calls += 1
|
||||
self.path.write_text(
|
||||
"FAKE TX ONLY\nRF TX = 0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return self.path
|
||||
|
||||
|
||||
def test_result_parsing_rejects_extra_types_and_inconsistent_fields() -> None:
|
||||
raw = asdict(ready_result())
|
||||
assert result_from_mapping(raw) == ready_result()
|
||||
|
||||
extra = dict(raw)
|
||||
extra["raw_iq"] = "forbidden"
|
||||
with pytest.raises(ValueError, match="result fields disagree"):
|
||||
result_from_mapping(extra)
|
||||
|
||||
wrong = dict(raw)
|
||||
wrong["rf_tx"] = 1
|
||||
with pytest.raises(ValueError, match="rf_tx"):
|
||||
result_from_mapping(wrong)
|
||||
|
||||
wrong_type = dict(raw)
|
||||
wrong_type["capture_pid"] = True
|
||||
with pytest.raises(ValueError, match="integer result fields"):
|
||||
result_from_mapping(wrong_type)
|
||||
|
||||
incomplete = dict(raw)
|
||||
incomplete["file_growing"] = False
|
||||
with pytest.raises(ValueError, match="live process"):
|
||||
result_from_mapping(incomplete)
|
||||
|
||||
|
||||
def test_complete_result_accepts_explicit_unreported_counters() -> None:
|
||||
final = complete_result()
|
||||
assert result_from_mapping(asdict(final)) == final
|
||||
unreported = replace(
|
||||
final,
|
||||
overruns=None,
|
||||
usb_errors=None,
|
||||
file_errors=None,
|
||||
)
|
||||
assert result_from_mapping(asdict(unreported)) == unreported
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "now", "reason"),
|
||||
[
|
||||
(
|
||||
ready_result(
|
||||
published_at=(NOW - timedelta(seconds=31)).isoformat(),
|
||||
capture_started_at=(NOW - timedelta(seconds=31)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=29)).isoformat(),
|
||||
),
|
||||
NOW,
|
||||
"STALE_READY",
|
||||
),
|
||||
(
|
||||
ready_result(expected_capture_end=(NOW + timedelta(seconds=9)).isoformat()),
|
||||
NOW,
|
||||
"WRONG_CAPTURE_WINDOW",
|
||||
),
|
||||
(
|
||||
ready_result(
|
||||
capture_started_at=(NOW - timedelta(seconds=51)).isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=9)).isoformat(),
|
||||
),
|
||||
NOW,
|
||||
"CAPTURE_WINDOW_EXPIRED",
|
||||
),
|
||||
(ready_result(task_id="WRONG_TASK_001"), NOW, "WRONG_TASK_ID"),
|
||||
(
|
||||
complete_result(),
|
||||
NOW,
|
||||
"MISSING_READY",
|
||||
),
|
||||
(
|
||||
ready_result(source_head="c" * 40),
|
||||
NOW,
|
||||
"WRONG_SOURCE_HEAD",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_freshness_window_task_and_source_rejections(
|
||||
result: RxHandoffResult,
|
||||
now: datetime,
|
||||
reason: str,
|
||||
) -> None:
|
||||
assert evaluate_ready(
|
||||
result,
|
||||
now=now,
|
||||
expected_source_head=HEAD,
|
||||
).reason == reason
|
||||
|
||||
|
||||
def test_ready_evaluation_passes_with_more_than_ten_seconds() -> None:
|
||||
evaluation = evaluate_ready(
|
||||
ready_result(),
|
||||
now=NOW + timedelta(seconds=2),
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert evaluation.ready
|
||||
assert evaluation.reason == "READY"
|
||||
assert evaluation.seconds_remaining == pytest.approx(58.0)
|
||||
|
||||
|
||||
def test_red_approval_and_duplicate_suppression(tmp_path: Path) -> None:
|
||||
result = ready_result()
|
||||
digest = result_digest(result)
|
||||
source = FakeSource(result, digest)
|
||||
executor = FakeExecutor(tmp_path / "fake_tx.txt")
|
||||
output: list[str] = []
|
||||
|
||||
status, state = handle_result(
|
||||
result,
|
||||
digest,
|
||||
HEAD,
|
||||
TxWatcherState(),
|
||||
source,
|
||||
executor,
|
||||
now_function=lambda: NOW + timedelta(seconds=2),
|
||||
input_function=lambda _prompt: "DENY",
|
||||
output_function=output.append,
|
||||
dry_run=True,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert status == "RED_APPROVAL_BLOCKED"
|
||||
assert executor.calls == 0
|
||||
assert any("RX IS READY" in line for line in output)
|
||||
|
||||
status, _ = handle_result(
|
||||
result,
|
||||
digest,
|
||||
HEAD,
|
||||
state,
|
||||
source,
|
||||
executor,
|
||||
now_function=lambda: NOW + timedelta(seconds=3),
|
||||
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||
output_function=output.append,
|
||||
dry_run=True,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert status == "DUPLICATE_SUPPRESSED"
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_window_and_commit_are_rechecked_after_exact_approval(tmp_path: Path) -> None:
|
||||
result = ready_result()
|
||||
digest = result_digest(result)
|
||||
source = FakeSource(result, digest)
|
||||
executor = FakeExecutor(tmp_path / "fake_tx.txt")
|
||||
times = iter((NOW + timedelta(seconds=2), NOW + timedelta(seconds=51)))
|
||||
|
||||
status, _ = handle_result(
|
||||
result,
|
||||
digest,
|
||||
HEAD,
|
||||
TxWatcherState(),
|
||||
source,
|
||||
executor,
|
||||
now_function=lambda: next(times),
|
||||
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||
output_function=lambda _text: None,
|
||||
dry_run=True,
|
||||
expected_source_head=HEAD,
|
||||
max_age_seconds=200.0,
|
||||
)
|
||||
assert status == "CAPTURE_WINDOW_EXPIRED"
|
||||
assert source.fetch_count == 1
|
||||
assert executor.calls == 0
|
||||
source = FakeSource(result, digest, head="d" * 40)
|
||||
status, _ = handle_result(
|
||||
result,
|
||||
digest,
|
||||
HEAD,
|
||||
TxWatcherState(),
|
||||
source,
|
||||
executor,
|
||||
now_function=lambda: NOW + timedelta(seconds=2),
|
||||
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||
output_function=lambda _text: None,
|
||||
dry_run=True,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert status == "RESULT_CHANGED"
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_stop_request_clears_stale_watcher_pid(tmp_path: Path) -> None:
|
||||
state_path = tmp_path / "watcher_state.json"
|
||||
save_tx_state(state_path, TxWatcherState(running=True, pid=2_000_000_000))
|
||||
|
||||
assert request_stop(state_path, timeout_s=1.0)
|
||||
state = load_tx_state(state_path)
|
||||
assert not state.running
|
||||
assert state.pid is None
|
||||
assert not state_path.with_suffix(".json.stop").exists()
|
||||
|
||||
|
||||
def test_version_one_state_migrates_without_losing_safety_fields(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
state_path = tmp_path / "legacy_state.json"
|
||||
state_path.write_text(
|
||||
"""{
|
||||
"running": false,
|
||||
"pid": null,
|
||||
"last_seen_digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"last_prompted_digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"last_authorized_digest": null,
|
||||
"channel_test_ready_digest": null,
|
||||
"channel_test_complete_digest": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
state = load_tx_state(state_path)
|
||||
assert state.state_version == 2
|
||||
assert state.last_seen_commit is None
|
||||
assert state.last_processed_rx_node_commit is None
|
||||
assert state.last_prompted_digest == "b" * 64
|
||||
assert state.channel_test_complete_digest == "c" * 64
|
||||
|
||||
|
||||
def test_history_cycle_processes_ready_duplicate_and_complete_in_order(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
channel = GitResultChannel(work)
|
||||
baseline = complete_result(
|
||||
channel_ready_result(),
|
||||
duration_seconds=1.0,
|
||||
file_size=4,
|
||||
sha256="c" * 64,
|
||||
)
|
||||
baseline_outcome = channel.publish(baseline)
|
||||
state = TxWatcherState(last_seen_digest=result_digest(baseline))
|
||||
assert migrate_state_cursor(channel, state)
|
||||
assert state.last_processed_rx_node_commit == baseline_outcome.commit
|
||||
|
||||
ready = channel_ready_result(published_at=NOW.isoformat())
|
||||
ready_outcome = channel.publish(ready)
|
||||
duplicate_commit = commit_on_result_branch(
|
||||
work,
|
||||
tmp_path,
|
||||
"Duplicate READY result",
|
||||
allow_empty=True,
|
||||
)
|
||||
complete = complete_result(
|
||||
ready,
|
||||
published_at=(NOW + timedelta(seconds=1)).isoformat(),
|
||||
duration_seconds=1.0,
|
||||
file_size=4,
|
||||
sha256="d" * 64,
|
||||
)
|
||||
complete_outcome = channel.publish(complete)
|
||||
prompts: list[str] = []
|
||||
executor = FakeExecutor(tmp_path / "forbidden.txt")
|
||||
saved_commits: list[str | None] = []
|
||||
|
||||
outcomes = process_history_cycle(
|
||||
channel,
|
||||
state,
|
||||
executor,
|
||||
now_function=lambda: NOW + timedelta(seconds=2),
|
||||
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
|
||||
save_function=lambda current: saved_commits.append(
|
||||
current.last_processed_rx_node_commit
|
||||
),
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
|
||||
assert outcomes == [
|
||||
(ready_outcome.commit, "CHANNEL_TEST_READY"),
|
||||
(duplicate_commit, "DUPLICATE_SUPPRESSED"),
|
||||
(complete_outcome.commit, "CHANNEL_TEST_COMPLETE"),
|
||||
]
|
||||
assert saved_commits == [
|
||||
ready_outcome.commit,
|
||||
duplicate_commit,
|
||||
complete_outcome.commit,
|
||||
]
|
||||
assert state.channel_test_ready_digest == result_digest(ready)
|
||||
assert state.channel_test_complete_digest == result_digest(complete)
|
||||
assert state.last_seen_commit == complete_outcome.commit
|
||||
assert state.last_processed_rx_node_commit == complete_outcome.commit
|
||||
assert prompts == []
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_malformed_intermediate_commit_blocks_later_history(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
channel = GitResultChannel(work)
|
||||
baseline = complete_result(
|
||||
channel_ready_result(),
|
||||
duration_seconds=1.0,
|
||||
file_size=4,
|
||||
sha256="c" * 64,
|
||||
)
|
||||
baseline_outcome = channel.publish(baseline)
|
||||
state = TxWatcherState(
|
||||
last_seen_digest=result_digest(baseline),
|
||||
last_seen_commit=baseline_outcome.commit,
|
||||
last_processed_rx_node_commit=baseline_outcome.commit,
|
||||
)
|
||||
ready = channel_ready_result()
|
||||
ready_outcome = channel.publish(ready)
|
||||
malformed_commit = commit_on_result_branch(
|
||||
work,
|
||||
tmp_path,
|
||||
"Malformed result",
|
||||
json_text="{not-json\n",
|
||||
)
|
||||
complete = complete_result(
|
||||
ready,
|
||||
duration_seconds=1.0,
|
||||
file_size=4,
|
||||
sha256="d" * 64,
|
||||
)
|
||||
complete_outcome = channel.publish(complete)
|
||||
saved_commits: list[str | None] = []
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
process_history_cycle(
|
||||
channel,
|
||||
state,
|
||||
FakeExecutor(tmp_path / "forbidden.txt"),
|
||||
now_function=lambda: NOW,
|
||||
input_function=lambda _prompt: "DENY",
|
||||
save_function=lambda current: saved_commits.append(
|
||||
current.last_processed_rx_node_commit
|
||||
),
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
|
||||
assert saved_commits == [ready_outcome.commit]
|
||||
assert state.last_processed_rx_node_commit == ready_outcome.commit
|
||||
assert state.last_processed_rx_node_commit != malformed_commit
|
||||
assert state.last_processed_rx_node_commit != complete_outcome.commit
|
||||
|
||||
|
||||
def test_result_history_rewrite_is_rejected(tmp_path: Path) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
channel = GitResultChannel(work)
|
||||
ready_outcome = channel.publish(channel_ready_result())
|
||||
original_branch_head = _git(["rev-parse", "HEAD"], work)
|
||||
_git(
|
||||
[
|
||||
"push",
|
||||
"--force",
|
||||
"origin",
|
||||
f"{original_branch_head}:refs/heads/rx-node",
|
||||
],
|
||||
work,
|
||||
)
|
||||
|
||||
with pytest.raises(ResultHistoryRewritten):
|
||||
channel.commits_since(ready_outcome.commit)
|
||||
|
||||
|
||||
def test_expired_historical_ready_is_observed_without_authorization(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
channel = GitResultChannel(work)
|
||||
started = NOW - timedelta(seconds=200)
|
||||
expired = ready_result(
|
||||
capture_started_at=started.isoformat(),
|
||||
expected_capture_end=(started + timedelta(seconds=60)).isoformat(),
|
||||
published_at=(NOW - timedelta(seconds=100)).isoformat(),
|
||||
)
|
||||
expired_outcome = channel.publish(expired)
|
||||
state = TxWatcherState()
|
||||
prompts: list[str] = []
|
||||
executor = FakeExecutor(tmp_path / "forbidden.txt")
|
||||
|
||||
outcomes = process_history_cycle(
|
||||
channel,
|
||||
state,
|
||||
executor,
|
||||
now_function=lambda: NOW,
|
||||
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
|
||||
save_function=lambda _state: None,
|
||||
output_function=lambda _text: None,
|
||||
dry_run=True,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
|
||||
assert outcomes == [(expired_outcome.commit, "STALE_READY")]
|
||||
assert state.last_processed_rx_node_commit == expired_outcome.commit
|
||||
assert prompts == []
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_real_origin_detection_and_worktree_publication(tmp_path: Path) -> None:
|
||||
remote, work = git_fixture(tmp_path)
|
||||
branch_before = _git(["branch", "--show-current"], work)
|
||||
channel = GitResultChannel(work)
|
||||
assert channel.branch_exists()
|
||||
remote_before = channel.fetch()
|
||||
|
||||
outcome = channel.publish(ready_result())
|
||||
assert outcome.status == "PUBLISHED"
|
||||
assert outcome.commit == outcome.remote_head
|
||||
assert outcome.commit != remote_before
|
||||
assert _git(["branch", "--show-current"], work) == branch_before
|
||||
assert not (work / RESULT_JSON_PATH).exists()
|
||||
assert not (work / RESULT_MARKDOWN_PATH).exists()
|
||||
|
||||
remote_head = _git(
|
||||
["--git-dir", str(remote), "rev-parse", "refs/heads/rx-node"],
|
||||
tmp_path,
|
||||
)
|
||||
assert remote_head == outcome.commit
|
||||
worktrees = _git(["worktree", "list", "--porcelain"], work)
|
||||
assert worktrees.count("worktree ") == 1
|
||||
|
||||
|
||||
def test_ready_then_complete_reaches_main_watcher_without_red_prompt(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
channel = GitResultChannel(work)
|
||||
ready = channel_ready_result()
|
||||
ready_outcome = channel.publish(ready)
|
||||
observed_ready, ready_digest, ready_head = channel.read()
|
||||
prompts: list[str] = []
|
||||
|
||||
status, state = handle_result(
|
||||
observed_ready,
|
||||
ready_digest,
|
||||
ready_head,
|
||||
TxWatcherState(),
|
||||
channel,
|
||||
FakeExecutor(tmp_path / "forbidden.txt"),
|
||||
now_function=lambda: NOW,
|
||||
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert status == "CHANNEL_TEST_READY"
|
||||
assert state.channel_test_ready_digest == ready_digest
|
||||
assert prompts == []
|
||||
|
||||
complete = complete_result(
|
||||
ready,
|
||||
published_at=(NOW + timedelta(seconds=1)).isoformat(),
|
||||
file_size=4,
|
||||
duration_seconds=1.0,
|
||||
sha256="d" * 64,
|
||||
)
|
||||
complete_outcome = channel.publish(complete)
|
||||
observed_complete, complete_digest, complete_head = channel.read()
|
||||
status, state = handle_result(
|
||||
observed_complete,
|
||||
complete_digest,
|
||||
complete_head,
|
||||
state,
|
||||
channel,
|
||||
FakeExecutor(tmp_path / "forbidden.txt"),
|
||||
now_function=lambda: NOW + timedelta(seconds=1),
|
||||
input_function=lambda prompt: prompts.append(prompt) or "AUTHORIZE RF TX",
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=HEAD,
|
||||
)
|
||||
assert status == "CHANNEL_TEST_COMPLETE"
|
||||
assert state.channel_test_complete_digest == complete_digest
|
||||
assert ready_outcome.remote_head != complete_outcome.remote_head
|
||||
assert prompts == []
|
||||
|
||||
|
||||
def test_smoke_utility_leaves_complete_not_ready(tmp_path: Path) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
ready, complete, expected = run_smoke(
|
||||
work,
|
||||
source_head=HEAD,
|
||||
hold_seconds=0,
|
||||
sleep_function=lambda _seconds: None,
|
||||
)
|
||||
assert ready.status == "PUBLISHED"
|
||||
assert complete.status == "PUBLISHED"
|
||||
assert ready.remote_head != complete.remote_head
|
||||
observed, _digest, head = GitResultChannel(work).read()
|
||||
assert observed == expected
|
||||
assert observed.state == COMPLETE_STATE
|
||||
assert not observed.rx_ready_for_tx
|
||||
assert head == complete.remote_head
|
||||
|
||||
|
||||
def test_missing_result_branch_is_blocked(tmp_path: Path) -> None:
|
||||
remote = tmp_path / "remote.git"
|
||||
work = tmp_path / "work"
|
||||
_git(["init", "--bare", str(remote)], tmp_path)
|
||||
work.mkdir()
|
||||
_git(["init"], work)
|
||||
_git(["remote", "add", "origin", str(remote)], work)
|
||||
channel = GitResultChannel(work)
|
||||
assert not channel.branch_exists()
|
||||
with pytest.raises(ResultBranchMissing):
|
||||
channel.publish(ready_result())
|
||||
|
||||
|
||||
def test_push_failure_is_reported_and_remote_head_is_unchanged(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
|
||||
class FailingPushChannel(GitResultChannel):
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if arguments and arguments[0] == "push":
|
||||
return subprocess.CompletedProcess(
|
||||
["git", *arguments],
|
||||
1,
|
||||
"",
|
||||
"simulated push failure",
|
||||
)
|
||||
return super()._run(arguments, cwd=cwd, check=check)
|
||||
|
||||
channel = FailingPushChannel(work)
|
||||
before = channel.remote_head()
|
||||
with pytest.raises(ResultChannelError, match="simulated push failure"):
|
||||
channel.publish(ready_result())
|
||||
assert channel.remote_head() == before
|
||||
assert _git(["worktree", "list", "--porcelain"], work).count("worktree ") == 1
|
||||
|
||||
|
||||
def test_rx_watcher_publishes_ready_after_local_task_approval(
|
||||
tmp_path: Path,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
_remote, work = git_fixture(tmp_path)
|
||||
base_head = _git(["rev-parse", "HEAD"], work)
|
||||
_git(["checkout", "-b", "video-development"], work)
|
||||
handoff = work / "docs" / "handoff"
|
||||
handoff.mkdir(parents=True)
|
||||
task = f"""TASK_ID = {EXPECTED_RX_TASK_ID}
|
||||
TASK_TITLE = Fake coordinated capture
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = {base_head}
|
||||
REQUIRES_APPROVAL = YES
|
||||
HARDWARE_ACCESS = YES
|
||||
RF_RX = YES
|
||||
RF_TX = NO
|
||||
ALLOW_COMMIT = NO
|
||||
ALLOW_PUSH = RX_RESULT_ONLY
|
||||
RISK_LEVEL = YELLOW
|
||||
|
||||
Fake task used only by the local coordination test.
|
||||
"""
|
||||
(handoff / "NEXT_RX_TASK.md").write_text(task, encoding="utf-8")
|
||||
_git(["add", "docs/handoff/NEXT_RX_TASK.md"], work)
|
||||
_git(["commit", "-m", "Add fake RX task"], work)
|
||||
_git(
|
||||
["push", "--set-upstream", "origin", "HEAD:refs/heads/video-development"],
|
||||
work,
|
||||
)
|
||||
source_head = _git(["rev-parse", "HEAD"], work)
|
||||
|
||||
fresh_start = datetime.now(timezone.utc)
|
||||
fresh_result = ready_result(
|
||||
source_head=source_head,
|
||||
published_at=fresh_start.isoformat(),
|
||||
capture_started_at=fresh_start.isoformat(),
|
||||
expected_capture_end=(fresh_start + timedelta(seconds=60)).isoformat(),
|
||||
)
|
||||
result_path = tmp_path / "ready.json"
|
||||
result_path.write_text(
|
||||
canonical_result_json(fresh_result),
|
||||
encoding="utf-8",
|
||||
)
|
||||
state_path = tmp_path / "rx_state.json"
|
||||
save_rx_state(
|
||||
state_path,
|
||||
RxWatcherState(
|
||||
last_seen_task_id=EXPECTED_RX_TASK_ID,
|
||||
last_approved_task_id=EXPECTED_RX_TASK_ID,
|
||||
),
|
||||
)
|
||||
arguments = Namespace(
|
||||
repository=work,
|
||||
state_file=state_path,
|
||||
publish_result=result_path,
|
||||
remote="origin",
|
||||
result_branch="rx-node",
|
||||
)
|
||||
|
||||
assert publish_result(arguments) == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "RX RESULT PUBLISH = PUBLISHED" in output
|
||||
assert "RX RESULT REMOTE HEAD" in output
|
||||
assert "RF TX = 0" in output
|
||||
|
||||
published, _digest, _head = GitResultChannel(work).read()
|
||||
assert published == fresh_result
|
||||
|
||||
|
||||
def test_rx_publisher_refuses_ready_after_capture_window() -> None:
|
||||
with pytest.raises(ValueError, match="window has already ended"):
|
||||
require_live_ready_window(
|
||||
ready_result(),
|
||||
now=NOW + timedelta(seconds=60),
|
||||
)
|
||||
145
tests/test_rf_handoff_reliability.py
Normal file
145
tests/test_rf_handoff_reliability.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Проверки повторной публикации READY и Git preflight без оборудования."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rf_handoff_channel import GitResultChannel, ResultChannelError
|
||||
|
||||
|
||||
OLD_HEAD = "1" * 40
|
||||
READY_HEAD = "2" * 40
|
||||
|
||||
|
||||
class SimulatedPushChannel(GitResultChannel):
|
||||
def __init__(
|
||||
self,
|
||||
push_results: list[subprocess.CompletedProcess[str]],
|
||||
observed_heads: list[str],
|
||||
) -> None:
|
||||
self.remote = "origin"
|
||||
self.branch = "rx-node"
|
||||
self.push_results = iter(push_results)
|
||||
self.observed_heads = iter(observed_heads)
|
||||
self.push_arguments: list[tuple[str, ...]] = []
|
||||
self.fetch_calls = 0
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
del cwd, check
|
||||
assert arguments[0] == "push"
|
||||
self.push_arguments.append(tuple(arguments))
|
||||
return next(self.push_results)
|
||||
|
||||
def remote_head(self) -> str:
|
||||
return next(self.observed_heads)
|
||||
|
||||
def fetch(self) -> str:
|
||||
self.fetch_calls += 1
|
||||
return OLD_HEAD
|
||||
|
||||
|
||||
def _push_result(returncode: int, stderr: str = "") -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.CompletedProcess(["git", "push"], returncode, "", stderr)
|
||||
|
||||
|
||||
def _git(arguments: list[str], cwd: Path) -> str:
|
||||
return subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def test_transient_failure_retries_the_same_ready_commit_once() -> None:
|
||||
channel = SimulatedPushChannel(
|
||||
[_push_result(1, "Authentication failed"), _push_result(0)],
|
||||
[OLD_HEAD, READY_HEAD],
|
||||
)
|
||||
assert channel._push_exact_commit(Path("checkout"), READY_HEAD, OLD_HEAD) == READY_HEAD
|
||||
assert len(channel.push_arguments) == 2
|
||||
assert channel.push_arguments[0] == channel.push_arguments[1]
|
||||
assert channel.push_arguments[0][-1] == f"{READY_HEAD}:refs/heads/rx-node"
|
||||
assert channel.fetch_calls == 1
|
||||
|
||||
|
||||
def test_ambiguous_push_is_success_when_remote_already_has_ready() -> None:
|
||||
channel = SimulatedPushChannel(
|
||||
[_push_result(1, "TLS connection was non-properly terminated")],
|
||||
[READY_HEAD],
|
||||
)
|
||||
assert channel._push_exact_commit(Path("checkout"), READY_HEAD, OLD_HEAD) == READY_HEAD
|
||||
assert len(channel.push_arguments) == 1
|
||||
assert channel.fetch_calls == 0
|
||||
|
||||
|
||||
def test_second_transient_failure_does_not_create_another_ready() -> None:
|
||||
channel = SimulatedPushChannel(
|
||||
[
|
||||
_push_result(1, "Authentication failed"),
|
||||
_push_result(1, "Authentication failed"),
|
||||
],
|
||||
[OLD_HEAD, OLD_HEAD],
|
||||
)
|
||||
with pytest.raises(ResultChannelError, match="after one retry"):
|
||||
channel._push_exact_commit(Path("checkout"), READY_HEAD, OLD_HEAD)
|
||||
assert len(channel.push_arguments) == 2
|
||||
assert channel.push_arguments[0] == channel.push_arguments[1]
|
||||
|
||||
|
||||
def test_failed_write_preflight_stops_before_any_publication() -> None:
|
||||
class FailingPreflight(GitResultChannel):
|
||||
def __init__(self) -> None:
|
||||
self.remote = "origin"
|
||||
self.branch = "rx-node"
|
||||
|
||||
def fetch(self) -> str:
|
||||
return OLD_HEAD
|
||||
|
||||
def remote_head(self) -> str:
|
||||
raise AssertionError("remote confirmation must not follow failed dry-run")
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
del cwd, check
|
||||
assert arguments[:3] == ["push", "--dry-run", "--porcelain"]
|
||||
return _push_result(1, "Authentication failed")
|
||||
|
||||
with pytest.raises(ResultChannelError, match="push preflight failed"):
|
||||
FailingPreflight().verify_write_access()
|
||||
|
||||
|
||||
def test_write_preflight_checks_access_without_changing_remote(tmp_path: Path) -> None:
|
||||
remote = tmp_path / "remote.git"
|
||||
work = tmp_path / "work"
|
||||
_git(["init", "--bare", str(remote)], tmp_path)
|
||||
work.mkdir()
|
||||
_git(["init"], work)
|
||||
_git(["config", "user.name", "Test"], work)
|
||||
_git(["config", "user.email", "test@example.invalid"], work)
|
||||
(work / "seed.txt").write_text("seed\n", encoding="utf-8")
|
||||
_git(["add", "seed.txt"], work)
|
||||
_git(["commit", "-m", "Seed result branch"], work)
|
||||
_git(["remote", "add", "origin", str(remote)], work)
|
||||
_git(["push", "origin", "HEAD:refs/heads/rx-node"], work)
|
||||
channel = GitResultChannel(work)
|
||||
before = channel.remote_head()
|
||||
assert channel.verify_write_access() == before
|
||||
assert channel.remote_head() == before
|
||||
190
tests/test_rx_agent_watcher.py
Normal file
190
tests/test_rx_agent_watcher.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Unit tests for the model-free RX task watcher."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.rx_agent_watcher import (
|
||||
ApprovalDecision,
|
||||
ManualExecutor,
|
||||
WatcherState,
|
||||
handle_task,
|
||||
is_new_task,
|
||||
load_state,
|
||||
mark_completed,
|
||||
parse_task,
|
||||
request_approval,
|
||||
request_stop,
|
||||
save_state,
|
||||
)
|
||||
|
||||
|
||||
HEAD = "1" * 40
|
||||
|
||||
|
||||
def task_text(*, task_id: str = "WATCHER_TEST_001", rf_tx: str = "NO") -> str:
|
||||
hardware = "YES" if rf_tx == "YES" else "NO"
|
||||
risk_level = "RED" if rf_tx == "YES" else "GREEN"
|
||||
return f"""TASK_ID = {task_id}
|
||||
TASK_TITLE = Watcher test
|
||||
SOURCE_BRANCH = video-development
|
||||
EXPECTED_HEAD = {HEAD}
|
||||
REQUIRES_APPROVAL = YES
|
||||
HARDWARE_ACCESS = {hardware}
|
||||
RF_RX = NO
|
||||
RF_TX = {rf_tx}
|
||||
ALLOW_COMMIT = NO
|
||||
ALLOW_PUSH = RX_RESULT_ONLY
|
||||
RISK_LEVEL = {risk_level}
|
||||
|
||||
# Body
|
||||
Do nothing.
|
||||
"""
|
||||
|
||||
|
||||
def responses(*values: str):
|
||||
iterator = iter(values)
|
||||
return lambda _prompt: next(iterator)
|
||||
|
||||
|
||||
def test_task_parsing_and_validation() -> None:
|
||||
task = parse_task(task_text())
|
||||
assert task.task_id == "WATCHER_TEST_001"
|
||||
assert task.source_branch == "video-development"
|
||||
assert task.safety_level == "GREEN"
|
||||
assert not task.rf_tx
|
||||
with pytest.raises(ValueError, match="missing task header"):
|
||||
parse_task("TASK_ID = INCOMPLETE")
|
||||
with pytest.raises(ValueError, match="duplicate"):
|
||||
parse_task(task_text().replace(
|
||||
"TASK_TITLE = Watcher test",
|
||||
"TASK_TITLE = Watcher test\nTASK_TITLE = Duplicate",
|
||||
))
|
||||
|
||||
|
||||
def test_state_persistence_and_completion(tmp_path: Path) -> None:
|
||||
path = tmp_path / "state.json"
|
||||
original = WatcherState(last_seen_task_id="WATCHER_TEST_001", running=True, pid=123)
|
||||
save_state(path, original)
|
||||
assert load_state(path) == original
|
||||
mark_completed(path, "WATCHER_TEST_001")
|
||||
completed = load_state(path)
|
||||
assert completed.last_completed_task_id == "WATCHER_TEST_001"
|
||||
assert completed.deferred_task_id is None
|
||||
with pytest.raises(ValueError, match="last seen"):
|
||||
mark_completed(path, "WATCHER_TEST_002")
|
||||
|
||||
|
||||
def test_approval_defer_stop_and_yellow_approve() -> None:
|
||||
green = parse_task(task_text())
|
||||
assert request_approval(green, responses("D"), lambda _text: None) == ApprovalDecision.DEFER
|
||||
assert request_approval(green, responses("S"), lambda _text: None) == ApprovalDecision.STOP
|
||||
yellow_text = task_text().replace(
|
||||
"HARDWARE_ACCESS = NO", "HARDWARE_ACCESS = YES"
|
||||
).replace("RISK_LEVEL = GREEN", "RISK_LEVEL = YELLOW")
|
||||
yellow = parse_task(yellow_text)
|
||||
assert yellow.safety_level == "YELLOW"
|
||||
assert request_approval(yellow, responses("A"), lambda _text: None) == ApprovalDecision.APPROVE
|
||||
|
||||
|
||||
def test_rf_tx_red_gate_requires_separate_phrase() -> None:
|
||||
red = parse_task(task_text(rf_tx="YES"))
|
||||
assert red.safety_level == "RED"
|
||||
assert request_approval(
|
||||
red, responses("A", "yes"), lambda _text: None
|
||||
) == ApprovalDecision.BLOCKED
|
||||
assert request_approval(
|
||||
red, responses("A", "AUTHORIZE RF TX"), lambda _text: None
|
||||
) == ApprovalDecision.APPROVE
|
||||
|
||||
|
||||
def test_non_rf_red_task_also_requires_separate_phrase() -> None:
|
||||
text = task_text(task_id="FIRMWARE_TEST_001").replace(
|
||||
"RISK_LEVEL = GREEN", "RISK_LEVEL = RED"
|
||||
)
|
||||
red = parse_task(text)
|
||||
assert not red.rf_tx
|
||||
assert request_approval(
|
||||
red, responses("A", "AUTHORIZE RF TX"), lambda _text: None
|
||||
) == ApprovalDecision.BLOCKED
|
||||
assert request_approval(
|
||||
red, responses("A", "AUTHORIZE FIRMWARE_TEST_001"), lambda _text: None
|
||||
) == ApprovalDecision.APPROVE
|
||||
|
||||
|
||||
def test_duplicate_task_is_suppressed_without_prompt(tmp_path: Path) -> None:
|
||||
text = task_text()
|
||||
task = parse_task(text)
|
||||
state = WatcherState(last_seen_task_id=task.task_id)
|
||||
assert not is_new_task(task, state)
|
||||
|
||||
def forbidden_input(_prompt: str) -> str:
|
||||
raise AssertionError("duplicate task prompted the user")
|
||||
|
||||
status, returned = handle_task(
|
||||
text,
|
||||
HEAD,
|
||||
state,
|
||||
ManualExecutor(tmp_path),
|
||||
dry_run=True,
|
||||
input_function=forbidden_input,
|
||||
output_function=lambda _text: None,
|
||||
)
|
||||
assert status == "DUPLICATE_SUPPRESSED"
|
||||
assert returned == state
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_defer_persists_seen_id_and_creates_no_result(tmp_path: Path) -> None:
|
||||
state = WatcherState()
|
||||
status, state = handle_task(
|
||||
task_text(),
|
||||
HEAD,
|
||||
state,
|
||||
ManualExecutor(tmp_path),
|
||||
dry_run=True,
|
||||
input_function=responses("D"),
|
||||
output_function=lambda _text: None,
|
||||
)
|
||||
assert status == "DEFERRED"
|
||||
assert state.last_seen_task_id == "WATCHER_TEST_001"
|
||||
assert state.deferred_task_id == "WATCHER_TEST_001"
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_dry_run_approval_writes_prompt_and_matching_results(tmp_path: Path) -> None:
|
||||
status, state = handle_task(
|
||||
task_text(),
|
||||
HEAD,
|
||||
WatcherState(),
|
||||
ManualExecutor(tmp_path),
|
||||
dry_run=True,
|
||||
input_function=responses("A"),
|
||||
output_function=lambda _text: None,
|
||||
)
|
||||
directory = tmp_path / "WATCHER_TEST_001"
|
||||
assert status == "PREPARED"
|
||||
assert state.last_seen_task_id == "WATCHER_TEST_001"
|
||||
assert state.last_completed_task_id is None
|
||||
assert state.last_approved_task_id == "WATCHER_TEST_001"
|
||||
assert (directory / "TASK.md").exists()
|
||||
markdown = (directory / "RX_RESULT.md").read_text(encoding="utf-8")
|
||||
result = json.loads((directory / "RX_RESULT.json").read_text(encoding="utf-8"))
|
||||
assert "TASK_STATUS = DRY_RUN_PREPARED" in markdown
|
||||
assert result["task_status"] == "DRY_RUN_PREPARED"
|
||||
assert result["rf_tx"] == "NO"
|
||||
assert result["hardware_actions"] == []
|
||||
with pytest.raises(FileExistsError):
|
||||
ManualExecutor(tmp_path).prepare(
|
||||
parse_task(task_text()), task_text(), HEAD, dry_run=True
|
||||
)
|
||||
|
||||
|
||||
def test_stop_request_for_idle_watcher(tmp_path: Path) -> None:
|
||||
state_path = tmp_path / "state.json"
|
||||
save_state(state_path, WatcherState(running=False, pid=None))
|
||||
assert request_stop(state_path, timeout_s=0.1)
|
||||
assert state_path.with_suffix(".json.stop").read_text(encoding="ascii") == "STOP\n"
|
||||
583
tests/test_tx_handoff_acknowledgement.py
Normal file
583
tests/test_tx_handoff_acknowledgement.py
Normal file
@@ -0,0 +1,583 @@
|
||||
"""Model-free tests for the MAIN-to-RX acknowledgement channel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
import tools.rx_agent_watcher as rx_agent_watcher
|
||||
|
||||
from tools.rf_handoff_channel import (
|
||||
CHANNEL_TEST_TASK_ID,
|
||||
COMPLETE_STATE,
|
||||
GitResultChannel,
|
||||
READY_STATE,
|
||||
ResultBranchMissing,
|
||||
ResultChannelError,
|
||||
ResultHistoryRewritten,
|
||||
RxHandoffResult,
|
||||
result_digest,
|
||||
)
|
||||
from tools.tx_agent_watcher import TxWatcherState, process_history_cycle
|
||||
from tools.tx_handoff_channel import (
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
CHANNEL_READY_OBSERVED,
|
||||
TX_ACK_SCHEMA_VERSION,
|
||||
TX_AUTHORIZED,
|
||||
TX_COMPLETE,
|
||||
TX_STARTED,
|
||||
TX_RESULT_PATHS,
|
||||
GitAcknowledgementChannel,
|
||||
TxHandoffAcknowledgement,
|
||||
acknowledgement_from_mapping,
|
||||
build_tx_execution_event,
|
||||
channel_test_acknowledgements,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 22, 12, 0, tzinfo=timezone.utc)
|
||||
SOURCE_HEAD = "a" * 40
|
||||
|
||||
|
||||
def _git(arguments: list[str], cwd: Path) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def repository_fixture(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
with_tx_node: bool = True,
|
||||
) -> tuple[Path, Path, str | None]:
|
||||
remote = tmp_path / "remote.git"
|
||||
work = tmp_path / "work"
|
||||
_git(["init", "--bare", str(remote)], tmp_path)
|
||||
work.mkdir()
|
||||
_git(["init"], work)
|
||||
_git(["config", "user.name", "Test"], work)
|
||||
_git(["config", "user.email", "test@example.invalid"], work)
|
||||
(work / "README.md").write_text("fixture\n", encoding="utf-8")
|
||||
_git(["add", "README.md"], work)
|
||||
_git(["commit", "-m", "Seed RX result branch"], work)
|
||||
initial_branch = _git(["branch", "--show-current"], work)
|
||||
_git(["remote", "add", "origin", str(remote)], work)
|
||||
_git(["push", "origin", "HEAD:refs/heads/rx-node"], work)
|
||||
tx_root: str | None = None
|
||||
if with_tx_node:
|
||||
_git(["switch", "--orphan", "tx-node"], work)
|
||||
_git(["rm", "-rf", "--ignore-unmatch", "."], work)
|
||||
_git(["commit", "--allow-empty", "-m", "Initialize tx-node"], work)
|
||||
tx_root = _git(["rev-parse", "HEAD"], work)
|
||||
_git(["push", "origin", "HEAD:refs/heads/tx-node"], work)
|
||||
_git(["switch", initial_branch], work)
|
||||
return remote, work, tx_root
|
||||
|
||||
|
||||
def channel_ready(**changes: object) -> RxHandoffResult:
|
||||
result = RxHandoffResult(
|
||||
schema_version=1,
|
||||
task_id=CHANNEL_TEST_TASK_ID,
|
||||
state=READY_STATE,
|
||||
rx_ready_for_tx=True,
|
||||
process_alive=True,
|
||||
file_growing=True,
|
||||
capture_pid=1234,
|
||||
published_at=NOW.isoformat(),
|
||||
capture_started_at=NOW.isoformat(),
|
||||
expected_capture_end=(NOW + timedelta(seconds=60)).isoformat(),
|
||||
capture_path="MODEL_FREE_FAKE_CAPTURE",
|
||||
common_clock_verified=False,
|
||||
source_head=SOURCE_HEAD,
|
||||
rf_tx=0,
|
||||
exit_code=None,
|
||||
file_size=None,
|
||||
duration_seconds=None,
|
||||
overruns=None,
|
||||
usb_errors=None,
|
||||
file_errors=None,
|
||||
sha256=None,
|
||||
clkin_after="NOT_RUN",
|
||||
rf_tx_observed="UNKNOWN",
|
||||
)
|
||||
return replace(result, **changes)
|
||||
|
||||
|
||||
def channel_complete(
|
||||
ready: RxHandoffResult,
|
||||
**changes: object,
|
||||
) -> RxHandoffResult:
|
||||
result = replace(
|
||||
ready,
|
||||
state=COMPLETE_STATE,
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
published_at=(NOW + timedelta(seconds=1)).isoformat(),
|
||||
exit_code=0,
|
||||
file_size=4,
|
||||
duration_seconds=1.0,
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
sha256="b" * 64,
|
||||
)
|
||||
return replace(result, **changes)
|
||||
|
||||
|
||||
def acknowledgement(
|
||||
state: str,
|
||||
source_commit: str,
|
||||
source_digest: str,
|
||||
**changes: object,
|
||||
) -> TxHandoffAcknowledgement:
|
||||
result = TxHandoffAcknowledgement(
|
||||
schema_version=1,
|
||||
task_id=CHANNEL_TEST_TASK_ID,
|
||||
state=state,
|
||||
source_rx_commit=source_commit,
|
||||
source_rx_digest=source_digest,
|
||||
source_head=SOURCE_HEAD,
|
||||
observed_at=NOW.isoformat(),
|
||||
main_watcher_pid=4321,
|
||||
rf_tx_authorized=False,
|
||||
rf_tx_started=False,
|
||||
rf_tx=0,
|
||||
)
|
||||
return replace(result, **changes)
|
||||
|
||||
|
||||
class FakeExecutor:
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def prepare(self, *_args: object, **_kwargs: object) -> Path:
|
||||
self.calls += 1
|
||||
raise AssertionError("channel test must not prepare RF TX")
|
||||
|
||||
|
||||
def test_acknowledgement_schema_binds_digest_and_source_commit() -> None:
|
||||
valid = acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
|
||||
raw = valid.__dict__.copy()
|
||||
assert acknowledgement_from_mapping(raw) == valid
|
||||
|
||||
with pytest.raises(ValueError, match="source_rx_commit"):
|
||||
acknowledgement_from_mapping({**raw, "source_rx_commit": "1" * 39})
|
||||
with pytest.raises(ValueError, match="source_rx_digest"):
|
||||
acknowledgement_from_mapping({**raw, "source_rx_digest": "2" * 63})
|
||||
with pytest.raises(ValueError, match="cannot claim RF TX"):
|
||||
acknowledgement_from_mapping({**raw, "rf_tx_authorized": True})
|
||||
|
||||
|
||||
def test_tx_events_preserve_authorization_binding_and_timestamps() -> None:
|
||||
authorization = TxHandoffAcknowledgement(
|
||||
schema_version=TX_ACK_SCHEMA_VERSION,
|
||||
task_id="RF001A_OTA_CAPTURE_004",
|
||||
state=TX_AUTHORIZED,
|
||||
source_rx_commit="1" * 40,
|
||||
source_rx_digest="2" * 64,
|
||||
source_head=SOURCE_HEAD,
|
||||
observed_at=NOW.isoformat(),
|
||||
main_watcher_pid=4321,
|
||||
rf_tx_authorized=True,
|
||||
rf_tx_started=False,
|
||||
rf_tx=0,
|
||||
tx_task_id="RF001A_OTA_TX_004",
|
||||
)
|
||||
started_at = (NOW + timedelta(seconds=1)).isoformat()
|
||||
started = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_STARTED,
|
||||
observed_at=started_at,
|
||||
tx_started_at=started_at,
|
||||
)
|
||||
finished_at = (NOW + timedelta(seconds=2.2)).isoformat()
|
||||
complete = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_COMPLETE,
|
||||
observed_at=finished_at,
|
||||
tx_started_at=started_at,
|
||||
tx_finished_at=finished_at,
|
||||
process_exit_code=0,
|
||||
underruns=0,
|
||||
errors=0,
|
||||
)
|
||||
assert started.source_rx_commit == authorization.source_rx_commit
|
||||
assert started.source_rx_digest == authorization.source_rx_digest
|
||||
assert started.rf_tx_started and started.rf_tx == 1
|
||||
assert complete.tx_finished_at == finished_at
|
||||
assert complete.tx_process_exit_code == 0
|
||||
|
||||
|
||||
def test_execution_event_publication_requires_authorization_and_start(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
authorization = TxHandoffAcknowledgement(
|
||||
schema_version=TX_ACK_SCHEMA_VERSION,
|
||||
task_id="RF001A_OTA_CAPTURE_004",
|
||||
state=TX_AUTHORIZED,
|
||||
source_rx_commit="1" * 40,
|
||||
source_rx_digest="2" * 64,
|
||||
source_head=SOURCE_HEAD,
|
||||
observed_at=NOW.isoformat(),
|
||||
main_watcher_pid=4321,
|
||||
rf_tx_authorized=True,
|
||||
rf_tx_started=False,
|
||||
rf_tx=0,
|
||||
tx_task_id="RF001A_OTA_TX_004",
|
||||
)
|
||||
started_at = (NOW + timedelta(seconds=1)).isoformat()
|
||||
finished_at = (NOW + timedelta(seconds=2.2)).isoformat()
|
||||
started = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_STARTED,
|
||||
observed_at=started_at,
|
||||
tx_started_at=started_at,
|
||||
)
|
||||
complete = build_tx_execution_event(
|
||||
authorization,
|
||||
state=TX_COMPLETE,
|
||||
observed_at=finished_at,
|
||||
tx_started_at=started_at,
|
||||
tx_finished_at=finished_at,
|
||||
process_exit_code=0,
|
||||
underruns=0,
|
||||
errors=0,
|
||||
)
|
||||
|
||||
with pytest.raises(ResultChannelError, match="TX_AUTHORIZED"):
|
||||
channel.publish_execution_event(started)
|
||||
channel.publish(authorization)
|
||||
with pytest.raises(ResultChannelError, match="TX_STARTED"):
|
||||
channel.publish_execution_event(complete)
|
||||
started_outcome = channel.publish_execution_event(started)
|
||||
complete_outcome = channel.publish_execution_event(complete)
|
||||
assert started_outcome.status == "PUBLISHED"
|
||||
assert complete_outcome.status == "PUBLISHED"
|
||||
|
||||
|
||||
def test_main_publish_uses_exact_allowlist_and_suppresses_duplicate(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
remote, work, tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
item = acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
|
||||
|
||||
first = channel.publish(item)
|
||||
second = channel.publish(item)
|
||||
|
||||
assert first.status == "PUBLISHED"
|
||||
assert second.status == "UNCHANGED"
|
||||
assert second.commit == first.commit
|
||||
assert tx_root is not None
|
||||
assert _git(
|
||||
["--git-dir", str(remote), "rev-list", "--count", "refs/heads/tx-node"],
|
||||
tmp_path,
|
||||
) == "2"
|
||||
tree = set(
|
||||
_git(
|
||||
[
|
||||
"--git-dir",
|
||||
str(remote),
|
||||
"ls-tree",
|
||||
"-r",
|
||||
"--name-only",
|
||||
"refs/heads/tx-node",
|
||||
],
|
||||
tmp_path,
|
||||
).splitlines()
|
||||
)
|
||||
assert tree == TX_RESULT_PATHS
|
||||
|
||||
|
||||
def test_two_acknowledgements_between_polls_are_both_confirmed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
ready_commit = "1" * 40
|
||||
ready_digest = "2" * 64
|
||||
complete_commit = "3" * 40
|
||||
complete_digest = "4" * 64
|
||||
ready_outcome = channel.publish(
|
||||
acknowledgement(CHANNEL_READY_OBSERVED, ready_commit, ready_digest)
|
||||
)
|
||||
complete_outcome = channel.publish(
|
||||
acknowledgement(
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
complete_commit,
|
||||
complete_digest,
|
||||
observed_at=(NOW + timedelta(seconds=1)).isoformat(),
|
||||
)
|
||||
)
|
||||
|
||||
status = channel_test_acknowledgements(
|
||||
channel,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
ready_source_commit=ready_commit,
|
||||
ready_source_digest=ready_digest,
|
||||
complete_source_commit=complete_commit,
|
||||
complete_source_digest=complete_digest,
|
||||
)
|
||||
|
||||
assert status.ready_confirmed
|
||||
assert status.complete_confirmed
|
||||
assert status.ready_ack_commit == ready_outcome.commit
|
||||
assert status.complete_ack_commit == complete_outcome.commit
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrong_field", ["digest", "commit"])
|
||||
def test_wrong_binding_does_not_confirm_ready(
|
||||
tmp_path: Path,
|
||||
wrong_field: str,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
expected_commit = "1" * 40
|
||||
expected_digest = "2" * 64
|
||||
channel.publish(
|
||||
acknowledgement(
|
||||
CHANNEL_READY_OBSERVED,
|
||||
"9" * 40 if wrong_field == "commit" else expected_commit,
|
||||
"8" * 64 if wrong_field == "digest" else expected_digest,
|
||||
)
|
||||
)
|
||||
|
||||
status = channel_test_acknowledgements(
|
||||
channel,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
ready_source_commit=expected_commit,
|
||||
ready_source_digest=expected_digest,
|
||||
complete_source_commit="3" * 40,
|
||||
complete_source_digest="4" * 64,
|
||||
)
|
||||
assert not status.ready_confirmed
|
||||
assert not status.complete_confirmed
|
||||
|
||||
|
||||
def test_stale_acknowledgement_with_old_source_head_is_rejected(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
channel.publish(
|
||||
acknowledgement(
|
||||
CHANNEL_READY_OBSERVED,
|
||||
"1" * 40,
|
||||
"2" * 64,
|
||||
source_head="f" * 40,
|
||||
)
|
||||
)
|
||||
status = channel_test_acknowledgements(
|
||||
channel,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
ready_source_commit="1" * 40,
|
||||
ready_source_digest="2" * 64,
|
||||
complete_source_commit="3" * 40,
|
||||
complete_source_digest="4" * 64,
|
||||
)
|
||||
assert not status.ready_confirmed
|
||||
|
||||
|
||||
def test_missing_tx_node_is_blocked(tmp_path: Path) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path, with_tx_node=False)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
with pytest.raises(ResultBranchMissing):
|
||||
channel_test_acknowledgements(
|
||||
channel,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
)
|
||||
|
||||
|
||||
def test_acknowledgement_push_failure_preserves_remote_head(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
|
||||
class FailingChannel(GitAcknowledgementChannel):
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
if arguments and arguments[0] == "push":
|
||||
return subprocess.CompletedProcess(
|
||||
["git", *arguments],
|
||||
1,
|
||||
"",
|
||||
"simulated push failure",
|
||||
)
|
||||
return super()._run(arguments, cwd=cwd, check=check)
|
||||
|
||||
channel = FailingChannel(work)
|
||||
before = channel.remote_head()
|
||||
with pytest.raises(ResultChannelError, match="simulated push failure"):
|
||||
channel.publish(
|
||||
acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
|
||||
)
|
||||
assert channel.remote_head() == before
|
||||
|
||||
|
||||
def test_tx_node_history_rewrite_is_rejected(tmp_path: Path) -> None:
|
||||
_remote, work, tx_root = repository_fixture(tmp_path)
|
||||
channel = GitAcknowledgementChannel(work)
|
||||
outcome = channel.publish(
|
||||
acknowledgement(CHANNEL_READY_OBSERVED, "1" * 40, "2" * 64)
|
||||
)
|
||||
assert tx_root is not None
|
||||
_git(
|
||||
["push", "--force", "origin", f"{tx_root}:refs/heads/tx-node"],
|
||||
work,
|
||||
)
|
||||
with pytest.raises(ResultHistoryRewritten):
|
||||
channel.commits_since(outcome.commit)
|
||||
|
||||
|
||||
def test_fake_two_way_flow_is_event_safe_and_uses_no_tx(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
rx_channel = GitResultChannel(work)
|
||||
tx_channel = GitAcknowledgementChannel(work)
|
||||
ready = channel_ready()
|
||||
ready_outcome = rx_channel.publish(ready)
|
||||
complete = channel_complete(ready)
|
||||
complete_outcome = rx_channel.publish(complete)
|
||||
state = TxWatcherState()
|
||||
executor = FakeExecutor()
|
||||
|
||||
outcomes = process_history_cycle(
|
||||
rx_channel,
|
||||
state,
|
||||
executor,
|
||||
now_function=lambda: NOW + timedelta(seconds=2),
|
||||
input_function=lambda _prompt: "AUTHORIZE RF TX",
|
||||
save_function=lambda _state: None,
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
acknowledgement_publisher=tx_channel,
|
||||
main_watcher_pid=4321,
|
||||
)
|
||||
|
||||
assert outcomes == [
|
||||
(ready_outcome.commit, "CHANNEL_TEST_READY"),
|
||||
(complete_outcome.commit, "CHANNEL_TEST_COMPLETE"),
|
||||
]
|
||||
confirmations = channel_test_acknowledgements(
|
||||
tx_channel,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
ready_source_commit=ready_outcome.commit,
|
||||
ready_source_digest=result_digest(ready),
|
||||
complete_source_commit=complete_outcome.commit,
|
||||
complete_source_digest=result_digest(complete),
|
||||
)
|
||||
assert confirmations.ready_confirmed
|
||||
assert confirmations.complete_confirmed
|
||||
assert executor.calls == 0
|
||||
assert state.last_authorized_digest is None
|
||||
|
||||
|
||||
def test_rx_watcher_confirms_both_main_acknowledgements(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
rx_channel = GitResultChannel(work)
|
||||
tx_channel = GitAcknowledgementChannel(work)
|
||||
ready = channel_ready()
|
||||
ready_outcome = rx_channel.publish(ready)
|
||||
complete = channel_complete(ready)
|
||||
complete_outcome = rx_channel.publish(complete)
|
||||
tx_channel.publish(
|
||||
acknowledgement(
|
||||
CHANNEL_READY_OBSERVED,
|
||||
ready_outcome.commit,
|
||||
result_digest(ready),
|
||||
)
|
||||
)
|
||||
tx_channel.publish(
|
||||
acknowledgement(
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
complete_outcome.commit,
|
||||
result_digest(complete),
|
||||
observed_at=(NOW + timedelta(seconds=1)).isoformat(),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rx_agent_watcher,
|
||||
"CHANNEL_TEST_READY_COMMIT",
|
||||
ready_outcome.commit,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rx_agent_watcher,
|
||||
"CHANNEL_TEST_READY_DIGEST",
|
||||
result_digest(ready),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rx_agent_watcher,
|
||||
"CHANNEL_TEST_COMPLETE_COMMIT",
|
||||
complete_outcome.commit,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
rx_agent_watcher,
|
||||
"CHANNEL_TEST_COMPLETE_DIGEST",
|
||||
result_digest(complete),
|
||||
)
|
||||
state = rx_agent_watcher.WatcherState()
|
||||
output: list[str] = []
|
||||
|
||||
rx_agent_watcher.confirm_main_channel_test(
|
||||
work,
|
||||
state,
|
||||
output_function=output.append,
|
||||
)
|
||||
|
||||
assert state.channel_test_ready_digest == result_digest(ready)
|
||||
assert state.channel_test_complete_digest == result_digest(complete)
|
||||
assert state.channel_test_ready_ack_commit is not None
|
||||
assert state.channel_test_complete_ack_commit is not None
|
||||
assert "MAIN READY RECEIVED = CONFIRMED" in output
|
||||
assert "MAIN COMPLETE RECEIVED = CONFIRMED" in output
|
||||
|
||||
|
||||
def test_ack_push_failure_does_not_advance_rx_cursor(tmp_path: Path) -> None:
|
||||
_remote, work, _tx_root = repository_fixture(tmp_path)
|
||||
rx_channel = GitResultChannel(work)
|
||||
ready_outcome = rx_channel.publish(channel_ready())
|
||||
|
||||
class FailingPublisher:
|
||||
def publish(self, acknowledgement: TxHandoffAcknowledgement) -> object:
|
||||
del acknowledgement
|
||||
raise ResultChannelError("simulated acknowledgement push failure")
|
||||
|
||||
state = TxWatcherState()
|
||||
with pytest.raises(ResultChannelError, match="simulated acknowledgement"):
|
||||
process_history_cycle(
|
||||
rx_channel,
|
||||
state,
|
||||
FakeExecutor(),
|
||||
now_function=lambda: NOW,
|
||||
input_function=lambda _prompt: "DENY",
|
||||
save_function=lambda _state: None,
|
||||
output_function=lambda _text: None,
|
||||
expected_source_head=SOURCE_HEAD,
|
||||
acknowledgement_publisher=FailingPublisher(),
|
||||
)
|
||||
assert state.last_processed_rx_node_commit is None
|
||||
assert state.channel_test_ready_digest is None
|
||||
assert ready_outcome.commit != state.last_seen_commit
|
||||
104
tools/capture_process_runner.ps1
Normal file
104
tools/capture_process_runner.ps1
Normal file
@@ -0,0 +1,104 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Executable,
|
||||
[Parameter(Mandatory = $true)][string]$ArgumentsJsonPath,
|
||||
[Parameter(Mandatory = $true)][string]$CapturePath,
|
||||
[Parameter(Mandatory = $true)][string]$StdoutPath,
|
||||
[Parameter(Mandatory = $true)][string]$StderrPath,
|
||||
[Parameter(Mandatory = $true)][string]$StartedStatusPath,
|
||||
[Parameter(Mandatory = $true)][string]$ResultStatusPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-JsonAtomically {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][object]$Value,
|
||||
[Parameter(Mandatory = $true)][string]$Path
|
||||
)
|
||||
|
||||
$temporaryPath = "$Path.tmp"
|
||||
$json = $Value | ConvertTo-Json -Depth 8
|
||||
$encoding = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($temporaryPath, $json + [Environment]::NewLine, $encoding)
|
||||
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
|
||||
}
|
||||
|
||||
$decodedArguments = Get-Content -Raw -LiteralPath $ArgumentsJsonPath |
|
||||
ConvertFrom-Json
|
||||
if ($decodedArguments -isnot [System.Array]) {
|
||||
throw 'ArgumentsJsonPath must contain a JSON array.'
|
||||
}
|
||||
$childArguments = @(
|
||||
foreach ($argument in $decodedArguments) {
|
||||
[string]$argument
|
||||
}
|
||||
)
|
||||
$startedAt = [DateTimeOffset]::UtcNow
|
||||
$finishedAt = $null
|
||||
$childProcess = $null
|
||||
$exitCode = $null
|
||||
$runnerError = $null
|
||||
$captureSize = 0
|
||||
$sha256 = $null
|
||||
|
||||
try {
|
||||
$startParameters = @{
|
||||
FilePath = $Executable
|
||||
ArgumentList = $childArguments
|
||||
RedirectStandardOutput = $StdoutPath
|
||||
RedirectStandardError = $StderrPath
|
||||
PassThru = $true
|
||||
WindowStyle = 'Hidden'
|
||||
}
|
||||
$childProcess = Start-Process @startParameters
|
||||
# PowerShell 5 can lose ExitCode for redirected processes unless the
|
||||
# underlying process handle is opened before WaitForExit().
|
||||
$null = $childProcess.Handle
|
||||
|
||||
Write-JsonAtomically -Path $StartedStatusPath -Value ([ordered]@{
|
||||
state = 'RUNNING'
|
||||
pid = $childProcess.Id
|
||||
started_at = $startedAt.ToString('o')
|
||||
capture_path = $CapturePath
|
||||
})
|
||||
|
||||
$childProcess.WaitForExit()
|
||||
$childProcess.Refresh()
|
||||
$exitCode = [int]$childProcess.ExitCode
|
||||
} catch {
|
||||
$runnerError = $_.Exception.ToString()
|
||||
} finally {
|
||||
$finishedAt = [DateTimeOffset]::UtcNow
|
||||
if (Test-Path -LiteralPath $CapturePath) {
|
||||
try {
|
||||
$captureSize = [int64](Get-Item -LiteralPath $CapturePath).Length
|
||||
$sha256 = (
|
||||
Get-FileHash -LiteralPath $CapturePath -Algorithm SHA256
|
||||
).Hash.ToLowerInvariant()
|
||||
} catch {
|
||||
$additionalError = $_.Exception.ToString()
|
||||
if ([string]::IsNullOrWhiteSpace($runnerError)) {
|
||||
$runnerError = $additionalError
|
||||
} else {
|
||||
$runnerError = $runnerError + [Environment]::NewLine + $additionalError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-JsonAtomically -Path $ResultStatusPath -Value ([ordered]@{
|
||||
pid = if ($null -ne $childProcess) { $childProcess.Id } else { $null }
|
||||
started_at = $startedAt.ToString('o')
|
||||
finished_at = $finishedAt.ToString('o')
|
||||
duration = ($finishedAt - $startedAt).TotalSeconds
|
||||
exit_code = $exitCode
|
||||
capture_path = $CapturePath
|
||||
capture_size = $captureSize
|
||||
sha256 = $sha256
|
||||
runner_error = $runnerError
|
||||
})
|
||||
}
|
||||
|
||||
if ($null -eq $exitCode) {
|
||||
exit 255
|
||||
}
|
||||
exit $exitCode
|
||||
69
tools/install_rf001_main_autostart.ps1
Normal file
69
tools/install_rf001_main_autostart.ps1
Normal file
@@ -0,0 +1,69 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$taskName = "SDR Rover HTTP Control Server"
|
||||
$repository = Split-Path -Parent $PSScriptRoot
|
||||
$pythonCommand = Get-Command python.exe -ErrorAction Stop
|
||||
$python = $pythonCommand.Source
|
||||
$serverScript = Join-Path $repository "tools\rf001_http_control.py"
|
||||
$tokenFile = Join-Path $repository ".local\rf001_http_token.txt"
|
||||
$stateFile = Join-Path $repository ".local\rf001_http_server_state.json"
|
||||
$logFile = Join-Path $repository ".local\logs\rf001_http_service.log"
|
||||
|
||||
foreach ($required in @($python, $serverScript, $tokenFile)) {
|
||||
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
|
||||
throw "Required file is absent: $required"
|
||||
}
|
||||
}
|
||||
|
||||
$arguments = @(
|
||||
"-u",
|
||||
"-B",
|
||||
('"{0}"' -f $serverScript),
|
||||
"serve",
|
||||
"--bind", "192.168.255.185",
|
||||
"--port", "8765",
|
||||
"--allowed-agent-ip", "192.168.255.56",
|
||||
"--state-file", ('"{0}"' -f $stateFile),
|
||||
"--token-file", ('"{0}"' -f $tokenFile),
|
||||
"--log-file", ('"{0}"' -f $logFile),
|
||||
"--bind-retry-seconds", "300"
|
||||
) -join " "
|
||||
|
||||
$account = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||
$action = New-ScheduledTaskAction -Execute $python -Argument $arguments -WorkingDirectory $repository
|
||||
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $account
|
||||
$principal = New-ScheduledTaskPrincipal -UserId $account -LogonType Interactive -RunLevel Limited
|
||||
$settings = New-ScheduledTaskSettingsSet `
|
||||
-AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries `
|
||||
-StartWhenAvailable `
|
||||
-RestartCount 10 `
|
||||
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||
-ExecutionTimeLimit ([TimeSpan]::Zero) `
|
||||
-MultipleInstances IgnoreNew
|
||||
|
||||
$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if ($null -ne $existing) {
|
||||
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Register-ScheduledTask `
|
||||
-TaskName $taskName `
|
||||
-Action $action `
|
||||
-Trigger $trigger `
|
||||
-Principal $principal `
|
||||
-Settings $settings `
|
||||
-Description "Authenticated RF-001 control server. No network RF TX operation." `
|
||||
-Force | Out-Null
|
||||
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
Start-Sleep -Seconds 2
|
||||
$task = Get-ScheduledTask -TaskName $taskName
|
||||
$taskInfo = Get-ScheduledTaskInfo -TaskName $taskName
|
||||
|
||||
"AUTOSTART_TASK=$taskName"
|
||||
"AUTOSTART_ACCOUNT=$account"
|
||||
"AUTOSTART_MODE=USER_LOGON"
|
||||
"TASK_STATE=$($task.State)"
|
||||
"LAST_TASK_RESULT=$($taskInfo.LastTaskResult)"
|
||||
"RF_TX=0"
|
||||
994
tools/install_rf001_rx_autostart.ps1
Normal file
994
tools/install_rf001_rx_autostart.ps1
Normal file
@@ -0,0 +1,994 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$taskName = "SDR Rover RX Agent"
|
||||
$sourceRoot = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot))
|
||||
$installRoot = Join-Path $env:LOCALAPPDATA "SDR_Rover_RX_Agent"
|
||||
$versionsRoot = Join-Path $installRoot "versions"
|
||||
$launcherRoot = Join-Path $installRoot "launcher"
|
||||
$sharedRoot = Join-Path $installRoot "shared"
|
||||
$logsRoot = Join-Path $installRoot "logs"
|
||||
$repository = Join-Path $env:USERPROFILE "Documents\Codex\SDR_Rover_Video"
|
||||
$python = Join-Path $repository ".venv\Scripts\pythonw.exe"
|
||||
$hackrfRoot = Join-Path $env:USERPROFILE "Documents\Codex\sdr-tools\envs\sdr\Library\bin"
|
||||
$workRoot = Join-Path $env:USERPROFILE "Documents\SDR_Rover_Runtime"
|
||||
$manifestPath = Join-Path $sourceRoot "BOOTSTRAP_MANIFEST.json"
|
||||
$signaturePath = Join-Path $sourceRoot "BOOTSTRAP_MANIFEST.hmac"
|
||||
$legacyToken = Join-Path $installRoot "app\.local\rf001_http_token.txt"
|
||||
$sharedToken = Join-Path $sharedRoot "rf001_http_token.txt"
|
||||
$expectedReferenceHash = "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
|
||||
|
||||
function Get-LowerSha256([string]$Path) {
|
||||
$stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
|
||||
$sha256 = [Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace("-", "").ToLowerInvariant()
|
||||
}
|
||||
finally {
|
||||
$sha256.Dispose()
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Test-FixedTimeHex([string]$Left, [string]$Right) {
|
||||
if ($Left.Length -ne $Right.Length) {
|
||||
return $false
|
||||
}
|
||||
$difference = 0
|
||||
for ($index = 0; $index -lt $Left.Length; $index++) {
|
||||
$difference = $difference -bor ([int][char]$Left[$index] -bxor [int][char]$Right[$index])
|
||||
}
|
||||
return $difference -eq 0
|
||||
}
|
||||
|
||||
function Assert-ExactReleasePath(
|
||||
[string]$InstallRoot,
|
||||
[string]$VersionsRoot,
|
||||
[string]$ReleaseId,
|
||||
[string]$TargetRelease
|
||||
) {
|
||||
$expectedVersions = [IO.Path]::GetFullPath((Join-Path $InstallRoot "versions"))
|
||||
$actualVersions = [IO.Path]::GetFullPath($VersionsRoot)
|
||||
if (-not $expectedVersions.Equals($actualVersions, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Release versions root is outside the fixed install root"
|
||||
}
|
||||
$expectedTarget = [IO.Path]::GetFullPath((Join-Path $actualVersions $ReleaseId))
|
||||
$actualTarget = [IO.Path]::GetFullPath($TargetRelease)
|
||||
if (-not $expectedTarget.Equals($actualTarget, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Release target path is not the installer-defined canonical path"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ReleaseDirectoryState(
|
||||
[string]$TargetRelease,
|
||||
[object[]]$ReleaseRows,
|
||||
[string]$ReleasePrefix
|
||||
) {
|
||||
if (-not (Test-Path -LiteralPath $TargetRelease)) {
|
||||
return [pscustomobject]@{
|
||||
state = "TARGET_ABSENT"
|
||||
mismatches = @()
|
||||
conflicting_files = @()
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $TargetRelease -PathType Container)) {
|
||||
$item = Get-Item -LiteralPath $TargetRelease -Force
|
||||
$digest = $null
|
||||
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) {
|
||||
try {
|
||||
$digest = Get-LowerSha256 $item.FullName
|
||||
}
|
||||
catch {
|
||||
$digest = $null
|
||||
}
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
state = "TARGET_CONFLICT"
|
||||
mismatches = @([ordered]@{
|
||||
kind = "TARGET_NOT_DIRECTORY"
|
||||
path = "."
|
||||
expected_bytes = $null
|
||||
actual_bytes = [int64]$item.Length
|
||||
expected_sha256 = $null
|
||||
actual_sha256 = $digest
|
||||
})
|
||||
conflicting_files = @([ordered]@{
|
||||
path = "."
|
||||
bytes = [int64]$item.Length
|
||||
sha256 = $digest
|
||||
reparse_point = (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
$expected = @{}
|
||||
$normalizedRows = @($ReleaseRows | ForEach-Object { $_ })
|
||||
foreach ($row in $normalizedRows) {
|
||||
$pathValues = @($row.path)
|
||||
$byteValues = @($row.bytes)
|
||||
$hashValues = @($row.sha256)
|
||||
if ($pathValues.Count -ne 1 -or $byteValues.Count -ne 1 -or $hashValues.Count -ne 1) {
|
||||
throw "Release manifest row fields are absent or ambiguous"
|
||||
}
|
||||
$manifestPath = [string]$pathValues[0]
|
||||
if (-not $manifestPath.StartsWith($ReleasePrefix, [StringComparison]::Ordinal)) {
|
||||
throw "Release manifest row is outside the expected prefix: $manifestPath"
|
||||
}
|
||||
$relative = $manifestPath.Substring($ReleasePrefix.Length)
|
||||
if (
|
||||
[string]::IsNullOrWhiteSpace($relative) -or
|
||||
$relative -match '(^/|\\|(^|/)\.\.(/|$)|:)'
|
||||
) {
|
||||
throw "Unsafe path inside release manifest: $relative"
|
||||
}
|
||||
$key = $relative.ToLowerInvariant()
|
||||
if ($expected.ContainsKey($key)) {
|
||||
throw "Duplicate path inside release manifest: $relative"
|
||||
}
|
||||
$expected[$key] = [pscustomobject]@{
|
||||
path = $relative
|
||||
bytes = [int64]($byteValues[0])
|
||||
sha256 = ([string]$hashValues[0]).ToLowerInvariant()
|
||||
}
|
||||
}
|
||||
if ($expected.Count -eq 0) {
|
||||
throw "Release manifest does not contain any files"
|
||||
}
|
||||
|
||||
$targetPrefix = [IO.Path]::GetFullPath($TargetRelease).TrimEnd('\') + '\'
|
||||
$actual = @{}
|
||||
$mismatches = @()
|
||||
$conflictingFiles = @()
|
||||
$entries = @(Get-ChildItem -LiteralPath $TargetRelease -Recurse -Force)
|
||||
foreach ($entry in $entries) {
|
||||
$relative = $entry.FullName.Substring($targetPrefix.Length).Replace('\', '/')
|
||||
$isReparsePoint = ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0
|
||||
if ($entry.PSIsContainer) {
|
||||
if ($isReparsePoint) {
|
||||
$mismatches += [ordered]@{
|
||||
kind = "REPARSE_POINT"
|
||||
path = $relative
|
||||
expected_bytes = $null
|
||||
actual_bytes = $null
|
||||
expected_sha256 = $null
|
||||
actual_sha256 = $null
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
$digest = $null
|
||||
$hashError = $null
|
||||
if (-not $isReparsePoint) {
|
||||
try {
|
||||
$digest = Get-LowerSha256 $entry.FullName
|
||||
}
|
||||
catch {
|
||||
$hashError = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
$metadata = [pscustomobject]@{
|
||||
path = $relative
|
||||
bytes = [int64]$entry.Length
|
||||
sha256 = $digest
|
||||
reparse_point = $isReparsePoint
|
||||
hash_error = $hashError
|
||||
}
|
||||
$conflictingFiles += $metadata
|
||||
$key = $relative.ToLowerInvariant()
|
||||
if ($actual.ContainsKey($key)) {
|
||||
$mismatches += [ordered]@{
|
||||
kind = "DUPLICATE_PATH"
|
||||
path = $relative
|
||||
expected_bytes = $null
|
||||
actual_bytes = [int64]$entry.Length
|
||||
expected_sha256 = $null
|
||||
actual_sha256 = $digest
|
||||
}
|
||||
continue
|
||||
}
|
||||
$actual[$key] = $metadata
|
||||
if ($isReparsePoint) {
|
||||
$mismatches += [ordered]@{
|
||||
kind = "REPARSE_POINT"
|
||||
path = $relative
|
||||
expected_bytes = $null
|
||||
actual_bytes = [int64]$entry.Length
|
||||
expected_sha256 = $null
|
||||
actual_sha256 = $null
|
||||
}
|
||||
}
|
||||
elseif ($null -ne $hashError) {
|
||||
$mismatches += [ordered]@{
|
||||
kind = "HASH_UNAVAILABLE"
|
||||
path = $relative
|
||||
expected_bytes = $null
|
||||
actual_bytes = [int64]$entry.Length
|
||||
expected_sha256 = $null
|
||||
actual_sha256 = $null
|
||||
error = $hashError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($key in $expected.Keys) {
|
||||
$wanted = $expected[$key]
|
||||
if (-not $actual.ContainsKey($key)) {
|
||||
$mismatches += [ordered]@{
|
||||
kind = "MISSING"
|
||||
path = $wanted.path
|
||||
expected_bytes = $wanted.bytes
|
||||
actual_bytes = $null
|
||||
expected_sha256 = $wanted.sha256
|
||||
actual_sha256 = $null
|
||||
}
|
||||
continue
|
||||
}
|
||||
$found = $actual[$key]
|
||||
if ($found.reparse_point -or $null -ne $found.hash_error) {
|
||||
continue
|
||||
}
|
||||
if ($found.bytes -ne $wanted.bytes) {
|
||||
$mismatches += [ordered]@{
|
||||
kind = "SIZE_MISMATCH"
|
||||
path = $wanted.path
|
||||
expected_bytes = $wanted.bytes
|
||||
actual_bytes = $found.bytes
|
||||
expected_sha256 = $wanted.sha256
|
||||
actual_sha256 = $found.sha256
|
||||
}
|
||||
}
|
||||
if ($found.sha256 -ne $wanted.sha256) {
|
||||
$mismatches += [ordered]@{
|
||||
kind = "SHA256_MISMATCH"
|
||||
path = $wanted.path
|
||||
expected_bytes = $wanted.bytes
|
||||
actual_bytes = $found.bytes
|
||||
expected_sha256 = $wanted.sha256
|
||||
actual_sha256 = $found.sha256
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($key in $actual.Keys) {
|
||||
if (-not $expected.ContainsKey($key)) {
|
||||
$found = $actual[$key]
|
||||
$mismatches += [ordered]@{
|
||||
kind = "UNEXPECTED"
|
||||
path = $found.path
|
||||
expected_bytes = $null
|
||||
actual_bytes = $found.bytes
|
||||
expected_sha256 = $null
|
||||
actual_sha256 = $found.sha256
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$state = if ($mismatches.Count -eq 0) { "TARGET_VERIFIED" } else { "TARGET_CONFLICT" }
|
||||
return [pscustomobject]@{
|
||||
state = $state
|
||||
mismatches = @($mismatches)
|
||||
conflicting_files = @($conflictingFiles)
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ReleaseMetadata(
|
||||
[string]$ReleaseRoot,
|
||||
[string]$ExpectedBootstrapId,
|
||||
[string]$ExpectedReleaseId,
|
||||
[string]$ExpectedAgentVersion,
|
||||
[int]$ExpectedProtocolVersion
|
||||
) {
|
||||
$readyPath = Join-Path $ReleaseRoot "READY.json"
|
||||
if (-not (Test-Path -LiteralPath $readyPath -PathType Leaf)) {
|
||||
throw "Release READY metadata is absent: $readyPath"
|
||||
}
|
||||
$ready = Get-Content -LiteralPath $readyPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if (
|
||||
$ready.schema_version -ne 1 -or
|
||||
$ready.status -ne "READY" -or
|
||||
$ready.bootstrap_id -ne $ExpectedBootstrapId -or
|
||||
$ready.release_id -ne $ExpectedReleaseId -or
|
||||
$ready.agent_version -ne $ExpectedAgentVersion -or
|
||||
$ready.protocol_version -ne $ExpectedProtocolVersion -or
|
||||
$ready.hardware_accessed -ne $false -or
|
||||
$ready.rf_tx -ne 0
|
||||
) {
|
||||
throw "Release READY metadata does not match the signed bootstrap manifest"
|
||||
}
|
||||
}
|
||||
|
||||
function New-UniqueQuarantineDestination(
|
||||
[string]$InstallRoot,
|
||||
[string]$ReleaseId,
|
||||
[string]$Category
|
||||
) {
|
||||
if ($Category -notin @("conflict", "failed_staging", "failed_release")) {
|
||||
throw "Unsupported quarantine category"
|
||||
}
|
||||
$quarantineRoot = Join-Path $InstallRoot "quarantine"
|
||||
New-Item -ItemType Directory -Force -Path $quarantineRoot | Out-Null
|
||||
for ($attempt = 0; $attempt -lt 16; $attempt++) {
|
||||
$stamp = [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssfff'Z'")
|
||||
$unique = [Guid]::NewGuid().ToString("N")
|
||||
$name = "{0}_{1}_{2}_{3}" -f $ReleaseId, $Category, $stamp, $unique
|
||||
$candidate = Join-Path $quarantineRoot $name
|
||||
if (
|
||||
-not (Test-Path -LiteralPath $candidate) -and
|
||||
-not (Test-Path -LiteralPath ($candidate + ".diagnostic.json"))
|
||||
) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
throw "Could not allocate a unique quarantine path"
|
||||
}
|
||||
|
||||
function Write-CreateNewUtf8Json([string]$Path, [object]$Value) {
|
||||
$encoding = New-Object Text.UTF8Encoding($false)
|
||||
$bytes = $encoding.GetBytes((($Value | ConvertTo-Json -Depth 10) + "`n"))
|
||||
$stream = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
|
||||
try {
|
||||
$stream.Write($bytes, 0, $bytes.Length)
|
||||
$stream.Flush($true)
|
||||
}
|
||||
finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Move-ConflictingTargetToQuarantine(
|
||||
[string]$InstallRoot,
|
||||
[string]$VersionsRoot,
|
||||
[string]$TargetRelease,
|
||||
[string]$ReleaseId,
|
||||
[string]$BootstrapId,
|
||||
[object]$State
|
||||
) {
|
||||
Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease
|
||||
if ($State.state -ne "TARGET_CONFLICT") {
|
||||
throw "Only TARGET_CONFLICT may be moved by the conflict quarantine operation"
|
||||
}
|
||||
$destination = New-UniqueQuarantineDestination $InstallRoot $ReleaseId "conflict"
|
||||
$diagnosticPath = $destination + ".diagnostic.json"
|
||||
$diagnostic = [ordered]@{
|
||||
schema_version = 1
|
||||
timestamp_utc = [DateTime]::UtcNow.ToString("o")
|
||||
source_path = [IO.Path]::GetFullPath($TargetRelease)
|
||||
quarantine_path = [IO.Path]::GetFullPath($destination)
|
||||
bootstrap_id = $BootstrapId
|
||||
expected_release_id = $ReleaseId
|
||||
mismatches = @($State.mismatches)
|
||||
conflicting_files = @($State.conflicting_files)
|
||||
}
|
||||
Write-CreateNewUtf8Json $diagnosticPath $diagnostic
|
||||
Move-Item -LiteralPath $TargetRelease -Destination $destination
|
||||
return [pscustomobject]@{
|
||||
path = $destination
|
||||
diagnostic_path = $diagnosticPath
|
||||
}
|
||||
}
|
||||
|
||||
function Move-FailedReleasePathToQuarantine(
|
||||
[string]$InstallRoot,
|
||||
[string]$Path,
|
||||
[string]$ReleaseId,
|
||||
[string]$BootstrapId,
|
||||
[string]$Category,
|
||||
[string]$Cause
|
||||
) {
|
||||
$versionsRoot = [IO.Path]::GetFullPath((Join-Path $InstallRoot "versions")).TrimEnd('\') + '\'
|
||||
$fullPath = [IO.Path]::GetFullPath($Path)
|
||||
if (-not $fullPath.StartsWith($versionsRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Failed release path is outside the fixed versions root"
|
||||
}
|
||||
$leaf = Split-Path -Leaf $fullPath
|
||||
$validCanonical = $leaf.Equals($ReleaseId, [StringComparison]::OrdinalIgnoreCase)
|
||||
$validStaging = $leaf.StartsWith(".$ReleaseId.staging_", [StringComparison]::OrdinalIgnoreCase)
|
||||
if (
|
||||
($Category -eq "failed_release" -and -not $validCanonical) -or
|
||||
($Category -eq "failed_staging" -and -not $validStaging)
|
||||
) {
|
||||
throw "Failed release path is not an installer-defined release path"
|
||||
}
|
||||
$destination = New-UniqueQuarantineDestination $InstallRoot $ReleaseId $Category
|
||||
$diagnosticPath = $destination + ".diagnostic.json"
|
||||
$diagnostic = [ordered]@{
|
||||
schema_version = 1
|
||||
timestamp_utc = [DateTime]::UtcNow.ToString("o")
|
||||
source_path = $fullPath
|
||||
quarantine_path = [IO.Path]::GetFullPath($destination)
|
||||
bootstrap_id = $BootstrapId
|
||||
expected_release_id = $ReleaseId
|
||||
category = $Category
|
||||
cause = $Cause
|
||||
}
|
||||
Write-CreateNewUtf8Json $diagnosticPath $diagnostic
|
||||
Move-Item -LiteralPath $fullPath -Destination $destination
|
||||
return $destination
|
||||
}
|
||||
|
||||
function Install-CleanRelease(
|
||||
[string]$InstallRoot,
|
||||
[string]$VersionsRoot,
|
||||
[string]$SourceRelease,
|
||||
[string]$TargetRelease,
|
||||
[string]$ReleaseId,
|
||||
[string]$BootstrapId,
|
||||
[string]$ReleaseBootstrapId,
|
||||
[string]$AgentVersion,
|
||||
[int]$ProtocolVersion,
|
||||
[object[]]$ReleaseRows,
|
||||
[string]$ReleasePrefix
|
||||
) {
|
||||
if ([string]::IsNullOrWhiteSpace($ReleaseBootstrapId)) {
|
||||
$ReleaseBootstrapId = $BootstrapId
|
||||
}
|
||||
Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease
|
||||
if (Test-Path -LiteralPath $TargetRelease) {
|
||||
throw "Canonical release path is not absent before clean deployment"
|
||||
}
|
||||
$staging = Join-Path $VersionsRoot (".{0}.staging_{1}" -f $ReleaseId, [Guid]::NewGuid().ToString("N"))
|
||||
$canonicalCreated = $false
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $staging | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $SourceRelease "app") -Destination $staging -Recurse
|
||||
Copy-Item -LiteralPath (Join-Path $SourceRelease "READY.json") -Destination $staging
|
||||
$stagingState = Get-ReleaseDirectoryState `
|
||||
-TargetRelease $staging `
|
||||
-ReleaseRows $ReleaseRows `
|
||||
-ReleasePrefix $ReleasePrefix
|
||||
if ($stagingState.state -ne "TARGET_VERIFIED") {
|
||||
throw "Staged release failed exact file verification: $($stagingState.mismatches | ConvertTo-Json -Depth 6 -Compress)"
|
||||
}
|
||||
Assert-ReleaseMetadata `
|
||||
-ReleaseRoot $staging `
|
||||
-ExpectedBootstrapId $ReleaseBootstrapId `
|
||||
-ExpectedReleaseId $ReleaseId `
|
||||
-ExpectedAgentVersion $AgentVersion `
|
||||
-ExpectedProtocolVersion $ProtocolVersion
|
||||
Move-Item -LiteralPath $staging -Destination $TargetRelease
|
||||
$canonicalCreated = $true
|
||||
$targetState = Get-ReleaseDirectoryState `
|
||||
-TargetRelease $TargetRelease `
|
||||
-ReleaseRows $ReleaseRows `
|
||||
-ReleasePrefix $ReleasePrefix
|
||||
if ($targetState.state -ne "TARGET_VERIFIED") {
|
||||
throw "Canonical release failed exact file verification: $($targetState.mismatches | ConvertTo-Json -Depth 6 -Compress)"
|
||||
}
|
||||
Assert-ReleaseMetadata `
|
||||
-ReleaseRoot $TargetRelease `
|
||||
-ExpectedBootstrapId $ReleaseBootstrapId `
|
||||
-ExpectedReleaseId $ReleaseId `
|
||||
-ExpectedAgentVersion $AgentVersion `
|
||||
-ExpectedProtocolVersion $ProtocolVersion
|
||||
return $targetState
|
||||
}
|
||||
catch {
|
||||
$failure = $_.Exception.Message
|
||||
$quarantineErrors = @()
|
||||
if ($canonicalCreated -and (Test-Path -LiteralPath $TargetRelease)) {
|
||||
try {
|
||||
Move-FailedReleasePathToQuarantine $InstallRoot $TargetRelease $ReleaseId $BootstrapId "failed_release" $failure | Out-Null
|
||||
}
|
||||
catch {
|
||||
$quarantineErrors += $_.Exception.Message
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath $staging) {
|
||||
try {
|
||||
Move-FailedReleasePathToQuarantine $InstallRoot $staging $ReleaseId $BootstrapId "failed_staging" $failure | Out-Null
|
||||
}
|
||||
catch {
|
||||
$quarantineErrors += $_.Exception.Message
|
||||
}
|
||||
}
|
||||
if ($quarantineErrors.Count -ne 0) {
|
||||
throw "Clean release deployment failed: $failure; failed-path quarantine errors: $($quarantineErrors -join '; ')"
|
||||
}
|
||||
throw "Clean release deployment failed: $failure"
|
||||
}
|
||||
}
|
||||
|
||||
function Prepare-TargetRelease(
|
||||
[string]$InstallRoot,
|
||||
[string]$VersionsRoot,
|
||||
[string]$SourceRelease,
|
||||
[string]$TargetRelease,
|
||||
[string]$ReleaseId,
|
||||
[string]$BootstrapId,
|
||||
[string]$ReleaseBootstrapId,
|
||||
[string]$AgentVersion,
|
||||
[int]$ProtocolVersion,
|
||||
[object[]]$ReleaseRows,
|
||||
[string]$ReleasePrefix
|
||||
) {
|
||||
if ([string]::IsNullOrWhiteSpace($ReleaseBootstrapId)) {
|
||||
$ReleaseBootstrapId = $BootstrapId
|
||||
}
|
||||
Assert-ExactReleasePath $InstallRoot $VersionsRoot $ReleaseId $TargetRelease
|
||||
$sourceState = Get-ReleaseDirectoryState `
|
||||
-TargetRelease $SourceRelease `
|
||||
-ReleaseRows $ReleaseRows `
|
||||
-ReleasePrefix $ReleasePrefix
|
||||
if ($sourceState.state -ne "TARGET_VERIFIED") {
|
||||
throw "Signed bootstrap release content is not exact: $($sourceState.mismatches | ConvertTo-Json -Depth 6 -Compress)"
|
||||
}
|
||||
Assert-ReleaseMetadata `
|
||||
-ReleaseRoot $SourceRelease `
|
||||
-ExpectedBootstrapId $ReleaseBootstrapId `
|
||||
-ExpectedReleaseId $ReleaseId `
|
||||
-ExpectedAgentVersion $AgentVersion `
|
||||
-ExpectedProtocolVersion $ProtocolVersion
|
||||
|
||||
$initial = Get-ReleaseDirectoryState `
|
||||
-TargetRelease $TargetRelease `
|
||||
-ReleaseRows $ReleaseRows `
|
||||
-ReleasePrefix $ReleasePrefix
|
||||
$quarantine = $null
|
||||
if ($initial.state -eq "TARGET_CONFLICT") {
|
||||
$quarantine = Move-ConflictingTargetToQuarantine `
|
||||
-InstallRoot $InstallRoot `
|
||||
-VersionsRoot $VersionsRoot `
|
||||
-TargetRelease $TargetRelease `
|
||||
-ReleaseId $ReleaseId `
|
||||
-BootstrapId $BootstrapId `
|
||||
-State $initial
|
||||
}
|
||||
if ($initial.state -ne "TARGET_VERIFIED") {
|
||||
$verified = Install-CleanRelease `
|
||||
-InstallRoot $InstallRoot `
|
||||
-VersionsRoot $VersionsRoot `
|
||||
-SourceRelease $SourceRelease `
|
||||
-TargetRelease $TargetRelease `
|
||||
-ReleaseId $ReleaseId `
|
||||
-BootstrapId $BootstrapId `
|
||||
-ReleaseBootstrapId $ReleaseBootstrapId `
|
||||
-AgentVersion $AgentVersion `
|
||||
-ProtocolVersion $ProtocolVersion `
|
||||
-ReleaseRows $ReleaseRows `
|
||||
-ReleasePrefix $ReleasePrefix
|
||||
}
|
||||
else {
|
||||
Assert-ReleaseMetadata `
|
||||
-ReleaseRoot $TargetRelease `
|
||||
-ExpectedBootstrapId $ReleaseBootstrapId `
|
||||
-ExpectedReleaseId $ReleaseId `
|
||||
-ExpectedAgentVersion $AgentVersion `
|
||||
-ExpectedProtocolVersion $ProtocolVersion
|
||||
$verified = $initial
|
||||
}
|
||||
return [pscustomobject]@{
|
||||
initial_state = $initial.state
|
||||
final_state = $verified.state
|
||||
reused = ($initial.state -eq "TARGET_VERIFIED")
|
||||
quarantine_path = if ($null -eq $quarantine) { $null } else { $quarantine.path }
|
||||
quarantine_diagnostic = if ($null -eq $quarantine) { $null } else { $quarantine.diagnostic_path }
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-SignedAgentSelfTest(
|
||||
[string]$Python,
|
||||
[string]$AgentScript,
|
||||
[string]$ExpectedAgentVersion,
|
||||
[string]$StandardOutputPath,
|
||||
[string]$StandardErrorPath
|
||||
) {
|
||||
$process = Start-Process `
|
||||
-FilePath $Python `
|
||||
-ArgumentList @("-B", "-X", "utf8", $AgentScript, "--self-test") `
|
||||
-RedirectStandardOutput $StandardOutputPath `
|
||||
-RedirectStandardError $StandardErrorPath `
|
||||
-Wait -PassThru -WindowStyle Hidden
|
||||
if ($process.ExitCode -ne 0) {
|
||||
throw "Agent self-test exited with code $($process.ExitCode)"
|
||||
}
|
||||
$output = if (Test-Path -LiteralPath $StandardOutputPath) {
|
||||
Get-Content -LiteralPath $StandardOutputPath -Raw -Encoding UTF8
|
||||
}
|
||||
else {
|
||||
""
|
||||
}
|
||||
if ($output -notmatch ("(?m)^AGENT VERSION = {0}\s*$" -f [regex]::Escape($ExpectedAgentVersion))) {
|
||||
throw "Agent self-test did not confirm signed AGENT_VERSION"
|
||||
}
|
||||
if ($output -notmatch '(?m)^SELF TEST = PASS\s*$') {
|
||||
throw "Agent self-test did not report PASS"
|
||||
}
|
||||
return $output
|
||||
}
|
||||
|
||||
foreach ($required in @(
|
||||
$manifestPath,
|
||||
$signaturePath,
|
||||
$python,
|
||||
(Join-Path $repository ".git"),
|
||||
(Join-Path $hackrfRoot "hackrf_info.exe"),
|
||||
(Join-Path $hackrfRoot "hackrf_clock.exe"),
|
||||
(Join-Path $hackrfRoot "hackrf_transfer.exe")
|
||||
)) {
|
||||
if (-not (Test-Path -LiteralPath $required)) {
|
||||
throw "Required path is absent: $required"
|
||||
}
|
||||
}
|
||||
|
||||
if ((Test-Path -LiteralPath $sharedToken) -and (Test-Path -LiteralPath $legacyToken)) {
|
||||
if ((Get-LowerSha256 $sharedToken) -ne (Get-LowerSha256 $legacyToken)) {
|
||||
throw "Installed signing-token copies differ; refusing to choose one"
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath $sharedToken) {
|
||||
$tokenSource = $sharedToken
|
||||
}
|
||||
elseif (Test-Path -LiteralPath $legacyToken) {
|
||||
$tokenSource = $legacyToken
|
||||
}
|
||||
else {
|
||||
throw "The existing HTTP signing token is absent; it is intentionally not in this package"
|
||||
}
|
||||
|
||||
$manifestBytes = [IO.File]::ReadAllBytes($manifestPath)
|
||||
$tokenText = [IO.File]::ReadAllText($tokenSource, [Text.Encoding]::UTF8).Trim()
|
||||
$tokenBytes = [Text.Encoding]::UTF8.GetBytes($tokenText)
|
||||
if ($tokenBytes.Length -lt 32) {
|
||||
throw "The existing HTTP signing token is invalid"
|
||||
}
|
||||
$domainBytes = [Text.Encoding]::UTF8.GetBytes("RF001-UPDATE-MANIFEST-V1`n")
|
||||
$signedBytes = New-Object byte[] ($domainBytes.Length + $manifestBytes.Length)
|
||||
[Buffer]::BlockCopy($domainBytes, 0, $signedBytes, 0, $domainBytes.Length)
|
||||
[Buffer]::BlockCopy($manifestBytes, 0, $signedBytes, $domainBytes.Length, $manifestBytes.Length)
|
||||
$hmac = New-Object Security.Cryptography.HMACSHA256 -ArgumentList (, $tokenBytes)
|
||||
try {
|
||||
$expectedSignature = ([BitConverter]::ToString($hmac.ComputeHash($signedBytes))).Replace("-", "").ToLowerInvariant()
|
||||
}
|
||||
finally {
|
||||
$hmac.Dispose()
|
||||
}
|
||||
$suppliedSignature = [IO.File]::ReadAllText($signaturePath, [Text.Encoding]::ASCII).Trim().ToLowerInvariant()
|
||||
if (-not (Test-FixedTimeHex $expectedSignature $suppliedSignature)) {
|
||||
throw "Bootstrap manifest signature mismatch"
|
||||
}
|
||||
|
||||
$manifest = [Text.Encoding]::UTF8.GetString($manifestBytes) | ConvertFrom-Json
|
||||
if ($manifest.schema_version -ne 1 -or $manifest.package_kind -ne "BOOTSTRAP") {
|
||||
throw "Unsupported bootstrap manifest"
|
||||
}
|
||||
if ($manifest.protocol_version -ne 1) {
|
||||
throw "Unsupported agent protocol version"
|
||||
}
|
||||
if ($manifest.main_server -ne "http://192.168.255.185:8765") {
|
||||
throw "Bootstrap MAIN address differs from the approved address"
|
||||
}
|
||||
if ($manifest.allowed_rx_ipv4 -ne "192.168.255.56") {
|
||||
throw "Bootstrap RX address differs from the approved address"
|
||||
}
|
||||
if ($manifest.reference_sha256 -ne $expectedReferenceHash) {
|
||||
throw "Bootstrap reference identity is invalid"
|
||||
}
|
||||
$releaseId = [string]$manifest.agent_release_id
|
||||
$agentVersion = [string]$manifest.agent_version
|
||||
$bootstrapId = [string]$manifest.bootstrap_id
|
||||
$releaseBootstrapId = [string]$manifest.agent_release_bootstrap_id
|
||||
if ([string]::IsNullOrWhiteSpace($releaseBootstrapId)) {
|
||||
$releaseBootstrapId = $bootstrapId
|
||||
}
|
||||
if ($bootstrapId -notmatch '^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
|
||||
throw "Bootstrap ID is invalid"
|
||||
}
|
||||
if ($releaseBootstrapId -notmatch '^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
|
||||
throw "Agent release bootstrap ID is invalid"
|
||||
}
|
||||
if ($releaseId -notmatch '^rf001-agent-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
|
||||
throw "Bootstrap release_id is invalid"
|
||||
}
|
||||
if ($agentVersion -notmatch '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$') {
|
||||
throw "Bootstrap agent version is invalid"
|
||||
}
|
||||
if ($releaseId -ne "rf001-agent-$agentVersion") {
|
||||
throw "Bootstrap release_id does not match agent_version"
|
||||
}
|
||||
|
||||
$releasePrefix = "versions/$releaseId/"
|
||||
$expectedPaths = @(
|
||||
"INSTALL_RX_AGENT.cmd",
|
||||
"README_RX_AGENT.txt",
|
||||
"UNINSTALL_RX_AGENT.cmd",
|
||||
"launcher/rf001_agent_launcher.py",
|
||||
"reference/lab065_reference_staircase.cs8",
|
||||
"tools/install_rf001_rx_autostart.ps1",
|
||||
"tools/uninstall_rf001_rx_autostart.ps1",
|
||||
"${releasePrefix}READY.json",
|
||||
"${releasePrefix}app/tools/hackrf_process_check.py",
|
||||
"${releasePrefix}app/tools/rf001_agent_update.py",
|
||||
"${releasePrefix}app/tools/rf001_capture_runner.py",
|
||||
"${releasePrefix}app/tools/rf001_http_control.py",
|
||||
"${releasePrefix}app/tools/rf001_offline_decode.py",
|
||||
"${releasePrefix}app/tools/rf001_rx_capture.py",
|
||||
"${releasePrefix}app/tools/rf001_rx_http_agent.py",
|
||||
"${releasePrefix}app/tools/rf001_staircase_job.py",
|
||||
"${releasePrefix}app/tools/rf001_staircase_search.py"
|
||||
)
|
||||
$expectedSet = @{}
|
||||
foreach ($path in $expectedPaths) {
|
||||
$expectedSet[$path.ToLowerInvariant()] = $true
|
||||
}
|
||||
$manifestSet = @{}
|
||||
$sourcePrefix = $sourceRoot.TrimEnd('\') + '\'
|
||||
foreach ($row in @($manifest.files)) {
|
||||
$relative = [string]$row.path
|
||||
if ($relative -match '(^/|\\|(^|/)\.\.(/|$)|:)' -or [string]::IsNullOrWhiteSpace($relative)) {
|
||||
throw "Unsafe bootstrap path: $relative"
|
||||
}
|
||||
$key = $relative.ToLowerInvariant()
|
||||
if ($manifestSet.ContainsKey($key)) {
|
||||
throw "Duplicate bootstrap path: $relative"
|
||||
}
|
||||
if (-not $expectedSet.ContainsKey($key)) {
|
||||
throw "Bootstrap file is outside the fixed allowlist: $relative"
|
||||
}
|
||||
$manifestSet[$key] = $true
|
||||
$source = [IO.Path]::GetFullPath((Join-Path $sourceRoot ($relative.Replace('/', '\'))))
|
||||
if (-not $source.StartsWith($sourcePrefix, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Bootstrap path escaped source root: $relative"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) {
|
||||
throw "Bootstrap file is absent: $relative"
|
||||
}
|
||||
$item = Get-Item -LiteralPath $source
|
||||
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Bootstrap reparse points are prohibited: $relative"
|
||||
}
|
||||
if ($item.Length -ne [int64]$row.bytes) {
|
||||
throw "Bootstrap file size mismatch: $relative"
|
||||
}
|
||||
if ((Get-LowerSha256 $source) -ne ([string]$row.sha256).ToLowerInvariant()) {
|
||||
throw "Bootstrap file SHA256 mismatch: $relative"
|
||||
}
|
||||
}
|
||||
if ($manifestSet.Count -ne $expectedSet.Count) {
|
||||
throw "Bootstrap manifest does not contain the complete fixed allowlist"
|
||||
}
|
||||
$actualFiles = Get-ChildItem -LiteralPath $sourceRoot -Recurse -File | ForEach-Object {
|
||||
$_.FullName.Substring($sourcePrefix.Length).Replace('\', '/')
|
||||
}
|
||||
$allowedPackageFiles = @{}
|
||||
foreach ($path in $expectedPaths + @("BOOTSTRAP_MANIFEST.json", "BOOTSTRAP_MANIFEST.hmac")) {
|
||||
$allowedPackageFiles[$path.ToLowerInvariant()] = $true
|
||||
}
|
||||
foreach ($relative in $actualFiles) {
|
||||
if (-not $allowedPackageFiles.ContainsKey($relative.ToLowerInvariant())) {
|
||||
throw "Unexpected file in the bootstrap package: $relative"
|
||||
}
|
||||
}
|
||||
if ($actualFiles.Count -ne $allowedPackageFiles.Count) {
|
||||
throw "Bootstrap package file set is incomplete or ambiguous"
|
||||
}
|
||||
|
||||
$existingTask = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if ($null -eq $existingTask) {
|
||||
throw "The existing scheduled task '$taskName' is absent"
|
||||
}
|
||||
$wasRunning = $existingTask.State -eq "Running"
|
||||
$backupRoot = Join-Path $installRoot (
|
||||
"backups\bootstrap_{0}_{1}" -f
|
||||
[DateTime]::UtcNow.ToString("yyyyMMddTHHmmssfff'Z'"),
|
||||
[Guid]::NewGuid().ToString("N").Substring(0, 12)
|
||||
)
|
||||
New-Item -ItemType Directory -Path $backupRoot | Out-Null
|
||||
$taskXmlPath = Join-Path $backupRoot "scheduled_task.xml"
|
||||
[IO.File]::WriteAllText($taskXmlPath, (Export-ScheduledTask -TaskName $taskName), [Text.Encoding]::Unicode)
|
||||
$backupInventory = @()
|
||||
foreach ($name in @("app", "launcher", "shared", "agent_config.json", "active_release.json", "launcher_status.json", "agent_health.json")) {
|
||||
$current = Join-Path $installRoot $name
|
||||
$existed = Test-Path -LiteralPath $current
|
||||
$backupInventory += [ordered]@{
|
||||
name = $name
|
||||
existed = $existed
|
||||
container = ($existed -and (Test-Path -LiteralPath $current -PathType Container))
|
||||
}
|
||||
if ($existed) {
|
||||
Copy-Item -LiteralPath $current -Destination (Join-Path $backupRoot $name) -Recurse -Force
|
||||
}
|
||||
}
|
||||
Write-CreateNewUtf8Json (Join-Path $backupRoot "install_state_inventory.json") $backupInventory
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $versionsRoot, $launcherRoot, $sharedRoot, $logsRoot | Out-Null
|
||||
if (-not (Test-Path -LiteralPath $sharedToken)) {
|
||||
Copy-Item -LiteralPath $tokenSource -Destination $sharedToken
|
||||
}
|
||||
if ((Get-LowerSha256 $sharedToken) -ne (Get-LowerSha256 $tokenSource)) {
|
||||
throw "The preserved signing token differs after copying"
|
||||
}
|
||||
|
||||
$sourceRelease = Join-Path $sourceRoot ($releasePrefix.Replace('/', '\').TrimEnd('\'))
|
||||
$targetRelease = Join-Path $versionsRoot $releaseId
|
||||
$sourceAgentScript = Join-Path $sourceRelease "app\tools\rf001_rx_http_agent.py"
|
||||
Invoke-SignedAgentSelfTest `
|
||||
-Python $python `
|
||||
-AgentScript $sourceAgentScript `
|
||||
-ExpectedAgentVersion $agentVersion `
|
||||
-StandardOutputPath (Join-Path $backupRoot "source_agent_self_test.stdout.txt") `
|
||||
-StandardErrorPath (Join-Path $backupRoot "source_agent_self_test.stderr.txt") | Out-Null
|
||||
$releaseRows = @($manifest.files | Where-Object { ([string]$_.path).StartsWith($releasePrefix) })
|
||||
$targetPreparation = Prepare-TargetRelease `
|
||||
-InstallRoot $installRoot `
|
||||
-VersionsRoot $versionsRoot `
|
||||
-SourceRelease $sourceRelease `
|
||||
-TargetRelease $targetRelease `
|
||||
-ReleaseId $releaseId `
|
||||
-BootstrapId $bootstrapId `
|
||||
-ReleaseBootstrapId $releaseBootstrapId `
|
||||
-AgentVersion $agentVersion `
|
||||
-ProtocolVersion ([int]$manifest.protocol_version) `
|
||||
-ReleaseRows $releaseRows `
|
||||
-ReleasePrefix $releasePrefix
|
||||
|
||||
Copy-Item -LiteralPath (Join-Path $sourceRoot "launcher\rf001_agent_launcher.py") -Destination $launcherRoot -Force
|
||||
Copy-Item -LiteralPath (Join-Path $sourceRoot "tools\uninstall_rf001_rx_autostart.ps1") -Destination $installRoot -Force
|
||||
Copy-Item -LiteralPath (Join-Path $sourceRoot "UNINSTALL_RX_AGENT.cmd") -Destination $installRoot -Force
|
||||
$referenceRoot = Join-Path $workRoot "references"
|
||||
$referenceDestination = Join-Path $referenceRoot "lab065_reference_staircase.cs8"
|
||||
New-Item -ItemType Directory -Force -Path $referenceRoot | Out-Null
|
||||
if (Test-Path -LiteralPath $referenceDestination) {
|
||||
if ((Get-LowerSha256 $referenceDestination) -ne $expectedReferenceHash) {
|
||||
throw "The existing staircase reference differs; refusing to overwrite it"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Copy-Item -LiteralPath (Join-Path $sourceRoot "reference\lab065_reference_staircase.cs8") -Destination $referenceDestination
|
||||
}
|
||||
if ((Get-LowerSha256 $referenceDestination) -ne $expectedReferenceHash) {
|
||||
throw "Installed staircase reference SHA256 is invalid"
|
||||
}
|
||||
|
||||
$configPath = Join-Path $installRoot "agent_config.json"
|
||||
$activeReleasePath = Join-Path $installRoot "active_release.json"
|
||||
$launcherStatusPath = Join-Path $installRoot "launcher_status.json"
|
||||
$launcherRequestPath = Join-Path $installRoot "launcher_request.json"
|
||||
$healthPath = Join-Path $installRoot "agent_health.json"
|
||||
$agentLogPath = Join-Path $logsRoot "agent.log"
|
||||
$launcherLogPath = Join-Path $logsRoot "launcher.log"
|
||||
$agentArguments = @(
|
||||
"--server", "http://192.168.255.185:8765",
|
||||
"--token-file", $sharedToken,
|
||||
"--agent-id", "user-pc",
|
||||
"--repository", $repository,
|
||||
"--hackrf-info", (Join-Path $hackrfRoot "hackrf_info.exe"),
|
||||
"--hackrf-clock", (Join-Path $hackrfRoot "hackrf_clock.exe"),
|
||||
"--hackrf-transfer", (Join-Path $hackrfRoot "hackrf_transfer.exe"),
|
||||
"--work-root", $workRoot,
|
||||
"--poll-interval", "5",
|
||||
"--quiet-idle"
|
||||
)
|
||||
$config = [ordered]@{
|
||||
schema_version = 1
|
||||
install_root = $installRoot
|
||||
releases_root = $versionsRoot
|
||||
active_release_file = $activeReleasePath
|
||||
launcher_status = $launcherStatusPath
|
||||
launcher_request = $launcherRequestPath
|
||||
health_file = $healthPath
|
||||
agent_log = $agentLogPath
|
||||
startup_timeout_seconds = 30
|
||||
restart_delay_seconds = 2
|
||||
agent_arguments = $agentArguments
|
||||
hardware_accessed = $false
|
||||
rf_tx = 0
|
||||
}
|
||||
[IO.File]::WriteAllText($configPath, (($config | ConvertTo-Json -Depth 5 -Compress) + "`n"), [Text.Encoding]::UTF8)
|
||||
$activeRelease = [ordered]@{
|
||||
schema_version = 1
|
||||
release_id = $releaseId
|
||||
updated_at = [DateTime]::UtcNow.ToString("o")
|
||||
}
|
||||
[IO.File]::WriteAllText($activeReleasePath, (($activeRelease | ConvertTo-Json -Compress) + "`n"), [Text.Encoding]::UTF8)
|
||||
|
||||
$agentScript = Join-Path $targetRelease "app\tools\rf001_rx_http_agent.py"
|
||||
$selfTestOutput = Join-Path $backupRoot "new_agent_self_test.stdout.txt"
|
||||
$selfTestError = Join-Path $backupRoot "new_agent_self_test.stderr.txt"
|
||||
Invoke-SignedAgentSelfTest `
|
||||
-Python $python `
|
||||
-AgentScript $agentScript `
|
||||
-ExpectedAgentVersion $agentVersion `
|
||||
-StandardOutputPath $selfTestOutput `
|
||||
-StandardErrorPath $selfTestError | Out-Null
|
||||
|
||||
$launcherScript = Join-Path $launcherRoot "rf001_agent_launcher.py"
|
||||
$launcherArguments = @(
|
||||
"-u",
|
||||
"-B",
|
||||
"-X", "utf8",
|
||||
('"{0}"' -f $launcherScript),
|
||||
"--config", ('"{0}"' -f $configPath),
|
||||
"--log-file", ('"{0}"' -f $launcherLogPath)
|
||||
) -join " "
|
||||
$action = New-ScheduledTaskAction -Execute $python -Argument $launcherArguments -WorkingDirectory $launcherRoot
|
||||
$healthBefore = if (Test-Path -LiteralPath $healthPath) { (Get-Item -LiteralPath $healthPath).LastWriteTimeUtc } else { [DateTime]::MinValue }
|
||||
$installStartedAt = [DateTime]::UtcNow
|
||||
|
||||
try {
|
||||
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
Set-ScheduledTask -TaskName $taskName -Action $action | Out-Null
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(35)
|
||||
$confirmed = $false
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$currentTask = Get-ScheduledTask -TaskName $taskName
|
||||
if ((Test-Path -LiteralPath $healthPath) -and (Test-Path -LiteralPath $launcherStatusPath)) {
|
||||
$healthItem = Get-Item -LiteralPath $healthPath
|
||||
$health = Get-Content -LiteralPath $healthPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
$launcherStatus = Get-Content -LiteralPath $launcherStatusPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if (
|
||||
$currentTask.State -eq "Running" -and
|
||||
$healthItem.LastWriteTimeUtc -gt $healthBefore -and
|
||||
$healthItem.LastWriteTimeUtc -ge $installStartedAt.AddSeconds(-2) -and
|
||||
$health.state -eq "RUNNING" -and
|
||||
$health.agent_version -eq $agentVersion -and
|
||||
$health.release_id -eq $releaseId -and
|
||||
$health.rf_tx -eq 0 -and
|
||||
$launcherStatus.state -eq "RUNNING" -and
|
||||
$launcherStatus.active_release_id -eq $releaseId -and
|
||||
$launcherStatus.active_agent_version -eq $agentVersion -and
|
||||
$launcherStatus.rf_tx -eq 0
|
||||
) {
|
||||
$confirmed = $true
|
||||
}
|
||||
}
|
||||
} while (-not $confirmed -and [DateTime]::UtcNow -lt $deadline)
|
||||
if (-not $confirmed) {
|
||||
$currentInfo = Get-ScheduledTaskInfo -TaskName $taskName
|
||||
throw "Versioned agent did not report healthy startup; LastTaskResult=$($currentInfo.LastTaskResult)"
|
||||
}
|
||||
}
|
||||
catch {
|
||||
$failure = $_.Exception.Message
|
||||
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
$xml = [IO.File]::ReadAllText($taskXmlPath, [Text.Encoding]::Unicode)
|
||||
Register-ScheduledTask -TaskName $taskName -Xml $xml -Force | Out-Null
|
||||
foreach ($name in @("launcher", "agent_config.json", "active_release.json", "launcher_status.json", "agent_health.json")) {
|
||||
$inventoryRow = @($backupInventory | Where-Object { $_.name -eq $name })
|
||||
if ($inventoryRow.Count -ne 1) {
|
||||
throw "Bootstrap failed and rollback inventory is invalid. Original cause: $failure"
|
||||
}
|
||||
$current = Join-Path $installRoot $name
|
||||
$saved = Join-Path $backupRoot $name
|
||||
if ($inventoryRow[0].existed) {
|
||||
if ($inventoryRow[0].container) {
|
||||
if (-not (Test-Path -LiteralPath $current -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $current -Force | Out-Null
|
||||
}
|
||||
Copy-Item -Path (Join-Path $saved '*') -Destination $current -Recurse -Force
|
||||
}
|
||||
else {
|
||||
Copy-Item -LiteralPath $saved -Destination $current -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($wasRunning) {
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
}
|
||||
throw "Bootstrap failed; previous scheduled task restored. Cause: $failure"
|
||||
}
|
||||
|
||||
$taskAfter = Get-ScheduledTask -TaskName $taskName
|
||||
$taskInfoAfter = Get-ScheduledTaskInfo -TaskName $taskName
|
||||
"BOOTSTRAP_INSTALL=COMPLETE"
|
||||
"INSTALL_ROOT=$installRoot"
|
||||
"BACKUP_ROOT=$backupRoot"
|
||||
"AUTOSTART_TASK=$taskName"
|
||||
"AUTOSTART_ACCOUNT=$($taskAfter.Principal.UserId)"
|
||||
"AUTOSTART_RUN_LEVEL=$($taskAfter.Principal.RunLevel)"
|
||||
"TASK_STATE=$($taskAfter.State)"
|
||||
"LAST_TASK_RESULT=$($taskInfoAfter.LastTaskResult)"
|
||||
"BOOTSTRAP_ID=$bootstrapId"
|
||||
"AGENT_RELEASE_ID=$releaseId"
|
||||
"AGENT_VERSION=$agentVersion"
|
||||
"TARGET_INITIAL_STATE=$($targetPreparation.initial_state)"
|
||||
"TARGET_FINAL_STATE=$($targetPreparation.final_state)"
|
||||
"TARGET_REUSED=$($targetPreparation.reused)"
|
||||
"CONFLICT_QUARANTINE_PATH=$($targetPreparation.quarantine_path)"
|
||||
"CONFLICT_DIAGNOSTIC_PATH=$($targetPreparation.quarantine_diagnostic)"
|
||||
"CLEAN_RELEASE_HASH_VERIFICATION=PASS"
|
||||
"REFERENCE_SHA256=$expectedReferenceHash"
|
||||
"MANIFEST_AUTHENTICATION=HMAC-SHA256"
|
||||
"ARBITRARY_SHELL=DISABLED"
|
||||
"HARDWARE_ACCESSED=false"
|
||||
"RF_TX=0"
|
||||
@@ -31,6 +31,12 @@ from tools.qpsk_rf_common import (
|
||||
GAP_MS, PACKET_COUNT, PAYLOAD_BYTES, SEED, TEST_ID,
|
||||
payload_for_sequence, read_iq_file, sha256_file,
|
||||
)
|
||||
from tools.rf001a_streaming import (
|
||||
DEFAULT_CHUNK_SAMPLES,
|
||||
MemoryPeakSampler,
|
||||
cs8_sample_count,
|
||||
decode_cs8_streaming,
|
||||
)
|
||||
|
||||
|
||||
def _bit_errors(observed: bytes, expected: bytes) -> tuple[int, int]:
|
||||
@@ -124,7 +130,7 @@ def summarize_result(
|
||||
unique_sequences = set(sequences)
|
||||
cfo = [row["cfo_hz"] for row in rows]
|
||||
report: dict[str, Any] = {
|
||||
"rf001_status": "PREPARED_NOT_RUN",
|
||||
"rf001_status": "OFFLINE_ANALYZED",
|
||||
"test_id": truth.get("test_id") if truth is not None else None,
|
||||
"modulation": "QPSK",
|
||||
"symbol_rate": 500_000,
|
||||
@@ -242,10 +248,32 @@ def decode_file(
|
||||
iq_format: str = "c64le",
|
||||
truth_metadata_path: Path | None = None,
|
||||
decoder_backend: str = "numba",
|
||||
capture_started_at: str | None = None,
|
||||
tx_started_at: str | None = None,
|
||||
tx_finished_at: str | None = None,
|
||||
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
|
||||
) -> dict[str, Any]:
|
||||
"""Открывает только файл; truth читается после вызова рабочего приёмника."""
|
||||
iq, clipping_codes = read_iq_file(iq_path, iq_format)
|
||||
reception = receive_iq_stream(iq, decoder_backend=decoder_backend)
|
||||
"""Декодирует c64le целиком, а большие cs8 — ограниченными окнами."""
|
||||
|
||||
streaming = None
|
||||
with MemoryPeakSampler() as memory:
|
||||
if iq_format == "cs8":
|
||||
streaming = decode_cs8_streaming(
|
||||
iq_path,
|
||||
capture_started_at=capture_started_at,
|
||||
tx_started_at=tx_started_at,
|
||||
tx_finished_at=tx_finished_at,
|
||||
decoder_backend=decoder_backend,
|
||||
chunk_samples=chunk_samples,
|
||||
)
|
||||
reception = streaming.reception
|
||||
iq_sample_count = cs8_sample_count(iq_path)
|
||||
clipping_codes = streaming.clipping_codes
|
||||
iq = None
|
||||
else:
|
||||
iq, clipping_codes = read_iq_file(iq_path, iq_format)
|
||||
reception = receive_iq_stream(iq, decoder_backend=decoder_backend)
|
||||
iq_sample_count = int(iq.size)
|
||||
truth = (json.loads(truth_metadata_path.read_text(encoding="utf-8"))
|
||||
if truth_metadata_path is not None else None)
|
||||
if truth is not None and (
|
||||
@@ -262,17 +290,38 @@ def decode_file(
|
||||
or truth.get("seed") != SEED
|
||||
):
|
||||
raise ValueError("TX-ведомость не соответствует режиму RF-001")
|
||||
raw_bits = (_raw_ber_after_decode(iq, reception, int(truth["seed"]), decoder_backend)
|
||||
if truth is not None else None)
|
||||
return summarize_result(
|
||||
raw_bits = (
|
||||
_raw_ber_after_decode(iq, reception, int(truth["seed"]), decoder_backend)
|
||||
if truth is not None and iq is not None
|
||||
else None
|
||||
)
|
||||
report = summarize_result(
|
||||
reception,
|
||||
iq_sample_count=int(iq.size),
|
||||
iq_sample_count=iq_sample_count,
|
||||
iq_sha256=sha256_file(iq_path),
|
||||
iq_format=iq_format,
|
||||
clipping_codes=clipping_codes,
|
||||
truth=truth,
|
||||
raw_bits=raw_bits,
|
||||
)
|
||||
report["peak_analysis_memory_bytes"] = memory.peak_bytes
|
||||
report["peak_additional_ram_bytes"] = memory.additional_peak_bytes
|
||||
if streaming is not None:
|
||||
report.update({
|
||||
"streaming_decoder": True,
|
||||
"window_source": streaming.window_source,
|
||||
"analysis_windows": [
|
||||
{"start_sample": window.start, "stop_sample": window.stop}
|
||||
for window in streaming.windows
|
||||
],
|
||||
"overlap_samples": streaming.overlap_samples,
|
||||
"maximum_loaded_complex_samples": streaming.maximum_loaded_samples,
|
||||
"maximum_loaded_iq_bytes": streaming.maximum_loaded_samples * 8,
|
||||
"raw_ber_scope": "NOT_COMPUTED_FOR_STREAMING_WINDOWS",
|
||||
})
|
||||
else:
|
||||
report["streaming_decoder"] = False
|
||||
return report
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -282,6 +331,10 @@ def main() -> None:
|
||||
parser.add_argument("--truth-metadata", type=Path)
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
parser.add_argument("--numba-packages", type=Path)
|
||||
parser.add_argument("--capture-started-at")
|
||||
parser.add_argument("--tx-started-at")
|
||||
parser.add_argument("--tx-finished-at")
|
||||
parser.add_argument("--chunk-samples", type=int, default=DEFAULT_CHUNK_SAMPLES)
|
||||
arguments = parser.parse_args()
|
||||
if arguments.report.exists():
|
||||
parser.error("отчёт уже существует; перезапись запрещена")
|
||||
@@ -290,7 +343,11 @@ def main() -> None:
|
||||
parser.error("каталог пакетов Numba отсутствует")
|
||||
sys.path.insert(0, str(arguments.numba_packages.resolve()))
|
||||
result = decode_file(arguments.iq_file, iq_format=arguments.iq_format,
|
||||
truth_metadata_path=arguments.truth_metadata)
|
||||
truth_metadata_path=arguments.truth_metadata,
|
||||
capture_started_at=arguments.capture_started_at,
|
||||
tx_started_at=arguments.tx_started_at,
|
||||
tx_finished_at=arguments.tx_finished_at,
|
||||
chunk_samples=arguments.chunk_samples)
|
||||
arguments.report.parent.mkdir(parents=True, exist_ok=True)
|
||||
arguments.report.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8")
|
||||
|
||||
1009
tools/rf001_agent_launcher.py
Normal file
1009
tools/rf001_agent_launcher.py
Normal file
File diff suppressed because it is too large
Load Diff
422
tools/rf001_agent_update.py
Normal file
422
tools/rf001_agent_update.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""Fixed release packaging and verification for the RF-001 RX agent.
|
||||
|
||||
The module deliberately has no command execution, SDR access, or generic
|
||||
archive extraction. A release contains one exact allowlist of Python files
|
||||
and a content manifest. Approval metadata is authenticated separately with
|
||||
the already provisioned HTTP-channel HMAC secret.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Any, Mapping
|
||||
import zipfile
|
||||
|
||||
|
||||
UPDATE_MANIFEST_DOMAIN = b"RF001-UPDATE-MANIFEST-V1\n"
|
||||
CONTENT_MANIFEST_NAME = "RELEASE_CONTENT.json"
|
||||
RELEASE_ID_PREFIX = "rf001-agent-"
|
||||
RELEASE_ID_PATTERN = re.compile(r"^rf001-agent-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$")
|
||||
AGENT_VERSION_PATTERN = re.compile(r"^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$")
|
||||
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||
MAX_PACKAGE_BYTES = 64 * 1024 * 1024
|
||||
MAX_UNCOMPRESSED_BYTES = 128 * 1024 * 1024
|
||||
MAX_ARCHIVE_ENTRIES = 32
|
||||
MAX_CONTENT_MANIFEST_BYTES = 64 * 1024
|
||||
|
||||
RELEASE_SOURCE_FILES: dict[str, str] = {
|
||||
"app/tools/hackrf_process_check.py": "tools/hackrf_process_check.py",
|
||||
"app/tools/rf001_agent_update.py": "tools/rf001_agent_update.py",
|
||||
"app/tools/rf001_capture_runner.py": "tools/rf001_capture_runner.py",
|
||||
"app/tools/rf001_http_control.py": "tools/rf001_http_control.py",
|
||||
"app/tools/rf001_offline_decode.py": "tools/rf001_offline_decode.py",
|
||||
"app/tools/rf001_rx_capture.py": "tools/rf001_rx_capture.py",
|
||||
"app/tools/rf001_rx_http_agent.py": "tools/rf001_rx_http_agent.py",
|
||||
"app/tools/rf001_staircase_job.py": "tools/rf001_staircase_job.py",
|
||||
"app/tools/rf001_staircase_search.py": "tools/rf001_staircase_search.py",
|
||||
}
|
||||
REQUIRED_RELEASE_FILES = frozenset(RELEASE_SOURCE_FILES)
|
||||
|
||||
|
||||
class ReleaseValidationError(ValueError):
|
||||
"""A release is malformed, unauthenticated, or outside its allowlist."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseInspection:
|
||||
release_id: str
|
||||
agent_version: str
|
||||
protocol_version: int
|
||||
package_sha256: str
|
||||
package_bytes: int
|
||||
content_manifest_sha256: str
|
||||
uncompressed_bytes: int
|
||||
file_count: int
|
||||
|
||||
def descriptor(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"release_id": self.release_id,
|
||||
"agent_version": self.agent_version,
|
||||
"protocol_version": self.protocol_version,
|
||||
"package_sha256": self.package_sha256,
|
||||
"package_bytes": self.package_bytes,
|
||||
"content_manifest_sha256": self.content_manifest_sha256,
|
||||
"uncompressed_bytes": self.uncompressed_bytes,
|
||||
"file_count": self.file_count,
|
||||
}
|
||||
|
||||
|
||||
def source_agent_version(agent_source: Path) -> str:
|
||||
"""Read the single literal AGENT_VERSION from an agent source file."""
|
||||
try:
|
||||
syntax = ast.parse(agent_source.read_text(encoding="utf-8"), agent_source.name)
|
||||
except (OSError, SyntaxError, UnicodeError) as error:
|
||||
raise ReleaseValidationError("agent source cannot be parsed") from error
|
||||
values: list[ast.expr] = []
|
||||
for statement in syntax.body:
|
||||
if isinstance(statement, ast.Assign):
|
||||
if any(
|
||||
isinstance(target, ast.Name) and target.id == "AGENT_VERSION"
|
||||
for target in statement.targets
|
||||
):
|
||||
if not (
|
||||
len(statement.targets) == 1
|
||||
and isinstance(statement.targets[0], ast.Name)
|
||||
and statement.targets[0].id == "AGENT_VERSION"
|
||||
):
|
||||
raise ReleaseValidationError(
|
||||
"agent source AGENT_VERSION assignment is ambiguous"
|
||||
)
|
||||
values.append(statement.value)
|
||||
elif (
|
||||
isinstance(statement, ast.AnnAssign)
|
||||
and isinstance(statement.target, ast.Name)
|
||||
and statement.target.id == "AGENT_VERSION"
|
||||
):
|
||||
if statement.value is None:
|
||||
raise ReleaseValidationError("agent source AGENT_VERSION has no value")
|
||||
values.append(statement.value)
|
||||
if len(values) != 1:
|
||||
raise ReleaseValidationError(
|
||||
"agent source must contain one AGENT_VERSION assignment"
|
||||
)
|
||||
value = values[0]
|
||||
if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
|
||||
raise ReleaseValidationError("agent source AGENT_VERSION must be a literal string")
|
||||
if AGENT_VERSION_PATTERN.fullmatch(value.value) is None:
|
||||
raise ReleaseValidationError("agent source AGENT_VERSION is invalid")
|
||||
return value.value
|
||||
|
||||
|
||||
def validate_release_identity(
|
||||
source_root: Path,
|
||||
*,
|
||||
release_id: str,
|
||||
requested_agent_version: str,
|
||||
) -> str:
|
||||
"""Require release id, requested version, and packaged source to agree."""
|
||||
if RELEASE_ID_PATTERN.fullmatch(release_id) is None:
|
||||
raise ReleaseValidationError("release_id is invalid")
|
||||
if AGENT_VERSION_PATTERN.fullmatch(requested_agent_version) is None:
|
||||
raise ReleaseValidationError("agent_version is invalid")
|
||||
if release_id != f"{RELEASE_ID_PREFIX}{requested_agent_version}":
|
||||
raise ReleaseValidationError("release_id does not match agent_version")
|
||||
agent_source = (
|
||||
source_root / RELEASE_SOURCE_FILES["app/tools/rf001_rx_http_agent.py"]
|
||||
).resolve()
|
||||
actual_agent_version = source_agent_version(agent_source)
|
||||
if actual_agent_version != requested_agent_version:
|
||||
raise ReleaseValidationError(
|
||||
"source AGENT_VERSION does not match requested agent_version"
|
||||
)
|
||||
return actual_agent_version
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(4 * 1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def sign_update_manifest(token: bytes, manifest: Mapping[str, Any]) -> str:
|
||||
return hmac.new(
|
||||
token,
|
||||
UPDATE_MANIFEST_DOMAIN + canonical_json_bytes(manifest),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def verify_update_manifest_signature(
|
||||
token: bytes,
|
||||
manifest: Mapping[str, Any],
|
||||
signature: str,
|
||||
) -> None:
|
||||
if SHA256_PATTERN.fullmatch(signature) is None:
|
||||
raise ReleaseValidationError("update manifest signature is invalid")
|
||||
expected = sign_update_manifest(token, manifest)
|
||||
if not hmac.compare_digest(expected, signature):
|
||||
raise ReleaseValidationError("update manifest signature mismatch")
|
||||
|
||||
|
||||
def _validate_member_name(name: str) -> PurePosixPath:
|
||||
if not name or "\\" in name or name.startswith("/"):
|
||||
raise ReleaseValidationError(f"unsafe archive path: {name!r}")
|
||||
path = PurePosixPath(name)
|
||||
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
|
||||
raise ReleaseValidationError(f"unsafe archive path: {name!r}")
|
||||
if any(":" in part for part in path.parts):
|
||||
raise ReleaseValidationError(f"drive-like archive path: {name!r}")
|
||||
return path
|
||||
|
||||
|
||||
def _is_link(info: zipfile.ZipInfo) -> bool:
|
||||
unix_mode = (info.external_attr >> 16) & 0xFFFF
|
||||
return stat.S_IFMT(unix_mode) == stat.S_IFLNK
|
||||
|
||||
|
||||
def _load_content_manifest(
|
||||
archive: zipfile.ZipFile,
|
||||
info: zipfile.ZipInfo,
|
||||
) -> tuple[dict[str, Any], bytes]:
|
||||
if info.file_size > MAX_CONTENT_MANIFEST_BYTES:
|
||||
raise ReleaseValidationError("release content manifest is too large")
|
||||
raw = archive.read(info)
|
||||
try:
|
||||
decoded = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise ReleaseValidationError("release content manifest is invalid") from error
|
||||
if not isinstance(decoded, dict):
|
||||
raise ReleaseValidationError("release content manifest must be an object")
|
||||
if raw != canonical_json_bytes(decoded):
|
||||
raise ReleaseValidationError("release content manifest is not canonical JSON")
|
||||
return decoded, raw
|
||||
|
||||
|
||||
def _validate_content_manifest(manifest: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
if manifest.get("schema_version") != 1:
|
||||
raise ReleaseValidationError("unsupported release content schema")
|
||||
release_id = manifest.get("release_id")
|
||||
agent_version = manifest.get("agent_version")
|
||||
protocol_version = manifest.get("protocol_version")
|
||||
if not isinstance(release_id, str) or RELEASE_ID_PATTERN.fullmatch(release_id) is None:
|
||||
raise ReleaseValidationError("release_id is invalid")
|
||||
if not isinstance(agent_version, str) or AGENT_VERSION_PATTERN.fullmatch(agent_version) is None:
|
||||
raise ReleaseValidationError("agent_version is invalid")
|
||||
if type(protocol_version) is not int or protocol_version <= 0:
|
||||
raise ReleaseValidationError("protocol_version is invalid")
|
||||
rows = manifest.get("files")
|
||||
if not isinstance(rows, list):
|
||||
raise ReleaseValidationError("release files list is invalid")
|
||||
files: dict[str, dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise ReleaseValidationError("release file entry is invalid")
|
||||
name = row.get("path")
|
||||
digest = row.get("sha256")
|
||||
size = row.get("bytes")
|
||||
if not isinstance(name, str):
|
||||
raise ReleaseValidationError("release file path is invalid")
|
||||
_validate_member_name(name)
|
||||
key = name.casefold()
|
||||
if key in (existing.casefold() for existing in files):
|
||||
raise ReleaseValidationError("duplicate release file path")
|
||||
if SHA256_PATTERN.fullmatch(str(digest)) is None:
|
||||
raise ReleaseValidationError(f"release file hash is invalid: {name}")
|
||||
if type(size) is not int or size < 0:
|
||||
raise ReleaseValidationError(f"release file size is invalid: {name}")
|
||||
files[name] = {"path": name, "sha256": digest, "bytes": size}
|
||||
if frozenset(files) != REQUIRED_RELEASE_FILES:
|
||||
unexpected = sorted(set(files) - REQUIRED_RELEASE_FILES)
|
||||
missing = sorted(REQUIRED_RELEASE_FILES - set(files))
|
||||
raise ReleaseValidationError(
|
||||
f"release file allowlist mismatch; unexpected={unexpected}; missing={missing}"
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
def inspect_release_archive(
|
||||
package_path: Path,
|
||||
*,
|
||||
expected_package_sha256: str | None = None,
|
||||
expected_package_bytes: int | None = None,
|
||||
expected_content_manifest_sha256: str | None = None,
|
||||
extraction_root: Path | None = None,
|
||||
) -> ReleaseInspection:
|
||||
package_path = package_path.resolve()
|
||||
package_bytes = package_path.stat().st_size
|
||||
if package_bytes <= 0 or package_bytes > MAX_PACKAGE_BYTES:
|
||||
raise ReleaseValidationError("release package size is outside the limit")
|
||||
if expected_package_bytes is not None and package_bytes != expected_package_bytes:
|
||||
raise ReleaseValidationError("release package size mismatch")
|
||||
package_sha256 = sha256_file(package_path)
|
||||
if expected_package_sha256 is not None and package_sha256 != expected_package_sha256:
|
||||
raise ReleaseValidationError("release package SHA256 mismatch")
|
||||
|
||||
with zipfile.ZipFile(package_path, "r") as archive:
|
||||
entries = archive.infolist()
|
||||
if not entries or len(entries) > MAX_ARCHIVE_ENTRIES:
|
||||
raise ReleaseValidationError("release archive entry count is invalid")
|
||||
seen: set[str] = set()
|
||||
regular: dict[str, zipfile.ZipInfo] = {}
|
||||
uncompressed_bytes = 0
|
||||
for info in entries:
|
||||
path = _validate_member_name(info.filename)
|
||||
normalized = path.as_posix()
|
||||
key = normalized.casefold()
|
||||
if key in seen:
|
||||
raise ReleaseValidationError("ambiguous duplicate archive entry")
|
||||
seen.add(key)
|
||||
if info.flag_bits & 0x1:
|
||||
raise ReleaseValidationError("encrypted archive entries are prohibited")
|
||||
if _is_link(info):
|
||||
raise ReleaseValidationError("archive links are prohibited")
|
||||
if info.is_dir():
|
||||
raise ReleaseValidationError("explicit archive directory entries are prohibited")
|
||||
uncompressed_bytes += info.file_size
|
||||
if uncompressed_bytes > MAX_UNCOMPRESSED_BYTES:
|
||||
raise ReleaseValidationError("release uncompressed size exceeds the limit")
|
||||
regular[normalized] = info
|
||||
|
||||
if CONTENT_MANIFEST_NAME not in regular:
|
||||
raise ReleaseValidationError("release content manifest is absent")
|
||||
content_manifest, content_raw = _load_content_manifest(
|
||||
archive,
|
||||
regular[CONTENT_MANIFEST_NAME],
|
||||
)
|
||||
content_sha256 = hashlib.sha256(content_raw).hexdigest()
|
||||
if (
|
||||
expected_content_manifest_sha256 is not None
|
||||
and content_sha256 != expected_content_manifest_sha256
|
||||
):
|
||||
raise ReleaseValidationError("release content manifest SHA256 mismatch")
|
||||
files = _validate_content_manifest(content_manifest)
|
||||
expected_names = set(REQUIRED_RELEASE_FILES) | {CONTENT_MANIFEST_NAME}
|
||||
if set(regular) != expected_names:
|
||||
raise ReleaseValidationError("release archive contents are not allowlisted")
|
||||
for name, metadata in files.items():
|
||||
info = regular[name]
|
||||
if info.file_size != metadata["bytes"]:
|
||||
raise ReleaseValidationError(f"release file size mismatch: {name}")
|
||||
digest = hashlib.sha256()
|
||||
with archive.open(info, "r") as stream:
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
if digest.hexdigest() != metadata["sha256"]:
|
||||
raise ReleaseValidationError(f"release file SHA256 mismatch: {name}")
|
||||
|
||||
if extraction_root is not None:
|
||||
extraction_root = extraction_root.resolve()
|
||||
extraction_root.mkdir(parents=True, exist_ok=False)
|
||||
for name in sorted(REQUIRED_RELEASE_FILES):
|
||||
target = extraction_root.joinpath(*PurePosixPath(name).parts)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
resolved = target.resolve()
|
||||
try:
|
||||
resolved.relative_to(extraction_root)
|
||||
except ValueError as error:
|
||||
raise ReleaseValidationError("archive path escaped extraction root") from error
|
||||
with archive.open(regular[name], "r") as source:
|
||||
with target.open("xb") as destination:
|
||||
shutil.copyfileobj(source, destination, length=1024 * 1024)
|
||||
manifest_target = extraction_root / CONTENT_MANIFEST_NAME
|
||||
with manifest_target.open("xb") as stream:
|
||||
stream.write(content_raw)
|
||||
|
||||
return ReleaseInspection(
|
||||
release_id=str(content_manifest["release_id"]),
|
||||
agent_version=str(content_manifest["agent_version"]),
|
||||
protocol_version=int(content_manifest["protocol_version"]),
|
||||
package_sha256=package_sha256,
|
||||
package_bytes=package_bytes,
|
||||
content_manifest_sha256=content_sha256,
|
||||
uncompressed_bytes=uncompressed_bytes,
|
||||
file_count=len(files),
|
||||
)
|
||||
|
||||
|
||||
def build_release_archive(
|
||||
source_root: Path,
|
||||
package_path: Path,
|
||||
*,
|
||||
release_id: str,
|
||||
agent_version: str,
|
||||
protocol_version: int,
|
||||
) -> ReleaseInspection:
|
||||
if protocol_version <= 0:
|
||||
raise ReleaseValidationError("protocol_version is invalid")
|
||||
source_root = source_root.resolve()
|
||||
validate_release_identity(
|
||||
source_root,
|
||||
release_id=release_id,
|
||||
requested_agent_version=agent_version,
|
||||
)
|
||||
rows = []
|
||||
sources: dict[str, Path] = {}
|
||||
for archive_name, relative_source in RELEASE_SOURCE_FILES.items():
|
||||
source = (source_root / relative_source).resolve()
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"release source is absent: {source}")
|
||||
sources[archive_name] = source
|
||||
rows.append(
|
||||
{
|
||||
"path": archive_name,
|
||||
"bytes": source.stat().st_size,
|
||||
"sha256": sha256_file(source),
|
||||
}
|
||||
)
|
||||
content = {
|
||||
"schema_version": 1,
|
||||
"release_id": release_id,
|
||||
"agent_version": agent_version,
|
||||
"protocol_version": protocol_version,
|
||||
"files": sorted(rows, key=lambda row: str(row["path"])),
|
||||
}
|
||||
content_raw = canonical_json_bytes(content)
|
||||
package_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if package_path.exists():
|
||||
raise FileExistsError(f"release package already exists: {package_path}")
|
||||
timestamp = (2026, 9, 29, 0, 0, 0)
|
||||
with zipfile.ZipFile(
|
||||
package_path,
|
||||
"x",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9,
|
||||
) as archive:
|
||||
manifest_info = zipfile.ZipInfo(CONTENT_MANIFEST_NAME, timestamp)
|
||||
manifest_info.compress_type = zipfile.ZIP_DEFLATED
|
||||
manifest_info.external_attr = 0o100600 << 16
|
||||
archive.writestr(manifest_info, content_raw)
|
||||
for name in sorted(sources):
|
||||
info = zipfile.ZipInfo(name, timestamp)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100600 << 16
|
||||
archive.writestr(info, sources[name].read_bytes())
|
||||
return inspect_release_archive(package_path)
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, value: Mapping[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_bytes(canonical_json_bytes(value) + b"\n")
|
||||
os.replace(temporary, path)
|
||||
334
tools/rf001_bootstrap_install_orchestrator.py
Normal file
334
tools/rf001_bootstrap_install_orchestrator.py
Normal file
@@ -0,0 +1,334 @@
|
||||
"""Безопасный запуск уже распакованного bootstrap-пакета RX-агента.
|
||||
|
||||
Утилита принадлежит MAIN и не входит в подписанный bootstrap. Она проверяет
|
||||
HMAC и точный состав распакованного пакета до запуска, сохраняет stdout,
|
||||
stderr и снимки дерева только во внешнем каталоге, а после завершения
|
||||
подтверждает, что пакет не изменился.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
MANIFEST_NAME = "BOOTSTRAP_MANIFEST.json"
|
||||
SIGNATURE_NAME = "BOOTSTRAP_MANIFEST.hmac"
|
||||
INSTALLER_NAME = "INSTALL_RX_AGENT.cmd"
|
||||
SIGNATURE_DOMAIN = b"RF001-UPDATE-MANIFEST-V1\n"
|
||||
SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class BootstrapInstallError(RuntimeError):
|
||||
"""Базовая ошибка безопасного запуска bootstrap."""
|
||||
|
||||
|
||||
class PackageValidationError(BootstrapInstallError):
|
||||
"""Пакет не прошёл HMAC, manifest или точную проверку состава."""
|
||||
|
||||
|
||||
class EvidencePathError(BootstrapInstallError):
|
||||
"""Каталог доказательств небезопасен или уже содержит журналы запуска."""
|
||||
|
||||
|
||||
class PackageMutationError(BootstrapInstallError):
|
||||
"""Дерево bootstrap изменилось во время запуска установщика."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TreeEntry:
|
||||
path: str
|
||||
bytes: int
|
||||
sha256: str
|
||||
modified_time_ns: int
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def snapshot_tree(root: Path) -> list[TreeEntry]:
|
||||
entries: list[TreeEntry] = []
|
||||
for path in sorted(
|
||||
(candidate for candidate in root.rglob("*") if candidate.is_file()),
|
||||
key=lambda candidate: candidate.as_posix().casefold(),
|
||||
):
|
||||
stat = path.stat()
|
||||
entries.append(
|
||||
TreeEntry(
|
||||
path=path.relative_to(root).as_posix(),
|
||||
bytes=stat.st_size,
|
||||
sha256=sha256_file(path),
|
||||
modified_time_ns=stat.st_mtime_ns,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _content_identity(entries: Sequence[TreeEntry]) -> list[tuple[str, int, str]]:
|
||||
return [(entry.path, entry.bytes, entry.sha256) for entry in entries]
|
||||
|
||||
|
||||
def _write_json(path: Path, value: Any) -> None:
|
||||
path.write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _relative_manifest_path(value: object) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise PackageValidationError("bootstrap manifest contains an empty path")
|
||||
if "\\" in value or ":" in value:
|
||||
raise PackageValidationError(f"unsafe bootstrap path: {value}")
|
||||
relative = PurePosixPath(value)
|
||||
if relative.is_absolute() or ".." in relative.parts or "." in relative.parts:
|
||||
raise PackageValidationError(f"unsafe bootstrap path: {value}")
|
||||
return relative.as_posix()
|
||||
|
||||
|
||||
def validate_pristine_package(package_root: Path, token_file: Path) -> dict[str, Any]:
|
||||
package_root = package_root.resolve()
|
||||
token_file = token_file.resolve()
|
||||
if not package_root.is_dir():
|
||||
raise PackageValidationError("bootstrap package directory is absent")
|
||||
manifest_path = package_root / MANIFEST_NAME
|
||||
signature_path = package_root / SIGNATURE_NAME
|
||||
installer_path = package_root / INSTALLER_NAME
|
||||
for required in (manifest_path, signature_path, installer_path, token_file):
|
||||
if not required.is_file():
|
||||
raise PackageValidationError(f"required file is absent: {required}")
|
||||
|
||||
manifest_bytes = manifest_path.read_bytes()
|
||||
token = token_file.read_text(encoding="utf-8").strip().encode("utf-8")
|
||||
if len(token) < 32:
|
||||
raise PackageValidationError("installed signing token is invalid")
|
||||
supplied_signature = signature_path.read_text(encoding="ascii").strip().lower()
|
||||
expected_signature = hmac.new(
|
||||
token,
|
||||
SIGNATURE_DOMAIN + manifest_bytes,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not SHA256_PATTERN.fullmatch(supplied_signature) or not hmac.compare_digest(
|
||||
supplied_signature,
|
||||
expected_signature,
|
||||
):
|
||||
raise PackageValidationError("bootstrap manifest signature mismatch")
|
||||
|
||||
try:
|
||||
manifest = json.loads(manifest_bytes.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise PackageValidationError("bootstrap manifest is invalid JSON") from error
|
||||
if not isinstance(manifest, dict):
|
||||
raise PackageValidationError("bootstrap manifest root must be an object")
|
||||
if manifest.get("schema_version") != 1 or manifest.get("package_kind") != "BOOTSTRAP":
|
||||
raise PackageValidationError("unsupported bootstrap manifest")
|
||||
rows = manifest.get("files")
|
||||
if not isinstance(rows, list) or not rows:
|
||||
raise PackageValidationError("bootstrap manifest file list is invalid")
|
||||
|
||||
expected: dict[str, tuple[int, str]] = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise PackageValidationError("bootstrap manifest file row is invalid")
|
||||
relative = _relative_manifest_path(row.get("path"))
|
||||
folded = relative.casefold()
|
||||
if folded in expected:
|
||||
raise PackageValidationError(f"duplicate bootstrap path: {relative}")
|
||||
byte_count = row.get("bytes")
|
||||
digest = row.get("sha256")
|
||||
if type(byte_count) is not int or byte_count < 0:
|
||||
raise PackageValidationError(f"invalid bootstrap size: {relative}")
|
||||
if not isinstance(digest, str) or not SHA256_PATTERN.fullmatch(digest.lower()):
|
||||
raise PackageValidationError(f"invalid bootstrap SHA256: {relative}")
|
||||
expected[folded] = (byte_count, digest.lower())
|
||||
|
||||
source = package_root.joinpath(*PurePosixPath(relative).parts)
|
||||
if not source.is_file() or source.is_symlink():
|
||||
raise PackageValidationError(f"bootstrap file is absent or unsafe: {relative}")
|
||||
if source.stat().st_size != byte_count:
|
||||
raise PackageValidationError(f"bootstrap file size mismatch: {relative}")
|
||||
if sha256_file(source) != digest.lower():
|
||||
raise PackageValidationError(f"bootstrap file SHA256 mismatch: {relative}")
|
||||
|
||||
actual_entries = snapshot_tree(package_root)
|
||||
actual = {entry.path.casefold() for entry in actual_entries}
|
||||
allowed = set(expected) | {MANIFEST_NAME.casefold(), SIGNATURE_NAME.casefold()}
|
||||
unexpected = sorted(actual - allowed)
|
||||
missing = sorted(allowed - actual)
|
||||
if unexpected or missing:
|
||||
raise PackageValidationError(
|
||||
"bootstrap package allowlist mismatch; "
|
||||
f"unexpected={unexpected}; missing={missing}"
|
||||
)
|
||||
return {
|
||||
"bootstrap_id": manifest.get("bootstrap_id"),
|
||||
"agent_release_id": manifest.get("agent_release_id"),
|
||||
"agent_version": manifest.get("agent_version"),
|
||||
"hmac": "PASS",
|
||||
"manifest_file_count": len(expected),
|
||||
"package_file_count": len(actual_entries),
|
||||
"unexpected": 0,
|
||||
"missing": 0,
|
||||
}
|
||||
|
||||
|
||||
def _assert_external_evidence(package_root: Path, evidence_root: Path) -> None:
|
||||
package_root = package_root.resolve()
|
||||
evidence_root = evidence_root.resolve()
|
||||
if evidence_root == package_root or package_root in evidence_root.parents:
|
||||
raise EvidencePathError("evidence directory must be outside the package tree")
|
||||
|
||||
|
||||
def run_installer(
|
||||
package_root: Path,
|
||||
evidence_root: Path,
|
||||
token_file: Path,
|
||||
*,
|
||||
command: Sequence[str] | None = None,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
package_root = package_root.resolve()
|
||||
evidence_root = evidence_root.resolve()
|
||||
_assert_external_evidence(package_root, evidence_root)
|
||||
preflight = validate_pristine_package(package_root, token_file)
|
||||
|
||||
evidence_root.mkdir(parents=True, exist_ok=True)
|
||||
paths = {
|
||||
"stdout": evidence_root / "install.stdout.txt",
|
||||
"stderr": evidence_root / "install.stderr.txt",
|
||||
"runtime": evidence_root / "install_runtime.json",
|
||||
"before": evidence_root / "package_tree_before.json",
|
||||
"immediate": evidence_root / "package_tree_immediate.json",
|
||||
"after": evidence_root / "package_tree_after.json",
|
||||
"preflight": evidence_root / "package_preflight.json",
|
||||
}
|
||||
occupied = [str(path) for path in paths.values() if path.exists()]
|
||||
if occupied:
|
||||
raise EvidencePathError(f"runtime evidence paths already exist: {occupied}")
|
||||
|
||||
before = snapshot_tree(package_root)
|
||||
_write_json(paths["preflight"], preflight)
|
||||
_write_json(paths["before"], [asdict(entry) for entry in before])
|
||||
invocation = list(command) if command is not None else [
|
||||
os.environ.get("COMSPEC", "cmd.exe"),
|
||||
"/d",
|
||||
"/c",
|
||||
"call",
|
||||
str(package_root / INSTALLER_NAME),
|
||||
]
|
||||
metadata: dict[str, Any] = {
|
||||
"started_at_utc": utc_now(),
|
||||
"package_root": str(package_root),
|
||||
"evidence_root": str(evidence_root),
|
||||
"working_directory": str(package_root),
|
||||
"stdout_path": str(paths["stdout"]),
|
||||
"stderr_path": str(paths["stderr"]),
|
||||
"rf_rx": 0,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
with paths["stdout"].open("wb") as stdout_stream, paths["stderr"].open(
|
||||
"wb"
|
||||
) as stderr_stream:
|
||||
process = subprocess.Popen(
|
||||
invocation,
|
||||
cwd=package_root,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=stdout_stream,
|
||||
stderr=stderr_stream,
|
||||
creationflags=creation_flags,
|
||||
)
|
||||
metadata["installer_process_id"] = process.pid
|
||||
immediate = snapshot_tree(package_root)
|
||||
_write_json(paths["immediate"], [asdict(entry) for entry in immediate])
|
||||
metadata["package_content_unchanged_immediate"] = (
|
||||
_content_identity(immediate) == _content_identity(before)
|
||||
)
|
||||
metadata["package_metadata_unchanged_immediate"] = immediate == before
|
||||
try:
|
||||
exit_code = process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
metadata["timed_out"] = True
|
||||
metadata["completed_at_utc"] = utc_now()
|
||||
_write_json(paths["runtime"], metadata)
|
||||
raise BootstrapInstallError(
|
||||
"installer timeout; the process was not restarted or killed automatically"
|
||||
) from error
|
||||
|
||||
after = snapshot_tree(package_root)
|
||||
_write_json(paths["after"], [asdict(entry) for entry in after])
|
||||
metadata.update(
|
||||
{
|
||||
"completed_at_utc": utc_now(),
|
||||
"exit_code": exit_code,
|
||||
"timed_out": False,
|
||||
"package_content_unchanged_after": (
|
||||
_content_identity(after) == _content_identity(before)
|
||||
),
|
||||
"package_metadata_unchanged_after": after == before,
|
||||
"stdout_bytes": paths["stdout"].stat().st_size,
|
||||
"stderr_bytes": paths["stderr"].stat().st_size,
|
||||
}
|
||||
)
|
||||
_write_json(paths["runtime"], metadata)
|
||||
if not metadata["package_content_unchanged_immediate"] or not metadata[
|
||||
"package_content_unchanged_after"
|
||||
]:
|
||||
raise PackageMutationError("bootstrap package changed during installer run")
|
||||
if exit_code != 0:
|
||||
raise BootstrapInstallError(f"installer exited with code {exit_code}")
|
||||
return metadata
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--package-root", required=True, type=Path)
|
||||
parser.add_argument("--evidence-root", required=True, type=Path)
|
||||
parser.add_argument("--token-file", required=True, type=Path)
|
||||
parser.add_argument("--timeout-seconds", type=float, default=None)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = parse_args()
|
||||
try:
|
||||
result = run_installer(
|
||||
arguments.package_root,
|
||||
arguments.evidence_root,
|
||||
arguments.token_file,
|
||||
timeout_seconds=arguments.timeout_seconds,
|
||||
)
|
||||
except BootstrapInstallError as error:
|
||||
print(f"BOOTSTRAP INSTALL = FAILED: {error}")
|
||||
print("RF RX = 0")
|
||||
print("RF TX = 0")
|
||||
return 1
|
||||
print("BOOTSTRAP INSTALL = COMPLETE")
|
||||
print(f"INSTALL EXIT CODE = {result['exit_code']}")
|
||||
print(f"EVIDENCE ROOT = {result['evidence_root']}")
|
||||
print("PACKAGE TREE UNCHANGED = YES")
|
||||
print("RF RX = 0")
|
||||
print("RF TX = 0")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
16
tools/rf001_bootstrap_readme.txt
Normal file
16
tools/rf001_bootstrap_readme.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
RF-001 RX Agent — первоначальная установка механизма обновления
|
||||
|
||||
1. Распакуйте ZIP целиком в отдельный каталог.
|
||||
2. Запустите INSTALL_RX_AGENT.cmd обычным двойным щелчком.
|
||||
3. Дождитесь строки BOOTSTRAP_INSTALL=COMPLETE и нажмите любую клавишу.
|
||||
|
||||
Установщик использует существующее задание «SDR Rover RX Agent» и уже
|
||||
установленный секрет HTTP-канала. Секрета в этом ZIP нет. Пользователь,
|
||||
уровень прав, сетевые настройки, драйверы и параметры SDR не меняются.
|
||||
|
||||
Установка не запускает захват или передачу. При неудаче запуска новой версии
|
||||
восстанавливается прежнее определение задания Планировщика.
|
||||
|
||||
MAIN: 192.168.255.185:8765
|
||||
Разрешённый адрес RX: 192.168.255.56
|
||||
RF TX: 0
|
||||
355
tools/rf001_build_agent_packages.py
Normal file
355
tools/rf001_build_agent_packages.py
Normal file
@@ -0,0 +1,355 @@
|
||||
"""Build authenticated RF-001 agent release and bootstrap packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
from typing import BinaryIO, Mapping
|
||||
import zipfile
|
||||
|
||||
try:
|
||||
from tools.rf001_agent_update import (
|
||||
RELEASE_SOURCE_FILES,
|
||||
UPDATE_MANIFEST_DOMAIN,
|
||||
build_release_archive,
|
||||
canonical_json_bytes,
|
||||
sha256_file,
|
||||
validate_release_identity,
|
||||
)
|
||||
except ModuleNotFoundError: # direct execution from tools/
|
||||
from rf001_agent_update import ( # type: ignore[no-redef]
|
||||
RELEASE_SOURCE_FILES,
|
||||
UPDATE_MANIFEST_DOMAIN,
|
||||
build_release_archive,
|
||||
canonical_json_bytes,
|
||||
sha256_file,
|
||||
validate_release_identity,
|
||||
)
|
||||
|
||||
|
||||
BOOTSTRAP_ID_PATTERN = re.compile(
|
||||
r"^rf001-bootstrap-[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$"
|
||||
)
|
||||
BOOTSTRAP_MANIFEST_NAME = "BOOTSTRAP_MANIFEST.json"
|
||||
BOOTSTRAP_SIGNATURE_NAME = "BOOTSTRAP_MANIFEST.hmac"
|
||||
REFERENCE_SHA256 = "fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
|
||||
MAX_BOOTSTRAP_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BootstrapInspection:
|
||||
bootstrap_id: str
|
||||
agent_release_id: str
|
||||
agent_version: str
|
||||
protocol_version: int
|
||||
package_sha256: str
|
||||
package_bytes: int
|
||||
content_manifest_sha256: str
|
||||
file_count: int
|
||||
|
||||
|
||||
def _token_bytes(path: Path) -> bytes:
|
||||
value = path.read_text(encoding="utf-8-sig").strip().encode("utf-8")
|
||||
if len(value) < 32:
|
||||
raise ValueError("existing signing token is missing or too short")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_archive_name(name: str) -> None:
|
||||
path = PurePosixPath(name)
|
||||
if (
|
||||
not name
|
||||
or "\\" in name
|
||||
or name.startswith("/")
|
||||
or path.is_absolute()
|
||||
or any(part in ("", ".", "..") or ":" in part for part in path.parts)
|
||||
):
|
||||
raise ValueError(f"unsafe bootstrap path: {name!r}")
|
||||
|
||||
|
||||
def _source_files(
|
||||
source_root: Path,
|
||||
*,
|
||||
agent_release_id: str,
|
||||
) -> dict[str, Path]:
|
||||
result = {
|
||||
"INSTALL_RX_AGENT.cmd": source_root / "INSTALL_RX_AGENT.cmd",
|
||||
"README_RX_AGENT.txt": source_root / "tools" / "rf001_bootstrap_readme.txt",
|
||||
"UNINSTALL_RX_AGENT.cmd": source_root / "UNINSTALL_RX_AGENT.cmd",
|
||||
"launcher/rf001_agent_launcher.py": source_root
|
||||
/ "tools"
|
||||
/ "rf001_agent_launcher.py",
|
||||
"reference/lab065_reference_staircase.cs8": source_root
|
||||
/ ".local"
|
||||
/ "rf001_probe"
|
||||
/ "lab065_reference_staircase.cs8",
|
||||
"tools/install_rf001_rx_autostart.ps1": source_root
|
||||
/ "tools"
|
||||
/ "install_rf001_rx_autostart.ps1",
|
||||
"tools/uninstall_rf001_rx_autostart.ps1": source_root
|
||||
/ "tools"
|
||||
/ "uninstall_rf001_rx_autostart.ps1",
|
||||
}
|
||||
for archive_name, relative in RELEASE_SOURCE_FILES.items():
|
||||
result[f"versions/{agent_release_id}/{archive_name}"] = source_root / relative
|
||||
return result
|
||||
|
||||
|
||||
def _copy_to_zip(
|
||||
archive: zipfile.ZipFile,
|
||||
name: str,
|
||||
source: Path,
|
||||
*,
|
||||
timestamp: tuple[int, int, int, int, int, int],
|
||||
) -> None:
|
||||
info = zipfile.ZipInfo(name, timestamp)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100600 << 16
|
||||
with source.open("rb") as input_stream, archive.open(info, "w") as output_stream:
|
||||
shutil.copyfileobj(input_stream, output_stream, length=1024 * 1024)
|
||||
|
||||
|
||||
def _write_bytes(
|
||||
archive: zipfile.ZipFile,
|
||||
name: str,
|
||||
value: bytes,
|
||||
*,
|
||||
timestamp: tuple[int, int, int, int, int, int],
|
||||
) -> None:
|
||||
info = zipfile.ZipInfo(name, timestamp)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
info.external_attr = 0o100600 << 16
|
||||
archive.writestr(info, value)
|
||||
|
||||
|
||||
def build_bootstrap_archive(
|
||||
source_root: Path,
|
||||
package_path: Path,
|
||||
*,
|
||||
bootstrap_id: str,
|
||||
agent_release_id: str,
|
||||
agent_version: str,
|
||||
protocol_version: int,
|
||||
token_path: Path,
|
||||
agent_release_bootstrap_id: str | None = None,
|
||||
) -> BootstrapInspection:
|
||||
if BOOTSTRAP_ID_PATTERN.fullmatch(bootstrap_id) is None:
|
||||
raise ValueError("bootstrap_id is invalid")
|
||||
if protocol_version <= 0:
|
||||
raise ValueError("protocol_version is invalid")
|
||||
release_bootstrap_id = agent_release_bootstrap_id or bootstrap_id
|
||||
if BOOTSTRAP_ID_PATTERN.fullmatch(release_bootstrap_id) is None:
|
||||
raise ValueError("agent_release_bootstrap_id is invalid")
|
||||
source_root = source_root.resolve()
|
||||
validate_release_identity(
|
||||
source_root,
|
||||
release_id=agent_release_id,
|
||||
requested_agent_version=agent_version,
|
||||
)
|
||||
sources = _source_files(source_root, agent_release_id=agent_release_id)
|
||||
for name, source in sources.items():
|
||||
_validate_archive_name(name)
|
||||
if not source.is_file():
|
||||
raise FileNotFoundError(f"bootstrap source is absent: {source}")
|
||||
reference = sources["reference/lab065_reference_staircase.cs8"]
|
||||
if sha256_file(reference) != REFERENCE_SHA256:
|
||||
raise ValueError("staircase reference SHA256 is invalid")
|
||||
|
||||
ready = canonical_json_bytes(
|
||||
{
|
||||
"agent_version": agent_version,
|
||||
"bootstrap_id": release_bootstrap_id,
|
||||
"hardware_accessed": False,
|
||||
"protocol_version": protocol_version,
|
||||
"release_id": agent_release_id,
|
||||
"rf_tx": 0,
|
||||
"schema_version": 1,
|
||||
"status": "READY",
|
||||
}
|
||||
) + b"\n"
|
||||
ready_name = f"versions/{agent_release_id}/READY.json"
|
||||
rows = [
|
||||
{
|
||||
"path": name,
|
||||
"bytes": source.stat().st_size,
|
||||
"sha256": sha256_file(source),
|
||||
}
|
||||
for name, source in sources.items()
|
||||
]
|
||||
rows.append(
|
||||
{
|
||||
"path": ready_name,
|
||||
"bytes": len(ready),
|
||||
"sha256": hashlib.sha256(ready).hexdigest(),
|
||||
}
|
||||
)
|
||||
manifest: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"package_kind": "BOOTSTRAP",
|
||||
"bootstrap_id": bootstrap_id,
|
||||
"agent_release_bootstrap_id": release_bootstrap_id,
|
||||
"agent_release_id": agent_release_id,
|
||||
"agent_version": agent_version,
|
||||
"protocol_version": protocol_version,
|
||||
"main_server": "http://192.168.255.185:8765",
|
||||
"allowed_rx_ipv4": "192.168.255.56",
|
||||
"reference_sha256": REFERENCE_SHA256,
|
||||
"files": sorted(rows, key=lambda row: str(row["path"])),
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
manifest_raw = canonical_json_bytes(manifest)
|
||||
signature = hmac.new(
|
||||
_token_bytes(token_path),
|
||||
UPDATE_MANIFEST_DOMAIN + manifest_raw,
|
||||
hashlib.sha256,
|
||||
).hexdigest().encode("ascii") + b"\n"
|
||||
package_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if package_path.exists():
|
||||
raise FileExistsError(f"bootstrap package already exists: {package_path}")
|
||||
timestamp = (2026, 9, 29, 0, 0, 0)
|
||||
with zipfile.ZipFile(
|
||||
package_path,
|
||||
"x",
|
||||
compression=zipfile.ZIP_DEFLATED,
|
||||
compresslevel=9,
|
||||
) as archive:
|
||||
_write_bytes(
|
||||
archive, BOOTSTRAP_MANIFEST_NAME, manifest_raw, timestamp=timestamp
|
||||
)
|
||||
_write_bytes(
|
||||
archive, BOOTSTRAP_SIGNATURE_NAME, signature, timestamp=timestamp
|
||||
)
|
||||
_write_bytes(archive, ready_name, ready, timestamp=timestamp)
|
||||
for name in sorted(sources):
|
||||
_copy_to_zip(archive, name, sources[name], timestamp=timestamp)
|
||||
return inspect_bootstrap_archive(package_path, token_path=token_path)
|
||||
|
||||
|
||||
def _digest_stream(stream: BinaryIO) -> str:
|
||||
digest = hashlib.sha256()
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def inspect_bootstrap_archive(
|
||||
package_path: Path,
|
||||
*,
|
||||
token_path: Path,
|
||||
) -> BootstrapInspection:
|
||||
package_bytes = package_path.stat().st_size
|
||||
if package_bytes <= 0 or package_bytes > MAX_BOOTSTRAP_BYTES:
|
||||
raise ValueError("bootstrap package size is outside the limit")
|
||||
with zipfile.ZipFile(package_path, "r") as archive:
|
||||
infos: dict[str, zipfile.ZipInfo] = {}
|
||||
for info in archive.infolist():
|
||||
_validate_archive_name(info.filename)
|
||||
key = info.filename.casefold()
|
||||
if key in (name.casefold() for name in infos):
|
||||
raise ValueError("duplicate bootstrap archive entry")
|
||||
if info.is_dir() or info.flag_bits & 0x1:
|
||||
raise ValueError("bootstrap archive contains a prohibited entry")
|
||||
infos[info.filename] = info
|
||||
if BOOTSTRAP_MANIFEST_NAME not in infos or BOOTSTRAP_SIGNATURE_NAME not in infos:
|
||||
raise ValueError("bootstrap authentication metadata is absent")
|
||||
manifest_raw = archive.read(infos[BOOTSTRAP_MANIFEST_NAME])
|
||||
manifest = json.loads(manifest_raw.decode("utf-8"))
|
||||
if not isinstance(manifest, dict) or manifest_raw != canonical_json_bytes(manifest):
|
||||
raise ValueError("bootstrap manifest is not canonical JSON")
|
||||
supplied = archive.read(infos[BOOTSTRAP_SIGNATURE_NAME]).decode("ascii").strip()
|
||||
expected = hmac.new(
|
||||
_token_bytes(token_path),
|
||||
UPDATE_MANIFEST_DOMAIN + manifest_raw,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(supplied, expected):
|
||||
raise ValueError("bootstrap manifest signature mismatch")
|
||||
rows = manifest.get("files")
|
||||
if not isinstance(rows, list):
|
||||
raise ValueError("bootstrap file list is invalid")
|
||||
expected_names = {BOOTSTRAP_MANIFEST_NAME, BOOTSTRAP_SIGNATURE_NAME}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError("bootstrap file entry is invalid")
|
||||
name = row.get("path")
|
||||
if not isinstance(name, str):
|
||||
raise ValueError("bootstrap file path is invalid")
|
||||
_validate_archive_name(name)
|
||||
if name in expected_names or name not in infos:
|
||||
raise ValueError("bootstrap file set is invalid")
|
||||
expected_names.add(name)
|
||||
info = infos[name]
|
||||
if info.file_size != row.get("bytes"):
|
||||
raise ValueError(f"bootstrap file size mismatch: {name}")
|
||||
with archive.open(info, "r") as stream:
|
||||
digest = _digest_stream(stream)
|
||||
if digest != row.get("sha256"):
|
||||
raise ValueError(f"bootstrap file SHA256 mismatch: {name}")
|
||||
if set(infos) != expected_names:
|
||||
raise ValueError("bootstrap archive contains unexpected files")
|
||||
return BootstrapInspection(
|
||||
bootstrap_id=str(manifest["bootstrap_id"]),
|
||||
agent_release_id=str(manifest["agent_release_id"]),
|
||||
agent_version=str(manifest["agent_version"]),
|
||||
protocol_version=int(manifest["protocol_version"]),
|
||||
package_sha256=sha256_file(package_path),
|
||||
package_bytes=package_bytes,
|
||||
content_manifest_sha256=hashlib.sha256(manifest_raw).hexdigest(),
|
||||
file_count=len(rows),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
release = subparsers.add_parser("release")
|
||||
release.add_argument("--source-root", type=Path, required=True)
|
||||
release.add_argument("--package", type=Path, required=True)
|
||||
release.add_argument("--release-id", required=True)
|
||||
release.add_argument("--agent-version", required=True)
|
||||
release.add_argument("--protocol-version", type=int, default=1)
|
||||
bootstrap = subparsers.add_parser("bootstrap")
|
||||
bootstrap.add_argument("--source-root", type=Path, required=True)
|
||||
bootstrap.add_argument("--package", type=Path, required=True)
|
||||
bootstrap.add_argument("--bootstrap-id", required=True)
|
||||
bootstrap.add_argument("--agent-release-id", required=True)
|
||||
bootstrap.add_argument("--agent-version", required=True)
|
||||
bootstrap.add_argument("--agent-release-bootstrap-id")
|
||||
bootstrap.add_argument("--protocol-version", type=int, default=1)
|
||||
bootstrap.add_argument("--token-file", type=Path, required=True)
|
||||
arguments = parser.parse_args()
|
||||
if arguments.command == "release":
|
||||
inspection = build_release_archive(
|
||||
arguments.source_root,
|
||||
arguments.package,
|
||||
release_id=arguments.release_id,
|
||||
agent_version=arguments.agent_version,
|
||||
protocol_version=arguments.protocol_version,
|
||||
)
|
||||
else:
|
||||
inspection = build_bootstrap_archive(
|
||||
arguments.source_root,
|
||||
arguments.package,
|
||||
bootstrap_id=arguments.bootstrap_id,
|
||||
agent_release_id=arguments.agent_release_id,
|
||||
agent_version=arguments.agent_version,
|
||||
protocol_version=arguments.protocol_version,
|
||||
token_path=arguments.token_file,
|
||||
agent_release_bootstrap_id=arguments.agent_release_bootstrap_id,
|
||||
)
|
||||
print(f"PACKAGE = {arguments.package.resolve()}")
|
||||
print(f"PACKAGE SHA256 = {inspection.package_sha256}")
|
||||
print(f"PACKAGE BYTES = {inspection.package_bytes}")
|
||||
print("HARDWARE ACCESSED = false")
|
||||
print("RF TX = 0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
133
tools/rf001_capture_runner.py
Normal file
133
tools/rf001_capture_runner.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Run one finite RF-001 RX child and persist start/final evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def atomic_json(path: Path, value: Mapping[str, object]) -> None:
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def run_capture(
|
||||
executable: Path,
|
||||
arguments_path: Path,
|
||||
capture_path: Path,
|
||||
stdout_path: Path,
|
||||
stderr_path: Path,
|
||||
started_path: Path,
|
||||
result_path: Path,
|
||||
) -> int:
|
||||
arguments_value = json.loads(arguments_path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(arguments_value, list) or not all(
|
||||
isinstance(item, str) for item in arguments_value
|
||||
):
|
||||
raise ValueError("arguments JSON must be an array of strings")
|
||||
started_at = utc_now()
|
||||
child: subprocess.Popen[bytes] | None = None
|
||||
exit_code: int | None = None
|
||||
runner_error: str | None = None
|
||||
try:
|
||||
with stdout_path.open("xb") as stdout_stream, stderr_path.open(
|
||||
"xb"
|
||||
) as stderr_stream:
|
||||
child = subprocess.Popen(
|
||||
[str(executable), *arguments_value],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=stdout_stream,
|
||||
stderr=stderr_stream,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
close_fds=True,
|
||||
)
|
||||
atomic_json(
|
||||
started_path,
|
||||
{
|
||||
"state": "RUNNING",
|
||||
"pid": int(child.pid),
|
||||
"started_at": started_at,
|
||||
"capture_path": str(capture_path),
|
||||
"rf_tx": 0,
|
||||
},
|
||||
)
|
||||
exit_code = int(child.wait())
|
||||
except Exception as error: # persisted at the process boundary
|
||||
runner_error = f"{type(error).__name__}: {error}"
|
||||
finished_at = utc_now()
|
||||
capture_size = capture_path.stat().st_size if capture_path.is_file() else 0
|
||||
capture_hash: str | None = None
|
||||
if capture_path.is_file():
|
||||
try:
|
||||
capture_hash = sha256_file(capture_path)
|
||||
except OSError as error:
|
||||
detail = f"{type(error).__name__}: {error}"
|
||||
runner_error = detail if runner_error is None else f"{runner_error}; {detail}"
|
||||
duration = (
|
||||
datetime.fromisoformat(finished_at) - datetime.fromisoformat(started_at)
|
||||
).total_seconds()
|
||||
atomic_json(
|
||||
result_path,
|
||||
{
|
||||
"pid": int(child.pid) if child is not None else None,
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
"duration": duration,
|
||||
"exit_code": exit_code,
|
||||
"capture_path": str(capture_path),
|
||||
"capture_size": capture_size,
|
||||
"sha256": capture_hash,
|
||||
"runner_error": runner_error,
|
||||
"rf_tx": 0,
|
||||
},
|
||||
)
|
||||
return 255 if exit_code is None else exit_code
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--executable", required=True, type=Path)
|
||||
parser.add_argument("--arguments-json", required=True, type=Path)
|
||||
parser.add_argument("--capture-path", required=True, type=Path)
|
||||
parser.add_argument("--stdout-path", required=True, type=Path)
|
||||
parser.add_argument("--stderr-path", required=True, type=Path)
|
||||
parser.add_argument("--started-status", required=True, type=Path)
|
||||
parser.add_argument("--result-status", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
raise SystemExit(
|
||||
run_capture(
|
||||
arguments.executable,
|
||||
arguments.arguments_json,
|
||||
arguments.capture_path,
|
||||
arguments.stdout_path,
|
||||
arguments.stderr_path,
|
||||
arguments.started_status,
|
||||
arguments.result_status,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1805
tools/rf001_http_control.py
Normal file
1805
tools/rf001_http_control.py
Normal file
File diff suppressed because it is too large
Load Diff
742
tools/rf001_launcher_health_diagnostic.py
Normal file
742
tools/rf001_launcher_health_diagnostic.py
Normal file
@@ -0,0 +1,742 @@
|
||||
"""Isolated Windows diagnostic for the RF-001 launcher/health handshake.
|
||||
|
||||
The probe imports the production launcher and calls its ``start_release`` path,
|
||||
but replaces only the child command with this module's ``health-child`` mode.
|
||||
That child imports ``write_health`` from the immutable canonical release, writes
|
||||
one diagnostic health file, never polls HTTP, never touches SDR, and exits after
|
||||
the parent has preserved process evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from types import ModuleType
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
EXPECTED_STATE = "RUNNING"
|
||||
NO_HARDWARE = False
|
||||
NO_RF_TX = 0
|
||||
_ORIGINAL_POPEN = subprocess.Popen
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def parse_utc(value: object) -> datetime | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, value: Mapping[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"JSON object is required: {path}")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProcessRecord:
|
||||
pid: int
|
||||
parent_pid: int
|
||||
name: str | None
|
||||
executable: str | None
|
||||
command_line: str | None
|
||||
created_at: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HandshakeExpectation:
|
||||
release_id: str
|
||||
agent_version: str
|
||||
launch_nonce: str
|
||||
state: str
|
||||
launcher_pid: int
|
||||
popen_pid: int
|
||||
launched_at: str
|
||||
diagnostic_script: str
|
||||
|
||||
|
||||
def _powershell_json(script: str) -> object:
|
||||
encoded = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||
process = _ORIGINAL_POPEN(
|
||||
[
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-EncodedCommand",
|
||||
encoded,
|
||||
],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=False,
|
||||
)
|
||||
stdout, stderr = process.communicate(timeout=20)
|
||||
if process.returncode != 0:
|
||||
detail = stderr.decode("utf-8", errors="replace").strip()
|
||||
raise RuntimeError(f"Windows process inventory failed: {detail}")
|
||||
text = stdout.decode("utf-8-sig", errors="strict").strip()
|
||||
if not text:
|
||||
return []
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def capture_windows_processes() -> list[ProcessRecord]:
|
||||
if os.name != "nt":
|
||||
raise RuntimeError("Windows process inventory is required")
|
||||
script = r'''
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
|
||||
$rows = @(Get-CimInstance Win32_Process | ForEach-Object {
|
||||
$created = $null
|
||||
if ($null -ne $_.CreationDate) {
|
||||
$created = $_.CreationDate.ToUniversalTime().ToString("o")
|
||||
}
|
||||
[ordered]@{
|
||||
pid = [int]$_.ProcessId
|
||||
parent_pid = [int]$_.ParentProcessId
|
||||
name = $_.Name
|
||||
executable = $_.ExecutablePath
|
||||
command_line = $_.CommandLine
|
||||
created_at = $created
|
||||
}
|
||||
})
|
||||
ConvertTo-Json -InputObject $rows -Depth 4 -Compress
|
||||
'''
|
||||
raw = _powershell_json(script)
|
||||
if isinstance(raw, dict):
|
||||
raw = [raw]
|
||||
if not isinstance(raw, list):
|
||||
raise ValueError("Windows process inventory is not a list")
|
||||
records: list[ProcessRecord] = []
|
||||
for row in raw:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
records.append(
|
||||
ProcessRecord(
|
||||
pid=int(row["pid"]),
|
||||
parent_pid=int(row["parent_pid"]),
|
||||
name=row.get("name") if isinstance(row.get("name"), str) else None,
|
||||
executable=(
|
||||
row.get("executable")
|
||||
if isinstance(row.get("executable"), str)
|
||||
else None
|
||||
),
|
||||
command_line=(
|
||||
row.get("command_line")
|
||||
if isinstance(row.get("command_line"), str)
|
||||
else None
|
||||
),
|
||||
created_at=(
|
||||
row.get("created_at")
|
||||
if isinstance(row.get("created_at"), str)
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
return records
|
||||
|
||||
|
||||
def process_map(records: Sequence[ProcessRecord]) -> dict[int, ProcessRecord]:
|
||||
return {record.pid: record for record in records}
|
||||
|
||||
|
||||
def is_descendant(
|
||||
child_pid: int,
|
||||
ancestor_pid: int,
|
||||
records: Sequence[ProcessRecord],
|
||||
) -> bool:
|
||||
by_pid = process_map(records)
|
||||
current = by_pid.get(child_pid)
|
||||
visited: set[int] = set()
|
||||
while current is not None and current.pid not in visited:
|
||||
if current.parent_pid == ancestor_pid:
|
||||
return True
|
||||
visited.add(current.pid)
|
||||
current = by_pid.get(current.parent_pid)
|
||||
return False
|
||||
|
||||
|
||||
def _record_is_fresh(record: ProcessRecord, launched_at: datetime) -> bool:
|
||||
created_at = parse_utc(record.created_at)
|
||||
if created_at is None:
|
||||
return False
|
||||
return created_at >= launched_at
|
||||
|
||||
|
||||
def _record_matches_launch(
|
||||
record: ProcessRecord,
|
||||
expectation: HandshakeExpectation,
|
||||
) -> bool:
|
||||
command_line = record.command_line or ""
|
||||
return (
|
||||
expectation.launch_nonce in command_line
|
||||
and expectation.diagnostic_script.casefold() in command_line.casefold()
|
||||
)
|
||||
|
||||
|
||||
def compare_health_fields(
|
||||
health: Mapping[str, Any] | None,
|
||||
expectation: HandshakeExpectation,
|
||||
) -> dict[str, bool]:
|
||||
if health is None:
|
||||
return {
|
||||
"release_id_match": False,
|
||||
"agent_version_match": False,
|
||||
"launch_nonce_match": False,
|
||||
"state_match": False,
|
||||
"pid_match": False,
|
||||
}
|
||||
return {
|
||||
"release_id_match": health.get("release_id") == expectation.release_id,
|
||||
"agent_version_match": health.get("agent_version")
|
||||
== expectation.agent_version,
|
||||
"launch_nonce_match": health.get("launch_nonce")
|
||||
== expectation.launch_nonce,
|
||||
"state_match": health.get("state") == expectation.state,
|
||||
"pid_match": health.get("pid") == expectation.popen_pid,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_identity(
|
||||
health: Mapping[str, Any] | None,
|
||||
expectation: HandshakeExpectation,
|
||||
records: Sequence[ProcessRecord],
|
||||
*,
|
||||
popen_alive: bool,
|
||||
health_process_alive: bool,
|
||||
) -> dict[str, Any]:
|
||||
fields = compare_health_fields(health, expectation)
|
||||
result: dict[str, Any] = {
|
||||
**fields,
|
||||
"identity_valid": False,
|
||||
"identity_model": "REJECTED",
|
||||
"reason": None,
|
||||
}
|
||||
if health is None:
|
||||
result["reason"] = "HEALTH_ABSENT"
|
||||
return result
|
||||
if not all(
|
||||
fields[name]
|
||||
for name in (
|
||||
"release_id_match",
|
||||
"agent_version_match",
|
||||
"launch_nonce_match",
|
||||
"state_match",
|
||||
)
|
||||
):
|
||||
result["reason"] = "HEALTH_FIELDS_MISMATCH"
|
||||
return result
|
||||
updated_at = parse_utc(health.get("updated_at"))
|
||||
launched_at = parse_utc(expectation.launched_at)
|
||||
if updated_at is None or launched_at is None or updated_at < launched_at:
|
||||
result["reason"] = "STALE_HEALTH"
|
||||
return result
|
||||
health_pid = health.get("pid")
|
||||
if type(health_pid) is not int:
|
||||
result["reason"] = "HEALTH_PID_INVALID"
|
||||
return result
|
||||
by_pid = process_map(records)
|
||||
health_record = by_pid.get(health_pid)
|
||||
if health_record is None:
|
||||
result["reason"] = "HEALTH_PROCESS_ABSENT"
|
||||
return result
|
||||
if not health_process_alive:
|
||||
result["reason"] = "HEALTH_PROCESS_DIED"
|
||||
return result
|
||||
if not _record_is_fresh(health_record, launched_at):
|
||||
result["reason"] = "PROCESS_CREATION_TIME_MISMATCH"
|
||||
return result
|
||||
if not _record_matches_launch(health_record, expectation):
|
||||
result["reason"] = "PROCESS_COMMAND_MISMATCH"
|
||||
return result
|
||||
|
||||
if health_pid == expectation.popen_pid:
|
||||
if not popen_alive:
|
||||
result["reason"] = "TARGET_DIED"
|
||||
return result
|
||||
result.update(
|
||||
identity_valid=True,
|
||||
identity_model="EXACT_POPEN_PROCESS",
|
||||
reason="PASS",
|
||||
)
|
||||
return result
|
||||
|
||||
popen_record = by_pid.get(expectation.popen_pid)
|
||||
if popen_record is None:
|
||||
result["reason"] = "POPEN_PROCESS_NOT_CAPTURED"
|
||||
return result
|
||||
direct_children = [
|
||||
record
|
||||
for record in records
|
||||
if record.parent_pid == expectation.popen_pid
|
||||
and _record_is_fresh(record, launched_at)
|
||||
and _record_matches_launch(record, expectation)
|
||||
]
|
||||
if health_record.parent_pid != expectation.popen_pid:
|
||||
if is_descendant(health_pid, expectation.popen_pid, records):
|
||||
result["reason"] = "UNEXPECTED_GRANDCHILD"
|
||||
else:
|
||||
result["reason"] = "UNRELATED_PID"
|
||||
return result
|
||||
if len(direct_children) != 1 or direct_children[0].pid != health_pid:
|
||||
result["reason"] = "AMBIGUOUS_DIRECT_CHILD"
|
||||
return result
|
||||
if not _record_matches_launch(popen_record, expectation):
|
||||
result["reason"] = "POPEN_COMMAND_MISMATCH"
|
||||
return result
|
||||
result.update(
|
||||
identity_valid=True,
|
||||
identity_model="UNIQUE_DIRECT_CHILD_WITH_NONCE_AND_CREATION_TIME",
|
||||
reason="PASS",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def strict_owned_process_ids(
|
||||
expectation: HandshakeExpectation,
|
||||
records: Sequence[ProcessRecord],
|
||||
) -> list[int]:
|
||||
launched_at = parse_utc(expectation.launched_at)
|
||||
if launched_at is None:
|
||||
return []
|
||||
result: list[int] = []
|
||||
for record in records:
|
||||
if record.pid == expectation.launcher_pid:
|
||||
continue
|
||||
if not _record_is_fresh(record, launched_at):
|
||||
continue
|
||||
if not _record_matches_launch(record, expectation):
|
||||
continue
|
||||
if record.pid == expectation.popen_pid or is_descendant(
|
||||
record.pid, expectation.popen_pid, records
|
||||
):
|
||||
result.append(record.pid)
|
||||
return sorted(set(result), reverse=True)
|
||||
|
||||
|
||||
def relevant_process_tree(
|
||||
records: Sequence[ProcessRecord],
|
||||
expectation: HandshakeExpectation,
|
||||
health_pid: int | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
by_pid = process_map(records)
|
||||
selected: set[int] = {expectation.launcher_pid, expectation.popen_pid}
|
||||
if health_pid is not None:
|
||||
selected.add(health_pid)
|
||||
for record in records:
|
||||
if is_descendant(record.pid, expectation.popen_pid, records):
|
||||
selected.add(record.pid)
|
||||
for seed in tuple(selected):
|
||||
current = by_pid.get(seed)
|
||||
while current is not None and current.parent_pid not in selected:
|
||||
if current.parent_pid == 0:
|
||||
break
|
||||
selected.add(current.parent_pid)
|
||||
current = by_pid.get(current.parent_pid)
|
||||
return [asdict(by_pid[pid]) for pid in sorted(selected) if pid in by_pid]
|
||||
|
||||
|
||||
def _load_module(path: Path, name: str) -> ModuleType:
|
||||
specification = importlib.util.spec_from_file_location(name, path)
|
||||
if specification is None or specification.loader is None:
|
||||
raise ImportError(f"Could not load Python module: {path}")
|
||||
module = importlib.util.module_from_spec(specification)
|
||||
sys.modules[name] = module
|
||||
specification.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def run_health_child(arguments: argparse.Namespace) -> int:
|
||||
agent_source = arguments.agent_source.resolve()
|
||||
agent = _load_module(agent_source, f"rf001_diag_agent_{os.getpid()}")
|
||||
namespace = argparse.Namespace(
|
||||
health_file=arguments.health_file.resolve(),
|
||||
release_id=arguments.release_id,
|
||||
launch_nonce=arguments.launch_nonce,
|
||||
)
|
||||
child_report = arguments.child_report.resolve()
|
||||
started_at = utc_now()
|
||||
atomic_write_json(
|
||||
child_report,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"phase": "BEFORE_HEALTH",
|
||||
"pid": os.getpid(),
|
||||
"parent_pid": os.getppid(),
|
||||
"sys_executable": sys.executable,
|
||||
"command_line": list(sys.argv),
|
||||
"started_at": started_at,
|
||||
"network_task_polling": False,
|
||||
"hardware_accessed": NO_HARDWARE,
|
||||
"rf_tx": NO_RF_TX,
|
||||
},
|
||||
)
|
||||
agent.write_health(namespace, None)
|
||||
health_written_at = utc_now()
|
||||
atomic_write_json(
|
||||
child_report,
|
||||
{
|
||||
"schema_version": 1,
|
||||
"phase": "HEALTH_WRITTEN",
|
||||
"pid": os.getpid(),
|
||||
"parent_pid": os.getppid(),
|
||||
"sys_executable": sys.executable,
|
||||
"command_line": list(sys.argv),
|
||||
"started_at": started_at,
|
||||
"health_written_at": health_written_at,
|
||||
"network_task_polling": False,
|
||||
"hardware_accessed": NO_HARDWARE,
|
||||
"rf_tx": NO_RF_TX,
|
||||
},
|
||||
)
|
||||
time.sleep(arguments.hold_seconds)
|
||||
return 0
|
||||
|
||||
|
||||
def _process_is_present(pid: int, records: Sequence[ProcessRecord]) -> bool:
|
||||
return any(record.pid == pid for record in records)
|
||||
|
||||
|
||||
def _terminate_owned_processes(
|
||||
expectation: HandshakeExpectation,
|
||||
records: Sequence[ProcessRecord],
|
||||
) -> list[dict[str, Any]]:
|
||||
actions: list[dict[str, Any]] = []
|
||||
for pid in strict_owned_process_ids(expectation, records):
|
||||
fresh = capture_windows_processes()
|
||||
current = process_map(fresh).get(pid)
|
||||
original = process_map(records).get(pid)
|
||||
if current is None or original is None:
|
||||
continue
|
||||
if current.created_at != original.created_at or not _record_matches_launch(
|
||||
current, expectation
|
||||
):
|
||||
actions.append({"pid": pid, "status": "IDENTITY_CHANGED_NOT_TERMINATED"})
|
||||
continue
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
actions.append({"pid": pid, "status": "ALREADY_EXITED"})
|
||||
else:
|
||||
actions.append({"pid": pid, "status": "TERMINATED"})
|
||||
return actions
|
||||
|
||||
|
||||
def run_probe_once(
|
||||
launcher: ModuleType,
|
||||
*,
|
||||
canonical_release: Path,
|
||||
output_directory: Path,
|
||||
startup_timeout: float,
|
||||
hold_seconds: float,
|
||||
) -> dict[str, Any]:
|
||||
output_directory.mkdir(parents=True, exist_ok=False)
|
||||
ready = read_json(canonical_release / "READY.json")
|
||||
release_id = str(ready["release_id"])
|
||||
agent_version = str(ready["agent_version"])
|
||||
agent_source = canonical_release / "app" / "tools" / "rf001_rx_http_agent.py"
|
||||
health_path = output_directory / "diagnostic_health.json"
|
||||
child_report_path = output_directory / "diagnostic_child.json"
|
||||
config: dict[str, object] = {
|
||||
"install_root": str(output_directory / "isolated_install_root"),
|
||||
"releases_root": str(canonical_release.parent),
|
||||
"active_release_file": str(output_directory / "unused_active_release.json"),
|
||||
"launcher_status": str(output_directory / "unused_launcher_status.json"),
|
||||
"launcher_request": str(output_directory / "unused_launcher_request.json"),
|
||||
"health_file": str(health_path),
|
||||
"agent_log": str(output_directory / "diagnostic_child.log"),
|
||||
"startup_timeout_seconds": startup_timeout,
|
||||
"agent_arguments": [],
|
||||
}
|
||||
captured: dict[str, Any] = {}
|
||||
process_ready = threading.Event()
|
||||
health_observed = threading.Event()
|
||||
observer_snapshot: list[ProcessRecord] = []
|
||||
original_command = launcher._agent_command
|
||||
original_spawn_agent = launcher._spawn_agent
|
||||
launcher_pid = os.getpid()
|
||||
launched_at = utc_now()
|
||||
|
||||
def diagnostic_command(
|
||||
_config: Mapping[str, Any],
|
||||
actual_agent_source: Path,
|
||||
actual_release_id: str,
|
||||
launch_nonce: str,
|
||||
) -> list[str]:
|
||||
captured["launch_nonce"] = launch_nonce
|
||||
command = [
|
||||
sys.executable,
|
||||
"-u",
|
||||
"-B",
|
||||
"-X",
|
||||
"utf8",
|
||||
str(Path(__file__).resolve()),
|
||||
"health-child",
|
||||
"--agent-source",
|
||||
str(actual_agent_source),
|
||||
"--release-id",
|
||||
actual_release_id,
|
||||
"--launch-nonce",
|
||||
launch_nonce,
|
||||
"--health-file",
|
||||
str(health_path),
|
||||
"--child-report",
|
||||
str(child_report_path),
|
||||
"--hold-seconds",
|
||||
str(hold_seconds),
|
||||
]
|
||||
captured["command"] = command
|
||||
return command
|
||||
|
||||
def recording_spawn_agent(
|
||||
command: Sequence[str],
|
||||
*,
|
||||
cwd: Path,
|
||||
log_stream: Any,
|
||||
) -> subprocess.Popen[str]:
|
||||
process = original_spawn_agent(command, cwd=cwd, log_stream=log_stream)
|
||||
captured["process"] = process
|
||||
captured["popen_pid"] = process.pid
|
||||
captured["popen_created_at"] = utc_now()
|
||||
process_ready.set()
|
||||
return process
|
||||
|
||||
def observer() -> None:
|
||||
nonlocal observer_snapshot
|
||||
if not process_ready.wait(timeout=10):
|
||||
return
|
||||
deadline = time.monotonic() + max(10.0, startup_timeout + 5.0)
|
||||
while time.monotonic() < deadline:
|
||||
if health_path.exists() and child_report_path.exists():
|
||||
observer_snapshot = capture_windows_processes()
|
||||
health_observed.set()
|
||||
return
|
||||
time.sleep(0.02)
|
||||
|
||||
observer_thread = threading.Thread(target=observer, name="rf001-health-observer")
|
||||
observer_thread.start()
|
||||
launcher._agent_command = diagnostic_command
|
||||
launcher._spawn_agent = recording_spawn_agent
|
||||
start_result = "UNKNOWN"
|
||||
start_error: str | None = None
|
||||
returned_process: subprocess.Popen[str] | None = None
|
||||
try:
|
||||
returned_process, returned_version = launcher.start_release(
|
||||
config,
|
||||
release_id,
|
||||
expected_version=agent_version,
|
||||
)
|
||||
start_result = "PASS"
|
||||
captured["returned_version"] = returned_version
|
||||
except Exception as error:
|
||||
start_result = "FAIL"
|
||||
start_error = f"{type(error).__name__}: {error}"
|
||||
finally:
|
||||
launcher._agent_command = original_command
|
||||
launcher._spawn_agent = original_spawn_agent
|
||||
observer_thread.join(timeout=15)
|
||||
|
||||
popen_pid = int(captured.get("popen_pid", -1))
|
||||
launch_nonce = str(captured.get("launch_nonce", ""))
|
||||
expectation = HandshakeExpectation(
|
||||
release_id=release_id,
|
||||
agent_version=agent_version,
|
||||
launch_nonce=launch_nonce,
|
||||
state=EXPECTED_STATE,
|
||||
launcher_pid=launcher_pid,
|
||||
popen_pid=popen_pid,
|
||||
launched_at=launched_at,
|
||||
diagnostic_script=str(Path(__file__).resolve()),
|
||||
)
|
||||
health = read_json(health_path) if health_path.exists() else None
|
||||
child_report = read_json(child_report_path) if child_report_path.exists() else None
|
||||
evidence_snapshot = observer_snapshot or capture_windows_processes()
|
||||
health_pid = health.get("pid") if isinstance(health, dict) else None
|
||||
typed_health_pid = health_pid if type(health_pid) is int else None
|
||||
popen_process = captured.get("process")
|
||||
popen_alive_at_evaluation = (
|
||||
isinstance(popen_process, subprocess.Popen) and popen_process.poll() is None
|
||||
)
|
||||
health_process_alive = (
|
||||
typed_health_pid is not None
|
||||
and _process_is_present(typed_health_pid, evidence_snapshot)
|
||||
)
|
||||
evaluation = evaluate_identity(
|
||||
health,
|
||||
expectation,
|
||||
evidence_snapshot,
|
||||
popen_alive=popen_alive_at_evaluation,
|
||||
health_process_alive=health_process_alive,
|
||||
)
|
||||
post_failure_snapshot = capture_windows_processes()
|
||||
cleanup_actions = _terminate_owned_processes(expectation, post_failure_snapshot)
|
||||
deadline = time.monotonic() + 5.0
|
||||
final_snapshot = capture_windows_processes()
|
||||
while strict_owned_process_ids(expectation, final_snapshot) and time.monotonic() < deadline:
|
||||
time.sleep(0.1)
|
||||
final_snapshot = capture_windows_processes()
|
||||
orphan_pids = strict_owned_process_ids(expectation, final_snapshot)
|
||||
health_created_at = None
|
||||
if health_path.exists():
|
||||
health_created_at = datetime.fromtimestamp(
|
||||
health_path.stat().st_ctime,
|
||||
tz=timezone.utc,
|
||||
).isoformat()
|
||||
report: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"mode": "ISOLATED_LAUNCHER_HEALTH_DIAGNOSTIC",
|
||||
"launcher_source": str(Path(launcher.__file__).resolve()),
|
||||
"canonical_release": str(canonical_release.resolve()),
|
||||
"canonical_release_id": release_id,
|
||||
"canonical_agent_version": agent_version,
|
||||
"launcher_pid": launcher_pid,
|
||||
"launcher_executable": sys.executable,
|
||||
"launcher_command_line": list(sys.argv),
|
||||
"popen_pid": popen_pid,
|
||||
"popen_command": captured.get("command"),
|
||||
"popen_created_at": captured.get("popen_created_at"),
|
||||
"launch_nonce": launch_nonce,
|
||||
"launched_at": launched_at,
|
||||
"start_release_result": start_result,
|
||||
"start_release_error": start_error,
|
||||
"health_observed_before_cleanup": health_observed.is_set(),
|
||||
"health_path": str(health_path.resolve()),
|
||||
"health_file_created_at": health_created_at,
|
||||
"health": health,
|
||||
"child_report": child_report,
|
||||
"field_comparison": compare_health_fields(health, expectation),
|
||||
"strict_identity_evaluation": evaluation,
|
||||
"process_tree_at_health": relevant_process_tree(
|
||||
evidence_snapshot,
|
||||
expectation,
|
||||
typed_health_pid,
|
||||
),
|
||||
"popen_alive_at_evaluation": popen_alive_at_evaluation,
|
||||
"health_process_alive_at_evaluation": health_process_alive,
|
||||
"cleanup_actions": cleanup_actions,
|
||||
"orphan_pids_after_cleanup": orphan_pids,
|
||||
"network_task_polling": False,
|
||||
"hardware_accessed": NO_HARDWARE,
|
||||
"rf_tx": NO_RF_TX,
|
||||
"completed_at": utc_now(),
|
||||
}
|
||||
atomic_write_json(output_directory / "diagnostic_report.json", report)
|
||||
return report
|
||||
|
||||
|
||||
def run_probe(arguments: argparse.Namespace) -> int:
|
||||
canonical_release = arguments.canonical_release.resolve()
|
||||
launcher_source = arguments.launcher_source.resolve()
|
||||
output_root = arguments.output_root.resolve()
|
||||
if output_root.exists():
|
||||
raise FileExistsError(f"diagnostic output root already exists: {output_root}")
|
||||
output_root.mkdir(parents=True)
|
||||
launcher = _load_module(launcher_source, "rf001_diagnostic_launcher")
|
||||
reports = []
|
||||
for index in range(1, arguments.runs + 1):
|
||||
reports.append(
|
||||
run_probe_once(
|
||||
launcher,
|
||||
canonical_release=canonical_release,
|
||||
output_directory=output_root / f"run_{index:02d}",
|
||||
startup_timeout=arguments.startup_timeout,
|
||||
hold_seconds=arguments.hold_seconds,
|
||||
)
|
||||
)
|
||||
nonces = [str(report["launch_nonce"]) for report in reports]
|
||||
summary = {
|
||||
"schema_version": 1,
|
||||
"mode": "ISOLATED_LAUNCHER_HEALTH_DIAGNOSTIC",
|
||||
"runs": reports,
|
||||
"run_count": len(reports),
|
||||
"nonces_unique": len(nonces) == len(set(nonces)),
|
||||
"orphan_pids_after_test": sorted(
|
||||
{
|
||||
pid
|
||||
for report in reports
|
||||
for pid in report["orphan_pids_after_cleanup"]
|
||||
}
|
||||
),
|
||||
"network_task_polling": False,
|
||||
"hardware_accessed": NO_HARDWARE,
|
||||
"rf_tx": NO_RF_TX,
|
||||
"completed_at": utc_now(),
|
||||
}
|
||||
atomic_write_json(output_root / "diagnostic_summary.json", summary)
|
||||
print(f"DIAGNOSTIC_REPORT={output_root / 'diagnostic_summary.json'}")
|
||||
print(f"RUNS={len(reports)}")
|
||||
print(f"NONCES_UNIQUE={summary['nonces_unique']}")
|
||||
print(f"ORPHAN_PIDS={summary['orphan_pids_after_test']}")
|
||||
print("NETWORK_TASK_POLLING=false")
|
||||
print("HARDWARE_ACCESSED=false")
|
||||
print("RF_TX=0")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
child = subparsers.add_parser("health-child")
|
||||
child.add_argument("--agent-source", required=True, type=Path)
|
||||
child.add_argument("--release-id", required=True)
|
||||
child.add_argument("--launch-nonce", required=True)
|
||||
child.add_argument("--health-file", required=True, type=Path)
|
||||
child.add_argument("--child-report", required=True, type=Path)
|
||||
child.add_argument("--hold-seconds", required=True, type=float)
|
||||
probe = subparsers.add_parser("probe")
|
||||
probe.add_argument("--launcher-source", required=True, type=Path)
|
||||
probe.add_argument("--canonical-release", required=True, type=Path)
|
||||
probe.add_argument("--output-root", required=True, type=Path)
|
||||
probe.add_argument("--runs", type=int, default=2)
|
||||
probe.add_argument("--startup-timeout", type=float, default=2.0)
|
||||
probe.add_argument("--hold-seconds", type=float, default=10.0)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
arguments = build_parser().parse_args()
|
||||
if arguments.command == "health-child":
|
||||
raise SystemExit(run_health_child(arguments))
|
||||
if arguments.runs < 1 or arguments.runs > 4:
|
||||
raise ValueError("runs must be 1 to 4")
|
||||
if arguments.startup_timeout <= 0 or arguments.hold_seconds <= 0:
|
||||
raise ValueError("diagnostic timeouts must be positive")
|
||||
raise SystemExit(run_probe(arguments))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
86
tools/rf001_offline_decode.py
Normal file
86
tools/rf001_offline_decode.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Fixed software-only RF-001A decoder used by the HTTP RX agent."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
Decoder = Callable[..., dict[str, Any]]
|
||||
|
||||
|
||||
def decode_offline(
|
||||
repository: Path,
|
||||
iq_path: Path,
|
||||
truth_path: Path,
|
||||
report_path: Path,
|
||||
*,
|
||||
capture_started_at: str | None = None,
|
||||
tx_started_at: str | None = None,
|
||||
tx_finished_at: str | None = None,
|
||||
decoder: Decoder | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Decode one generated cs8 file with the dependency-free legacy backend."""
|
||||
|
||||
repository = repository.resolve()
|
||||
if not repository.is_dir():
|
||||
raise FileNotFoundError(f"repository is absent: {repository}")
|
||||
for required in (iq_path, truth_path):
|
||||
if not required.is_file():
|
||||
raise FileNotFoundError(f"required input is absent: {required}")
|
||||
if report_path.exists():
|
||||
raise FileExistsError(f"report already exists: {report_path}")
|
||||
|
||||
if decoder is None:
|
||||
sys.path.insert(0, str(repository))
|
||||
from tools.qpsk_rf_rx import decode_file
|
||||
|
||||
decoder = decode_file
|
||||
result = decoder(
|
||||
iq_path,
|
||||
iq_format="cs8",
|
||||
truth_metadata_path=truth_path,
|
||||
decoder_backend="legacy",
|
||||
capture_started_at=capture_started_at,
|
||||
tx_started_at=tx_started_at,
|
||||
tx_finished_at=tx_finished_at,
|
||||
)
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(
|
||||
json.dumps(result, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", required=True, type=Path)
|
||||
parser.add_argument("--iq-file", required=True, type=Path)
|
||||
parser.add_argument("--truth-metadata", required=True, type=Path)
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
parser.add_argument("--capture-started-at")
|
||||
parser.add_argument("--tx-started-at")
|
||||
parser.add_argument("--tx-finished-at")
|
||||
arguments = parser.parse_args()
|
||||
result = decode_offline(
|
||||
arguments.repository,
|
||||
arguments.iq_file,
|
||||
arguments.truth_metadata,
|
||||
arguments.report,
|
||||
capture_started_at=arguments.capture_started_at,
|
||||
tx_started_at=arguments.tx_started_at,
|
||||
tx_finished_at=arguments.tx_finished_at,
|
||||
)
|
||||
print(
|
||||
"RF-001 OFFLINE RX: "
|
||||
f"CRC={result['crc_valid_packets']}; "
|
||||
f"exact={result['exact_packets']}; RF TX=0"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
684
tools/rf001_rx_capture.py
Normal file
684
tools/rf001_rx_capture.py
Normal file
@@ -0,0 +1,684 @@
|
||||
"""Fixed, receive-only RF-001A capture operations for the HTTP agent.
|
||||
|
||||
This module has no transmit operation. A capture uses one immutable RF profile,
|
||||
one known RX serial number, a unique directory outside Git, and one finite
|
||||
``hackrf_transfer`` child process. Failed readiness checks stop that exact
|
||||
process tree and never retry the capture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
|
||||
FIXED_RX_SERIAL = "000000000000000097c063dc2e5327d3"
|
||||
PROFILE_NAME = "RF001A_COMMON_CLOCK_001"
|
||||
MINIMUM_FREE_BYTES = 6_000_000_000
|
||||
READINESS_TIMEOUT_SECONDS = 5.0
|
||||
CAPTURE_SECONDS = 300
|
||||
CAPTURE_SAMPLES = 2_400_000_000
|
||||
CAPTURE_FILE_NAME = "rf001a_ota_capture.cs8"
|
||||
MANIFEST_FILE_NAME = "capture_manifest.json"
|
||||
STARTED_FILE_NAME = "capture_started.json"
|
||||
RESULT_FILE_NAME = "capture_result.json"
|
||||
REPORT_FILE_NAME = "offline_decode_report.json"
|
||||
MAX_REPORT_BYTES = 192 * 1024
|
||||
|
||||
EXPECTED_PROFILE: dict[str, object] = {
|
||||
"test_id": PROFILE_NAME,
|
||||
"center_frequency_hz": 1_400_000_000,
|
||||
"sample_rate_hz": 8_000_000,
|
||||
"symbol_rate_sps": 500_000,
|
||||
"modulation": "QPSK",
|
||||
"inner_fec": "2/3",
|
||||
"application_payload_bytes": 256,
|
||||
"packet_count": 100,
|
||||
"inter_packet_gap_ms": 5,
|
||||
"clock_mode": "COMMON_10_MHZ",
|
||||
"rx_lna_db": 24,
|
||||
"rx_vga_db": 24,
|
||||
"rf_amp_enabled": False,
|
||||
"antenna_bias_enabled": False,
|
||||
"hardware_format": "cs8",
|
||||
"rx_capture_seconds": 20,
|
||||
"rx_capture_samples": 160_000_000,
|
||||
}
|
||||
|
||||
CommandRunner = Callable[[Sequence[str], float], Any]
|
||||
BackgroundLauncher = Callable[[Sequence[str]], int]
|
||||
ProcessGate = Callable[[], tuple[bool, Mapping[str, object]]]
|
||||
ProcessStopper = Callable[[int], None]
|
||||
|
||||
|
||||
def _atomic_json(path: Path, value: Mapping[str, object]) -> None:
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"JSON object required: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def load_fixed_profile(repository: Path) -> dict[str, object]:
|
||||
path = repository / "configs" / "rf" / "rf001a_common_clock.json"
|
||||
profile = _read_json(path)
|
||||
mismatches = {
|
||||
key: {"expected": expected, "actual": profile.get(key)}
|
||||
for key, expected in EXPECTED_PROFILE.items()
|
||||
if profile.get(key) != expected
|
||||
}
|
||||
if mismatches:
|
||||
raise ValueError(f"RF-001A capture profile mismatch: {mismatches}")
|
||||
return {key: profile[key] for key in EXPECTED_PROFILE}
|
||||
|
||||
|
||||
def resolve_hackrf_tools(
|
||||
hackrf_info_path: Path | None,
|
||||
hackrf_clock_path: Path | None,
|
||||
hackrf_transfer_path: Path | None,
|
||||
) -> tuple[Path, Path, Path]:
|
||||
if hackrf_info_path is None:
|
||||
raise FileNotFoundError("hackrf_info path is required for RX capture")
|
||||
base = hackrf_info_path.parent
|
||||
info = hackrf_info_path
|
||||
clock = hackrf_clock_path or base / "hackrf_clock.exe"
|
||||
transfer = hackrf_transfer_path or base / "hackrf_transfer.exe"
|
||||
for path in (info, clock, transfer):
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"required HackRF tool is absent: {path}")
|
||||
return info, clock, transfer
|
||||
|
||||
|
||||
def default_process_gate() -> tuple[bool, Mapping[str, object]]:
|
||||
from tools.hackrf_process_check import ProcessState, check_processes
|
||||
|
||||
check = check_processes()
|
||||
return check.state is ProcessState.CLEAR, check.to_dict()
|
||||
|
||||
|
||||
def default_background_launcher(arguments: Sequence[str]) -> int:
|
||||
flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(
|
||||
subprocess, "CREATE_NO_WINDOW", 0
|
||||
)
|
||||
process = subprocess.Popen(
|
||||
list(arguments),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=flags,
|
||||
close_fds=True,
|
||||
)
|
||||
return int(process.pid)
|
||||
|
||||
|
||||
def default_process_stopper(pid: int) -> None:
|
||||
completed = subprocess.run(
|
||||
["taskkill.exe", "/PID", str(pid), "/T", "/F"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=15,
|
||||
check=False,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
if completed.returncode not in {0, 128}:
|
||||
raise RuntimeError(
|
||||
f"taskkill failed for pid {pid}: {completed.returncode}: "
|
||||
f"{completed.stderr.strip()}"
|
||||
)
|
||||
|
||||
|
||||
def _iso_utc(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp must contain a timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _bounded_command(result: Any) -> dict[str, object]:
|
||||
return {
|
||||
"arguments": list(result.arguments),
|
||||
"returncode": int(result.returncode),
|
||||
"stdout": str(result.stdout)[-8_000:],
|
||||
"stderr": str(result.stderr)[-8_000:],
|
||||
}
|
||||
|
||||
|
||||
def start_rx_capture(
|
||||
repository: Path,
|
||||
work_root: Path,
|
||||
session_id: str,
|
||||
rx_serial: str,
|
||||
*,
|
||||
hackrf_info_path: Path | None,
|
||||
hackrf_clock_path: Path | None,
|
||||
hackrf_transfer_path: Path | None,
|
||||
command_runner: CommandRunner,
|
||||
process_gate: ProcessGate = default_process_gate,
|
||||
background_launcher: BackgroundLauncher = default_background_launcher,
|
||||
process_stopper: ProcessStopper = default_process_stopper,
|
||||
sleep: Callable[[float], None] = time.sleep,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> tuple[dict[str, object], list[str]]:
|
||||
"""Start one finite RX capture and return only after the READY gate."""
|
||||
|
||||
errors: list[str] = []
|
||||
result: dict[str, object] = {
|
||||
"session_id": session_id,
|
||||
"state": "NOT_READY",
|
||||
"hardware_accessed": False,
|
||||
"common_clock_verified": False,
|
||||
"rf_rx": 0,
|
||||
"rf_tx": 0,
|
||||
"automatic_retry": False,
|
||||
}
|
||||
runner_pid: int | None = None
|
||||
child_pid: int | None = None
|
||||
try:
|
||||
if rx_serial != FIXED_RX_SERIAL:
|
||||
raise ValueError("RX serial does not match the fixed RF-001 receiver")
|
||||
profile = load_fixed_profile(repository)
|
||||
info_path, clock_path, transfer_path = resolve_hackrf_tools(
|
||||
hackrf_info_path,
|
||||
hackrf_clock_path,
|
||||
hackrf_transfer_path,
|
||||
)
|
||||
|
||||
session_directory = work_root / "rx_capture" / session_id
|
||||
session_directory.parent.mkdir(parents=True, exist_ok=True)
|
||||
if session_directory.exists():
|
||||
raise FileExistsError(f"capture session already exists: {session_directory}")
|
||||
free_bytes = shutil.disk_usage(session_directory.parent).free
|
||||
result["free_bytes_before"] = free_bytes
|
||||
if free_bytes < MINIMUM_FREE_BYTES:
|
||||
raise RuntimeError(
|
||||
f"capture volume has {free_bytes} free bytes; "
|
||||
f"{MINIMUM_FREE_BYTES} required"
|
||||
)
|
||||
|
||||
clear, process_evidence = process_gate()
|
||||
result["process_gate"] = dict(process_evidence)
|
||||
if not clear:
|
||||
raise RuntimeError("conflicting SDR process gate is not CLEAR")
|
||||
|
||||
info = command_runner([str(info_path)], 20.0)
|
||||
result["hackrf_info"] = _bounded_command(info)
|
||||
result["hardware_accessed"] = True
|
||||
if info.returncode != 0 or rx_serial.lower() not in (
|
||||
f"{info.stdout}\n{info.stderr}".lower()
|
||||
):
|
||||
raise RuntimeError("hackrf_info did not confirm the fixed RX serial")
|
||||
|
||||
clock = command_runner([str(clock_path), "-d", rx_serial, "-i"], 20.0)
|
||||
result["hackrf_clock"] = _bounded_command(clock)
|
||||
clock_lines = {
|
||||
line.strip().lower()
|
||||
for line in f"{clock.stdout}\n{clock.stderr}".splitlines()
|
||||
}
|
||||
if clock.returncode != 0 or not any(
|
||||
line.endswith("clkin status: clock signal detected")
|
||||
for line in clock_lines
|
||||
):
|
||||
raise RuntimeError("CLKIN clock signal was not detected")
|
||||
result["common_clock_verified"] = True
|
||||
|
||||
session_directory.mkdir()
|
||||
|
||||
capture_path = session_directory / CAPTURE_FILE_NAME
|
||||
arguments_path = session_directory / "hackrf_transfer_arguments.json"
|
||||
stdout_path = session_directory / "hackrf_transfer.stdout.txt"
|
||||
stderr_path = session_directory / "hackrf_transfer.stderr.txt"
|
||||
started_path = session_directory / STARTED_FILE_NAME
|
||||
final_path = session_directory / RESULT_FILE_NAME
|
||||
manifest_path = session_directory / MANIFEST_FILE_NAME
|
||||
runner_path = Path(__file__).resolve().with_name("rf001_capture_runner.py")
|
||||
if not runner_path.is_file():
|
||||
raise FileNotFoundError(f"capture runner is absent: {runner_path}")
|
||||
|
||||
transfer_arguments = [
|
||||
"-d", rx_serial,
|
||||
"-r", str(capture_path),
|
||||
"-f", str(profile["center_frequency_hz"]),
|
||||
"-s", str(profile["sample_rate_hz"]),
|
||||
"-n", str(CAPTURE_SAMPLES),
|
||||
"-l", str(profile["rx_lna_db"]),
|
||||
"-g", str(profile["rx_vga_db"]),
|
||||
"-a", "0",
|
||||
"-p", "0",
|
||||
"-B",
|
||||
]
|
||||
arguments_path.write_text(
|
||||
json.dumps(transfer_arguments, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
launch_arguments = [
|
||||
sys.executable,
|
||||
"-B",
|
||||
"-X",
|
||||
"utf8",
|
||||
str(runner_path),
|
||||
"--executable",
|
||||
str(transfer_path),
|
||||
"--arguments-json",
|
||||
str(arguments_path),
|
||||
"--capture-path",
|
||||
str(capture_path),
|
||||
"--stdout-path",
|
||||
str(stdout_path),
|
||||
"--stderr-path",
|
||||
str(stderr_path),
|
||||
"--started-status",
|
||||
str(started_path),
|
||||
"--result-status",
|
||||
str(final_path),
|
||||
]
|
||||
runner_pid = background_launcher(launch_arguments)
|
||||
result["runner_pid"] = runner_pid
|
||||
result["rf_rx"] = 1
|
||||
|
||||
deadline = monotonic() + READINESS_TIMEOUT_SECONDS
|
||||
first_positive_size: int | None = None
|
||||
latest_size = 0
|
||||
started: dict[str, Any] | None = None
|
||||
while monotonic() < deadline:
|
||||
if final_path.is_file():
|
||||
final = _read_json(final_path)
|
||||
raise RuntimeError(f"capture exited before READY: {final}")
|
||||
if started_path.is_file():
|
||||
started = _read_json(started_path)
|
||||
raw_pid = started.get("pid")
|
||||
if type(raw_pid) is int and raw_pid > 0:
|
||||
child_pid = raw_pid
|
||||
if capture_path.is_file():
|
||||
latest_size = capture_path.stat().st_size
|
||||
if latest_size > 0:
|
||||
if first_positive_size is None:
|
||||
first_positive_size = latest_size
|
||||
elif latest_size > first_positive_size and started is not None:
|
||||
break
|
||||
sleep(0.2)
|
||||
else:
|
||||
raise RuntimeError("capture did not become READY within five seconds")
|
||||
|
||||
if started is None or child_pid is None or first_positive_size is None:
|
||||
raise RuntimeError("capture readiness evidence is incomplete")
|
||||
capture_started_at = str(started.get("started_at", ""))
|
||||
expected_end = _iso_utc(capture_started_at) + timedelta(
|
||||
seconds=CAPTURE_SECONDS
|
||||
)
|
||||
expected_bytes = CAPTURE_SAMPLES * 2
|
||||
manifest: dict[str, object] = {
|
||||
"schema_version": 1,
|
||||
"session_id": session_id,
|
||||
"profile": profile,
|
||||
"capture_seconds": CAPTURE_SECONDS,
|
||||
"capture_samples": CAPTURE_SAMPLES,
|
||||
"capture_duration_reason": "manual AUTHORIZE RF TX gate",
|
||||
"rx_serial": rx_serial,
|
||||
"capture_path": str(capture_path),
|
||||
"capture_started_at": capture_started_at,
|
||||
"expected_capture_end": expected_end.isoformat(),
|
||||
"expected_capture_bytes": expected_bytes,
|
||||
"runner_pid": runner_pid,
|
||||
"child_pid": child_pid,
|
||||
"started_status_path": str(started_path),
|
||||
"result_status_path": str(final_path),
|
||||
"stdout_path": str(stdout_path),
|
||||
"stderr_path": str(stderr_path),
|
||||
"common_clock_verified": True,
|
||||
"automatic_retry": False,
|
||||
"rf_rx": 1,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
_atomic_json(manifest_path, manifest)
|
||||
result.update(
|
||||
{
|
||||
"state": "READY",
|
||||
"session_directory": str(session_directory),
|
||||
"manifest_path": str(manifest_path),
|
||||
"capture_path": str(capture_path),
|
||||
"capture_started_at": capture_started_at,
|
||||
"expected_capture_end": expected_end.isoformat(),
|
||||
"expected_capture_bytes": expected_bytes,
|
||||
"child_pid": child_pid,
|
||||
"capture_bytes_at_ready": latest_size,
|
||||
"common_clock_verified": True,
|
||||
}
|
||||
)
|
||||
except (
|
||||
ImportError,
|
||||
OSError,
|
||||
ValueError,
|
||||
RuntimeError,
|
||||
subprocess.SubprocessError,
|
||||
) as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
for pid in (child_pid, runner_pid):
|
||||
if pid is None:
|
||||
continue
|
||||
try:
|
||||
process_stopper(pid)
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as stop_error:
|
||||
errors.append(
|
||||
f"failed to stop pid {pid}: {type(stop_error).__name__}: "
|
||||
f"{stop_error}"
|
||||
)
|
||||
result["rf_rx"] = 0
|
||||
return result, errors
|
||||
|
||||
|
||||
def read_capture_status(
|
||||
work_root: Path,
|
||||
session_id: str,
|
||||
) -> tuple[dict[str, object], list[str]]:
|
||||
errors: list[str] = []
|
||||
result: dict[str, object] = {
|
||||
"session_id": session_id,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
try:
|
||||
session_directory = work_root / "rx_capture" / session_id
|
||||
manifest = _read_json(session_directory / MANIFEST_FILE_NAME)
|
||||
if manifest.get("session_id") != session_id or manifest.get("rf_tx") != 0:
|
||||
raise ValueError("capture manifest does not match the requested session")
|
||||
capture_path = Path(str(manifest["capture_path"]))
|
||||
final_path = Path(str(manifest["result_status_path"]))
|
||||
resolved_session = session_directory.resolve()
|
||||
for path in (capture_path, final_path):
|
||||
try:
|
||||
path.resolve().relative_to(resolved_session)
|
||||
except ValueError as error:
|
||||
raise ValueError("capture manifest path escapes the session") from error
|
||||
expected_bytes = int(manifest["expected_capture_bytes"])
|
||||
current_bytes = capture_path.stat().st_size if capture_path.is_file() else 0
|
||||
result.update(
|
||||
{
|
||||
"manifest_path": str(session_directory / MANIFEST_FILE_NAME),
|
||||
"capture_path": str(capture_path),
|
||||
"capture_started_at": manifest.get("capture_started_at"),
|
||||
"expected_capture_end": manifest.get("expected_capture_end"),
|
||||
"expected_capture_bytes": expected_bytes,
|
||||
"capture_bytes": current_bytes,
|
||||
"common_clock_verified": manifest.get("common_clock_verified"),
|
||||
}
|
||||
)
|
||||
expected_end = _iso_utc(str(manifest["expected_capture_end"]))
|
||||
result["capture_window_open"] = datetime.now(timezone.utc) < expected_end
|
||||
if final_path.is_file():
|
||||
final = _read_json(final_path)
|
||||
result["runner_result"] = final
|
||||
complete = (
|
||||
final.get("exit_code") == 0
|
||||
and final.get("runner_error") in {None, ""}
|
||||
and final.get("capture_size") == expected_bytes
|
||||
and current_bytes == expected_bytes
|
||||
)
|
||||
result["state"] = "CAPTURE_COMPLETE" if complete else "CAPTURE_FAILED"
|
||||
if not complete:
|
||||
errors.append("finite RX capture did not meet the complete-file gate")
|
||||
else:
|
||||
result["state"] = "CAPTURE_RUNNING"
|
||||
except (OSError, ValueError, KeyError, TypeError) as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
result["state"] = "STATUS_FAILED"
|
||||
return result, errors
|
||||
|
||||
|
||||
def validate_tx_window(
|
||||
capture_started_at: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str,
|
||||
) -> None:
|
||||
capture = _iso_utc(capture_started_at)
|
||||
started = _iso_utc(tx_started_at)
|
||||
finished = _iso_utc(tx_finished_at)
|
||||
if not capture <= started <= finished:
|
||||
raise ValueError("TX timestamps are outside the ordered capture window")
|
||||
|
||||
|
||||
def decode_completed_capture(
|
||||
repository: Path,
|
||||
work_root: Path,
|
||||
session_id: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str,
|
||||
*,
|
||||
command_runner: CommandRunner,
|
||||
) -> tuple[dict[str, object], list[str]]:
|
||||
errors: list[str] = []
|
||||
result: dict[str, object] = {
|
||||
"session_id": session_id,
|
||||
"mode": "OFFLINE_CAPTURE_DECODE",
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
try:
|
||||
status, status_errors = read_capture_status(work_root, session_id)
|
||||
if status_errors or status.get("state") != "CAPTURE_COMPLETE":
|
||||
raise RuntimeError(f"capture is not complete: {status_errors or status}")
|
||||
capture_started_at = str(status["capture_started_at"])
|
||||
validate_tx_window(capture_started_at, tx_started_at, tx_finished_at)
|
||||
session_directory = work_root / "rx_capture" / session_id
|
||||
report_path = session_directory / "offline_decode_report.json"
|
||||
if report_path.exists():
|
||||
raise FileExistsError(f"decode report already exists: {report_path}")
|
||||
decoder_script = Path(__file__).resolve().with_name("rf001_offline_decode.py")
|
||||
truth_path = repository / "configs" / "rf" / "rf001a_truth_metadata.json"
|
||||
decode = command_runner(
|
||||
[
|
||||
os.fspath(Path(sys.executable)),
|
||||
"-B",
|
||||
"-X",
|
||||
"utf8",
|
||||
str(decoder_script),
|
||||
"--repository",
|
||||
str(repository),
|
||||
"--iq-file",
|
||||
str(status["capture_path"]),
|
||||
"--truth-metadata",
|
||||
str(truth_path),
|
||||
"--report",
|
||||
str(report_path),
|
||||
"--capture-started-at",
|
||||
capture_started_at,
|
||||
"--tx-started-at",
|
||||
tx_started_at,
|
||||
"--tx-finished-at",
|
||||
tx_finished_at,
|
||||
],
|
||||
1_800.0,
|
||||
)
|
||||
result["decode"] = _bounded_command(decode)
|
||||
if decode.returncode != 0:
|
||||
raise RuntimeError(f"offline capture decoder exited with {decode.returncode}")
|
||||
report = _read_json(report_path)
|
||||
diagnostics = read_capture_diagnostics(
|
||||
work_root,
|
||||
session_id,
|
||||
tx_started_at,
|
||||
tx_finished_at,
|
||||
)
|
||||
if diagnostics[1]:
|
||||
raise RuntimeError(
|
||||
f"capture diagnostics failed: {diagnostics[1]}"
|
||||
)
|
||||
result.update(
|
||||
{
|
||||
"report_path": str(report_path),
|
||||
"crc_valid_packets": report.get("crc_valid_packets"),
|
||||
"exact_packets": report.get("exact_packets"),
|
||||
"peak_analysis_memory_bytes": report.get("peak_analysis_memory_bytes"),
|
||||
"window_source": report.get("window_source"),
|
||||
"diagnostics": diagnostics[0],
|
||||
}
|
||||
)
|
||||
except (OSError, ValueError, RuntimeError, KeyError, TypeError) as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
return result, errors
|
||||
|
||||
|
||||
def _power_metrics(
|
||||
capture_path: Path,
|
||||
start_sample: int,
|
||||
stop_sample: int,
|
||||
*,
|
||||
chunk_samples: int = 1_000_000,
|
||||
) -> dict[str, object]:
|
||||
"""Measure one fixed cs8 window without loading it all into memory."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
total_samples = capture_path.stat().st_size // 2
|
||||
if not 0 <= start_sample < stop_sample <= total_samples:
|
||||
raise ValueError("diagnostic window is outside the cs8 capture")
|
||||
raw = np.memmap(capture_path, dtype=np.int8, mode="r")
|
||||
power_sum = 0.0
|
||||
i_sum = 0.0
|
||||
q_sum = 0.0
|
||||
rail_codes = 0
|
||||
peak_component = 0
|
||||
for chunk_start in range(start_sample, stop_sample, chunk_samples):
|
||||
chunk_stop = min(stop_sample, chunk_start + chunk_samples)
|
||||
values = np.asarray(
|
||||
raw[chunk_start * 2:chunk_stop * 2],
|
||||
dtype=np.int8,
|
||||
).reshape(-1, 2)
|
||||
wide = values.astype(np.float64)
|
||||
i_values = wide[:, 0]
|
||||
q_values = wide[:, 1]
|
||||
power_sum += float(np.sum(i_values * i_values + q_values * q_values))
|
||||
i_sum += float(np.sum(i_values))
|
||||
q_sum += float(np.sum(q_values))
|
||||
rail_codes += int(np.count_nonzero((values == -128) | (values == 127)))
|
||||
peak_component = max(peak_component, int(np.max(np.abs(wide))))
|
||||
sample_count = stop_sample - start_sample
|
||||
mean_power = power_sum / sample_count
|
||||
return {
|
||||
"start_sample": start_sample,
|
||||
"stop_sample": stop_sample,
|
||||
"sample_count": sample_count,
|
||||
"mean_power_code_squared": mean_power,
|
||||
"rms_complex_normalized": math.sqrt(mean_power) / 128.0,
|
||||
"mean_i_code": i_sum / sample_count,
|
||||
"mean_q_code": q_sum / sample_count,
|
||||
"peak_component_code": peak_component,
|
||||
"rail_codes": rail_codes,
|
||||
}
|
||||
|
||||
|
||||
def read_capture_diagnostics(
|
||||
work_root: Path,
|
||||
session_id: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str,
|
||||
) -> tuple[dict[str, object], list[str]]:
|
||||
"""Return the fixed report and signal metrics for one completed capture."""
|
||||
|
||||
errors: list[str] = []
|
||||
result: dict[str, object] = {
|
||||
"session_id": session_id,
|
||||
"mode": "OFFLINE_CAPTURE_DIAGNOSTICS",
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
try:
|
||||
status, status_errors = read_capture_status(work_root, session_id)
|
||||
if status_errors or status.get("state") != "CAPTURE_COMPLETE":
|
||||
raise RuntimeError(f"capture is not complete: {status_errors or status}")
|
||||
capture_started_at = str(status["capture_started_at"])
|
||||
validate_tx_window(capture_started_at, tx_started_at, tx_finished_at)
|
||||
capture_path = Path(str(status["capture_path"]))
|
||||
session_directory = work_root / "rx_capture" / session_id
|
||||
report_path = session_directory / REPORT_FILE_NAME
|
||||
if not report_path.is_file():
|
||||
raise FileNotFoundError(f"decode report is absent: {report_path}")
|
||||
report_size = report_path.stat().st_size
|
||||
if report_size <= 0 or report_size > MAX_REPORT_BYTES:
|
||||
raise ValueError(
|
||||
f"decode report size {report_size} is outside the allowed range"
|
||||
)
|
||||
report_bytes = report_path.read_bytes()
|
||||
report = json.loads(report_bytes.decode("utf-8-sig"))
|
||||
if not isinstance(report, dict) or report.get("rf_tx") != 0:
|
||||
raise ValueError("decode report is invalid or does not preserve rf_tx = 0")
|
||||
runner_result = status.get("runner_result")
|
||||
if not isinstance(runner_result, dict):
|
||||
raise ValueError("capture result is absent")
|
||||
capture_sha256 = str(runner_result.get("sha256", ""))
|
||||
if report.get("iq_sha256") != capture_sha256:
|
||||
raise ValueError("decode report does not match the capture SHA-256")
|
||||
|
||||
sample_rate = int(EXPECTED_PROFILE["sample_rate_hz"])
|
||||
total_samples = capture_path.stat().st_size // 2
|
||||
capture_time = _iso_utc(capture_started_at)
|
||||
tx_start = _iso_utc(tx_started_at)
|
||||
tx_finish = _iso_utc(tx_finished_at)
|
||||
tx_start_sample = round((tx_start - capture_time).total_seconds() * sample_rate)
|
||||
tx_stop_sample = round((tx_finish - capture_time).total_seconds() * sample_rate)
|
||||
tx_start_sample = max(0, min(total_samples, tx_start_sample))
|
||||
tx_stop_sample = max(0, min(total_samples, tx_stop_sample))
|
||||
if tx_stop_sample <= tx_start_sample:
|
||||
raise ValueError("TX window does not overlap the capture")
|
||||
tx_samples = tx_stop_sample - tx_start_sample
|
||||
pre_start = max(0, tx_start_sample - tx_samples)
|
||||
post_stop = min(total_samples, tx_stop_sample + tx_samples)
|
||||
|
||||
signal = _power_metrics(capture_path, tx_start_sample, tx_stop_sample)
|
||||
pre = _power_metrics(capture_path, pre_start, tx_start_sample)
|
||||
post = _power_metrics(capture_path, tx_stop_sample, post_stop)
|
||||
baseline_power = (
|
||||
float(pre["mean_power_code_squared"])
|
||||
+ float(post["mean_power_code_squared"])
|
||||
) / 2.0
|
||||
signal_power = float(signal["mean_power_code_squared"])
|
||||
power_ratio = signal_power / baseline_power if baseline_power > 0 else None
|
||||
power_delta_db = (
|
||||
10.0 * math.log10(power_ratio)
|
||||
if power_ratio is not None and power_ratio > 0
|
||||
else None
|
||||
)
|
||||
result.update(
|
||||
{
|
||||
"report_path": str(report_path),
|
||||
"report_bytes": report_size,
|
||||
"report_sha256": hashlib.sha256(report_bytes).hexdigest(),
|
||||
"capture_sha256": capture_sha256,
|
||||
"report": report,
|
||||
"signal_metrics": {
|
||||
"tx_window": signal,
|
||||
"pre_tx_baseline": pre,
|
||||
"post_tx_baseline": post,
|
||||
"tx_to_baseline_power_ratio": power_ratio,
|
||||
"tx_to_baseline_db": power_delta_db,
|
||||
},
|
||||
}
|
||||
)
|
||||
except (
|
||||
ImportError,
|
||||
OSError,
|
||||
ValueError,
|
||||
RuntimeError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
) as error:
|
||||
errors.append(f"{type(error).__name__}: {error}")
|
||||
return result, errors
|
||||
1529
tools/rf001_rx_http_agent.py
Normal file
1529
tools/rf001_rx_http_agent.py
Normal file
File diff suppressed because it is too large
Load Diff
74
tools/rf001_staircase_job.py
Normal file
74
tools/rf001_staircase_job.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Detached software-only worker for one fixed STAIRCASE_SEARCH task."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tools.rf001_http_control import PROTOCOL_VERSION, STAIRCASE_SEARCH_TASK, utc_now
|
||||
from tools.rf001_rx_http_agent import run_staircase_search
|
||||
|
||||
|
||||
def _atomic_write(path: Path, value: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def run_job(spec_path: Path, result_path: Path) -> dict[str, object]:
|
||||
spec = json.loads(spec_path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(spec, dict):
|
||||
raise ValueError("job spec must be a JSON object")
|
||||
task = spec.get("task")
|
||||
if not isinstance(task, dict) or task.get("kind") != STAIRCASE_SEARCH_TASK:
|
||||
raise ValueError("job spec does not contain STAIRCASE_SEARCH")
|
||||
started_at = utc_now()
|
||||
task_result, errors = run_staircase_search(
|
||||
Path(str(spec["work_root"])),
|
||||
str(task["session_id"]),
|
||||
str(task["expected_rx_sha256"]),
|
||||
str(task["expected_reference_sha256"]),
|
||||
progress_path=Path(str(spec["progress_path"])),
|
||||
)
|
||||
result = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"task_id": str(task["task_id"]),
|
||||
"agent_id": str(spec["agent_id"]),
|
||||
"status": "COMPLETE" if not errors else "FAILED",
|
||||
"started_at": started_at,
|
||||
"finished_at": utc_now(),
|
||||
"result": task_result,
|
||||
"errors": errors,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
_atomic_write(result_path, result)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--job-spec", required=True, type=Path)
|
||||
parser.add_argument("--result", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
result = run_job(arguments.job_spec, arguments.result)
|
||||
print(f"TASK ID = {result['task_id']}")
|
||||
print(f"TASK STATUS = {result['status']}")
|
||||
print("HARDWARE ACCESSED = false")
|
||||
print("RF TX = 0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
937
tools/rf001_staircase_search.py
Normal file
937
tools/rf001_staircase_search.py
Normal file
@@ -0,0 +1,937 @@
|
||||
"""Потоковый поиск эталонной лестницы Lab064/Lab065 в большом cs8-файле.
|
||||
|
||||
Утилита не обращается к SDR, не использует пакетный приёмник и не меняет
|
||||
исходные IQ-файлы. Полная запись просматривается блоками с перекрытием.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Iterable
|
||||
|
||||
import numpy as np
|
||||
from scipy import signal
|
||||
|
||||
EXPECTED_REFERENCE_SHA256 = (
|
||||
"fa260719659b2984fe29588238415622cd75c6daefec6cc9130ba1bf851bd3f9"
|
||||
)
|
||||
ANALYZER_VERSION = "2026.09.28.1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReferenceLayout:
|
||||
sample_rate_hz: int = 8_000_000
|
||||
symbol_rate: int = 20_000
|
||||
carrier_hz: float = 50_000.0
|
||||
leading_samples: int = 800_000
|
||||
segment_samples: int = 800_000
|
||||
gap_samples: int = 400_000
|
||||
trailing_samples: int = 800_000
|
||||
levels_db: tuple[float, ...] = (-18.0, -12.0, -6.0, 0.0)
|
||||
|
||||
@property
|
||||
def total_samples(self) -> int:
|
||||
return (
|
||||
self.leading_samples
|
||||
+ len(self.levels_db) * self.segment_samples
|
||||
+ (len(self.levels_db) - 1) * self.gap_samples
|
||||
+ self.trailing_samples
|
||||
)
|
||||
|
||||
def active_windows(self) -> tuple[tuple[int, int], ...]:
|
||||
cursor = self.leading_samples
|
||||
windows = []
|
||||
for index in range(len(self.levels_db)):
|
||||
windows.append((cursor, cursor + self.segment_samples))
|
||||
cursor += self.segment_samples
|
||||
if index + 1 < len(self.levels_db):
|
||||
cursor += self.gap_samples
|
||||
return tuple(windows)
|
||||
|
||||
def background_windows(self) -> tuple[tuple[int, int], ...]:
|
||||
width = min(self.gap_samples, self.leading_samples, self.trailing_samples) // 2
|
||||
cursor = (self.leading_samples - width) // 2
|
||||
windows = [(cursor, cursor + width)]
|
||||
cursor = self.leading_samples + self.segment_samples
|
||||
for index in range(len(self.levels_db) - 1):
|
||||
start = cursor + (self.gap_samples - width) // 2
|
||||
windows.append((start, start + width))
|
||||
cursor += self.gap_samples + self.segment_samples
|
||||
final_start = self.total_samples - self.trailing_samples
|
||||
start = final_start + (self.trailing_samples - width) // 2
|
||||
windows.append((start, start + width))
|
||||
return tuple(windows)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SearchCriteria:
|
||||
decimation: int = 32
|
||||
input_block_samples: int = 4_000_000
|
||||
anti_alias_passband_hz: float = 75_000.0
|
||||
anti_alias_stopband_hz: float = 120_000.0
|
||||
anti_alias_attenuation_db: float = 80.0
|
||||
feature_period_seconds: float = 0.001
|
||||
refinement_radius_seconds: float = 0.003
|
||||
cfo_min_hz: float = -3.0
|
||||
cfo_max_hz: float = 3.0
|
||||
cfo_step_hz: float = 0.25
|
||||
normalized_correlation_minimum: float = 0.05
|
||||
peak_to_background_minimum_db: float = 6.0
|
||||
maximum_step_error_db: float = 3.0
|
||||
l3_matched_to_residual_minimum_db: float = -18.0
|
||||
l3_spectral_contrast_minimum_db: float = 3.0
|
||||
histogram_stride: int = 1024
|
||||
maximum_reported_candidates: int = 5
|
||||
|
||||
|
||||
DEFAULT_LAYOUT = ReferenceLayout()
|
||||
DEFAULT_CRITERIA = SearchCriteria()
|
||||
|
||||
|
||||
def current_process_rss_bytes() -> int:
|
||||
if not hasattr(ctypes, "windll"):
|
||||
return 0
|
||||
|
||||
class ProcessMemoryCounters(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cb", ctypes.c_ulong),
|
||||
("page_fault_count", ctypes.c_ulong),
|
||||
("peak_working_set_size", ctypes.c_size_t),
|
||||
("working_set_size", ctypes.c_size_t),
|
||||
("quota_peak_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_peak_non_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_non_paged_pool_usage", ctypes.c_size_t),
|
||||
("pagefile_usage", ctypes.c_size_t),
|
||||
("peak_pagefile_usage", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
counters = ProcessMemoryCounters()
|
||||
counters.cb = ctypes.sizeof(counters)
|
||||
get_current_process = ctypes.windll.kernel32.GetCurrentProcess # type: ignore[attr-defined]
|
||||
get_current_process.restype = ctypes.c_void_p
|
||||
get_process_memory_info = ctypes.windll.psapi.GetProcessMemoryInfo # type: ignore[attr-defined]
|
||||
get_process_memory_info.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(ProcessMemoryCounters),
|
||||
ctypes.c_ulong,
|
||||
]
|
||||
get_process_memory_info.restype = ctypes.c_int
|
||||
succeeded = get_process_memory_info(
|
||||
get_current_process(),
|
||||
ctypes.byref(counters),
|
||||
counters.cb,
|
||||
)
|
||||
return int(counters.working_set_size) if succeeded else 0
|
||||
|
||||
|
||||
class MemoryPeakSampler:
|
||||
def __init__(self, poll_seconds: float = 0.02) -> None:
|
||||
self.poll_seconds = poll_seconds
|
||||
self.baseline_bytes = current_process_rss_bytes()
|
||||
self.peak_bytes = self.baseline_bytes
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._sample, daemon=True)
|
||||
|
||||
def _sample(self) -> None:
|
||||
while not self._stop.wait(self.poll_seconds):
|
||||
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||
|
||||
def __enter__(self) -> "MemoryPeakSampler":
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||
self._stop.set()
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
@property
|
||||
def additional_peak_bytes(self) -> int:
|
||||
return max(0, self.peak_bytes - self.baseline_bytes)
|
||||
|
||||
|
||||
def _write_progress(
|
||||
path: Path,
|
||||
*,
|
||||
phase: str,
|
||||
processed_samples: int,
|
||||
total_samples: int,
|
||||
) -> None:
|
||||
value = {
|
||||
"schema_version": 1,
|
||||
"phase": phase,
|
||||
"processed_samples": processed_samples,
|
||||
"total_samples": total_samples,
|
||||
"coverage_complete": (
|
||||
phase == "COMPLETE" and processed_samples == total_samples
|
||||
),
|
||||
"updated_at_epoch": time.time(),
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(value, separators=(",", ":"), sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def sha256_file(
|
||||
path: Path,
|
||||
progress: Callable[[int, int], None] | None = None,
|
||||
) -> str:
|
||||
digest = hashlib.sha256()
|
||||
total = path.stat().st_size
|
||||
processed = 0
|
||||
with path.open("rb") as stream:
|
||||
while block := stream.read(4 * 1024 * 1024):
|
||||
digest.update(block)
|
||||
processed += len(block)
|
||||
if progress is not None:
|
||||
progress(processed, total)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def cs8_sample_count(path: Path) -> int:
|
||||
size = path.stat().st_size
|
||||
if size <= 0 or size % 2:
|
||||
raise ValueError("cs8-файл должен содержать целое ненулевое число пар I/Q")
|
||||
return size // 2
|
||||
|
||||
|
||||
def design_antialias_filter(
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
) -> np.ndarray:
|
||||
"""Build the fixed linear-phase FIR used before every decimation."""
|
||||
|
||||
if criteria.decimation <= 0:
|
||||
raise ValueError("decimation must be positive")
|
||||
if criteria.decimation == 1:
|
||||
return np.ones(1, dtype=np.float64)
|
||||
nyquist = layout.sample_rate_hz / 2
|
||||
decimated_nyquist = layout.sample_rate_hz / (2 * criteria.decimation)
|
||||
if not (
|
||||
0 < criteria.anti_alias_passband_hz
|
||||
< criteria.anti_alias_stopband_hz
|
||||
< decimated_nyquist
|
||||
):
|
||||
raise ValueError("anti-alias passband and stopband are invalid")
|
||||
transition_width = (
|
||||
criteria.anti_alias_stopband_hz - criteria.anti_alias_passband_hz
|
||||
) / nyquist
|
||||
taps_count, beta = signal.kaiserord(
|
||||
criteria.anti_alias_attenuation_db,
|
||||
transition_width,
|
||||
)
|
||||
taps_count = max(3, taps_count | 1)
|
||||
cutoff_hz = (
|
||||
criteria.anti_alias_passband_hz + criteria.anti_alias_stopband_hz
|
||||
) / 2
|
||||
return signal.firwin(
|
||||
taps_count,
|
||||
cutoff_hz,
|
||||
window=("kaiser", beta),
|
||||
fs=layout.sample_rate_hz,
|
||||
).astype(np.float64)
|
||||
|
||||
|
||||
def _read_cs8_complex(path: Path, start: int, stop: int) -> np.ndarray:
|
||||
"""Read one bounded source interval; out-of-file samples are zero."""
|
||||
|
||||
if start >= stop:
|
||||
raise ValueError("invalid source interval")
|
||||
total = cs8_sample_count(path)
|
||||
values = np.zeros(stop - start, dtype=np.complex64)
|
||||
clipped_start = max(0, start)
|
||||
clipped_stop = min(total, stop)
|
||||
if clipped_start >= clipped_stop:
|
||||
return values
|
||||
raw = np.memmap(path, dtype=np.int8, mode="r")
|
||||
interleaved = np.asarray(
|
||||
raw[2 * clipped_start:2 * clipped_stop],
|
||||
dtype=np.float32,
|
||||
)
|
||||
destination = slice(clipped_start - start, clipped_stop - start)
|
||||
values[destination] = interleaved[0::2] + 1j * interleaved[1::2]
|
||||
return values
|
||||
|
||||
|
||||
def read_filtered_decimated_window(
|
||||
path: Path,
|
||||
start: int,
|
||||
stop: int,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
taps: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
"""Read a decimated window with FIR overlap and delay correction.
|
||||
|
||||
Output index ``k`` is centred on input sample ``k * decimation``. The
|
||||
function reads half the FIR on both sides, so adjacent blocks have exactly
|
||||
the overlap required to match one full-file convolution. Zeros are used
|
||||
only outside the physical file.
|
||||
"""
|
||||
|
||||
total = cs8_sample_count(path)
|
||||
total_decimated = math.ceil(total / criteria.decimation)
|
||||
if not 0 <= start < stop <= total_decimated:
|
||||
raise ValueError("invalid filtered-decimated window")
|
||||
delay = (taps.size - 1) // 2
|
||||
first_center = start * criteria.decimation
|
||||
last_center = (stop - 1) * criteria.decimation
|
||||
source_start = first_center - delay
|
||||
source_stop = last_center + delay + 1
|
||||
values = _read_cs8_complex(path, source_start, source_stop)
|
||||
if taps.size == 1:
|
||||
return values[::criteria.decimation].astype(np.complex64, copy=False)
|
||||
filtered = signal.fftconvolve(values, taps, mode="valid")
|
||||
result = filtered[::criteria.decimation]
|
||||
if result.size != stop - start:
|
||||
raise RuntimeError("filtered decimator produced an unexpected length")
|
||||
return result.astype(np.complex64, copy=False)
|
||||
|
||||
|
||||
def iter_filtered_decimated(
|
||||
path: Path,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
taps: np.ndarray,
|
||||
) -> Iterable[tuple[int, np.ndarray]]:
|
||||
"""Yield the full file in bounded, boundary-correct decimated blocks."""
|
||||
|
||||
if criteria.input_block_samples <= 0:
|
||||
raise ValueError("input block size must be positive")
|
||||
total = cs8_sample_count(path)
|
||||
total_decimated = math.ceil(total / criteria.decimation)
|
||||
block = max(1, criteria.input_block_samples // criteria.decimation)
|
||||
for start in range(0, total_decimated, block):
|
||||
stop = min(total_decimated, start + block)
|
||||
yield start, read_filtered_decimated_window(
|
||||
path,
|
||||
start,
|
||||
stop,
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
)
|
||||
|
||||
|
||||
def load_reference(
|
||||
path: Path,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
*,
|
||||
expected_sha256: str | None,
|
||||
) -> tuple[np.ndarray, str]:
|
||||
if cs8_sample_count(path) != layout.total_samples:
|
||||
raise ValueError("размер эталона не соответствует фиксированной структуре")
|
||||
digest = sha256_file(path)
|
||||
if expected_sha256 is not None and digest != expected_sha256:
|
||||
raise ValueError("SHA256 эталона не совпадает с разрешённым значением")
|
||||
stop = math.ceil(layout.total_samples / criteria.decimation)
|
||||
taps = design_antialias_filter(layout, criteria)
|
||||
reference = read_filtered_decimated_window(
|
||||
path,
|
||||
0,
|
||||
stop,
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
)
|
||||
reference -= np.mean(reference, dtype=np.complex128)
|
||||
return reference, digest
|
||||
|
||||
|
||||
def _frequency_grid(criteria: SearchCriteria) -> np.ndarray:
|
||||
count = round(
|
||||
(criteria.cfo_max_hz - criteria.cfo_min_hz) / criteria.cfo_step_hz
|
||||
)
|
||||
return criteria.cfo_min_hz + criteria.cfo_step_hz * np.arange(count + 1)
|
||||
|
||||
|
||||
def _histogram_quantile(histogram: np.ndarray, quantile: float) -> float:
|
||||
total = int(histogram.sum())
|
||||
if total == 0:
|
||||
return 0.0
|
||||
target = math.ceil(total * quantile)
|
||||
index = int(np.searchsorted(np.cumsum(histogram), target, side="left"))
|
||||
return min(1.0, (index + 0.5) / histogram.size)
|
||||
|
||||
|
||||
def _deduplicate_candidates(
|
||||
candidates: Iterable[dict[str, float | int]],
|
||||
minimum_separation: int,
|
||||
limit: int,
|
||||
) -> list[dict[str, float | int]]:
|
||||
selected: list[dict[str, float | int]] = []
|
||||
for candidate in sorted(candidates, key=lambda row: float(row["score"]), reverse=True):
|
||||
start = int(candidate["start_decimated_sample"])
|
||||
if any(
|
||||
abs(start - int(existing["start_decimated_sample"])) < minimum_separation
|
||||
for existing in selected
|
||||
):
|
||||
continue
|
||||
selected.append(candidate)
|
||||
if len(selected) == limit:
|
||||
break
|
||||
return selected
|
||||
|
||||
|
||||
def _power_features(values: np.ndarray, stride: int) -> np.ndarray:
|
||||
complete = values.size // stride
|
||||
if complete == 0:
|
||||
return np.empty(0, dtype=np.float64)
|
||||
reshaped = values[:complete * stride].reshape(complete, stride)
|
||||
return np.mean(np.abs(reshaped) ** 2, axis=1, dtype=np.float64)
|
||||
|
||||
|
||||
def _normalized_sliding_scores(
|
||||
values: np.ndarray,
|
||||
template: np.ndarray,
|
||||
) -> np.ndarray:
|
||||
centred = template.astype(np.float64) - float(np.mean(template))
|
||||
template_energy = float(np.vdot(centred, centred).real)
|
||||
count = centred.size
|
||||
prefix = np.concatenate(([0.0], np.cumsum(values, dtype=np.float64)))
|
||||
prefix_power = np.concatenate(
|
||||
([0.0], np.cumsum(values * values, dtype=np.float64))
|
||||
)
|
||||
window_sum = prefix[count:] - prefix[:-count]
|
||||
window_power = prefix_power[count:] - prefix_power[:-count]
|
||||
ac_power = window_power - (window_sum * window_sum) / count
|
||||
denominator = np.sqrt(np.maximum(template_energy * ac_power, 1e-30))
|
||||
correlation = signal.fftconvolve(values, centred[::-1], mode="valid")
|
||||
return np.abs(correlation) / denominator
|
||||
|
||||
|
||||
def _refine_candidate(
|
||||
capture_path: Path,
|
||||
reference: np.ndarray,
|
||||
approximate_start: int,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
taps: np.ndarray,
|
||||
) -> dict[str, float | int]:
|
||||
decimated_rate = layout.sample_rate_hz / criteria.decimation
|
||||
radius = max(1, round(criteria.refinement_radius_seconds * decimated_rate))
|
||||
total_decimated = math.ceil(
|
||||
cs8_sample_count(capture_path) / criteria.decimation
|
||||
)
|
||||
first = max(0, approximate_start - radius)
|
||||
last = min(total_decimated - reference.size, approximate_start + radius)
|
||||
received = read_filtered_decimated_window(
|
||||
capture_path,
|
||||
first,
|
||||
last + reference.size,
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
)
|
||||
count = reference.size
|
||||
prefix_power = np.concatenate(
|
||||
([0.0], np.cumsum(np.abs(received) ** 2, dtype=np.float64))
|
||||
)
|
||||
prefix_sum = np.concatenate(
|
||||
([0.0 + 0.0j], np.cumsum(received, dtype=np.complex128))
|
||||
)
|
||||
window_power = prefix_power[count:] - prefix_power[:-count]
|
||||
window_sum = prefix_sum[count:] - prefix_sum[:-count]
|
||||
window_ac_power = window_power - (np.abs(window_sum) ** 2) / count
|
||||
reference_energy = float(np.vdot(reference, reference).real)
|
||||
denominator = np.sqrt(np.maximum(reference_energy * window_ac_power, 1e-30))
|
||||
time_axis = np.arange(reference.size, dtype=np.float64) / decimated_rate
|
||||
best_score = -1.0
|
||||
best_index = 0
|
||||
best_cfo = 0.0
|
||||
for cfo_hz in _frequency_grid(criteria):
|
||||
rotated = reference * np.exp(2j * np.pi * cfo_hz * time_axis)
|
||||
correlation = signal.fftconvolve(
|
||||
received,
|
||||
np.conj(rotated[::-1]),
|
||||
mode="valid",
|
||||
)
|
||||
scores = np.abs(correlation) / denominator
|
||||
index = int(np.argmax(scores))
|
||||
score = float(scores[index])
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_index = index
|
||||
best_cfo = float(cfo_hz)
|
||||
start_decimated = first + best_index
|
||||
return {
|
||||
"score": best_score,
|
||||
"start_decimated_sample": start_decimated,
|
||||
"start_sample": start_decimated * criteria.decimation,
|
||||
"start_seconds": (
|
||||
start_decimated * criteria.decimation / layout.sample_rate_hz
|
||||
),
|
||||
"cfo_hz": best_cfo,
|
||||
}
|
||||
|
||||
|
||||
def locate_reference_streaming(
|
||||
capture_path: Path,
|
||||
reference: np.ndarray,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
progress: Callable[[int, int], None] | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Scan every source sample through a bounded anti-aliased feature stream."""
|
||||
|
||||
total_samples = cs8_sample_count(capture_path)
|
||||
total_decimated = math.ceil(total_samples / criteria.decimation)
|
||||
if total_decimated < reference.size:
|
||||
raise ValueError("RX короче эталонной лестницы")
|
||||
decimated_rate = layout.sample_rate_hz / criteria.decimation
|
||||
feature_stride = round(criteria.feature_period_seconds * decimated_rate)
|
||||
if feature_stride <= 0 or criteria.histogram_stride <= 0:
|
||||
raise ValueError("feature period and histogram stride must be positive")
|
||||
taps = design_antialias_filter(layout, criteria)
|
||||
feature_parts: list[np.ndarray] = []
|
||||
pending = np.empty(0, dtype=np.complex64)
|
||||
maximum_loaded = 0
|
||||
streamed_decimated = 0
|
||||
for block_start, block in iter_filtered_decimated(
|
||||
capture_path,
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
):
|
||||
if block_start != streamed_decimated:
|
||||
raise RuntimeError("decimated stream is not contiguous")
|
||||
streamed_decimated += block.size
|
||||
if progress is not None:
|
||||
progress(
|
||||
min(total_samples, streamed_decimated * criteria.decimation),
|
||||
total_samples,
|
||||
)
|
||||
combined = np.concatenate((pending, block)) if pending.size else block
|
||||
complete = combined.size // feature_stride
|
||||
used = complete * feature_stride
|
||||
if used:
|
||||
feature_parts.append(_power_features(combined[:used], feature_stride))
|
||||
pending = combined[used:].copy()
|
||||
maximum_loaded = max(maximum_loaded, int(combined.size))
|
||||
if pending.size:
|
||||
feature_parts.append(
|
||||
np.array([np.mean(np.abs(pending) ** 2, dtype=np.float64)])
|
||||
)
|
||||
features = np.concatenate(feature_parts)
|
||||
reference_features = _power_features(reference, feature_stride)
|
||||
if features.size < reference_features.size:
|
||||
raise ValueError("RX feature stream is shorter than the reference")
|
||||
scores = _normalized_sliding_scores(features, reference_features)
|
||||
sampled = np.clip(scores[::criteria.histogram_stride], 0.0, 1.0)
|
||||
histogram = np.histogram(sampled, bins=2000, range=(0.0, 1.0))[0]
|
||||
background_p99 = _histogram_quantile(histogram, 0.99)
|
||||
candidate_count = min(max(20, criteria.maximum_reported_candidates * 8), scores.size)
|
||||
indexes = np.argpartition(scores, -candidate_count)[-candidate_count:]
|
||||
coarse = [
|
||||
{
|
||||
"score": float(scores[index]),
|
||||
"start_decimated_sample": int(index * feature_stride),
|
||||
"start_feature": int(index),
|
||||
}
|
||||
for index in indexes
|
||||
]
|
||||
coarse = _deduplicate_candidates(
|
||||
coarse,
|
||||
max(1, reference.size // 3),
|
||||
criteria.maximum_reported_candidates,
|
||||
)
|
||||
refined = []
|
||||
for row in coarse:
|
||||
candidate = _refine_candidate(
|
||||
capture_path,
|
||||
reference,
|
||||
int(row["start_decimated_sample"]),
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
)
|
||||
temporal_score = float(row["score"])
|
||||
candidate["temporal_power_score"] = temporal_score
|
||||
candidate["score_to_background_p99_db"] = (
|
||||
20.0 * math.log10(temporal_score / background_p99)
|
||||
if temporal_score > 0 and background_p99 > 0
|
||||
else None
|
||||
)
|
||||
refined.append(candidate)
|
||||
independent = _deduplicate_candidates(
|
||||
refined,
|
||||
max(1, reference.size // 3),
|
||||
criteria.maximum_reported_candidates,
|
||||
)
|
||||
return {
|
||||
"coverage_start_sample": 0,
|
||||
"coverage_stop_sample": total_samples,
|
||||
"coverage_seconds": total_samples / layout.sample_rate_hz,
|
||||
"coverage_complete": streamed_decimated == total_decimated,
|
||||
"source_samples_processed": total_samples,
|
||||
"tested_feature_start_positions": int(scores.size),
|
||||
"feature_stride_decimated_samples": feature_stride,
|
||||
"feature_stride_source_samples": feature_stride * criteria.decimation,
|
||||
"frequency_grid_hz": [
|
||||
float(value) for value in _frequency_grid(criteria)
|
||||
],
|
||||
"background_score_p99": background_p99,
|
||||
"candidates": independent,
|
||||
"maximum_loaded_decimated_samples": maximum_loaded,
|
||||
"maximum_loaded_iq_bytes": maximum_loaded * np.dtype(np.complex64).itemsize,
|
||||
"anti_alias_filter": {
|
||||
"kind": "linear_phase_fir_kaiser",
|
||||
"tap_count": int(taps.size),
|
||||
"passband_hz": criteria.anti_alias_passband_hz,
|
||||
"stopband_hz": criteria.anti_alias_stopband_hz,
|
||||
"design_attenuation_db": criteria.anti_alias_attenuation_db,
|
||||
"group_delay_source_samples": (int(taps.size) - 1) // 2,
|
||||
"position_delay_correction_source_samples": (
|
||||
-((int(taps.size) - 1) // 2)
|
||||
),
|
||||
"boundary_handling": "symmetric_overlap_with_zero_extension_at_file_edges",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _read_candidate(
|
||||
capture_path: Path,
|
||||
start_decimated: int,
|
||||
count: int,
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
) -> np.ndarray:
|
||||
taps = design_antialias_filter(layout, criteria)
|
||||
return read_filtered_decimated_window(
|
||||
capture_path,
|
||||
start_decimated,
|
||||
start_decimated + count,
|
||||
layout,
|
||||
criteria,
|
||||
taps,
|
||||
)
|
||||
|
||||
|
||||
def _decimated_window(window: tuple[int, int], decimation: int) -> tuple[int, int]:
|
||||
return round(window[0] / decimation), round(window[1] / decimation)
|
||||
|
||||
|
||||
def _band_power(values: np.ndarray, sample_rate_hz: float, low: float, high: float) -> float:
|
||||
nperseg = min(4096, values.size)
|
||||
frequencies, density = signal.welch(
|
||||
values,
|
||||
fs=sample_rate_hz,
|
||||
nperseg=nperseg,
|
||||
noverlap=nperseg // 2,
|
||||
return_onesided=False,
|
||||
scaling="density",
|
||||
)
|
||||
mask = (frequencies >= low) & (frequencies <= high)
|
||||
if not np.any(mask):
|
||||
return 0.0
|
||||
return float(np.trapezoid(density[mask], frequencies[mask]))
|
||||
|
||||
|
||||
def verify_candidate(
|
||||
capture_path: Path,
|
||||
reference: np.ndarray,
|
||||
candidate: dict[str, float | int],
|
||||
layout: ReferenceLayout,
|
||||
criteria: SearchCriteria,
|
||||
) -> dict[str, object]:
|
||||
start_decimated = int(candidate["start_decimated_sample"])
|
||||
received = _read_candidate(
|
||||
capture_path,
|
||||
start_decimated,
|
||||
reference.size,
|
||||
layout,
|
||||
criteria,
|
||||
)
|
||||
decimated_rate = layout.sample_rate_hz / criteria.decimation
|
||||
cfo_hz = float(candidate["cfo_hz"])
|
||||
time_axis = np.arange(reference.size, dtype=np.float64) / decimated_rate
|
||||
rotated = reference * np.exp(2j * np.pi * cfo_hz * time_axis)
|
||||
segment_decimated = round(layout.segment_samples / criteria.decimation)
|
||||
trim = min(
|
||||
max(1, round(21 * layout.sample_rate_hz / layout.symbol_rate / criteria.decimation)),
|
||||
max(1, segment_decimated // 10),
|
||||
)
|
||||
rows = []
|
||||
for index, window in enumerate(layout.active_windows()):
|
||||
start, stop = _decimated_window(window, criteria.decimation)
|
||||
start += trim
|
||||
stop -= trim
|
||||
expected = rotated[start:stop]
|
||||
observed = received[start:stop]
|
||||
expected = expected - np.mean(expected, dtype=np.complex128)
|
||||
observed = observed - np.mean(observed, dtype=np.complex128)
|
||||
expected_energy = float(np.vdot(expected, expected).real)
|
||||
observed_energy = float(np.vdot(observed, observed).real)
|
||||
gain = np.vdot(expected, observed) / max(expected_energy, 1e-30)
|
||||
matched = gain * expected
|
||||
residual = observed - matched
|
||||
matched_power = float(np.mean(np.abs(matched) ** 2))
|
||||
residual_power = float(np.mean(np.abs(residual) ** 2))
|
||||
coherence = float(
|
||||
abs(np.vdot(expected, observed))
|
||||
/ math.sqrt(max(expected_energy * observed_energy, 1e-30))
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"label": f"L{index}",
|
||||
"expected_level_db": layout.levels_db[index],
|
||||
"matched_power": matched_power,
|
||||
"residual_power": residual_power,
|
||||
"matched_to_residual_db": (
|
||||
10.0 * math.log10(matched_power / residual_power)
|
||||
if matched_power > 0 and residual_power > 0
|
||||
else None
|
||||
),
|
||||
"normalized_coherence": coherence,
|
||||
"gain_abs": float(abs(gain)),
|
||||
}
|
||||
)
|
||||
|
||||
step_rows = []
|
||||
for lower, upper in zip(rows, rows[1:]):
|
||||
expected_step = float(upper["expected_level_db"]) - float(lower["expected_level_db"])
|
||||
lower_power = float(lower["matched_power"])
|
||||
upper_power = float(upper["matched_power"])
|
||||
observed_step = (
|
||||
10.0 * math.log10(upper_power / lower_power)
|
||||
if lower_power > 0 and upper_power > 0
|
||||
else None
|
||||
)
|
||||
step_rows.append(
|
||||
{
|
||||
"comparison": f"{upper['label']}/{lower['label']}",
|
||||
"expected_step_db": expected_step,
|
||||
"observed_step_db": observed_step,
|
||||
"error_db": observed_step - expected_step if observed_step is not None else None,
|
||||
}
|
||||
)
|
||||
finite_errors = [
|
||||
abs(float(row["error_db"]))
|
||||
for row in step_rows
|
||||
if row["error_db"] is not None
|
||||
]
|
||||
maximum_step_error = max(finite_errors) if len(finite_errors) == len(step_rows) else None
|
||||
|
||||
l3_start, l3_stop = _decimated_window(layout.active_windows()[-1], criteria.decimation)
|
||||
background_start, background_stop = _decimated_window(
|
||||
layout.background_windows()[-2], criteria.decimation
|
||||
)
|
||||
band_half_width = 0.675 * layout.symbol_rate
|
||||
low = layout.carrier_hz - band_half_width
|
||||
high = layout.carrier_hz + band_half_width
|
||||
l3_band_power = _band_power(received[l3_start:l3_stop], decimated_rate, low, high)
|
||||
background_band_power = _band_power(
|
||||
received[background_start:background_stop], decimated_rate, low, high
|
||||
)
|
||||
spectral_contrast = (
|
||||
10.0 * math.log10(l3_band_power / background_band_power)
|
||||
if l3_band_power > 0 and background_band_power > 0
|
||||
else None
|
||||
)
|
||||
l3_ratio = rows[-1]["matched_to_residual_db"]
|
||||
matched_powers = [float(row["matched_power"]) for row in rows]
|
||||
monotonic = all(upper > lower for lower, upper in zip(matched_powers, matched_powers[1:]))
|
||||
step_pass = (
|
||||
maximum_step_error is not None
|
||||
and maximum_step_error <= criteria.maximum_step_error_db
|
||||
and monotonic
|
||||
)
|
||||
coherence_pass = (
|
||||
l3_ratio is not None
|
||||
and float(l3_ratio) >= criteria.l3_matched_to_residual_minimum_db
|
||||
)
|
||||
spectral_pass = (
|
||||
spectral_contrast is not None
|
||||
and spectral_contrast >= criteria.l3_spectral_contrast_minimum_db
|
||||
)
|
||||
return {
|
||||
"segments": rows,
|
||||
"steps": step_rows,
|
||||
"maximum_absolute_step_error_db": maximum_step_error,
|
||||
"monotonic_matched_power": monotonic,
|
||||
"step_pattern_pass": step_pass,
|
||||
"l3_matched_to_residual_pass": coherence_pass,
|
||||
"l3_band_power": l3_band_power,
|
||||
"background_band_power": background_band_power,
|
||||
"l3_spectral_contrast_db": spectral_contrast,
|
||||
"spectral_contrast_pass": spectral_pass,
|
||||
"qualified": step_pass and coherence_pass and spectral_pass,
|
||||
}
|
||||
|
||||
|
||||
def classify_search(
|
||||
search: dict[str, object],
|
||||
verifications: list[dict[str, object]],
|
||||
criteria: SearchCriteria,
|
||||
) -> tuple[str, list[str]]:
|
||||
if search.get("coverage_complete") is not True:
|
||||
return "ANALYSIS_INCOMPLETE", ["FULL_CAPTURE_COVERAGE_NOT_CONFIRMED"]
|
||||
candidates = list(search["candidates"])
|
||||
if not candidates:
|
||||
return "NOT_DETECTED", ["NO_CANDIDATES"]
|
||||
peak = candidates[0]
|
||||
score = float(peak["score"])
|
||||
contrast = peak.get("score_to_background_p99_db")
|
||||
correlation_pass = score >= criteria.normalized_correlation_minimum
|
||||
contrast_pass = (
|
||||
contrast is not None
|
||||
and float(contrast) >= criteria.peak_to_background_minimum_db
|
||||
)
|
||||
qualified = [row for row in verifications if row.get("qualified") is True]
|
||||
reasons = []
|
||||
if not correlation_pass:
|
||||
reasons.append("NORMALIZED_CORRELATION_BELOW_THRESHOLD")
|
||||
if not contrast_pass:
|
||||
reasons.append("PEAK_TO_BACKGROUND_CONTRAST_BELOW_THRESHOLD")
|
||||
if not qualified:
|
||||
reasons.append("STEP_OR_SPECTRAL_CONFIRMATION_FAILED")
|
||||
if correlation_pass and contrast_pass and len(qualified) == 1:
|
||||
return "DETECTED", []
|
||||
if (correlation_pass and contrast_pass) or qualified:
|
||||
if len(qualified) > 1:
|
||||
reasons.append("MULTIPLE_QUALIFIED_CANDIDATES")
|
||||
return "AMBIGUOUS", reasons
|
||||
return "NOT_DETECTED", reasons
|
||||
|
||||
|
||||
def analyze(
|
||||
reference_path: Path,
|
||||
capture_path: Path,
|
||||
report_path: Path,
|
||||
*,
|
||||
expected_capture_sha256: str,
|
||||
layout: ReferenceLayout = DEFAULT_LAYOUT,
|
||||
criteria: SearchCriteria = DEFAULT_CRITERIA,
|
||||
expected_reference_sha256: str = EXPECTED_REFERENCE_SHA256,
|
||||
progress_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
if report_path.exists():
|
||||
raise FileExistsError(f"отчёт уже существует: {report_path}")
|
||||
total_samples = cs8_sample_count(capture_path)
|
||||
if progress_path is not None:
|
||||
_write_progress(
|
||||
progress_path,
|
||||
phase="VERIFYING_REFERENCE",
|
||||
processed_samples=0,
|
||||
total_samples=total_samples,
|
||||
)
|
||||
reference, reference_sha256 = load_reference(
|
||||
reference_path,
|
||||
layout,
|
||||
criteria,
|
||||
expected_sha256=expected_reference_sha256,
|
||||
)
|
||||
capture_progress: Callable[[int, int], None] | None = None
|
||||
if progress_path is not None:
|
||||
capture_progress = lambda processed, total: _write_progress(
|
||||
progress_path,
|
||||
phase="VERIFYING_RX_SHA256",
|
||||
processed_samples=processed // 2,
|
||||
total_samples=total // 2,
|
||||
)
|
||||
capture_sha256 = sha256_file(capture_path, progress=capture_progress)
|
||||
if capture_sha256 != expected_capture_sha256:
|
||||
raise ValueError("SHA256 RX-файла не совпадает с ожидаемым значением")
|
||||
analysis_started = time.perf_counter()
|
||||
with MemoryPeakSampler() as memory:
|
||||
search_progress: Callable[[int, int], None] | None = None
|
||||
if progress_path is not None:
|
||||
search_progress = lambda processed, total: _write_progress(
|
||||
progress_path,
|
||||
phase="SEARCHING",
|
||||
processed_samples=processed,
|
||||
total_samples=total,
|
||||
)
|
||||
search = locate_reference_streaming(
|
||||
capture_path,
|
||||
reference,
|
||||
layout,
|
||||
criteria,
|
||||
progress=search_progress,
|
||||
)
|
||||
verifications = []
|
||||
for candidate in search["candidates"]:
|
||||
verification = verify_candidate(
|
||||
capture_path,
|
||||
reference,
|
||||
candidate,
|
||||
layout,
|
||||
criteria,
|
||||
)
|
||||
verifications.append({"candidate": candidate, **verification})
|
||||
verdict, reasons = classify_search(search, verifications, criteria)
|
||||
analysis_seconds = time.perf_counter() - analysis_started
|
||||
result = {
|
||||
"schema_version": 1,
|
||||
"analyzer_version": ANALYZER_VERSION,
|
||||
"mode": "OFFLINE_REFERENCE_STAIRCASE_SEARCH",
|
||||
"reference_profile": "LAB065_REFERENCE_STAIRCASE",
|
||||
"reference_path": str(reference_path),
|
||||
"reference_sha256": reference_sha256,
|
||||
"capture_path": str(capture_path),
|
||||
"capture_sha256": capture_sha256,
|
||||
"capture_bytes": capture_path.stat().st_size,
|
||||
"capture_complex_samples": cs8_sample_count(capture_path),
|
||||
"layout": asdict(layout),
|
||||
"criteria_fixed_before_capture_analysis": asdict(criteria),
|
||||
"search": search,
|
||||
"verifications": verifications,
|
||||
"verdict": verdict,
|
||||
"verdict_reasons": reasons,
|
||||
"peak_process_memory_bytes": memory.peak_bytes,
|
||||
"peak_additional_memory_bytes": memory.additional_peak_bytes,
|
||||
"analysis_seconds": analysis_seconds,
|
||||
"hardware_accessed": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
serialized = json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + "\n"
|
||||
with report_path.open("x", encoding="utf-8") as stream:
|
||||
stream.write(serialized)
|
||||
if progress_path is not None:
|
||||
_write_progress(
|
||||
progress_path,
|
||||
phase="COMPLETE",
|
||||
processed_samples=total_samples,
|
||||
total_samples=total_samples,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--reference", required=True, type=Path)
|
||||
parser.add_argument("--capture", required=True, type=Path)
|
||||
parser.add_argument("--capture-sha256", required=True)
|
||||
parser.add_argument("--report", required=True, type=Path)
|
||||
parser.add_argument("--progress", type=Path)
|
||||
arguments = parser.parse_args()
|
||||
result = analyze(
|
||||
arguments.reference,
|
||||
arguments.capture,
|
||||
arguments.report,
|
||||
expected_capture_sha256=arguments.capture_sha256,
|
||||
progress_path=arguments.progress,
|
||||
)
|
||||
print(f"REFERENCE STAIRCASE = {result['verdict']}; RF TX = 0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
183
tools/rf001a_prepare.py
Normal file
183
tools/rf001a_prepare.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Prepare and verify RF-001A HackRF cs8 IQ without opening a radio."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
if __package__ in (None, ""):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from protocol.video003_modem import SAMPLE_RATE_HZ
|
||||
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE
|
||||
from tools.qpsk_rf_common import (
|
||||
GAP_MS, PACKET_COUNT, PAYLOAD_BYTES, SEED, TEST_ID, read_iq_file,
|
||||
sha256_file,
|
||||
)
|
||||
from tools.qpsk_rf_tx import write_dry_run
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC_PATH = ROOT / "configs/rf/rf001a_common_clock.json"
|
||||
|
||||
|
||||
def load_spec(path: Path = SPEC_PATH) -> dict:
|
||||
"""Reject a spec that diverges from the existing TX and RX PHY."""
|
||||
spec = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
expected = {
|
||||
"test_id": "RF001A_COMMON_CLOCK_001",
|
||||
"legacy_waveform_test_id": TEST_ID,
|
||||
"center_frequency_hz": 1_400_000_000,
|
||||
"sample_rate_hz": SAMPLE_RATE_HZ,
|
||||
"symbol_rate_sps": SYMBOL_RATE,
|
||||
"modulation": "QPSK",
|
||||
"inner_fec": "2/3",
|
||||
"application_payload_bytes": PAYLOAD_BYTES,
|
||||
"packet_count": PACKET_COUNT,
|
||||
"inter_packet_gap_ms": GAP_MS,
|
||||
"seed": SEED,
|
||||
"digital_carrier_offset_hz": int(NOMINAL_CARRIER_HZ),
|
||||
"clock_mode": "COMMON_10_MHZ",
|
||||
"tx_vga_db": 20,
|
||||
"rx_lna_db": 24,
|
||||
"rx_vga_db": 24,
|
||||
"rf_amp_enabled": False,
|
||||
"antenna_bias_enabled": False,
|
||||
"canonical_format": "c64le",
|
||||
"hardware_format": "cs8",
|
||||
"cs8_scale_codes_per_source_unit": 256,
|
||||
"expected_sample_count": 8_833_828,
|
||||
"rx_capture_seconds": 20,
|
||||
"rx_capture_samples": 160_000_000,
|
||||
}
|
||||
for key, value in expected.items():
|
||||
if type(spec.get(key)) is not type(value) or spec[key] != value:
|
||||
raise ValueError(f"RF-001A spec disagrees with existing PHY: {key}")
|
||||
digest = spec.get("expected_canonical_sha256", "")
|
||||
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
||||
raise ValueError("invalid expected canonical SHA256")
|
||||
hardware_digest = spec.get("expected_hardware_sha256", "")
|
||||
if len(hardware_digest) != 64 or any(char not in "0123456789abcdef" for char in hardware_digest):
|
||||
raise ValueError("invalid expected hardware SHA256")
|
||||
if spec["rx_capture_samples"] != spec["sample_rate_hz"] * spec["rx_capture_seconds"]:
|
||||
raise ValueError("RX capture sample count disagrees with rate and duration")
|
||||
return spec
|
||||
|
||||
|
||||
def quantize_cs8(source: np.ndarray, scale: int) -> np.ndarray:
|
||||
"""Interleave signed I then Q; fail if scaling would hit either rail."""
|
||||
if source.ndim != 1 or not np.iscomplexobj(source):
|
||||
raise ValueError("expected one-dimensional complex IQ")
|
||||
if not np.all(np.isfinite(source)):
|
||||
raise ValueError("nonfinite IQ")
|
||||
maximum = max(float(np.max(np.abs(source.real))), float(np.max(np.abs(source.imag))))
|
||||
if maximum * scale >= 126.5:
|
||||
raise ValueError("cs8 rail risk")
|
||||
raw = np.empty(source.size * 2, dtype=np.int8)
|
||||
raw[0::2] = np.rint(source.real * scale).astype(np.int8)
|
||||
raw[1::2] = np.rint(source.imag * scale).astype(np.int8)
|
||||
return raw
|
||||
|
||||
|
||||
def verify_artifacts(directory: Path, spec: dict) -> dict:
|
||||
"""Validate both files and reverse-read the actual HackRF bytes."""
|
||||
canonical = directory / "tx_waveform.c64le"
|
||||
hardware = directory / "tx_waveform.cs8"
|
||||
truth_path = directory / "tx_metadata.json"
|
||||
record_path = directory / "rf001a_metadata.json"
|
||||
truth = json.loads(truth_path.read_text(encoding="utf-8"))
|
||||
record = json.loads(record_path.read_text(encoding="utf-8"))
|
||||
count = spec["expected_sample_count"]
|
||||
if (truth["test_id"] != spec["legacy_waveform_test_id"]
|
||||
or truth["waveform_sample_count"] != count
|
||||
or truth["packet_count"] != spec["packet_count"]
|
||||
or truth["seed"] != spec["seed"]
|
||||
or truth["digital_carrier_offset_hz"] != spec["digital_carrier_offset_hz"]
|
||||
or [row["sequence_number"] for row in truth["expected_packet_hashes"]]
|
||||
!= list(range(spec["packet_count"]))):
|
||||
raise ValueError("TX truth disagrees with shared spec")
|
||||
if canonical.stat().st_size != count * 8 or hardware.stat().st_size != count * 2:
|
||||
raise ValueError("IQ byte count disagrees with shared spec")
|
||||
if sha256_file(canonical) != spec["expected_canonical_sha256"]:
|
||||
raise ValueError("canonical waveform SHA256 mismatch")
|
||||
if truth["iq_sha256"] != spec["expected_canonical_sha256"]:
|
||||
raise ValueError("TX metadata SHA256 mismatch")
|
||||
if record["test_id"] != spec["test_id"] or record["cs8_scale"] != spec["cs8_scale_codes_per_source_unit"]:
|
||||
raise ValueError("RF-001A artifact metadata mismatch")
|
||||
if record["hardware_sha256"] != spec["expected_hardware_sha256"] or record["hardware_sha256"] != sha256_file(hardware):
|
||||
raise ValueError("hardware waveform SHA256 mismatch")
|
||||
source, _ = read_iq_file(canonical)
|
||||
observed, clipping = read_iq_file(hardware, "cs8")
|
||||
scale = spec["cs8_scale_codes_per_source_unit"]
|
||||
expected_codes = quantize_cs8(source, scale)
|
||||
actual_codes = np.memmap(hardware, dtype=np.int8, mode="r")
|
||||
if not np.array_equal(actual_codes, expected_codes) or clipping != 0:
|
||||
raise ValueError("cs8 readback or rail-code mismatch")
|
||||
expected_quantized = (expected_codes[0::2].astype(np.float32)
|
||||
+ 1j * expected_codes[1::2].astype(np.float32)) / 128
|
||||
if not np.array_equal(observed, expected_quantized.astype(np.complex64)):
|
||||
raise ValueError("cs8 I/Q order or signed readback mismatch")
|
||||
maximum_error = max(float(np.max(np.abs(source.real - expected_codes[0::2] / scale))),
|
||||
float(np.max(np.abs(source.imag - expected_codes[1::2] / scale))))
|
||||
if maximum_error > 0.5001 / scale:
|
||||
raise ValueError("quantization error exceeds half a code")
|
||||
return {
|
||||
"samples": count,
|
||||
"duration_s": count / spec["sample_rate_hz"],
|
||||
"canonical_sha256": sha256_file(canonical),
|
||||
"hardware_sha256": record["hardware_sha256"],
|
||||
"hardware_bytes": hardware.stat().st_size,
|
||||
"peak_component": record["peak_component"],
|
||||
"rms_complex": record["rms_complex"],
|
||||
"cs8_rail_codes": clipping,
|
||||
"maximum_component_error_source_units": maximum_error,
|
||||
}
|
||||
|
||||
|
||||
def prepare(directory: Path, spec: dict) -> dict:
|
||||
"""Create a new ignored output directory, then verify its TX bytes."""
|
||||
canonical, _ = write_dry_run(directory)
|
||||
source, _ = read_iq_file(canonical)
|
||||
scale = spec["cs8_scale_codes_per_source_unit"]
|
||||
raw = quantize_cs8(source, scale)
|
||||
hardware = directory / "tx_waveform.cs8"
|
||||
raw.tofile(hardware)
|
||||
record = {
|
||||
"test_id": spec["test_id"],
|
||||
"shared_spec": "configs/rf/rf001a_common_clock.json",
|
||||
"legacy_truth": "tx_metadata.json",
|
||||
"canonical_sha256": sha256_file(canonical),
|
||||
"hardware_sha256": sha256_file(hardware),
|
||||
"cs8_scale": scale,
|
||||
"peak_component": float(max(np.max(np.abs(source.real)), np.max(np.abs(source.imag)))),
|
||||
"rms_complex": float(np.sqrt(np.mean(np.abs(source) ** 2))),
|
||||
"sample_count": int(source.size),
|
||||
"duration_s": source.size / spec["sample_rate_hz"],
|
||||
"hardware_format": "signed int8, interleaved I/Q",
|
||||
"rf_tx": 0,
|
||||
}
|
||||
(directory / "rf001a_metadata.json").write_text(
|
||||
json.dumps(record, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
return verify_artifacts(directory, spec)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--output-dir", type=Path)
|
||||
group.add_argument("--verify-dir", type=Path)
|
||||
arguments = parser.parse_args()
|
||||
spec = load_spec()
|
||||
result = (prepare(arguments.output_dir, spec) if arguments.output_dir is not None
|
||||
else verify_artifacts(arguments.verify_dir, spec))
|
||||
print(json.dumps(result, indent=2))
|
||||
print("RF TX=0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
363
tools/rf001a_streaming.py
Normal file
363
tools/rf001a_streaming.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Потоковое чтение больших cs8-захватов и оконный приём RF-001A."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
import ctypes
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from typing import Iterable
|
||||
|
||||
import numpy as np
|
||||
|
||||
from protocol.video003_modem import SAMPLE_RATE_HZ, build_frame, samples_per_symbol
|
||||
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE, shape_packet
|
||||
from protocol.video005_receiver import IqStreamResult, ReceivedIqPacket, receive_iq_stream
|
||||
from tools.qpsk_rf_common import PAYLOAD_BYTES, SEED, payload_for_sequence
|
||||
|
||||
|
||||
DEFAULT_CHUNK_SAMPLES = 4_000_000
|
||||
LOCATOR_BLOCK_SAMPLES = 400_000
|
||||
TIMESTAMP_MARGIN_SECONDS = 2.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SampleWindow:
|
||||
start: int
|
||||
stop: int
|
||||
|
||||
@property
|
||||
def sample_count(self) -> int:
|
||||
return self.stop - self.start
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StreamingReception:
|
||||
reception: IqStreamResult
|
||||
windows: tuple[SampleWindow, ...]
|
||||
window_source: str
|
||||
overlap_samples: int
|
||||
maximum_loaded_samples: int
|
||||
clipping_codes: int
|
||||
|
||||
|
||||
class MemoryPeakSampler:
|
||||
"""Измеряет пиковый working set процесса во время анализа."""
|
||||
|
||||
def __init__(self, poll_seconds: float = 0.02) -> None:
|
||||
self.poll_seconds = poll_seconds
|
||||
self.baseline_bytes = current_process_rss_bytes()
|
||||
self.peak_bytes = self.baseline_bytes
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._sample, daemon=True)
|
||||
|
||||
def _sample(self) -> None:
|
||||
while not self._stop.wait(self.poll_seconds):
|
||||
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||
|
||||
def __enter__(self) -> MemoryPeakSampler:
|
||||
self._thread.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self.peak_bytes = max(self.peak_bytes, current_process_rss_bytes())
|
||||
self._stop.set()
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
@property
|
||||
def additional_peak_bytes(self) -> int:
|
||||
return max(0, self.peak_bytes - self.baseline_bytes)
|
||||
|
||||
|
||||
def current_process_rss_bytes() -> int:
|
||||
"""Возвращает текущий working set процесса без сторонних библиотек."""
|
||||
|
||||
if not hasattr(ctypes, "windll"):
|
||||
return 0
|
||||
|
||||
class ProcessMemoryCounters(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cb", ctypes.c_ulong),
|
||||
("page_fault_count", ctypes.c_ulong),
|
||||
("peak_working_set_size", ctypes.c_size_t),
|
||||
("working_set_size", ctypes.c_size_t),
|
||||
("quota_peak_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_peak_non_paged_pool_usage", ctypes.c_size_t),
|
||||
("quota_non_paged_pool_usage", ctypes.c_size_t),
|
||||
("pagefile_usage", ctypes.c_size_t),
|
||||
("peak_pagefile_usage", ctypes.c_size_t),
|
||||
]
|
||||
|
||||
counters = ProcessMemoryCounters()
|
||||
counters.cb = ctypes.sizeof(counters)
|
||||
get_current_process = ctypes.windll.kernel32.GetCurrentProcess # type: ignore[attr-defined]
|
||||
get_current_process.restype = ctypes.c_void_p
|
||||
get_process_memory_info = ctypes.windll.psapi.GetProcessMemoryInfo # type: ignore[attr-defined]
|
||||
get_process_memory_info.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(ProcessMemoryCounters),
|
||||
ctypes.c_ulong,
|
||||
]
|
||||
get_process_memory_info.restype = ctypes.c_int
|
||||
succeeded = get_process_memory_info(
|
||||
get_current_process(),
|
||||
ctypes.byref(counters),
|
||||
counters.cb,
|
||||
)
|
||||
return int(counters.working_set_size) if succeeded else 0
|
||||
|
||||
|
||||
def cs8_sample_count(path: Path) -> int:
|
||||
size = path.stat().st_size
|
||||
if size == 0 or size % 2:
|
||||
raise ValueError("cs8 должен содержать целое ненулевое число пар I/Q")
|
||||
return size // 2
|
||||
|
||||
|
||||
def read_cs8_window(raw: np.memmap, start: int, stop: int) -> np.ndarray:
|
||||
"""Преобразует в complex64 только заданное окно cs8."""
|
||||
|
||||
total_samples = raw.size // 2
|
||||
if start < 0 or stop <= start or stop > total_samples:
|
||||
raise ValueError("границы окна cs8 выходят за пределы файла")
|
||||
values = np.asarray(raw[start * 2:stop * 2], dtype=np.int8)
|
||||
real = values[0::2].astype(np.float32)
|
||||
imaginary = values[1::2].astype(np.float32)
|
||||
return ((real + 1j * imaginary) / 128).astype(np.complex64)
|
||||
|
||||
|
||||
def rf001_packet_waveform_samples() -> int:
|
||||
"""Вычисляет длину волны из настоящего 256-байтного пакета RF-001."""
|
||||
|
||||
if PAYLOAD_BYTES != 256:
|
||||
raise AssertionError("канонический RF-001 payload неожиданно изменился")
|
||||
payload = payload_for_sequence(SEED, 0)
|
||||
frame = build_frame(payload, "QPSK", "2/3", sequence_number=0)
|
||||
return int(shape_packet(frame).size)
|
||||
|
||||
|
||||
def required_overlap_samples() -> int:
|
||||
"""Перекрывает целую максимальную волну одного пакета RF-001."""
|
||||
|
||||
return rf001_packet_waveform_samples()
|
||||
|
||||
|
||||
def _parse_utc(value: str) -> datetime:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp должен содержать часовой пояс")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def timestamp_sample_window(
|
||||
*,
|
||||
capture_started_at: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str,
|
||||
total_samples: int,
|
||||
sample_rate: int = SAMPLE_RATE_HZ,
|
||||
margin_seconds: float = TIMESTAMP_MARGIN_SECONDS,
|
||||
) -> SampleWindow:
|
||||
"""Переводит UTC-границы TX в безопасное окно отсчётов RX."""
|
||||
|
||||
if total_samples <= 0 or sample_rate <= 0 or margin_seconds < 0:
|
||||
raise ValueError("параметры timestamp-окна должны быть положительными")
|
||||
capture = _parse_utc(capture_started_at)
|
||||
started = _parse_utc(tx_started_at)
|
||||
finished = _parse_utc(tx_finished_at)
|
||||
if finished < started:
|
||||
raise ValueError("TX_FINISHED_AT не может предшествовать TX_STARTED_AT")
|
||||
margin_samples = round(margin_seconds * sample_rate)
|
||||
start_offset = round((started - capture).total_seconds() * sample_rate)
|
||||
stop_offset = round((finished - capture).total_seconds() * sample_rate)
|
||||
start = max(0, start_offset - margin_samples)
|
||||
stop = min(total_samples, stop_offset + margin_samples)
|
||||
if stop <= start:
|
||||
raise ValueError("TX timestamps не пересекаются с RX capture")
|
||||
return SampleWindow(start, stop)
|
||||
|
||||
|
||||
def _merge_windows(windows: Iterable[SampleWindow]) -> tuple[SampleWindow, ...]:
|
||||
ordered = sorted(windows, key=lambda item: item.start)
|
||||
merged: list[SampleWindow] = []
|
||||
for window in ordered:
|
||||
if not merged or window.start > merged[-1].stop:
|
||||
merged.append(window)
|
||||
else:
|
||||
merged[-1] = SampleWindow(
|
||||
merged[-1].start,
|
||||
max(merged[-1].stop, window.stop),
|
||||
)
|
||||
return tuple(merged)
|
||||
|
||||
|
||||
def locate_power_windows(
|
||||
path: Path,
|
||||
*,
|
||||
block_samples: int = LOCATOR_BLOCK_SAMPLES,
|
||||
padding_seconds: float = 1.5,
|
||||
) -> tuple[SampleWindow, ...]:
|
||||
"""Ищет повышенную мощность по всему cs8-файлу ограниченными блоками."""
|
||||
|
||||
total_samples = cs8_sample_count(path)
|
||||
if block_samples <= 0 or padding_seconds < 0:
|
||||
raise ValueError("параметры locator должны быть неотрицательными")
|
||||
powers: list[float] = []
|
||||
with path.open("rb") as stream:
|
||||
while raw_bytes := stream.read(block_samples * 2):
|
||||
values = np.frombuffer(raw_bytes, dtype=np.int8).astype(np.float32)
|
||||
powers.append(float(np.mean(values * values)))
|
||||
if not powers:
|
||||
return ()
|
||||
power_array = np.asarray(powers, dtype=np.float64)
|
||||
median = float(np.median(power_array))
|
||||
mad = float(np.median(np.abs(power_array - median)))
|
||||
threshold = median + max(6.0 * mad, median * 0.15, 1.0)
|
||||
active = np.flatnonzero(power_array > threshold)
|
||||
if active.size == 0:
|
||||
return ()
|
||||
padding = round(padding_seconds * SAMPLE_RATE_HZ)
|
||||
candidates = (
|
||||
SampleWindow(
|
||||
max(0, int(index) * block_samples - padding),
|
||||
min(total_samples, (int(index) + 1) * block_samples + padding),
|
||||
)
|
||||
for index in active
|
||||
)
|
||||
return _merge_windows(candidates)
|
||||
|
||||
|
||||
def count_cs8_clipping(path: Path, *, block_samples: int = DEFAULT_CHUNK_SAMPLES) -> int:
|
||||
"""Считает rail-коды во всём файле, сохраняя ограниченный объём памяти."""
|
||||
|
||||
cs8_sample_count(path)
|
||||
clipping = 0
|
||||
with path.open("rb") as stream:
|
||||
while raw_bytes := stream.read(block_samples * 2):
|
||||
values = np.frombuffer(raw_bytes, dtype=np.int8)
|
||||
clipping += int(np.count_nonzero((values == -128) | (values == 127)))
|
||||
return clipping
|
||||
|
||||
|
||||
def _is_overlap_duplicate(
|
||||
packets: list[ReceivedIqPacket],
|
||||
candidate: ReceivedIqPacket,
|
||||
) -> bool:
|
||||
tolerance = samples_per_symbol(SYMBOL_RATE)
|
||||
return any(
|
||||
packet.packet == candidate.packet
|
||||
and abs(packet.start_sample - candidate.start_sample) <= tolerance
|
||||
for packet in packets
|
||||
)
|
||||
|
||||
|
||||
def receive_cs8_windows(
|
||||
path: Path,
|
||||
windows: Iterable[SampleWindow],
|
||||
*,
|
||||
decoder_backend: str = "numba",
|
||||
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
|
||||
overlap_samples: int | None = None,
|
||||
) -> StreamingReception:
|
||||
"""Принимает заданные окна частями с перекрытием и удаляет повторы."""
|
||||
|
||||
selected = _merge_windows(windows)
|
||||
overlap = required_overlap_samples() if overlap_samples is None else overlap_samples
|
||||
if chunk_samples <= overlap or overlap < required_overlap_samples():
|
||||
raise ValueError("chunk должен быть длиннее полного overlap RF-001")
|
||||
raw = np.memmap(path, dtype=np.int8, mode="r")
|
||||
packets: list[ReceivedIqPacket] = []
|
||||
candidate_count = 0
|
||||
rejected_candidates = 0
|
||||
stage_time: dict[str, float] = {}
|
||||
maximum_loaded = 0
|
||||
for selected_window in selected:
|
||||
for primary_start in range(selected_window.start, selected_window.stop, chunk_samples):
|
||||
primary_stop = min(selected_window.stop, primary_start + chunk_samples)
|
||||
read_start = max(selected_window.start, primary_start - overlap)
|
||||
read_stop = min(selected_window.stop, primary_stop + overlap)
|
||||
iq = read_cs8_window(raw, read_start, read_stop)
|
||||
maximum_loaded = max(maximum_loaded, int(iq.size))
|
||||
received = receive_iq_stream(
|
||||
iq,
|
||||
carrier_hz=NOMINAL_CARRIER_HZ,
|
||||
decoder_backend=decoder_backend,
|
||||
)
|
||||
candidate_count += received.candidate_count
|
||||
rejected_candidates += received.rejected_candidates
|
||||
for name, duration in received.stage_time_s.items():
|
||||
stage_time[name] = stage_time.get(name, 0.0) + duration
|
||||
for packet in received.packets:
|
||||
global_packet = replace(
|
||||
packet,
|
||||
start_sample=packet.start_sample + read_start,
|
||||
)
|
||||
if not primary_start <= global_packet.start_sample < primary_stop:
|
||||
continue
|
||||
if not _is_overlap_duplicate(packets, global_packet):
|
||||
packets.append(global_packet)
|
||||
del iq
|
||||
packets.sort(key=lambda packet: packet.start_sample)
|
||||
return StreamingReception(
|
||||
IqStreamResult(
|
||||
packets=tuple(packets),
|
||||
candidate_count=candidate_count,
|
||||
rejected_candidates=rejected_candidates,
|
||||
stage_time_s=stage_time,
|
||||
),
|
||||
selected,
|
||||
"EXPLICIT",
|
||||
overlap,
|
||||
maximum_loaded,
|
||||
count_cs8_clipping(path),
|
||||
)
|
||||
|
||||
|
||||
def decode_cs8_streaming(
|
||||
path: Path,
|
||||
*,
|
||||
capture_started_at: str | None = None,
|
||||
tx_started_at: str | None = None,
|
||||
tx_finished_at: str | None = None,
|
||||
decoder_backend: str = "numba",
|
||||
chunk_samples: int = DEFAULT_CHUNK_SAMPLES,
|
||||
margin_seconds: float = TIMESTAMP_MARGIN_SECONDS,
|
||||
) -> StreamingReception:
|
||||
"""Выбирает timestamp-окно либо потоковый power locator и запускает приём."""
|
||||
|
||||
total_samples = cs8_sample_count(path)
|
||||
timestamp_values = (capture_started_at, tx_started_at, tx_finished_at)
|
||||
if all(value is not None for value in timestamp_values):
|
||||
windows = (
|
||||
timestamp_sample_window(
|
||||
capture_started_at=str(capture_started_at),
|
||||
tx_started_at=str(tx_started_at),
|
||||
tx_finished_at=str(tx_finished_at),
|
||||
total_samples=total_samples,
|
||||
margin_seconds=margin_seconds,
|
||||
),
|
||||
)
|
||||
source = "TX_TIMESTAMPS"
|
||||
elif any(value is not None for value in timestamp_values):
|
||||
raise ValueError("для timestamp-окна нужны все три времени RX/TX")
|
||||
else:
|
||||
windows = locate_power_windows(path)
|
||||
source = "STREAMING_POWER_LOCATOR"
|
||||
if not windows:
|
||||
return StreamingReception(
|
||||
IqStreamResult((), 0, 0, {}),
|
||||
(),
|
||||
source,
|
||||
required_overlap_samples(),
|
||||
0,
|
||||
count_cs8_clipping(path),
|
||||
)
|
||||
reception = receive_cs8_windows(
|
||||
path,
|
||||
windows,
|
||||
decoder_backend=decoder_backend,
|
||||
chunk_samples=chunk_samples,
|
||||
)
|
||||
return replace(reception, window_source=source)
|
||||
736
tools/rf_handoff_channel.py
Normal file
736
tools/rf_handoff_channel.py
Normal file
@@ -0,0 +1,736 @@
|
||||
"""Strict Git result channel for coordinated RX and MAIN PC handoff."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
EXPECTED_RX_TASK_ID = "RF001A_OTA_CAPTURE_004"
|
||||
EXPECTED_TX_TASK_ID = "RF001A_OTA_TX_004"
|
||||
EXPECTED_CAPTURE_DURATION_SECONDS = 60.0
|
||||
CHANNEL_TEST_TASK_ID = "RF_HANDOFF_CHANNEL_TEST_001"
|
||||
READY_STATE = "RX_READY_FOR_TX"
|
||||
COMPLETE_STATE = "CAPTURE_COMPLETE"
|
||||
RESULT_JSON_PATH = "docs/handoff/results/RX_RESULT.json"
|
||||
RESULT_MARKDOWN_PATH = "docs/handoff/results/RX_RESULT.md"
|
||||
RESULT_PATHS = frozenset((RESULT_JSON_PATH, RESULT_MARKDOWN_PATH))
|
||||
MAX_RESULT_BYTES = 64 * 1024
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_TRANSIENT_PUSH_ERRORS = (
|
||||
"authentication failed",
|
||||
"failed to authenticate",
|
||||
"could not resolve host",
|
||||
"connection reset",
|
||||
"connection timed out",
|
||||
"failed to connect",
|
||||
"remote end hung up",
|
||||
"rpc failed",
|
||||
"schannel",
|
||||
"ssl",
|
||||
"tls",
|
||||
"the requested url returned error: 5",
|
||||
"unable to access",
|
||||
)
|
||||
|
||||
|
||||
class ResultChannelError(RuntimeError):
|
||||
"""The result channel cannot be read or updated safely."""
|
||||
|
||||
|
||||
class ResultBranchMissing(ResultChannelError):
|
||||
"""The configured result branch does not exist on the remote."""
|
||||
|
||||
|
||||
class ResultHistoryRewritten(ResultChannelError):
|
||||
"""The result branch no longer descends from the processed commit."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RxHandoffResult:
|
||||
schema_version: int
|
||||
task_id: str
|
||||
state: str
|
||||
rx_ready_for_tx: bool
|
||||
process_alive: bool
|
||||
file_growing: bool
|
||||
capture_pid: int | None
|
||||
published_at: str
|
||||
capture_started_at: str
|
||||
expected_capture_end: str
|
||||
capture_path: str
|
||||
common_clock_verified: bool
|
||||
source_head: str
|
||||
rf_tx: int
|
||||
exit_code: int | None
|
||||
file_size: int | None
|
||||
duration_seconds: float | None
|
||||
overruns: int | None
|
||||
usb_errors: int | None
|
||||
file_errors: int | None
|
||||
sha256: str | None
|
||||
clkin_after: str
|
||||
rf_tx_observed: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReadyEvaluation:
|
||||
ready: bool
|
||||
reason: str
|
||||
seconds_remaining: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishOutcome:
|
||||
status: str
|
||||
commit: str
|
||||
remote_head: str
|
||||
json_path: str = RESULT_JSON_PATH
|
||||
markdown_path: str = RESULT_MARKDOWN_PATH
|
||||
|
||||
|
||||
def _parse_utc(value: str, field_name: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError(f"{field_name} must be an ISO-8601 timestamp") from error
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError(f"{field_name} must contain a timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _validate_types(result: RxHandoffResult) -> None:
|
||||
string_fields = (
|
||||
result.task_id,
|
||||
result.state,
|
||||
result.published_at,
|
||||
result.capture_started_at,
|
||||
result.expected_capture_end,
|
||||
result.capture_path,
|
||||
result.source_head,
|
||||
result.clkin_after,
|
||||
result.rf_tx_observed,
|
||||
)
|
||||
if any(type(value) is not str for value in string_fields):
|
||||
raise ValueError("string result fields must contain strings")
|
||||
boolean_fields = (
|
||||
result.rx_ready_for_tx,
|
||||
result.process_alive,
|
||||
result.file_growing,
|
||||
result.common_clock_verified,
|
||||
)
|
||||
if any(type(value) is not bool for value in boolean_fields):
|
||||
raise ValueError("boolean result fields must contain booleans")
|
||||
if type(result.schema_version) is not int or type(result.rf_tx) is not int:
|
||||
raise ValueError("schema_version and rf_tx must contain integers")
|
||||
optional_integer_fields = (
|
||||
result.capture_pid,
|
||||
result.exit_code,
|
||||
result.file_size,
|
||||
result.overruns,
|
||||
result.usb_errors,
|
||||
result.file_errors,
|
||||
)
|
||||
if any(
|
||||
value is not None and type(value) is not int
|
||||
for value in optional_integer_fields
|
||||
):
|
||||
raise ValueError("integer result fields must contain integers or null")
|
||||
if (
|
||||
result.duration_seconds is not None
|
||||
and (
|
||||
isinstance(result.duration_seconds, bool)
|
||||
or not isinstance(result.duration_seconds, (int, float))
|
||||
)
|
||||
):
|
||||
raise ValueError("duration_seconds must contain a number or null")
|
||||
if result.sha256 is not None and type(result.sha256) is not str:
|
||||
raise ValueError("sha256 must contain a string or null")
|
||||
|
||||
|
||||
def _validate_common(result: RxHandoffResult) -> None:
|
||||
_validate_types(result)
|
||||
if result.schema_version != SCHEMA_VERSION:
|
||||
raise ValueError(f"schema_version must be {SCHEMA_VERSION}")
|
||||
if result.state not in {READY_STATE, COMPLETE_STATE}:
|
||||
raise ValueError("state is not supported")
|
||||
if result.rf_tx != 0:
|
||||
raise ValueError("rf_tx must remain 0 on the RX node")
|
||||
if not result.task_id or any(character in result.task_id for character in "\r\n"):
|
||||
raise ValueError("task_id is invalid")
|
||||
if not _HEAD_RE.fullmatch(result.source_head):
|
||||
raise ValueError("source_head must be a lowercase 40-character Git hash")
|
||||
if not result.capture_path or any(
|
||||
character in result.capture_path for character in "\r\n"
|
||||
):
|
||||
raise ValueError("capture_path is invalid")
|
||||
published = _parse_utc(result.published_at, "published_at")
|
||||
started = _parse_utc(result.capture_started_at, "capture_started_at")
|
||||
expected_end = _parse_utc(result.expected_capture_end, "expected_capture_end")
|
||||
if published < started:
|
||||
raise ValueError("published_at cannot precede capture_started_at")
|
||||
if expected_end <= started:
|
||||
raise ValueError("expected_capture_end must follow capture_started_at")
|
||||
if result.clkin_after not in {"DETECTED", "NOT_DETECTED", "NOT_RUN"}:
|
||||
raise ValueError("clkin_after is invalid")
|
||||
if result.rf_tx_observed not in {
|
||||
"EXPECTED_EXTERNAL_TX",
|
||||
"UNKNOWN",
|
||||
}:
|
||||
raise ValueError("rf_tx_observed is invalid")
|
||||
|
||||
|
||||
def _validate_ready(result: RxHandoffResult) -> None:
|
||||
if not (
|
||||
result.rx_ready_for_tx
|
||||
and result.process_alive
|
||||
and result.file_growing
|
||||
):
|
||||
raise ValueError("READY requires a live process and growing capture")
|
||||
if result.task_id == CHANNEL_TEST_TASK_ID:
|
||||
if result.common_clock_verified:
|
||||
raise ValueError("channel smoke READY must not claim hardware clock proof")
|
||||
elif not result.common_clock_verified:
|
||||
raise ValueError("production READY requires verified common clock")
|
||||
if result.capture_pid is None or result.capture_pid <= 0:
|
||||
raise ValueError("READY requires a positive capture_pid")
|
||||
final_fields = (
|
||||
result.exit_code,
|
||||
result.file_size,
|
||||
result.duration_seconds,
|
||||
result.overruns,
|
||||
result.usb_errors,
|
||||
result.file_errors,
|
||||
result.sha256,
|
||||
)
|
||||
if any(value is not None for value in final_fields):
|
||||
raise ValueError("READY must not contain final capture evidence")
|
||||
|
||||
|
||||
def _validate_complete(result: RxHandoffResult) -> None:
|
||||
if result.rx_ready_for_tx or result.process_alive or result.file_growing:
|
||||
raise ValueError("COMPLETE cannot advertise a live capture")
|
||||
if result.capture_pid is None or result.capture_pid <= 0:
|
||||
raise ValueError("COMPLETE requires the capture_pid")
|
||||
if result.exit_code is None or result.file_size is None:
|
||||
raise ValueError("COMPLETE requires exit_code and file_size")
|
||||
if result.file_size < 0:
|
||||
raise ValueError("file_size cannot be negative")
|
||||
counters = (result.overruns, result.usb_errors, result.file_errors)
|
||||
if any(value is not None and value < 0 for value in counters):
|
||||
raise ValueError("error counters cannot be negative")
|
||||
if result.duration_seconds is None or result.duration_seconds < 0:
|
||||
raise ValueError("COMPLETE requires a non-negative duration_seconds")
|
||||
if result.sha256 is None or not _SHA256_RE.fullmatch(result.sha256):
|
||||
raise ValueError("COMPLETE requires a lowercase SHA256")
|
||||
|
||||
|
||||
def validate_result(result: RxHandoffResult) -> RxHandoffResult:
|
||||
_validate_common(result)
|
||||
if result.state == READY_STATE:
|
||||
_validate_ready(result)
|
||||
else:
|
||||
_validate_complete(result)
|
||||
return result
|
||||
|
||||
|
||||
def result_from_mapping(raw: Mapping[str, Any]) -> RxHandoffResult:
|
||||
expected_fields = {field.name for field in fields(RxHandoffResult)}
|
||||
actual_fields = set(raw)
|
||||
if actual_fields != expected_fields:
|
||||
missing = sorted(expected_fields - actual_fields)
|
||||
extra = sorted(actual_fields - expected_fields)
|
||||
raise ValueError(f"result fields disagree: missing={missing}, extra={extra}")
|
||||
result = RxHandoffResult(**{name: raw[name] for name in expected_fields})
|
||||
return validate_result(result)
|
||||
|
||||
|
||||
def load_result(path: Path) -> RxHandoffResult:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("result JSON root must be an object")
|
||||
return result_from_mapping(raw)
|
||||
|
||||
|
||||
def canonical_result_json(result: RxHandoffResult) -> str:
|
||||
validate_result(result)
|
||||
return json.dumps(
|
||||
asdict(result),
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
) + "\n"
|
||||
|
||||
|
||||
def result_digest(result: RxHandoffResult) -> str:
|
||||
return hashlib.sha256(canonical_result_json(result).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def render_result_markdown(result: RxHandoffResult) -> str:
|
||||
validate_result(result)
|
||||
rows = [
|
||||
"# RX result",
|
||||
"",
|
||||
f"SCHEMA_VERSION = {result.schema_version}",
|
||||
f"TASK_ID = {result.task_id}",
|
||||
f"STATE = {result.state}",
|
||||
f"RX_READY_FOR_TX = {'YES' if result.rx_ready_for_tx else 'NO'}",
|
||||
f"PROCESS_ALIVE = {'YES' if result.process_alive else 'NO'}",
|
||||
f"FILE_GROWING = {'YES' if result.file_growing else 'NO'}",
|
||||
f"CAPTURE_PID = {result.capture_pid}",
|
||||
f"PUBLISHED_AT = {result.published_at}",
|
||||
f"CAPTURE_STARTED_AT = {result.capture_started_at}",
|
||||
f"EXPECTED_CAPTURE_END = {result.expected_capture_end}",
|
||||
f"CAPTURE_PATH = {result.capture_path}",
|
||||
f"COMMON_CLOCK_VERIFIED = {'YES' if result.common_clock_verified else 'NO'}",
|
||||
f"SOURCE_HEAD = {result.source_head}",
|
||||
f"EXIT_CODE = {result.exit_code}",
|
||||
f"FILE_SIZE = {result.file_size}",
|
||||
f"DURATION_SECONDS = {result.duration_seconds}",
|
||||
f"OVERRUNS = {result.overruns}",
|
||||
f"USB_ERRORS = {result.usb_errors}",
|
||||
f"FILE_ERRORS = {result.file_errors}",
|
||||
f"SHA256 = {result.sha256}",
|
||||
f"CLKIN_AFTER = {result.clkin_after}",
|
||||
f"RF_TX_OBSERVED = {result.rf_tx_observed}",
|
||||
f"RF_TX = {result.rf_tx}",
|
||||
"",
|
||||
"Raw IQ, video, waveforms, and large logs are not stored in this branch.",
|
||||
"",
|
||||
]
|
||||
return "\n".join(rows)
|
||||
|
||||
|
||||
def evaluate_ready(
|
||||
result: RxHandoffResult,
|
||||
*,
|
||||
now: datetime,
|
||||
expected_task_id: str = EXPECTED_RX_TASK_ID,
|
||||
expected_source_head: str | None = None,
|
||||
max_age_seconds: float = 30.0,
|
||||
min_remaining_seconds: float = 10.0,
|
||||
) -> ReadyEvaluation:
|
||||
validate_result(result)
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("now must contain a timezone")
|
||||
now_utc = now.astimezone(timezone.utc)
|
||||
if result.task_id != expected_task_id:
|
||||
return ReadyEvaluation(False, "WRONG_TASK_ID")
|
||||
if expected_source_head is not None and result.source_head != expected_source_head:
|
||||
return ReadyEvaluation(False, "WRONG_SOURCE_HEAD")
|
||||
if result.state != READY_STATE or not result.rx_ready_for_tx:
|
||||
return ReadyEvaluation(False, "MISSING_READY")
|
||||
if not result.common_clock_verified:
|
||||
return ReadyEvaluation(False, "COMMON_CLOCK_NOT_VERIFIED")
|
||||
started = _parse_utc(result.capture_started_at, "capture_started_at")
|
||||
expected_end = _parse_utc(result.expected_capture_end, "expected_capture_end")
|
||||
published = _parse_utc(result.published_at, "published_at")
|
||||
planned_duration = (expected_end - started).total_seconds()
|
||||
if not (
|
||||
EXPECTED_CAPTURE_DURATION_SECONDS - 1.0
|
||||
<= planned_duration
|
||||
<= EXPECTED_CAPTURE_DURATION_SECONDS + 1.0
|
||||
):
|
||||
return ReadyEvaluation(False, "WRONG_CAPTURE_WINDOW")
|
||||
age = (now_utc - published).total_seconds()
|
||||
if age < -5.0:
|
||||
return ReadyEvaluation(False, "FUTURE_READY")
|
||||
if age > max_age_seconds:
|
||||
return ReadyEvaluation(False, "STALE_READY")
|
||||
remaining = (expected_end - now_utc).total_seconds()
|
||||
if remaining < min_remaining_seconds:
|
||||
return ReadyEvaluation(False, "CAPTURE_WINDOW_EXPIRED", remaining)
|
||||
return ReadyEvaluation(True, "READY", remaining)
|
||||
|
||||
|
||||
class GitResultChannel:
|
||||
"""Publish through a temporary worktree of the configured main repository."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: Path,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
branch: str = "rx-node",
|
||||
) -> None:
|
||||
self.repository = repository
|
||||
self.remote = remote
|
||||
self.branch = branch
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
cwd: Path | None = None,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=cwd or self.repository,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=check,
|
||||
)
|
||||
|
||||
def remote_head(self) -> str | None:
|
||||
result = self._run(
|
||||
["ls-remote", "--heads", self.remote, self.branch],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"git ls-remote failed for {self.remote}/{self.branch}: "
|
||||
f"{result.stderr.strip()}"
|
||||
)
|
||||
lines = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return None
|
||||
if len(lines) != 1:
|
||||
raise ResultChannelError("remote result branch lookup is ambiguous")
|
||||
head, reference = lines[0].split(maxsplit=1)
|
||||
if reference != f"refs/heads/{self.branch}" or not _HEAD_RE.fullmatch(head):
|
||||
raise ResultChannelError("remote result branch returned an invalid HEAD")
|
||||
return head
|
||||
|
||||
def branch_exists(self) -> bool:
|
||||
return self.remote_head() is not None
|
||||
|
||||
def verify_write_access(self) -> str:
|
||||
"""Проверить право записи dry-run push без коммита и изменения ветки."""
|
||||
|
||||
remote_head = self.fetch()
|
||||
dry_run = self._run(
|
||||
[
|
||||
"push",
|
||||
"--dry-run",
|
||||
"--porcelain",
|
||||
self.remote,
|
||||
f"{remote_head}:refs/heads/{self.branch}",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
if dry_run.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
"authenticated result-branch push preflight failed: "
|
||||
f"{dry_run.stderr.strip()}"
|
||||
)
|
||||
confirmed_head = self.remote_head()
|
||||
if confirmed_head != remote_head:
|
||||
raise ResultChannelError(
|
||||
"result branch changed during write preflight"
|
||||
)
|
||||
return remote_head
|
||||
|
||||
def _tracking_reference(self) -> str:
|
||||
return f"refs/remotes/{self.remote}/{self.branch}"
|
||||
|
||||
def _optional_revision(self, revision: str) -> str | None:
|
||||
result = self._run(["rev-parse", "--verify", revision], check=False)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
value = result.stdout.strip()
|
||||
if not _HEAD_RE.fullmatch(value):
|
||||
raise ResultChannelError(f"invalid Git revision for {revision}")
|
||||
return value
|
||||
|
||||
def _is_ancestor(self, ancestor: str, descendant: str) -> bool:
|
||||
result = self._run(
|
||||
["merge-base", "--is-ancestor", ancestor, descendant],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
if result.returncode == 1:
|
||||
return False
|
||||
raise ResultChannelError(
|
||||
f"cannot compare result history {ancestor}..{descendant}: "
|
||||
f"{result.stderr.strip()}"
|
||||
)
|
||||
|
||||
def fetch(self) -> str:
|
||||
initial_fetch = self._run(
|
||||
["fetch", "--quiet", self.remote, self.branch],
|
||||
check=False,
|
||||
)
|
||||
remote_head = self.remote_head()
|
||||
if remote_head is None:
|
||||
raise ResultBranchMissing(
|
||||
f"remote branch {self.remote}/{self.branch} does not exist"
|
||||
)
|
||||
if initial_fetch.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"git fetch failed for {self.remote}/{self.branch}: "
|
||||
f"{initial_fetch.stderr.strip()}"
|
||||
)
|
||||
tracking_reference = (
|
||||
f"refs/heads/{self.branch}:"
|
||||
f"{self._tracking_reference()}"
|
||||
)
|
||||
previous_tracking_head = self._optional_revision(self._tracking_reference())
|
||||
if (
|
||||
previous_tracking_head is not None
|
||||
and not self._is_ancestor(previous_tracking_head, remote_head)
|
||||
):
|
||||
raise ResultHistoryRewritten(
|
||||
"remote result branch is not a fast-forward of the local "
|
||||
f"tracking branch: {previous_tracking_head} -> {remote_head}"
|
||||
)
|
||||
tracking_fetch = self._run(
|
||||
["fetch", "--quiet", self.remote, tracking_reference],
|
||||
check=False,
|
||||
)
|
||||
if tracking_fetch.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"tracking fetch failed for {self.remote}/{self.branch}: "
|
||||
f"{tracking_fetch.stderr.strip()}"
|
||||
)
|
||||
tracking_head = self._run(
|
||||
["rev-parse", self._tracking_reference()]
|
||||
).stdout.strip()
|
||||
if tracking_head != remote_head:
|
||||
raise ResultChannelError("remote-tracking result branch is stale")
|
||||
return remote_head
|
||||
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||
remote_head = self.fetch()
|
||||
return self.read_commit(remote_head)
|
||||
|
||||
def read_commit(self, commit: str) -> tuple[RxHandoffResult, str, str]:
|
||||
"""Read and validate the result stored by one exact commit."""
|
||||
|
||||
if not _HEAD_RE.fullmatch(commit):
|
||||
raise ResultChannelError("result commit must be a full lowercase Git hash")
|
||||
commit_check = self._run(
|
||||
["cat-file", "-e", f"{commit}^{{commit}}"],
|
||||
check=False,
|
||||
)
|
||||
if commit_check.returncode != 0:
|
||||
raise ResultChannelError(f"result commit is unavailable: {commit}")
|
||||
json_result = self._run(
|
||||
["show", f"{commit}:{RESULT_JSON_PATH}"],
|
||||
check=False,
|
||||
)
|
||||
if json_result.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"result JSON is unavailable at commit {commit}: "
|
||||
f"{json_result.stderr.strip()}"
|
||||
)
|
||||
raw = json.loads(json_result.stdout)
|
||||
if not isinstance(raw, dict):
|
||||
raise ResultChannelError(
|
||||
f"result JSON root is not an object at commit {commit}"
|
||||
)
|
||||
result = result_from_mapping(raw)
|
||||
return result, result_digest(result), commit
|
||||
|
||||
def commits_since(
|
||||
self,
|
||||
last_processed_commit: str | None,
|
||||
) -> tuple[list[str], str]:
|
||||
"""Return every new result-branch commit from oldest to newest."""
|
||||
|
||||
remote_head = self.fetch()
|
||||
if last_processed_commit is not None:
|
||||
if not _HEAD_RE.fullmatch(last_processed_commit):
|
||||
raise ResultChannelError(
|
||||
"last processed result commit must be a full lowercase Git hash"
|
||||
)
|
||||
if self._optional_revision(last_processed_commit) is None:
|
||||
raise ResultHistoryRewritten(
|
||||
"last processed result commit is unavailable locally"
|
||||
)
|
||||
if not self._is_ancestor(last_processed_commit, remote_head):
|
||||
raise ResultHistoryRewritten(
|
||||
"remote result history no longer descends from the last "
|
||||
f"processed commit: {last_processed_commit} -> {remote_head}"
|
||||
)
|
||||
if last_processed_commit == remote_head:
|
||||
return [], remote_head
|
||||
revision_range = f"{last_processed_commit}..{remote_head}"
|
||||
history = self._run(
|
||||
["rev-list", "--reverse", "--topo-order", revision_range]
|
||||
).stdout
|
||||
commits = [line.strip() for line in history.splitlines() if line.strip()]
|
||||
return commits, remote_head
|
||||
|
||||
history = self._run(
|
||||
["rev-list", "--reverse", "--topo-order", remote_head]
|
||||
).stdout
|
||||
all_commits = [line.strip() for line in history.splitlines() if line.strip()]
|
||||
first_result_index: int | None = None
|
||||
for index, commit in enumerate(all_commits):
|
||||
exists = self._run(
|
||||
["cat-file", "-e", f"{commit}:{RESULT_JSON_PATH}"],
|
||||
check=False,
|
||||
)
|
||||
if exists.returncode == 0:
|
||||
first_result_index = index
|
||||
break
|
||||
if first_result_index is None:
|
||||
return [], remote_head
|
||||
return all_commits[first_result_index:], remote_head
|
||||
|
||||
def find_commit_by_digest(self, digest: str) -> str | None:
|
||||
"""Find the newest valid result commit with a known canonical digest."""
|
||||
|
||||
if not _SHA256_RE.fullmatch(digest):
|
||||
raise ResultChannelError("stored result digest is invalid")
|
||||
remote_head = self.fetch()
|
||||
history = self._run(["rev-list", remote_head]).stdout
|
||||
for commit in (
|
||||
line.strip() for line in history.splitlines() if line.strip()
|
||||
):
|
||||
try:
|
||||
_result, observed_digest, _head = self.read_commit(commit)
|
||||
except (json.JSONDecodeError, ResultChannelError, ValueError):
|
||||
continue
|
||||
if observed_digest == digest:
|
||||
return commit
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _is_transient_push_error(stderr: str) -> bool:
|
||||
normalized = stderr.casefold()
|
||||
return any(pattern in normalized for pattern in _TRANSIENT_PUSH_ERRORS)
|
||||
|
||||
def _push_exact_commit(
|
||||
self,
|
||||
checkout: Path,
|
||||
commit: str,
|
||||
remote_before: str,
|
||||
) -> str:
|
||||
"""Отправить неизменный коммит с одной безопасной повторной попыткой."""
|
||||
|
||||
push_arguments = [
|
||||
"push",
|
||||
"--quiet",
|
||||
self.remote,
|
||||
f"{commit}:refs/heads/{self.branch}",
|
||||
]
|
||||
first = self._run(push_arguments, cwd=checkout, check=False)
|
||||
if first.returncode == 0:
|
||||
remote_after = self.remote_head()
|
||||
if remote_after != commit:
|
||||
raise ResultChannelError(
|
||||
"remote result HEAD does not confirm the published commit"
|
||||
)
|
||||
return commit
|
||||
|
||||
observed_after_failure = self.remote_head()
|
||||
if observed_after_failure == commit:
|
||||
return commit
|
||||
if observed_after_failure != remote_before:
|
||||
raise ResultChannelError(
|
||||
"result push failed and remote branch changed to a conflicting "
|
||||
f"commit: {remote_before} -> {observed_after_failure}"
|
||||
)
|
||||
if not self._is_transient_push_error(first.stderr):
|
||||
raise ResultChannelError(f"result push failed: {first.stderr.strip()}")
|
||||
|
||||
fetched_head = self.fetch()
|
||||
if fetched_head != remote_before:
|
||||
raise ResultChannelError(
|
||||
"remote result branch changed before the single retry"
|
||||
)
|
||||
second = self._run(push_arguments, cwd=checkout, check=False)
|
||||
if second.returncode != 0:
|
||||
observed_after_retry = self.remote_head()
|
||||
if observed_after_retry == commit:
|
||||
return commit
|
||||
raise ResultChannelError(
|
||||
"result push failed after one retry of the same commit: "
|
||||
f"{second.stderr.strip()}"
|
||||
)
|
||||
remote_after = self.remote_head()
|
||||
if remote_after != commit:
|
||||
raise ResultChannelError(
|
||||
"remote result HEAD does not confirm the retried commit"
|
||||
)
|
||||
return commit
|
||||
|
||||
def publish(self, result: RxHandoffResult) -> PublishOutcome:
|
||||
validate_result(result)
|
||||
remote_before = self.fetch()
|
||||
tracking_reference = f"refs/remotes/{self.remote}/{self.branch}"
|
||||
with tempfile.TemporaryDirectory(prefix="rx-result-worktree-") as directory:
|
||||
checkout = Path(directory) / "rx-node"
|
||||
worktree_added = False
|
||||
try:
|
||||
self._run(
|
||||
["worktree", "add", "--detach", str(checkout), tracking_reference]
|
||||
)
|
||||
worktree_added = True
|
||||
json_path = checkout / RESULT_JSON_PATH
|
||||
markdown_path = checkout / RESULT_MARKDOWN_PATH
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_text = canonical_result_json(result)
|
||||
markdown_text = render_result_markdown(result)
|
||||
if len(json_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("result JSON exceeds the size limit")
|
||||
if len(markdown_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("result Markdown exceeds the size limit")
|
||||
json_path.write_text(json_text, encoding="utf-8", newline="\n")
|
||||
markdown_path.write_text(
|
||||
markdown_text,
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
self._run(["add", "--", *sorted(RESULT_PATHS)], cwd=checkout)
|
||||
staged_text = self._run(
|
||||
["diff", "--cached", "--name-only", "--"],
|
||||
cwd=checkout,
|
||||
).stdout
|
||||
staged = {
|
||||
line.strip().replace("\\", "/")
|
||||
for line in staged_text.splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
if not staged:
|
||||
return PublishOutcome("UNCHANGED", remote_before, remote_before)
|
||||
if staged != RESULT_PATHS:
|
||||
raise ResultChannelError(
|
||||
f"refusing unexpected staged paths: {sorted(staged)}"
|
||||
)
|
||||
self._run(
|
||||
[
|
||||
"-c",
|
||||
"user.name=RX Node",
|
||||
"-c",
|
||||
"user.email=rx-node@local.invalid",
|
||||
"commit",
|
||||
"--quiet",
|
||||
"-m",
|
||||
f"Publish {result.task_id} {result.state}",
|
||||
],
|
||||
cwd=checkout,
|
||||
)
|
||||
commit = self._run(["rev-parse", "HEAD"], cwd=checkout).stdout.strip()
|
||||
remote_after = self._push_exact_commit(
|
||||
checkout,
|
||||
commit,
|
||||
remote_before,
|
||||
)
|
||||
return PublishOutcome("PUBLISHED", commit, remote_after)
|
||||
finally:
|
||||
if worktree_added:
|
||||
removal = self._run(
|
||||
["worktree", "remove", "--force", str(checkout)],
|
||||
check=False,
|
||||
)
|
||||
if removal.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"temporary worktree cleanup failed: "
|
||||
f"{removal.stderr.strip()}"
|
||||
)
|
||||
self._run(["worktree", "prune"], check=False)
|
||||
154
tools/rf_handoff_smoke.py
Normal file
154
tools/rf_handoff_smoke.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Publish a model-free READY/COMPLETE smoke sequence to the RX result branch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tools.rf_handoff_channel import ( # noqa: E402
|
||||
CHANNEL_TEST_TASK_ID,
|
||||
COMPLETE_STATE,
|
||||
READY_STATE,
|
||||
GitResultChannel,
|
||||
PublishOutcome,
|
||||
RxHandoffResult,
|
||||
)
|
||||
|
||||
|
||||
def _git_head(repository: Path) -> str:
|
||||
return subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=repository,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def run_smoke(
|
||||
repository: Path,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
branch: str = "rx-node",
|
||||
source_head: str | None = None,
|
||||
hold_seconds: float = 7.0,
|
||||
sleep_function: Callable[[float], None] = time.sleep,
|
||||
) -> tuple[PublishOutcome, PublishOutcome, RxHandoffResult]:
|
||||
"""Publish fake READY then COMPLETE; never access SDR or open a TX gate."""
|
||||
|
||||
repository = repository.resolve()
|
||||
observed_source_head = source_head or _git_head(repository)
|
||||
channel = GitResultChannel(repository, remote=remote, branch=branch)
|
||||
channel.fetch()
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="rf-handoff-smoke-") as directory:
|
||||
fake_capture = Path(directory) / "model_free_fake_capture.txt"
|
||||
started = datetime.now(timezone.utc)
|
||||
fake_capture.write_text(
|
||||
"RF_HANDOFF_CHANNEL_TEST_001\nREADY\nRF TX = 0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
published = datetime.now(timezone.utc)
|
||||
ready = RxHandoffResult(
|
||||
schema_version=1,
|
||||
task_id=CHANNEL_TEST_TASK_ID,
|
||||
state=READY_STATE,
|
||||
rx_ready_for_tx=True,
|
||||
process_alive=True,
|
||||
file_growing=True,
|
||||
capture_pid=max(1, os.getpid()),
|
||||
published_at=published.isoformat(),
|
||||
capture_started_at=started.isoformat(),
|
||||
expected_capture_end=(started + timedelta(seconds=120)).isoformat(),
|
||||
capture_path=str(fake_capture.resolve()),
|
||||
common_clock_verified=False,
|
||||
source_head=observed_source_head,
|
||||
rf_tx=0,
|
||||
exit_code=None,
|
||||
file_size=None,
|
||||
duration_seconds=None,
|
||||
overruns=None,
|
||||
usb_errors=None,
|
||||
file_errors=None,
|
||||
sha256=None,
|
||||
clkin_after="NOT_RUN",
|
||||
rf_tx_observed="UNKNOWN",
|
||||
)
|
||||
ready_outcome = channel.publish(ready)
|
||||
sleep_function(hold_seconds)
|
||||
|
||||
fake_capture.write_text(
|
||||
"RF_HANDOFF_CHANNEL_TEST_001\nCOMPLETE\nRF TX = 0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
finished = datetime.now(timezone.utc)
|
||||
content = fake_capture.read_bytes()
|
||||
complete = replace(
|
||||
ready,
|
||||
state=COMPLETE_STATE,
|
||||
rx_ready_for_tx=False,
|
||||
process_alive=False,
|
||||
file_growing=False,
|
||||
published_at=finished.isoformat(),
|
||||
exit_code=0,
|
||||
file_size=len(content),
|
||||
duration_seconds=(finished - started).total_seconds(),
|
||||
overruns=0,
|
||||
usb_errors=0,
|
||||
file_errors=0,
|
||||
sha256=hashlib.sha256(content).hexdigest(),
|
||||
)
|
||||
complete_outcome = channel.publish(complete)
|
||||
return ready_outcome, complete_outcome, complete
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path, default=ROOT)
|
||||
parser.add_argument("--remote", default="origin")
|
||||
parser.add_argument("--result-branch", default="rx-node")
|
||||
parser.add_argument("--source-head")
|
||||
parser.add_argument("--hold-seconds", type=float, default=7.0)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
arguments = build_parser().parse_args()
|
||||
if arguments.hold_seconds < 0:
|
||||
raise SystemExit("--hold-seconds cannot be negative")
|
||||
ready, complete, result = run_smoke(
|
||||
arguments.repository,
|
||||
remote=arguments.remote,
|
||||
branch=arguments.result_branch,
|
||||
source_head=arguments.source_head,
|
||||
hold_seconds=arguments.hold_seconds,
|
||||
)
|
||||
print("TASK_ID =")
|
||||
print(CHANNEL_TEST_TASK_ID)
|
||||
print(f"READY PUBLISH = {ready.status}")
|
||||
print(f"READY REMOTE HEAD = {ready.remote_head}")
|
||||
print(f"COMPLETE PUBLISH = {complete.status}")
|
||||
print(f"COMPLETE REMOTE HEAD = {complete.remote_head}")
|
||||
print(f"FINAL STATE = {result.state}")
|
||||
print("REAL RX = NOT_RUN")
|
||||
print("RF TX = 0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
797
tools/rx_agent_watcher.py
Normal file
797
tools/rx_agent_watcher.py
Normal file
@@ -0,0 +1,797 @@
|
||||
"""Локальный Git-watcher заданий RX без LLM и доступа к SDR."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Callable, Protocol
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tools.rf_handoff_channel import ( # noqa: E402
|
||||
EXPECTED_RX_TASK_ID,
|
||||
GitResultChannel,
|
||||
READY_STATE,
|
||||
RxHandoffResult,
|
||||
load_result,
|
||||
)
|
||||
from tools.tx_handoff_channel import ( # noqa: E402
|
||||
CHANNEL_TEST_COMPLETE_COMMIT,
|
||||
CHANNEL_TEST_COMPLETE_DIGEST,
|
||||
CHANNEL_TEST_READY_COMMIT,
|
||||
CHANNEL_TEST_READY_DIGEST,
|
||||
GitAcknowledgementChannel,
|
||||
channel_test_acknowledgements,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_TASK_PATH = "docs/handoff/NEXT_RX_TASK.md"
|
||||
DEFAULT_STATE_PATH = ROOT / ".local/rx_agent_state.json"
|
||||
DEFAULT_OUTBOX = ROOT / ".local/rx_agent_outbox"
|
||||
REQUIRED_FIELDS = (
|
||||
"TASK_ID",
|
||||
"TASK_TITLE",
|
||||
"SOURCE_BRANCH",
|
||||
"EXPECTED_HEAD",
|
||||
"REQUIRES_APPROVAL",
|
||||
"HARDWARE_ACCESS",
|
||||
"RF_RX",
|
||||
"RF_TX",
|
||||
"ALLOW_COMMIT",
|
||||
"ALLOW_PUSH",
|
||||
)
|
||||
OPTIONAL_FIELDS = ("RISK_LEVEL",)
|
||||
HEADER_FIELDS = REQUIRED_FIELDS + OPTIONAL_FIELDS
|
||||
TASK_ID_PATTERN = re.compile(r"^[A-Z0-9][A-Z0-9_-]{2,63}$")
|
||||
BRANCH_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
|
||||
REMOTE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
HEAD_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskDefinition:
|
||||
"""Проверенный машинно читаемый заголовок задания."""
|
||||
|
||||
task_id: str
|
||||
task_title: str
|
||||
source_branch: str
|
||||
expected_head: str
|
||||
requires_approval: bool
|
||||
hardware_access: bool
|
||||
rf_rx: bool
|
||||
rf_tx: bool
|
||||
allow_commit: bool
|
||||
allow_push: str
|
||||
risk_level: str
|
||||
|
||||
@property
|
||||
def safety_level(self) -> str:
|
||||
if self.rf_tx or self.risk_level == "RED":
|
||||
return "RED"
|
||||
if self.hardware_access or self.rf_rx or self.risk_level == "YELLOW":
|
||||
return "YELLOW"
|
||||
return "GREEN"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatcherState:
|
||||
"""Локальное состояние машины, которое не должно попадать в Git."""
|
||||
|
||||
last_seen_task_id: str | None = None
|
||||
last_completed_task_id: str | None = None
|
||||
last_approved_task_id: str | None = None
|
||||
deferred_task_id: str | None = None
|
||||
running: bool = False
|
||||
pid: int | None = None
|
||||
channel_test_ready_digest: str | None = None
|
||||
channel_test_complete_digest: str | None = None
|
||||
channel_test_ready_ack_commit: str | None = None
|
||||
channel_test_complete_ack_commit: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedResult:
|
||||
task_id: str
|
||||
task_status: str
|
||||
source_head: str
|
||||
started_at: str
|
||||
finished_at: str
|
||||
summary: str
|
||||
checks: list[str]
|
||||
hardware_actions: list[str]
|
||||
rf_tx: str
|
||||
files_created: list[str]
|
||||
errors: list[str]
|
||||
next_action: str
|
||||
|
||||
|
||||
class ApprovalDecision(Enum):
|
||||
APPROVE = "APPROVE"
|
||||
DEFER = "DEFER"
|
||||
STOP = "STOP"
|
||||
BLOCKED = "BLOCKED"
|
||||
|
||||
|
||||
class TaskExecutor(Protocol):
|
||||
"""Интерфейс исполнителя, не зависящий от конкретного LLM-клиента."""
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
task: TaskDefinition,
|
||||
task_text: str,
|
||||
source_head: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> tuple[Path, Path, Path]:
|
||||
"""Подготовить локальный outbox без запуска агента."""
|
||||
...
|
||||
|
||||
|
||||
def _yes_no(value: str, field: str) -> bool:
|
||||
normalized = value.upper()
|
||||
if normalized not in {"YES", "NO"}:
|
||||
raise ValueError(f"{field} must be YES or NO")
|
||||
return normalized == "YES"
|
||||
|
||||
|
||||
def parse_task(text: str) -> TaskDefinition:
|
||||
"""Разобрать простой key-value заголовок и отклонить неоднозначность."""
|
||||
values: dict[str, str] = {}
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line == "---":
|
||||
continue
|
||||
if line.startswith("#") or line.startswith("```"):
|
||||
if values:
|
||||
break
|
||||
continue
|
||||
if "=" not in line:
|
||||
if values:
|
||||
break
|
||||
continue
|
||||
key, value = (part.strip() for part in line.split("=", 1))
|
||||
if key not in HEADER_FIELDS:
|
||||
if values:
|
||||
break
|
||||
continue
|
||||
if key in values:
|
||||
raise ValueError(f"duplicate task header field: {key}")
|
||||
if not value:
|
||||
raise ValueError(f"empty task header field: {key}")
|
||||
values[key] = value
|
||||
|
||||
missing = [field for field in REQUIRED_FIELDS if field not in values]
|
||||
if missing:
|
||||
raise ValueError(f"missing task header fields: {', '.join(missing)}")
|
||||
task_id = values["TASK_ID"]
|
||||
branch = values["SOURCE_BRANCH"]
|
||||
expected_head = values["EXPECTED_HEAD"].lower()
|
||||
if not TASK_ID_PATTERN.fullmatch(task_id):
|
||||
raise ValueError("invalid TASK_ID")
|
||||
if not BRANCH_PATTERN.fullmatch(branch) or ".." in branch or branch.endswith("/"):
|
||||
raise ValueError("invalid SOURCE_BRANCH")
|
||||
if not HEAD_PATTERN.fullmatch(expected_head):
|
||||
raise ValueError("EXPECTED_HEAD must be a full lowercase Git hash")
|
||||
allow_push = values["ALLOW_PUSH"].upper()
|
||||
if allow_push not in {"NO", "RX_RESULT_ONLY"}:
|
||||
raise ValueError("ALLOW_PUSH must be NO or RX_RESULT_ONLY")
|
||||
risk_level = values.get("RISK_LEVEL", "").upper()
|
||||
if risk_level and risk_level not in {"GREEN", "YELLOW", "RED"}:
|
||||
raise ValueError("RISK_LEVEL must be GREEN, YELLOW or RED")
|
||||
if not risk_level:
|
||||
risk_level = (
|
||||
"RED" if _yes_no(values["RF_TX"], "RF_TX") else
|
||||
"YELLOW" if (
|
||||
_yes_no(values["HARDWARE_ACCESS"], "HARDWARE_ACCESS")
|
||||
or _yes_no(values["RF_RX"], "RF_RX")
|
||||
) else "GREEN"
|
||||
)
|
||||
task = TaskDefinition(
|
||||
task_id=task_id,
|
||||
task_title=values["TASK_TITLE"],
|
||||
source_branch=branch,
|
||||
expected_head=expected_head,
|
||||
requires_approval=_yes_no(values["REQUIRES_APPROVAL"], "REQUIRES_APPROVAL"),
|
||||
hardware_access=_yes_no(values["HARDWARE_ACCESS"], "HARDWARE_ACCESS"),
|
||||
rf_rx=_yes_no(values["RF_RX"], "RF_RX"),
|
||||
rf_tx=_yes_no(values["RF_TX"], "RF_TX"),
|
||||
allow_commit=_yes_no(values["ALLOW_COMMIT"], "ALLOW_COMMIT"),
|
||||
allow_push=allow_push,
|
||||
risk_level=risk_level,
|
||||
)
|
||||
if task.rf_tx and not task.hardware_access:
|
||||
raise ValueError("RF_TX=YES requires HARDWARE_ACCESS=YES")
|
||||
if task.rf_tx and task.risk_level != "RED":
|
||||
raise ValueError("RF_TX=YES requires RISK_LEVEL=RED")
|
||||
if (task.hardware_access or task.rf_rx) and task.risk_level == "GREEN":
|
||||
raise ValueError("hardware access cannot use RISK_LEVEL=GREEN")
|
||||
return task
|
||||
|
||||
|
||||
def load_state(path: Path) -> WatcherState:
|
||||
"""Прочитать локальное состояние; отсутствие файла означает первый запуск."""
|
||||
if not path.exists():
|
||||
return WatcherState()
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
allowed = set(WatcherState.__dataclass_fields__)
|
||||
if not isinstance(raw, dict) or any(key not in allowed for key in raw):
|
||||
raise ValueError("invalid watcher state")
|
||||
return WatcherState(**raw)
|
||||
|
||||
|
||||
def save_state(path: Path, state: WatcherState) -> None:
|
||||
"""Атомарно сохранить состояние рядом с целевым файлом."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(asdict(state), indent=2) + "\n", encoding="utf-8")
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def is_new_task(task: TaskDefinition, state: WatcherState) -> bool:
|
||||
"""Подавить задание, которое уже показывалось или было завершено."""
|
||||
return task.task_id not in {state.last_seen_task_id, state.last_completed_task_id}
|
||||
|
||||
|
||||
def task_summary(task: TaskDefinition) -> str:
|
||||
return (
|
||||
"NEW TASK DETECTED\n\n"
|
||||
f"TASK ID = {task.task_id}\n"
|
||||
f"TITLE = {task.task_title}\n"
|
||||
f"SAFETY LEVEL = {task.safety_level}\n"
|
||||
f"HARDWARE ACCESS = {'YES' if task.hardware_access else 'NO'}\n"
|
||||
f"RF RX = {'YES' if task.rf_rx else 'NO'}\n"
|
||||
f"RF TX = {'YES' if task.rf_tx else 'NO'}"
|
||||
)
|
||||
|
||||
|
||||
def request_approval(
|
||||
task: TaskDefinition,
|
||||
input_function: Callable[[str], str] = input,
|
||||
output_function: Callable[[str], None] = print,
|
||||
) -> ApprovalDecision:
|
||||
"""Запросить A/D/S; для RED нужна вторая точная фраза."""
|
||||
output_function(task_summary(task))
|
||||
choice = input_function("[A] Approve [D] Defer [S] Stop watcher: ").strip().upper()
|
||||
if choice == "D":
|
||||
return ApprovalDecision.DEFER
|
||||
if choice == "S":
|
||||
return ApprovalDecision.STOP
|
||||
if choice != "A":
|
||||
return ApprovalDecision.BLOCKED
|
||||
if task.safety_level == "RED":
|
||||
required_phrase = "AUTHORIZE RF TX" if task.rf_tx else f"AUTHORIZE {task.task_id}"
|
||||
phrase = input_function(f"Type {required_phrase} to continue: ").strip()
|
||||
if phrase != required_phrase:
|
||||
return ApprovalDecision.BLOCKED
|
||||
return ApprovalDecision.APPROVE
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def write_result(output_directory: Path, result: PreparedResult) -> tuple[Path, Path]:
|
||||
"""Записать согласованные Markdown и JSON без публикации."""
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
markdown_path = output_directory / "RX_RESULT.md"
|
||||
json_path = output_directory / "RX_RESULT.json"
|
||||
markdown = (
|
||||
f"TASK_ID = {result.task_id}\n"
|
||||
f"TASK_STATUS = {result.task_status}\n"
|
||||
f"SOURCE_HEAD = {result.source_head}\n"
|
||||
f"STARTED_AT = {result.started_at}\n"
|
||||
f"FINISHED_AT = {result.finished_at}\n\n"
|
||||
f"SUMMARY =\n{result.summary}\n\n"
|
||||
f"CHECKS =\n{json.dumps(result.checks)}\n\n"
|
||||
f"HARDWARE_ACTIONS =\n{json.dumps(result.hardware_actions)}\n\n"
|
||||
f"RF_TX =\n{result.rf_tx}\n\n"
|
||||
f"FILES_CREATED =\n{json.dumps(result.files_created)}\n\n"
|
||||
f"ERRORS =\n{json.dumps(result.errors)}\n\n"
|
||||
f"NEXT_ACTION =\n{result.next_action}\n"
|
||||
)
|
||||
markdown_path.write_text(markdown, encoding="utf-8")
|
||||
json_path.write_text(json.dumps(asdict(result), indent=2) + "\n", encoding="utf-8")
|
||||
return markdown_path, json_path
|
||||
|
||||
|
||||
class ManualExecutor:
|
||||
"""Подготовить локальный prompt и каркас результата без запуска агента."""
|
||||
|
||||
def __init__(self, outbox: Path = DEFAULT_OUTBOX) -> None:
|
||||
self.outbox = outbox
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
task: TaskDefinition,
|
||||
task_text: str,
|
||||
source_head: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> tuple[Path, Path, Path]:
|
||||
directory = self.outbox / task.task_id
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
prompt_path = directory / "TASK.md"
|
||||
prompt_path.write_text(task_text, encoding="utf-8")
|
||||
result_markdown_path = directory / "RX_RESULT.md"
|
||||
result_json_path = directory / "RX_RESULT.json"
|
||||
timestamp = utc_now()
|
||||
status = "DRY_RUN_PREPARED" if dry_run else "AWAITING_MANUAL_EXECUTION"
|
||||
result = PreparedResult(
|
||||
task_id=task.task_id,
|
||||
task_status=status,
|
||||
source_head=source_head,
|
||||
started_at=timestamp,
|
||||
finished_at=timestamp,
|
||||
summary="Task prepared for manual transfer; no agent was invoked.",
|
||||
checks=["TASK_HEADER_VALID", "APPROVAL_GATE_PASS"],
|
||||
hardware_actions=[],
|
||||
rf_tx="NO",
|
||||
files_created=[
|
||||
str(prompt_path),
|
||||
str(result_markdown_path),
|
||||
str(result_json_path),
|
||||
],
|
||||
errors=[],
|
||||
next_action="Open TASK.md in the chosen executor after explicit user approval.",
|
||||
)
|
||||
markdown_path, json_path = write_result(directory, result)
|
||||
return prompt_path, markdown_path, json_path
|
||||
|
||||
|
||||
class GitTaskSource:
|
||||
"""Прочитать задание непосредственно из remote-tracking reference."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: Path,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
branch: str = "video-development",
|
||||
task_path: str = DEFAULT_TASK_PATH,
|
||||
) -> None:
|
||||
if not BRANCH_PATTERN.fullmatch(branch) or ".." in branch:
|
||||
raise ValueError("invalid branch")
|
||||
if not REMOTE_PATTERN.fullmatch(remote):
|
||||
raise ValueError("invalid remote")
|
||||
self.repository = repository
|
||||
self.remote = remote
|
||||
self.branch = branch
|
||||
self.task_path = task_path
|
||||
|
||||
def _run(
|
||||
self,
|
||||
arguments: list[str],
|
||||
*,
|
||||
check: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=self.repository,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=check,
|
||||
)
|
||||
|
||||
def fetch(self) -> None:
|
||||
self._run(["fetch", "--quiet", self.remote, self.branch])
|
||||
|
||||
def read(self) -> tuple[str, str]:
|
||||
remote_ref = f"{self.remote}/{self.branch}"
|
||||
head = self._run(["rev-parse", remote_ref]).stdout.strip()
|
||||
text = self._run(["show", f"{remote_ref}:{self.task_path}"]).stdout
|
||||
task = parse_task(text)
|
||||
if task.source_branch != self.branch:
|
||||
raise ValueError("task SOURCE_BRANCH disagrees with watched branch")
|
||||
ancestor = self._run(
|
||||
["merge-base", "--is-ancestor", task.expected_head, head],
|
||||
check=False,
|
||||
)
|
||||
if ancestor.returncode != 0:
|
||||
raise ValueError("EXPECTED_HEAD is not an ancestor of remote task HEAD")
|
||||
return text, head
|
||||
|
||||
|
||||
def confirm_main_channel_test(
|
||||
repository: Path,
|
||||
state: WatcherState,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
result_branch: str = "rx-node",
|
||||
acknowledgement_branch: str = "tx-node",
|
||||
output_function: Callable[[str], None] = print,
|
||||
) -> WatcherState:
|
||||
"""Confirm that MAIN acknowledged both exact channel-test events."""
|
||||
|
||||
rx_channel = GitResultChannel(
|
||||
repository,
|
||||
remote=remote,
|
||||
branch=result_branch,
|
||||
)
|
||||
rx_channel.fetch()
|
||||
ready_result, ready_digest, _ready_commit = rx_channel.read_commit(
|
||||
CHANNEL_TEST_READY_COMMIT
|
||||
)
|
||||
complete_result, complete_digest, _complete_commit = rx_channel.read_commit(
|
||||
CHANNEL_TEST_COMPLETE_COMMIT
|
||||
)
|
||||
if ready_digest != CHANNEL_TEST_READY_DIGEST:
|
||||
raise ValueError("historical channel-test READY digest is wrong")
|
||||
if complete_digest != CHANNEL_TEST_COMPLETE_DIGEST:
|
||||
raise ValueError("historical channel-test COMPLETE digest is wrong")
|
||||
if ready_result.source_head != complete_result.source_head:
|
||||
raise ValueError("channel-test source HEAD changed between READY and COMPLETE")
|
||||
|
||||
acknowledgement_channel = GitAcknowledgementChannel(
|
||||
repository,
|
||||
remote=remote,
|
||||
branch=acknowledgement_branch,
|
||||
)
|
||||
confirmations = channel_test_acknowledgements(
|
||||
acknowledgement_channel,
|
||||
expected_source_head=ready_result.source_head,
|
||||
ready_source_commit=CHANNEL_TEST_READY_COMMIT,
|
||||
ready_source_digest=CHANNEL_TEST_READY_DIGEST,
|
||||
complete_source_commit=CHANNEL_TEST_COMPLETE_COMMIT,
|
||||
complete_source_digest=CHANNEL_TEST_COMPLETE_DIGEST,
|
||||
)
|
||||
if not confirmations.ready_confirmed:
|
||||
raise ValueError("MAIN READY acknowledgement is not confirmed")
|
||||
if not confirmations.complete_confirmed:
|
||||
raise ValueError("MAIN COMPLETE acknowledgement is not confirmed")
|
||||
|
||||
state.channel_test_ready_digest = CHANNEL_TEST_READY_DIGEST
|
||||
state.channel_test_complete_digest = CHANNEL_TEST_COMPLETE_DIGEST
|
||||
state.channel_test_ready_ack_commit = confirmations.ready_ack_commit
|
||||
state.channel_test_complete_ack_commit = confirmations.complete_ack_commit
|
||||
output_function("MAIN READY RECEIVED = CONFIRMED")
|
||||
output_function("MAIN COMPLETE RECEIVED = CONFIRMED")
|
||||
output_function(
|
||||
f"MAIN READY ACK COMMIT = {confirmations.ready_ack_commit}"
|
||||
)
|
||||
output_function(
|
||||
f"MAIN COMPLETE ACK COMMIT = {confirmations.complete_ack_commit}"
|
||||
)
|
||||
output_function("RF TX = 0")
|
||||
return state
|
||||
|
||||
|
||||
def handle_task(
|
||||
task_text: str,
|
||||
source_head: str,
|
||||
state: WatcherState,
|
||||
executor: TaskExecutor,
|
||||
*,
|
||||
dry_run: bool,
|
||||
input_function: Callable[[str], str] = input,
|
||||
output_function: Callable[[str], None] = print,
|
||||
) -> tuple[str, WatcherState]:
|
||||
"""Обработать одно задание без изменений Git и запуска агента."""
|
||||
task = parse_task(task_text)
|
||||
if not is_new_task(task, state):
|
||||
return "DUPLICATE_SUPPRESSED", state
|
||||
decision = request_approval(task, input_function, output_function)
|
||||
state.last_seen_task_id = task.task_id
|
||||
if decision == ApprovalDecision.DEFER:
|
||||
state.deferred_task_id = task.task_id
|
||||
return "DEFERRED", state
|
||||
if decision == ApprovalDecision.STOP:
|
||||
return "STOP", state
|
||||
if decision != ApprovalDecision.APPROVE:
|
||||
return "APPROVAL_BLOCKED", state
|
||||
state.deferred_task_id = None
|
||||
state.last_approved_task_id = task.task_id
|
||||
paths = executor.prepare(task, task_text, source_head, dry_run=dry_run)
|
||||
output_function(f"TASK PREPARED = {paths[0]}")
|
||||
return "PREPARED", state
|
||||
|
||||
|
||||
def stop_path_for(state_path: Path) -> Path:
|
||||
return state_path.with_suffix(state_path.suffix + ".stop")
|
||||
|
||||
|
||||
def process_exists(pid: int | None) -> bool:
|
||||
if pid is None or pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def request_stop(state_path: Path, timeout_s: float) -> bool:
|
||||
"""Запросить остановку watcher и дождаться снятия running state."""
|
||||
stop_path = stop_path_for(state_path)
|
||||
stop_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
stop_path.write_text("STOP\n", encoding="ascii")
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
state = load_state(state_path)
|
||||
if not state.running or not process_exists(state.pid):
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def interruptible_input(prompt: str, stop_path: Path) -> str:
|
||||
"""Читать консоль Windows, позволяя stop script прервать ожидание."""
|
||||
if os.name != "nt":
|
||||
return input(prompt)
|
||||
import msvcrt
|
||||
|
||||
print(prompt, end="", flush=True)
|
||||
characters: list[str] = []
|
||||
while True:
|
||||
if stop_path.exists():
|
||||
print()
|
||||
return "S"
|
||||
if not msvcrt.kbhit():
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
character = msvcrt.getwch()
|
||||
if character in {"\r", "\n"}:
|
||||
print()
|
||||
return "".join(characters)
|
||||
if character == "\b":
|
||||
if characters:
|
||||
characters.pop()
|
||||
print("\b \b", end="", flush=True)
|
||||
continue
|
||||
characters.append(character)
|
||||
print(character, end="", flush=True)
|
||||
|
||||
|
||||
def mark_completed(state_path: Path, task_id: str) -> None:
|
||||
if not TASK_ID_PATTERN.fullmatch(task_id):
|
||||
raise ValueError("invalid TASK_ID")
|
||||
state = load_state(state_path)
|
||||
if state.last_seen_task_id != task_id:
|
||||
raise ValueError("only the last seen task may be marked completed")
|
||||
state.last_completed_task_id = task_id
|
||||
state.deferred_task_id = None
|
||||
save_state(state_path, state)
|
||||
|
||||
|
||||
def publish_result(arguments: argparse.Namespace) -> int:
|
||||
"""Publish only the approved small RX result to an existing branch."""
|
||||
|
||||
repository = arguments.repository.resolve()
|
||||
task = parse_task((repository / DEFAULT_TASK_PATH).read_text(encoding="utf-8-sig"))
|
||||
state = load_state(arguments.state_file.resolve())
|
||||
result = load_result(arguments.publish_result.resolve())
|
||||
require_live_ready_window(result)
|
||||
if task.task_id != result.task_id:
|
||||
raise ValueError("result TASK_ID disagrees with current RX task")
|
||||
if task.allow_push != "RX_RESULT_ONLY" or task.rf_tx:
|
||||
raise ValueError("current task does not allow RX result publication")
|
||||
if state.last_approved_task_id != result.task_id:
|
||||
raise ValueError("current RX task has not passed the local approval gate")
|
||||
|
||||
def git_output(arguments: list[str]) -> str:
|
||||
return subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=repository,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
branch = git_output(["branch", "--show-current"])
|
||||
if branch != task.source_branch:
|
||||
raise ValueError("current branch disagrees with task SOURCE_BRANCH")
|
||||
if git_output(["status", "--porcelain", "--untracked-files=no"]):
|
||||
raise ValueError("tracked worktree must be clean before result publication")
|
||||
head = git_output(["rev-parse", "HEAD"])
|
||||
remote_head = git_output(["rev-parse", f"{arguments.remote}/{task.source_branch}"])
|
||||
if head != remote_head:
|
||||
raise ValueError("current HEAD disagrees with the remote task branch")
|
||||
if result.source_head != head:
|
||||
raise ValueError("result SOURCE_HEAD disagrees with current checkout")
|
||||
channel = GitResultChannel(
|
||||
repository,
|
||||
remote=arguments.remote,
|
||||
branch=arguments.result_branch,
|
||||
)
|
||||
outcome = channel.publish(result)
|
||||
print(f"RX RESULT PUBLISH = {outcome.status}")
|
||||
print(f"RX RESULT HEAD = {outcome.commit}")
|
||||
print(f"RX RESULT REMOTE HEAD = {outcome.remote_head}")
|
||||
print("RF TX = 0")
|
||||
return 0
|
||||
|
||||
|
||||
def require_live_ready_window(
|
||||
result: RxHandoffResult,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
"""Запретить READY после окончания конечного окна захвата."""
|
||||
|
||||
if result.state != READY_STATE:
|
||||
return
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
expected_end = datetime.fromisoformat(
|
||||
result.expected_capture_end.replace("Z", "+00:00")
|
||||
).astimezone(timezone.utc)
|
||||
if current >= expected_end:
|
||||
raise ValueError("READY capture window has already ended")
|
||||
|
||||
|
||||
def verify_result_channel_write_access(
|
||||
repository: Path,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
result_branch: str = "rx-node",
|
||||
output_function: Callable[[str], None] = print,
|
||||
) -> str:
|
||||
"""Проверить право публикации результата RX без изменения ветки."""
|
||||
|
||||
channel = GitResultChannel(
|
||||
repository,
|
||||
remote=remote,
|
||||
branch=result_branch,
|
||||
)
|
||||
remote_head = channel.verify_write_access()
|
||||
output_function("RX-NODE WRITE PREFLIGHT = PASS")
|
||||
output_function(f"RX-NODE REMOTE HEAD = {remote_head}")
|
||||
output_function("RF TX = 0")
|
||||
return remote_head
|
||||
|
||||
|
||||
def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
state_path = arguments.state_file.resolve()
|
||||
if arguments.publish_result is not None:
|
||||
return publish_result(arguments)
|
||||
if arguments.check_result_write:
|
||||
verify_result_channel_write_access(
|
||||
arguments.repository.resolve(),
|
||||
remote=arguments.remote,
|
||||
result_branch=arguments.result_branch,
|
||||
)
|
||||
return 0
|
||||
if arguments.request_stop:
|
||||
stopped = request_stop(state_path, arguments.stop_timeout)
|
||||
print("WATCHER STOPPED" if stopped else "WATCHER STOP TIMEOUT")
|
||||
return 0 if stopped else 1
|
||||
if arguments.mark_completed is not None:
|
||||
mark_completed(state_path, arguments.mark_completed)
|
||||
print(f"COMPLETED TASK = {arguments.mark_completed}")
|
||||
return 0
|
||||
if arguments.dry_run and arguments.task_file is None:
|
||||
raise ValueError("--dry-run requires --task-file and never fetches Git")
|
||||
|
||||
state = load_state(state_path)
|
||||
if state.running and process_exists(state.pid):
|
||||
raise RuntimeError(f"watcher already running with PID {state.pid}")
|
||||
state.running = True
|
||||
state.pid = os.getpid()
|
||||
save_state(state_path, state)
|
||||
stop_path = stop_path_for(state_path)
|
||||
stop_path.unlink(missing_ok=True)
|
||||
executor = ManualExecutor(arguments.outbox.resolve())
|
||||
source = GitTaskSource(
|
||||
arguments.repository.resolve(),
|
||||
remote=arguments.remote,
|
||||
branch=arguments.branch,
|
||||
)
|
||||
if arguments.approval is not None:
|
||||
replies = [
|
||||
"A" if arguments.approval == "approve" else
|
||||
"D" if arguments.approval == "defer" else "S"
|
||||
]
|
||||
iterator = iter(replies)
|
||||
|
||||
def automated_input(_prompt: str) -> str:
|
||||
return next(iterator, "")
|
||||
|
||||
input_function = automated_input
|
||||
else:
|
||||
def console_input(prompt: str) -> str:
|
||||
return interruptible_input(prompt, stop_path)
|
||||
|
||||
input_function = console_input
|
||||
|
||||
try:
|
||||
while not stop_path.exists():
|
||||
try:
|
||||
if arguments.task_file is not None:
|
||||
task_text = arguments.task_file.read_text(encoding="utf-8")
|
||||
source_head = parse_task(task_text).expected_head
|
||||
else:
|
||||
source.fetch()
|
||||
task_text, source_head = source.read()
|
||||
task = parse_task(task_text)
|
||||
if task.task_id == EXPECTED_RX_TASK_ID and not arguments.dry_run:
|
||||
if is_new_task(task, state):
|
||||
verify_result_channel_write_access(
|
||||
arguments.repository.resolve(),
|
||||
remote=arguments.remote,
|
||||
result_branch=arguments.result_branch,
|
||||
)
|
||||
confirm_main_channel_test(
|
||||
arguments.repository.resolve(),
|
||||
state,
|
||||
remote=arguments.remote,
|
||||
result_branch=arguments.result_branch,
|
||||
acknowledgement_branch=arguments.acknowledgement_branch,
|
||||
)
|
||||
save_state(state_path, state)
|
||||
status, state = handle_task(
|
||||
task_text,
|
||||
source_head,
|
||||
state,
|
||||
executor,
|
||||
dry_run=arguments.dry_run,
|
||||
input_function=input_function,
|
||||
)
|
||||
save_state(state_path, state)
|
||||
if status != "DUPLICATE_SUPPRESSED":
|
||||
print(f"WATCHER STATUS = {status}")
|
||||
if status == "STOP":
|
||||
break
|
||||
except (OSError, subprocess.SubprocessError, ValueError) as error:
|
||||
print(f"WATCHER ERROR = {error}", file=sys.stderr)
|
||||
if arguments.once or arguments.dry_run:
|
||||
return 1
|
||||
if arguments.once or arguments.dry_run or arguments.task_file is not None:
|
||||
break
|
||||
stop_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
for _ in range(max(1, int(arguments.poll_interval * 10))):
|
||||
if stop_path.exists():
|
||||
break
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
state.running = False
|
||||
state.pid = None
|
||||
save_state(state_path, state)
|
||||
stop_path.unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path, default=ROOT)
|
||||
parser.add_argument("--remote", default="origin")
|
||||
parser.add_argument("--branch", default="video-development")
|
||||
parser.add_argument("--poll-interval", type=float, default=30.0)
|
||||
parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_PATH)
|
||||
parser.add_argument("--outbox", type=Path, default=DEFAULT_OUTBOX)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--task-file", type=Path)
|
||||
parser.add_argument("--approval", choices=("approve", "defer", "stop"))
|
||||
parser.add_argument("--request-stop", action="store_true")
|
||||
parser.add_argument("--stop-timeout", type=float, default=35.0)
|
||||
parser.add_argument("--mark-completed")
|
||||
parser.add_argument("--publish-result", type=Path)
|
||||
parser.add_argument("--check-result-write", action="store_true")
|
||||
parser.add_argument("--result-branch", default="rx-node")
|
||||
parser.add_argument("--acknowledgement-branch", default="tx-node")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
arguments = build_parser().parse_args()
|
||||
if arguments.poll_interval <= 0 or arguments.stop_timeout <= 0:
|
||||
raise SystemExit("intervals must be positive")
|
||||
raise SystemExit(run_watcher(arguments))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
9
tools/start_rx_watcher.cmd
Normal file
9
tools/start_rx_watcher.cmd
Normal file
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0.."
|
||||
python -B tools\rx_agent_watcher.py
|
||||
set "RX_WATCHER_EXIT=%ERRORLEVEL%"
|
||||
echo.
|
||||
echo RX watcher exited with code %RX_WATCHER_EXIT%.
|
||||
pause
|
||||
exit /b %RX_WATCHER_EXIT%
|
||||
9
tools/start_tx_watcher.cmd
Normal file
9
tools/start_tx_watcher.cmd
Normal file
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0.."
|
||||
python -B tools\tx_agent_watcher.py --poll-interval 5
|
||||
set "TX_WATCHER_EXIT=%ERRORLEVEL%"
|
||||
echo.
|
||||
echo TX watcher exited with code %TX_WATCHER_EXIT%.
|
||||
pause
|
||||
exit /b %TX_WATCHER_EXIT%
|
||||
8
tools/stop_rx_watcher.cmd
Normal file
8
tools/stop_rx_watcher.cmd
Normal file
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0.."
|
||||
python -B tools\rx_agent_watcher.py --request-stop --stop-timeout 35
|
||||
set "RX_WATCHER_EXIT=%ERRORLEVEL%"
|
||||
echo.
|
||||
pause
|
||||
exit /b %RX_WATCHER_EXIT%
|
||||
8
tools/stop_tx_watcher.cmd
Normal file
8
tools/stop_tx_watcher.cmd
Normal file
@@ -0,0 +1,8 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0.."
|
||||
python -B tools\tx_agent_watcher.py --request-stop --stop-timeout 15
|
||||
set "TX_WATCHER_EXIT=%ERRORLEVEL%"
|
||||
echo.
|
||||
pause
|
||||
exit /b %TX_WATCHER_EXIT%
|
||||
729
tools/tx_agent_watcher.py
Normal file
729
tools/tx_agent_watcher.py
Normal file
@@ -0,0 +1,729 @@
|
||||
"""Poll the RX result branch and gate a single RF-001A TX authorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Callable, Protocol
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tools.rf_handoff_channel import ( # noqa: E402
|
||||
CHANNEL_TEST_TASK_ID,
|
||||
COMPLETE_STATE,
|
||||
EXPECTED_TX_TASK_ID,
|
||||
GitResultChannel,
|
||||
READY_STATE,
|
||||
ResultBranchMissing,
|
||||
ResultChannelError,
|
||||
ResultHistoryRewritten,
|
||||
RxHandoffResult,
|
||||
evaluate_ready,
|
||||
load_result,
|
||||
result_digest,
|
||||
)
|
||||
from tools.tx_handoff_channel import ( # noqa: E402
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
CHANNEL_READY_OBSERVED,
|
||||
RX_READY_OBSERVED,
|
||||
TX_ACK_SCHEMA_VERSION,
|
||||
TX_AUTHORIZED,
|
||||
TX_COMPLETE,
|
||||
TX_STARTED,
|
||||
GitAcknowledgementChannel,
|
||||
TxHandoffAcknowledgement,
|
||||
load_acknowledgement,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_STATE_PATH = ROOT / ".local" / "tx_agent_watcher_state.json"
|
||||
DEFAULT_OUTBOX = ROOT / ".local" / "tx_authorizations"
|
||||
STATE_VERSION = 2
|
||||
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class ResultSource(Protocol):
|
||||
def fetch(self) -> str | None: ...
|
||||
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]: ...
|
||||
|
||||
def read_commit(self, commit: str) -> tuple[RxHandoffResult, str, str]: ...
|
||||
|
||||
def commits_since(
|
||||
self,
|
||||
last_processed_commit: str | None,
|
||||
) -> tuple[list[str], str]: ...
|
||||
|
||||
def find_commit_by_digest(self, digest: str) -> str | None: ...
|
||||
|
||||
|
||||
class TxExecutor(Protocol):
|
||||
def prepare(
|
||||
self,
|
||||
result: RxHandoffResult,
|
||||
digest: str,
|
||||
result_head: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> Path: ...
|
||||
|
||||
|
||||
class AcknowledgementPublisher(Protocol):
|
||||
def publish(
|
||||
self,
|
||||
acknowledgement: TxHandoffAcknowledgement,
|
||||
) -> object: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class TxWatcherState:
|
||||
state_version: int = STATE_VERSION
|
||||
running: bool = False
|
||||
pid: int | None = None
|
||||
last_seen_digest: str | None = None
|
||||
last_seen_commit: str | None = None
|
||||
last_processed_rx_node_commit: str | None = None
|
||||
last_prompted_digest: str | None = None
|
||||
last_authorized_digest: str | None = None
|
||||
channel_test_ready_digest: str | None = None
|
||||
channel_test_complete_digest: str | None = None
|
||||
|
||||
|
||||
class StaticResultSource:
|
||||
"""Read one local fixture without network or Git writes."""
|
||||
|
||||
def __init__(self, result: RxHandoffResult) -> None:
|
||||
self.result = result
|
||||
self.digest = result_digest(result)
|
||||
|
||||
def fetch(self) -> None:
|
||||
return None
|
||||
|
||||
def read(self) -> tuple[RxHandoffResult, str, str]:
|
||||
return self.result, self.digest, self.result.source_head
|
||||
|
||||
def read_commit(self, commit: str) -> tuple[RxHandoffResult, str, str]:
|
||||
if commit != self.result.source_head:
|
||||
raise ResultHistoryRewritten("static result commit changed")
|
||||
return self.read()
|
||||
|
||||
def commits_since(
|
||||
self,
|
||||
last_processed_commit: str | None,
|
||||
) -> tuple[list[str], str]:
|
||||
head = self.result.source_head
|
||||
return ([] if last_processed_commit == head else [head], head)
|
||||
|
||||
def find_commit_by_digest(self, digest: str) -> str | None:
|
||||
return self.result.source_head if digest == self.digest else None
|
||||
|
||||
|
||||
class ManualTxExecutor:
|
||||
"""Write a local authorization record; never access SDR hardware."""
|
||||
|
||||
def __init__(self, outbox: Path) -> None:
|
||||
self.outbox = outbox
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
result: RxHandoffResult,
|
||||
digest: str,
|
||||
result_head: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> Path:
|
||||
self.outbox.mkdir(parents=True, exist_ok=True)
|
||||
suffix = "fake" if dry_run else "authorized"
|
||||
target = self.outbox / f"{EXPECTED_TX_TASK_ID}_{suffix}.json"
|
||||
temporary = target.with_suffix(target.suffix + ".tmp")
|
||||
payload = {
|
||||
"task_id": EXPECTED_TX_TASK_ID,
|
||||
"authorization": "AUTHORIZE RF TX",
|
||||
"rx_task_id": result.task_id,
|
||||
"rx_result_digest": digest,
|
||||
"rx_result_head": result_head,
|
||||
"source_head": result.source_head,
|
||||
"capture_started_at": result.capture_started_at,
|
||||
"expected_capture_end": result.expected_capture_end,
|
||||
"prepared_at": datetime.now(timezone.utc).isoformat(),
|
||||
"dry_run": dry_run,
|
||||
"action": "FAKE_TX_ONLY" if dry_run else "MANUAL_EXECUTOR_GATE_OPEN",
|
||||
"rf_tx_started": False,
|
||||
"rf_tx": 0,
|
||||
}
|
||||
temporary.write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, target)
|
||||
return target
|
||||
|
||||
|
||||
def load_state(path: Path) -> TxWatcherState:
|
||||
if not path.exists():
|
||||
return TxWatcherState()
|
||||
raw = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("TX watcher state must be a JSON object")
|
||||
allowed = set(TxWatcherState.__dataclass_fields__)
|
||||
if any(key not in allowed for key in raw):
|
||||
raise ValueError("TX watcher state contains an unknown field")
|
||||
version = raw.get("state_version", 1)
|
||||
if type(version) is not int or version not in {1, STATE_VERSION}:
|
||||
raise ValueError("TX watcher state version is not supported")
|
||||
migrated = dict(raw)
|
||||
migrated["state_version"] = STATE_VERSION
|
||||
return TxWatcherState(**migrated)
|
||||
|
||||
|
||||
def save_state(path: Path, state: TxWatcherState) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(asdict(state), indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def stop_path_for(state_path: Path) -> Path:
|
||||
return state_path.with_suffix(state_path.suffix + ".stop")
|
||||
|
||||
|
||||
def process_exists(pid: int | None) -> bool:
|
||||
if pid is None or pid <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except (OSError, SystemError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def request_stop(state_path: Path, timeout_s: float) -> bool:
|
||||
stop_path = stop_path_for(state_path)
|
||||
stop_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
stop_path.write_text("STOP\n", encoding="ascii")
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
state = load_state(state_path)
|
||||
if not state.running:
|
||||
stop_path.unlink(missing_ok=True)
|
||||
return True
|
||||
if not process_exists(state.pid):
|
||||
state.running = False
|
||||
state.pid = None
|
||||
save_state(state_path, state)
|
||||
stop_path.unlink(missing_ok=True)
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
|
||||
def interruptible_input(prompt: str, stop_path: Path) -> str:
|
||||
if os.name != "nt":
|
||||
return input(prompt)
|
||||
|
||||
import msvcrt
|
||||
|
||||
print(prompt, end="", flush=True)
|
||||
characters: list[str] = []
|
||||
while True:
|
||||
if stop_path.exists():
|
||||
print()
|
||||
return "S"
|
||||
if not msvcrt.kbhit():
|
||||
time.sleep(0.1)
|
||||
continue
|
||||
character = msvcrt.getwch()
|
||||
if character in {"\r", "\n"}:
|
||||
print()
|
||||
return "".join(characters)
|
||||
if character == "\b":
|
||||
if characters:
|
||||
characters.pop()
|
||||
print("\b \b", end="", flush=True)
|
||||
continue
|
||||
characters.append(character)
|
||||
print(character, end="", flush=True)
|
||||
|
||||
|
||||
def ready_message(result: RxHandoffResult) -> str:
|
||||
return (
|
||||
"RX IS READY\n\n"
|
||||
f"TASK ID =\n{result.task_id}\n\n"
|
||||
f"CAPTURE STARTED =\n{result.capture_started_at}\n\n"
|
||||
f"CAPTURE END EXPECTED =\n{result.expected_capture_end}\n\n"
|
||||
f"TX TASK =\n{EXPECTED_TX_TASK_ID}\n\n"
|
||||
"RF TX REQUIRES RED APPROVAL\n\n"
|
||||
"Enter:\nAUTHORIZE RF TX"
|
||||
)
|
||||
|
||||
|
||||
def _valid_result_head(result_head: str) -> bool:
|
||||
return _HEAD_RE.fullmatch(result_head) is not None
|
||||
|
||||
|
||||
def handle_channel_test(
|
||||
result: RxHandoffResult,
|
||||
digest: str,
|
||||
result_head: str,
|
||||
state: TxWatcherState,
|
||||
*,
|
||||
output_function: Callable[[str], None] = print,
|
||||
) -> tuple[str, TxWatcherState]:
|
||||
"""Record model-free channel smoke states without opening the RED gate."""
|
||||
|
||||
if digest == state.last_seen_digest:
|
||||
return "DUPLICATE_SUPPRESSED", state
|
||||
state.last_seen_digest = digest
|
||||
if result.task_id != CHANNEL_TEST_TASK_ID:
|
||||
return "WRONG_TASK_ID", state
|
||||
if not _valid_result_head(result_head):
|
||||
return "INVALID_RESULT_COMMIT", state
|
||||
if result.common_clock_verified:
|
||||
return "CHANNEL_TEST_CLAIMS_HARDWARE", state
|
||||
if result.state == READY_STATE:
|
||||
state.channel_test_ready_digest = digest
|
||||
output_function("CHANNEL TEST READY RECEIVED = YES")
|
||||
output_function(f"RX RESULT HEAD = {result_head}")
|
||||
output_function("RF TX = 0")
|
||||
return "CHANNEL_TEST_READY", state
|
||||
if result.state == COMPLETE_STATE:
|
||||
state.channel_test_complete_digest = digest
|
||||
output_function("CHANNEL TEST COMPLETE RECEIVED = YES")
|
||||
output_function(f"RX RESULT HEAD = {result_head}")
|
||||
output_function("RF TX = 0")
|
||||
if state.channel_test_ready_digest is None:
|
||||
return "CHANNEL_TEST_COMPLETE_WITHOUT_READY", state
|
||||
return "CHANNEL_TEST_COMPLETE", state
|
||||
return "CHANNEL_TEST_INVALID_STATE", state
|
||||
|
||||
|
||||
def handle_result(
|
||||
result: RxHandoffResult,
|
||||
digest: str,
|
||||
result_head: str,
|
||||
state: TxWatcherState,
|
||||
source: ResultSource,
|
||||
executor: TxExecutor,
|
||||
*,
|
||||
now_function: Callable[[], datetime],
|
||||
input_function: Callable[[str], str],
|
||||
output_function: Callable[[str], None] = print,
|
||||
dry_run: bool = False,
|
||||
expected_source_head: str | None = None,
|
||||
max_age_seconds: float = 30.0,
|
||||
min_remaining_seconds: float = 10.0,
|
||||
) -> tuple[str, TxWatcherState]:
|
||||
"""Validate READY, request exact approval, then revalidate remote state."""
|
||||
|
||||
if result.task_id == CHANNEL_TEST_TASK_ID:
|
||||
return handle_channel_test(
|
||||
result,
|
||||
digest,
|
||||
result_head,
|
||||
state,
|
||||
output_function=output_function,
|
||||
)
|
||||
if digest == state.last_seen_digest:
|
||||
return "DUPLICATE_SUPPRESSED", state
|
||||
if not _valid_result_head(result_head):
|
||||
state.last_seen_digest = digest
|
||||
return "INVALID_RESULT_COMMIT", state
|
||||
|
||||
evaluation = evaluate_ready(
|
||||
result,
|
||||
now=now_function(),
|
||||
expected_source_head=expected_source_head,
|
||||
max_age_seconds=max_age_seconds,
|
||||
min_remaining_seconds=min_remaining_seconds,
|
||||
)
|
||||
state.last_seen_digest = digest
|
||||
if not evaluation.ready:
|
||||
return evaluation.reason, state
|
||||
|
||||
output_function(ready_message(result))
|
||||
state.last_prompted_digest = digest
|
||||
phrase = input_function("Type AUTHORIZE RF TX to prepare one burst: ").strip()
|
||||
if phrase != "AUTHORIZE RF TX":
|
||||
output_function("RED APPROVAL BLOCKED")
|
||||
output_function("RF TX = 0")
|
||||
return "RED_APPROVAL_BLOCKED", state
|
||||
|
||||
source.fetch()
|
||||
refreshed, refreshed_digest, refreshed_head = source.read()
|
||||
if refreshed_digest != digest or refreshed_head != result_head:
|
||||
output_function("RX RESULT CHANGED; NEW APPROVAL REQUIRED")
|
||||
output_function("RF TX = 0")
|
||||
return "RESULT_CHANGED", state
|
||||
|
||||
refreshed_evaluation = evaluate_ready(
|
||||
refreshed,
|
||||
now=now_function(),
|
||||
expected_source_head=expected_source_head,
|
||||
max_age_seconds=max_age_seconds,
|
||||
min_remaining_seconds=min_remaining_seconds,
|
||||
)
|
||||
if not refreshed_evaluation.ready:
|
||||
output_function(f"TX WINDOW BLOCKED = {refreshed_evaluation.reason}")
|
||||
output_function("RF TX = 0")
|
||||
return refreshed_evaluation.reason, state
|
||||
|
||||
authorization_path = executor.prepare(
|
||||
refreshed,
|
||||
refreshed_digest,
|
||||
refreshed_head,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
state.last_authorized_digest = refreshed_digest
|
||||
output_function(f"TX AUTHORIZATION PREPARED = {authorization_path}")
|
||||
output_function("RF TX = 0")
|
||||
return "TX_AUTHORIZATION_PREPARED", state
|
||||
|
||||
|
||||
def _git_output(repository: Path, arguments: list[str]) -> str:
|
||||
return subprocess.run(
|
||||
["git", *arguments],
|
||||
cwd=repository,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def migrate_state_cursor(
|
||||
source: ResultSource,
|
||||
state: TxWatcherState,
|
||||
) -> bool:
|
||||
"""Map a version-1 digest-only state to its exact result commit."""
|
||||
|
||||
state.state_version = STATE_VERSION
|
||||
if state.last_processed_rx_node_commit is not None:
|
||||
if state.last_seen_commit is None:
|
||||
state.last_seen_commit = state.last_processed_rx_node_commit
|
||||
return True
|
||||
return False
|
||||
if state.last_seen_digest is None:
|
||||
return False
|
||||
matched_commit = source.find_commit_by_digest(state.last_seen_digest)
|
||||
if matched_commit is None:
|
||||
raise ResultHistoryRewritten(
|
||||
"stored result digest cannot be mapped to origin/rx-node history"
|
||||
)
|
||||
state.last_seen_commit = matched_commit
|
||||
state.last_processed_rx_node_commit = matched_commit
|
||||
return True
|
||||
|
||||
|
||||
def acknowledgement_for_result(
|
||||
result: RxHandoffResult,
|
||||
digest: str,
|
||||
result_head: str,
|
||||
status: str,
|
||||
*,
|
||||
observed_at: datetime,
|
||||
main_watcher_pid: int,
|
||||
) -> TxHandoffAcknowledgement | None:
|
||||
"""Build an acknowledgement only for an event handled successfully."""
|
||||
|
||||
acknowledgement_state: str | None = None
|
||||
rf_tx_authorized = False
|
||||
if status == "CHANNEL_TEST_READY":
|
||||
acknowledgement_state = CHANNEL_READY_OBSERVED
|
||||
elif status == "CHANNEL_TEST_COMPLETE":
|
||||
acknowledgement_state = CHANNEL_COMPLETE_OBSERVED
|
||||
elif status == "RED_APPROVAL_BLOCKED":
|
||||
acknowledgement_state = RX_READY_OBSERVED
|
||||
elif status == "TX_AUTHORIZATION_PREPARED":
|
||||
acknowledgement_state = TX_AUTHORIZED
|
||||
rf_tx_authorized = True
|
||||
if acknowledgement_state is None:
|
||||
return None
|
||||
return TxHandoffAcknowledgement(
|
||||
schema_version=TX_ACK_SCHEMA_VERSION,
|
||||
task_id=result.task_id,
|
||||
state=acknowledgement_state,
|
||||
source_rx_commit=result_head,
|
||||
source_rx_digest=digest,
|
||||
source_head=result.source_head,
|
||||
observed_at=observed_at.astimezone(timezone.utc).isoformat(),
|
||||
main_watcher_pid=main_watcher_pid,
|
||||
rf_tx_authorized=rf_tx_authorized,
|
||||
rf_tx_started=False,
|
||||
rf_tx=0,
|
||||
tx_task_id=(EXPECTED_TX_TASK_ID if rf_tx_authorized else None),
|
||||
)
|
||||
|
||||
|
||||
def process_history_cycle(
|
||||
source: ResultSource,
|
||||
state: TxWatcherState,
|
||||
executor: TxExecutor,
|
||||
*,
|
||||
now_function: Callable[[], datetime],
|
||||
input_function: Callable[[str], str],
|
||||
save_function: Callable[[TxWatcherState], None],
|
||||
output_function: Callable[[str], None] = print,
|
||||
dry_run: bool = False,
|
||||
expected_source_head: str | None = None,
|
||||
max_age_seconds: float = 30.0,
|
||||
min_remaining_seconds: float = 10.0,
|
||||
acknowledgement_publisher: AcknowledgementPublisher | None = None,
|
||||
main_watcher_pid: int | None = None,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Process every new result commit in chronological order."""
|
||||
|
||||
commits, _remote_head = source.commits_since(
|
||||
state.last_processed_rx_node_commit
|
||||
)
|
||||
outcomes: list[tuple[str, str]] = []
|
||||
for commit in commits:
|
||||
result, digest, result_head = source.read_commit(commit)
|
||||
candidate_state = replace(state)
|
||||
status, candidate_state = handle_result(
|
||||
result,
|
||||
digest,
|
||||
result_head,
|
||||
candidate_state,
|
||||
source,
|
||||
executor,
|
||||
now_function=now_function,
|
||||
input_function=input_function,
|
||||
output_function=output_function,
|
||||
dry_run=dry_run,
|
||||
expected_source_head=expected_source_head,
|
||||
max_age_seconds=max_age_seconds,
|
||||
min_remaining_seconds=min_remaining_seconds,
|
||||
)
|
||||
acknowledgement = acknowledgement_for_result(
|
||||
result,
|
||||
digest,
|
||||
result_head,
|
||||
status,
|
||||
observed_at=now_function(),
|
||||
main_watcher_pid=main_watcher_pid or os.getpid(),
|
||||
)
|
||||
if acknowledgement is not None and acknowledgement_publisher is not None:
|
||||
publication = acknowledgement_publisher.publish(acknowledgement)
|
||||
output_function(
|
||||
"TX ACKNOWLEDGEMENT PUBLISH = "
|
||||
f"{getattr(publication, 'status', 'PUBLISHED')}"
|
||||
)
|
||||
output_function(
|
||||
"TX ACKNOWLEDGEMENT HEAD = "
|
||||
f"{getattr(publication, 'commit', 'UNKNOWN')}"
|
||||
)
|
||||
for field_name in TxWatcherState.__dataclass_fields__:
|
||||
setattr(state, field_name, getattr(candidate_state, field_name))
|
||||
state.last_seen_commit = result_head
|
||||
state.last_processed_rx_node_commit = result_head
|
||||
state.state_version = STATE_VERSION
|
||||
save_function(state)
|
||||
outcomes.append((result_head, status))
|
||||
if status != "DUPLICATE_SUPPRESSED":
|
||||
output_function(f"TX WATCHER STATUS = {status}")
|
||||
return outcomes
|
||||
|
||||
|
||||
def run_watcher(arguments: argparse.Namespace) -> int:
|
||||
state_path = arguments.state_file.resolve()
|
||||
if arguments.publish_tx_event is not None:
|
||||
if arguments.dry_run:
|
||||
raise ValueError("--publish-tx-event cannot be combined with --dry-run")
|
||||
repository = arguments.repository.resolve()
|
||||
event = load_acknowledgement(arguments.publish_tx_event.resolve())
|
||||
if event.state not in {TX_STARTED, TX_COMPLETE}:
|
||||
raise ValueError("published execution event has the wrong state")
|
||||
branch = _git_output(repository, ["branch", "--show-current"])
|
||||
if branch != arguments.source_branch:
|
||||
raise ValueError("TX event publisher is on the wrong source branch")
|
||||
if _git_output(repository, ["status", "--porcelain", "--untracked-files=no"]):
|
||||
raise ValueError("tracked worktree must be clean before TX event publication")
|
||||
_git_output(
|
||||
repository,
|
||||
["fetch", "--quiet", arguments.remote, arguments.source_branch],
|
||||
)
|
||||
head = _git_output(repository, ["rev-parse", "HEAD"])
|
||||
remote_head = _git_output(
|
||||
repository,
|
||||
["rev-parse", f"{arguments.remote}/{arguments.source_branch}"],
|
||||
)
|
||||
if head != remote_head:
|
||||
raise ValueError("current HEAD disagrees with remote source branch")
|
||||
if event.source_head != head:
|
||||
raise ValueError("TX event SOURCE_HEAD disagrees with current HEAD")
|
||||
publisher = GitAcknowledgementChannel(
|
||||
repository,
|
||||
remote=arguments.remote,
|
||||
branch=arguments.acknowledgement_branch,
|
||||
)
|
||||
outcome = publisher.publish_execution_event(event)
|
||||
print(f"TX EVENT PUBLISH = {outcome.status}")
|
||||
print(f"TX EVENT STATE = {event.state}")
|
||||
print(f"TX EVENT HEAD = {outcome.commit}")
|
||||
print(f"TX EVENT REMOTE HEAD = {outcome.remote_head}")
|
||||
return 0
|
||||
if arguments.request_stop:
|
||||
stopped = request_stop(state_path, arguments.stop_timeout)
|
||||
print("TX WATCHER STOPPED" if stopped else "TX WATCHER STOP TIMEOUT")
|
||||
return 0 if stopped else 1
|
||||
if arguments.result_file is not None and not arguments.dry_run:
|
||||
raise ValueError("--result-file is allowed only with --dry-run")
|
||||
if arguments.dry_run and arguments.result_file is None:
|
||||
raise ValueError("--dry-run requires --result-file and never fetches Git")
|
||||
if arguments.approval is not None and not arguments.dry_run:
|
||||
raise ValueError("--approval is allowed only with --dry-run")
|
||||
|
||||
repository = arguments.repository.resolve()
|
||||
state = load_state(state_path)
|
||||
if state.running and process_exists(state.pid):
|
||||
raise RuntimeError(f"TX watcher already running with PID {state.pid}")
|
||||
state.running = True
|
||||
state.pid = os.getpid()
|
||||
save_state(state_path, state)
|
||||
stop_path = stop_path_for(state_path)
|
||||
stop_path.unlink(missing_ok=True)
|
||||
|
||||
executor = ManualTxExecutor(arguments.outbox.resolve())
|
||||
acknowledgement_publisher: AcknowledgementPublisher | None = None
|
||||
|
||||
def input_function(prompt: str) -> str:
|
||||
if arguments.approval == "authorize":
|
||||
return "AUTHORIZE RF TX"
|
||||
if arguments.approval == "deny":
|
||||
return "DENY"
|
||||
return interruptible_input(prompt, stop_path)
|
||||
|
||||
try:
|
||||
if arguments.result_file is not None:
|
||||
static_source = StaticResultSource(load_result(arguments.result_file))
|
||||
source: ResultSource = static_source
|
||||
expected_source_head = (
|
||||
arguments.expected_source_head or static_source.result.source_head
|
||||
)
|
||||
else:
|
||||
branch = _git_output(repository, ["branch", "--show-current"])
|
||||
if branch != arguments.source_branch:
|
||||
raise ValueError(
|
||||
"TX watcher must run on the configured source branch"
|
||||
)
|
||||
expected_source_head = (
|
||||
arguments.expected_source_head
|
||||
or _git_output(repository, ["rev-parse", "HEAD"])
|
||||
)
|
||||
source = GitResultChannel(
|
||||
repository,
|
||||
remote=arguments.remote,
|
||||
branch=arguments.result_branch,
|
||||
)
|
||||
acknowledgement_publisher = GitAcknowledgementChannel(
|
||||
repository,
|
||||
remote=arguments.remote,
|
||||
branch=arguments.acknowledgement_branch,
|
||||
)
|
||||
if not _HEAD_RE.fullmatch(expected_source_head):
|
||||
raise ValueError(
|
||||
"expected source HEAD must be a full lowercase Git hash"
|
||||
)
|
||||
if migrate_state_cursor(source, state):
|
||||
save_state(state_path, state)
|
||||
|
||||
while not stop_path.exists():
|
||||
try:
|
||||
process_history_cycle(
|
||||
source,
|
||||
state,
|
||||
executor,
|
||||
now_function=lambda: datetime.now(timezone.utc),
|
||||
input_function=input_function,
|
||||
save_function=lambda current: save_state(
|
||||
state_path,
|
||||
current,
|
||||
),
|
||||
dry_run=arguments.dry_run,
|
||||
expected_source_head=expected_source_head,
|
||||
max_age_seconds=arguments.max_age,
|
||||
min_remaining_seconds=arguments.min_remaining,
|
||||
acknowledgement_publisher=acknowledgement_publisher,
|
||||
main_watcher_pid=os.getpid(),
|
||||
)
|
||||
if arguments.once or arguments.dry_run:
|
||||
break
|
||||
except (
|
||||
OSError,
|
||||
ValueError,
|
||||
ResultBranchMissing,
|
||||
ResultChannelError,
|
||||
RuntimeError,
|
||||
json.JSONDecodeError,
|
||||
subprocess.SubprocessError,
|
||||
) as error:
|
||||
print(f"TX WATCHER ERROR = {error}", file=sys.stderr)
|
||||
if arguments.once or arguments.dry_run:
|
||||
return 1
|
||||
if arguments.once:
|
||||
break
|
||||
for _ in range(max(1, int(arguments.poll_interval * 10))):
|
||||
if stop_path.exists():
|
||||
break
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
state.running = False
|
||||
state.pid = None
|
||||
save_state(state_path, state)
|
||||
stop_path.unlink(missing_ok=True)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path, default=ROOT)
|
||||
parser.add_argument("--remote", default="origin")
|
||||
parser.add_argument("--source-branch", default="video-development")
|
||||
parser.add_argument("--result-branch", default="rx-node")
|
||||
parser.add_argument("--acknowledgement-branch", default="tx-node")
|
||||
parser.add_argument("--expected-source-head")
|
||||
parser.add_argument("--poll-interval", type=float, default=5.0)
|
||||
parser.add_argument("--max-age", type=float, default=30.0)
|
||||
parser.add_argument("--min-remaining", type=float, default=10.0)
|
||||
parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_PATH)
|
||||
parser.add_argument("--outbox", type=Path, default=DEFAULT_OUTBOX)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--result-file", type=Path)
|
||||
parser.add_argument("--publish-tx-event", type=Path)
|
||||
parser.add_argument("--approval", choices=("authorize", "deny"))
|
||||
parser.add_argument("--request-stop", action="store_true")
|
||||
parser.add_argument("--stop-timeout", type=float, default=15.0)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
arguments = build_parser().parse_args()
|
||||
positive = (
|
||||
arguments.poll_interval,
|
||||
arguments.max_age,
|
||||
arguments.min_remaining,
|
||||
arguments.stop_timeout,
|
||||
)
|
||||
if any(value <= 0 for value in positive):
|
||||
raise SystemExit("intervals must be positive")
|
||||
raise SystemExit(run_watcher(arguments))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
806
tools/tx_handoff_channel.py
Normal file
806
tools/tx_handoff_channel.py
Normal file
@@ -0,0 +1,806 @@
|
||||
"""Strict MAIN-to-RX acknowledgement channel stored on ``tx-node``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, fields, replace
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
|
||||
from tools.rf_handoff_channel import (
|
||||
CHANNEL_TEST_TASK_ID,
|
||||
MAX_RESULT_BYTES,
|
||||
PublishOutcome,
|
||||
ResultBranchMissing,
|
||||
ResultChannelError,
|
||||
ResultHistoryRewritten,
|
||||
GitResultChannel,
|
||||
)
|
||||
|
||||
|
||||
TX_ACK_SCHEMA_VERSION = 2
|
||||
LEGACY_TX_ACK_SCHEMA_VERSION = 1
|
||||
TX_RESULT_JSON_PATH = "docs/handoff/results/TX_RESULT.json"
|
||||
TX_RESULT_MARKDOWN_PATH = "docs/handoff/results/TX_RESULT.md"
|
||||
TX_RESULT_PATHS = frozenset((TX_RESULT_JSON_PATH, TX_RESULT_MARKDOWN_PATH))
|
||||
TX_NODE_BRANCH = "tx-node"
|
||||
|
||||
CHANNEL_READY_OBSERVED = "CHANNEL_READY_OBSERVED"
|
||||
CHANNEL_COMPLETE_OBSERVED = "CHANNEL_COMPLETE_OBSERVED"
|
||||
RX_READY_OBSERVED = "RX_READY_OBSERVED"
|
||||
TX_AUTHORIZATION_CREATED = "TX_AUTHORIZATION_CREATED"
|
||||
TX_AUTHORIZED = "TX_AUTHORIZED"
|
||||
TX_STARTED = "TX_STARTED"
|
||||
TX_COMPLETE = "TX_COMPLETE"
|
||||
TX_ACK_STATES = frozenset(
|
||||
(
|
||||
CHANNEL_READY_OBSERVED,
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
RX_READY_OBSERVED,
|
||||
TX_AUTHORIZATION_CREATED,
|
||||
TX_AUTHORIZED,
|
||||
TX_STARTED,
|
||||
TX_COMPLETE,
|
||||
)
|
||||
)
|
||||
|
||||
CHANNEL_TEST_READY_COMMIT = "4cb6c8cad1ab5194755c2740c2a5a83933d099d8"
|
||||
CHANNEL_TEST_READY_DIGEST = (
|
||||
"23fc54f1f788f068fd4a842daf0a445bd4ec192866b4d89406c74f1797a9952b"
|
||||
)
|
||||
CHANNEL_TEST_COMPLETE_COMMIT = "83d91ff5309777ab639a24f774e9d0b8825a11e9"
|
||||
CHANNEL_TEST_COMPLETE_DIGEST = (
|
||||
"7d98ed2e70e98a087b968c0d65fb1db4277b81f251e075d20583de2e5002b2d6"
|
||||
)
|
||||
|
||||
_HEAD_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
_TASK_ID_RE = re.compile(r"^[A-Z0-9][A-Z0-9_-]{2,79}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TxHandoffAcknowledgement:
|
||||
"""One strict acknowledgement of an exact event from ``rx-node``."""
|
||||
|
||||
schema_version: int
|
||||
task_id: str
|
||||
state: str
|
||||
source_rx_commit: str
|
||||
source_rx_digest: str
|
||||
source_head: str
|
||||
observed_at: str
|
||||
main_watcher_pid: int
|
||||
rf_tx_authorized: bool
|
||||
rf_tx_started: bool
|
||||
rf_tx: int
|
||||
tx_task_id: str | None = None
|
||||
tx_started_at: str | None = None
|
||||
tx_finished_at: str | None = None
|
||||
tx_process_exit_code: int | None = None
|
||||
tx_underruns: int | None = None
|
||||
tx_errors: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelTestAcknowledgements:
|
||||
ready_confirmed: bool
|
||||
complete_confirmed: bool
|
||||
ready_ack_commit: str | None
|
||||
complete_ack_commit: str | None
|
||||
|
||||
|
||||
def _parse_utc(value: str) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("observed_at must be an ISO-8601 timestamp") from error
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("observed_at must contain a timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def validate_acknowledgement(
|
||||
acknowledgement: TxHandoffAcknowledgement,
|
||||
) -> TxHandoffAcknowledgement:
|
||||
if type(acknowledgement.schema_version) is not int:
|
||||
raise ValueError("schema_version must contain an integer")
|
||||
if acknowledgement.schema_version not in {
|
||||
LEGACY_TX_ACK_SCHEMA_VERSION,
|
||||
TX_ACK_SCHEMA_VERSION,
|
||||
}:
|
||||
raise ValueError(
|
||||
"schema_version must be a supported TX acknowledgement version"
|
||||
)
|
||||
string_values = (
|
||||
acknowledgement.task_id,
|
||||
acknowledgement.state,
|
||||
acknowledgement.source_rx_commit,
|
||||
acknowledgement.source_rx_digest,
|
||||
acknowledgement.source_head,
|
||||
acknowledgement.observed_at,
|
||||
)
|
||||
if any(type(value) is not str for value in string_values):
|
||||
raise ValueError("acknowledgement string fields must contain strings")
|
||||
if not _TASK_ID_RE.fullmatch(acknowledgement.task_id):
|
||||
raise ValueError("task_id is invalid")
|
||||
if acknowledgement.state not in TX_ACK_STATES:
|
||||
raise ValueError("state is not supported")
|
||||
if not _HEAD_RE.fullmatch(acknowledgement.source_rx_commit):
|
||||
raise ValueError("source_rx_commit must be a lowercase 40-character hash")
|
||||
if not _SHA256_RE.fullmatch(acknowledgement.source_rx_digest):
|
||||
raise ValueError("source_rx_digest must be a lowercase SHA256")
|
||||
if not _HEAD_RE.fullmatch(acknowledgement.source_head):
|
||||
raise ValueError("source_head must be a lowercase 40-character hash")
|
||||
observed_at = _parse_utc(acknowledgement.observed_at)
|
||||
if (
|
||||
type(acknowledgement.main_watcher_pid) is not int
|
||||
or acknowledgement.main_watcher_pid <= 0
|
||||
):
|
||||
raise ValueError("main_watcher_pid must be a positive integer")
|
||||
if type(acknowledgement.rf_tx_authorized) is not bool:
|
||||
raise ValueError("rf_tx_authorized must contain a boolean")
|
||||
if type(acknowledgement.rf_tx_started) is not bool:
|
||||
raise ValueError("rf_tx_started must contain a boolean")
|
||||
if type(acknowledgement.rf_tx) is not int or acknowledgement.rf_tx not in {0, 1}:
|
||||
raise ValueError("rf_tx must be 0 or 1")
|
||||
optional_integers = (
|
||||
acknowledgement.tx_process_exit_code,
|
||||
acknowledgement.tx_underruns,
|
||||
acknowledgement.tx_errors,
|
||||
)
|
||||
if any(value is not None and type(value) is not int for value in optional_integers):
|
||||
raise ValueError("TX counters and exit code must contain integers or null")
|
||||
if any(
|
||||
value is not None and value < 0
|
||||
for value in (acknowledgement.tx_underruns, acknowledgement.tx_errors)
|
||||
):
|
||||
raise ValueError("TX error counters cannot be negative")
|
||||
optional_strings = (
|
||||
acknowledgement.tx_task_id,
|
||||
acknowledgement.tx_started_at,
|
||||
acknowledgement.tx_finished_at,
|
||||
)
|
||||
if any(value is not None and type(value) is not str for value in optional_strings):
|
||||
raise ValueError("optional TX fields must contain strings or null")
|
||||
if (
|
||||
acknowledgement.tx_task_id is not None
|
||||
and not _TASK_ID_RE.fullmatch(acknowledgement.tx_task_id)
|
||||
):
|
||||
raise ValueError("tx_task_id is invalid")
|
||||
tx_started_at = (
|
||||
_parse_utc(acknowledgement.tx_started_at)
|
||||
if acknowledgement.tx_started_at is not None
|
||||
else None
|
||||
)
|
||||
tx_finished_at = (
|
||||
_parse_utc(acknowledgement.tx_finished_at)
|
||||
if acknowledgement.tx_finished_at is not None
|
||||
else None
|
||||
)
|
||||
if (
|
||||
tx_started_at is not None
|
||||
and tx_finished_at is not None
|
||||
and tx_finished_at < tx_started_at
|
||||
):
|
||||
raise ValueError("tx_finished_at cannot precede tx_started_at")
|
||||
if acknowledgement.schema_version == LEGACY_TX_ACK_SCHEMA_VERSION and any(
|
||||
value is not None
|
||||
for value in (
|
||||
acknowledgement.tx_task_id,
|
||||
acknowledgement.tx_started_at,
|
||||
acknowledgement.tx_finished_at,
|
||||
acknowledgement.tx_process_exit_code,
|
||||
acknowledgement.tx_underruns,
|
||||
acknowledgement.tx_errors,
|
||||
)
|
||||
):
|
||||
raise ValueError("legacy acknowledgement cannot contain TX event metadata")
|
||||
|
||||
if (
|
||||
acknowledgement.state
|
||||
in {
|
||||
CHANNEL_READY_OBSERVED,
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
RX_READY_OBSERVED,
|
||||
}
|
||||
and (
|
||||
acknowledgement.rf_tx_authorized
|
||||
or acknowledgement.rf_tx_started
|
||||
or acknowledgement.rf_tx != 0
|
||||
)
|
||||
):
|
||||
raise ValueError("observation states cannot claim RF TX authorization")
|
||||
if acknowledgement.state in {
|
||||
CHANNEL_READY_OBSERVED,
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
} and acknowledgement.task_id != CHANNEL_TEST_TASK_ID:
|
||||
raise ValueError("channel acknowledgement has the wrong task_id")
|
||||
if acknowledgement.state == TX_AUTHORIZATION_CREATED and (
|
||||
acknowledgement.schema_version != LEGACY_TX_ACK_SCHEMA_VERSION
|
||||
or not acknowledgement.rf_tx_authorized
|
||||
or acknowledgement.rf_tx_started
|
||||
or acknowledgement.rf_tx != 0
|
||||
):
|
||||
raise ValueError("legacy TX authorization state is inconsistent")
|
||||
if acknowledgement.state == TX_AUTHORIZED and (
|
||||
acknowledgement.schema_version != TX_ACK_SCHEMA_VERSION
|
||||
or not acknowledgement.rf_tx_authorized
|
||||
or acknowledgement.rf_tx_started
|
||||
or acknowledgement.rf_tx != 0
|
||||
or acknowledgement.tx_task_id is None
|
||||
or acknowledgement.tx_started_at is not None
|
||||
or acknowledgement.tx_finished_at is not None
|
||||
or acknowledgement.tx_process_exit_code is not None
|
||||
):
|
||||
raise ValueError("TX authorization state is inconsistent")
|
||||
if acknowledgement.state == TX_STARTED and not (
|
||||
acknowledgement.rf_tx_authorized
|
||||
and acknowledgement.rf_tx_started
|
||||
and acknowledgement.rf_tx == 1
|
||||
and acknowledgement.tx_task_id is not None
|
||||
and acknowledgement.tx_started_at is not None
|
||||
and acknowledgement.tx_finished_at is None
|
||||
and acknowledgement.tx_process_exit_code is None
|
||||
):
|
||||
raise ValueError("TX start state is inconsistent")
|
||||
if acknowledgement.state == TX_STARTED and (
|
||||
tx_started_at is None or observed_at < tx_started_at
|
||||
):
|
||||
raise ValueError("TX start observation cannot precede process start")
|
||||
if acknowledgement.state == TX_COMPLETE and not (
|
||||
acknowledgement.rf_tx_authorized
|
||||
and acknowledgement.rf_tx_started
|
||||
and acknowledgement.rf_tx == 1
|
||||
and acknowledgement.tx_task_id is not None
|
||||
and acknowledgement.tx_started_at is not None
|
||||
and acknowledgement.tx_finished_at is not None
|
||||
and acknowledgement.tx_process_exit_code is not None
|
||||
and acknowledgement.tx_underruns is not None
|
||||
and acknowledgement.tx_errors is not None
|
||||
):
|
||||
raise ValueError("TX completion state is inconsistent")
|
||||
if acknowledgement.state == TX_COMPLETE and (
|
||||
tx_finished_at is None or observed_at < tx_finished_at
|
||||
):
|
||||
raise ValueError("TX completion observation cannot precede process finish")
|
||||
if acknowledgement.state in {
|
||||
CHANNEL_READY_OBSERVED,
|
||||
CHANNEL_COMPLETE_OBSERVED,
|
||||
RX_READY_OBSERVED,
|
||||
} and any(
|
||||
value is not None
|
||||
for value in (
|
||||
acknowledgement.tx_task_id,
|
||||
acknowledgement.tx_started_at,
|
||||
acknowledgement.tx_finished_at,
|
||||
acknowledgement.tx_process_exit_code,
|
||||
acknowledgement.tx_underruns,
|
||||
acknowledgement.tx_errors,
|
||||
)
|
||||
):
|
||||
raise ValueError("observation state cannot contain TX event metadata")
|
||||
return acknowledgement
|
||||
|
||||
|
||||
def build_tx_execution_event(
|
||||
authorization: TxHandoffAcknowledgement,
|
||||
*,
|
||||
state: str,
|
||||
observed_at: str,
|
||||
tx_started_at: str,
|
||||
tx_finished_at: str | None = None,
|
||||
process_exit_code: int | None = None,
|
||||
underruns: int | None = None,
|
||||
errors: int | None = None,
|
||||
) -> TxHandoffAcknowledgement:
|
||||
"""Построить строгое событие TX_STARTED или TX_COMPLETE из разрешения."""
|
||||
|
||||
validate_acknowledgement(authorization)
|
||||
if authorization.state != TX_AUTHORIZED:
|
||||
raise ValueError("TX execution event requires a TX_AUTHORIZED source")
|
||||
if state not in {TX_STARTED, TX_COMPLETE}:
|
||||
raise ValueError("TX execution event state is not supported")
|
||||
event = replace(
|
||||
authorization,
|
||||
state=state,
|
||||
observed_at=observed_at,
|
||||
rf_tx_started=True,
|
||||
rf_tx=1,
|
||||
tx_started_at=tx_started_at,
|
||||
tx_finished_at=tx_finished_at,
|
||||
tx_process_exit_code=process_exit_code,
|
||||
tx_underruns=underruns,
|
||||
tx_errors=errors,
|
||||
)
|
||||
return validate_acknowledgement(event)
|
||||
|
||||
|
||||
def acknowledgement_from_mapping(
|
||||
raw: Mapping[str, Any],
|
||||
) -> TxHandoffAcknowledgement:
|
||||
expected_fields = {field.name for field in fields(TxHandoffAcknowledgement)}
|
||||
optional_event_fields = {
|
||||
"tx_task_id",
|
||||
"tx_started_at",
|
||||
"tx_finished_at",
|
||||
"tx_process_exit_code",
|
||||
"tx_underruns",
|
||||
"tx_errors",
|
||||
}
|
||||
actual_fields = set(raw)
|
||||
schema_version = raw.get("schema_version")
|
||||
legacy_fields = expected_fields - optional_event_fields
|
||||
if schema_version == LEGACY_TX_ACK_SCHEMA_VERSION and actual_fields == legacy_fields:
|
||||
normalized = {**raw, **dict.fromkeys(optional_event_fields)}
|
||||
elif actual_fields == expected_fields:
|
||||
normalized = dict(raw)
|
||||
else:
|
||||
missing = sorted(expected_fields - actual_fields)
|
||||
extra = sorted(actual_fields - expected_fields)
|
||||
raise ValueError(
|
||||
f"acknowledgement fields disagree: missing={missing}, extra={extra}"
|
||||
)
|
||||
acknowledgement = TxHandoffAcknowledgement(
|
||||
**{name: normalized[name] for name in expected_fields}
|
||||
)
|
||||
return validate_acknowledgement(acknowledgement)
|
||||
|
||||
|
||||
def load_acknowledgement(path: Path) -> TxHandoffAcknowledgement:
|
||||
"""Прочитать строгое малое TX-событие из локального JSON."""
|
||||
|
||||
raw = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("TX acknowledgement root must be a JSON object")
|
||||
return acknowledgement_from_mapping(raw)
|
||||
|
||||
|
||||
def canonical_acknowledgement_json(
|
||||
acknowledgement: TxHandoffAcknowledgement,
|
||||
) -> str:
|
||||
validate_acknowledgement(acknowledgement)
|
||||
return json.dumps(
|
||||
asdict(acknowledgement),
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
) + "\n"
|
||||
|
||||
|
||||
def acknowledgement_digest(acknowledgement: TxHandoffAcknowledgement) -> str:
|
||||
return hashlib.sha256(
|
||||
canonical_acknowledgement_json(acknowledgement).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def render_acknowledgement_markdown(
|
||||
acknowledgement: TxHandoffAcknowledgement,
|
||||
) -> str:
|
||||
validate_acknowledgement(acknowledgement)
|
||||
return "\n".join(
|
||||
(
|
||||
"# MAIN acknowledgement",
|
||||
"",
|
||||
f"SCHEMA_VERSION = {acknowledgement.schema_version}",
|
||||
f"TASK_ID = {acknowledgement.task_id}",
|
||||
f"STATE = {acknowledgement.state}",
|
||||
f"SOURCE_RX_COMMIT = {acknowledgement.source_rx_commit}",
|
||||
f"SOURCE_RX_DIGEST = {acknowledgement.source_rx_digest}",
|
||||
f"SOURCE_HEAD = {acknowledgement.source_head}",
|
||||
f"OBSERVED_AT = {acknowledgement.observed_at}",
|
||||
f"MAIN_WATCHER_PID = {acknowledgement.main_watcher_pid}",
|
||||
"RF_TX_AUTHORIZED = "
|
||||
f"{'YES' if acknowledgement.rf_tx_authorized else 'NO'}",
|
||||
f"RF_TX_STARTED = {'YES' if acknowledgement.rf_tx_started else 'NO'}",
|
||||
f"RF_TX = {acknowledgement.rf_tx}",
|
||||
f"TX_TASK_ID = {acknowledgement.tx_task_id}",
|
||||
f"TX_STARTED_AT = {acknowledgement.tx_started_at}",
|
||||
f"TX_FINISHED_AT = {acknowledgement.tx_finished_at}",
|
||||
f"TX_PROCESS_EXIT_CODE = {acknowledgement.tx_process_exit_code}",
|
||||
f"TX_UNDERRUNS = {acknowledgement.tx_underruns}",
|
||||
f"TX_ERRORS = {acknowledgement.tx_errors}",
|
||||
"",
|
||||
"Raw IQ, video, waveforms, source code, local state and large logs "
|
||||
"are not stored in this branch.",
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class GitAcknowledgementChannel(GitResultChannel):
|
||||
"""Read and publish strict acknowledgements on an independent Git branch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: Path,
|
||||
*,
|
||||
remote: str = "origin",
|
||||
branch: str = TX_NODE_BRANCH,
|
||||
) -> None:
|
||||
super().__init__(repository, remote=remote, branch=branch)
|
||||
|
||||
def read(self) -> tuple[TxHandoffAcknowledgement, str, str]:
|
||||
remote_head = self.fetch()
|
||||
return self.read_commit(remote_head)
|
||||
|
||||
def read_commit(
|
||||
self,
|
||||
commit: str,
|
||||
) -> tuple[TxHandoffAcknowledgement, str, str]:
|
||||
if not _HEAD_RE.fullmatch(commit):
|
||||
raise ResultChannelError(
|
||||
"acknowledgement commit must be a full lowercase Git hash"
|
||||
)
|
||||
commit_check = self._run(
|
||||
["cat-file", "-e", f"{commit}^{{commit}}"],
|
||||
check=False,
|
||||
)
|
||||
if commit_check.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"acknowledgement commit is unavailable: {commit}"
|
||||
)
|
||||
tree_text = self._run(
|
||||
["ls-tree", "-r", "--name-only", commit]
|
||||
).stdout
|
||||
tree_paths = {
|
||||
line.strip().replace("\\", "/")
|
||||
for line in tree_text.splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
if tree_paths != TX_RESULT_PATHS:
|
||||
raise ResultChannelError(
|
||||
f"tx-node tree contains unexpected paths: {sorted(tree_paths)}"
|
||||
)
|
||||
for path in TX_RESULT_PATHS:
|
||||
size_text = self._run(["cat-file", "-s", f"{commit}:{path}"]).stdout
|
||||
if int(size_text.strip()) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError(f"acknowledgement file exceeds limit: {path}")
|
||||
json_result = self._run(
|
||||
["show", f"{commit}:{TX_RESULT_JSON_PATH}"],
|
||||
check=False,
|
||||
)
|
||||
if json_result.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
f"TX_RESULT.json is unavailable at commit {commit}: "
|
||||
f"{json_result.stderr.strip()}"
|
||||
)
|
||||
raw = json.loads(json_result.stdout)
|
||||
if not isinstance(raw, dict):
|
||||
raise ResultChannelError(
|
||||
f"TX_RESULT.json root is not an object at commit {commit}"
|
||||
)
|
||||
acknowledgement = acknowledgement_from_mapping(raw)
|
||||
return acknowledgement, acknowledgement_digest(acknowledgement), commit
|
||||
|
||||
def commits_since(
|
||||
self,
|
||||
last_processed_commit: str | None,
|
||||
) -> tuple[list[str], str]:
|
||||
remote_head = self.fetch()
|
||||
if last_processed_commit is not None:
|
||||
if not _HEAD_RE.fullmatch(last_processed_commit):
|
||||
raise ResultChannelError(
|
||||
"last processed acknowledgement commit is invalid"
|
||||
)
|
||||
if self._optional_revision(last_processed_commit) is None:
|
||||
raise ResultHistoryRewritten(
|
||||
"last processed acknowledgement commit is unavailable"
|
||||
)
|
||||
if not self._is_ancestor(last_processed_commit, remote_head):
|
||||
raise ResultHistoryRewritten(
|
||||
"tx-node no longer descends from the last processed commit"
|
||||
)
|
||||
if last_processed_commit == remote_head:
|
||||
return [], remote_head
|
||||
history = self._run(
|
||||
[
|
||||
"rev-list",
|
||||
"--reverse",
|
||||
"--topo-order",
|
||||
f"{last_processed_commit}..{remote_head}",
|
||||
]
|
||||
).stdout
|
||||
return (
|
||||
[line.strip() for line in history.splitlines() if line.strip()],
|
||||
remote_head,
|
||||
)
|
||||
|
||||
history = self._run(
|
||||
["rev-list", "--reverse", "--topo-order", remote_head]
|
||||
).stdout
|
||||
all_commits = [line.strip() for line in history.splitlines() if line.strip()]
|
||||
first_result_index: int | None = None
|
||||
for index, commit in enumerate(all_commits):
|
||||
exists = self._run(
|
||||
["cat-file", "-e", f"{commit}:{TX_RESULT_JSON_PATH}"],
|
||||
check=False,
|
||||
)
|
||||
if exists.returncode == 0:
|
||||
first_result_index = index
|
||||
break
|
||||
if first_result_index is None:
|
||||
return [], remote_head
|
||||
return all_commits[first_result_index:], remote_head
|
||||
|
||||
def _matching_commit(
|
||||
self,
|
||||
acknowledgement: TxHandoffAcknowledgement,
|
||||
) -> str | None:
|
||||
commits, _remote_head = self.commits_since(None)
|
||||
for commit in reversed(commits):
|
||||
observed, _digest, _head = self.read_commit(commit)
|
||||
if (
|
||||
observed.task_id == acknowledgement.task_id
|
||||
and observed.state == acknowledgement.state
|
||||
and observed.source_rx_commit
|
||||
== acknowledgement.source_rx_commit
|
||||
and observed.source_rx_digest
|
||||
== acknowledgement.source_rx_digest
|
||||
and observed.source_head == acknowledgement.source_head
|
||||
and observed.rf_tx_authorized
|
||||
== acknowledgement.rf_tx_authorized
|
||||
and observed.rf_tx_started == acknowledgement.rf_tx_started
|
||||
and observed.rf_tx == acknowledgement.rf_tx
|
||||
and observed.tx_task_id == acknowledgement.tx_task_id
|
||||
and observed.tx_started_at == acknowledgement.tx_started_at
|
||||
and observed.tx_finished_at == acknowledgement.tx_finished_at
|
||||
and observed.tx_process_exit_code
|
||||
== acknowledgement.tx_process_exit_code
|
||||
and observed.tx_underruns == acknowledgement.tx_underruns
|
||||
and observed.tx_errors == acknowledgement.tx_errors
|
||||
):
|
||||
return commit
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _same_execution_binding(
|
||||
left: TxHandoffAcknowledgement,
|
||||
right: TxHandoffAcknowledgement,
|
||||
) -> bool:
|
||||
return (
|
||||
left.task_id == right.task_id
|
||||
and left.source_rx_commit == right.source_rx_commit
|
||||
and left.source_rx_digest == right.source_rx_digest
|
||||
and left.source_head == right.source_head
|
||||
and left.tx_task_id == right.tx_task_id
|
||||
)
|
||||
|
||||
def publish_execution_event(
|
||||
self,
|
||||
event: TxHandoffAcknowledgement,
|
||||
) -> PublishOutcome:
|
||||
"""Опубликовать TX-событие только после связанного TX_AUTHORIZED."""
|
||||
|
||||
validate_acknowledgement(event)
|
||||
if event.state not in {TX_STARTED, TX_COMPLETE}:
|
||||
raise ResultChannelError("execution event must be TX_STARTED or TX_COMPLETE")
|
||||
commits, _remote_head = self.commits_since(None)
|
||||
authorization_found = False
|
||||
start_found = event.state == TX_STARTED
|
||||
for commit in commits:
|
||||
observed, _digest, _head = self.read_commit(commit)
|
||||
if not self._same_execution_binding(observed, event):
|
||||
continue
|
||||
if observed.state == TX_AUTHORIZED:
|
||||
authorization_found = True
|
||||
if (
|
||||
observed.state == TX_STARTED
|
||||
and observed.tx_started_at == event.tx_started_at
|
||||
):
|
||||
start_found = True
|
||||
if not authorization_found:
|
||||
raise ResultChannelError("matching TX_AUTHORIZED event is absent")
|
||||
if not start_found:
|
||||
raise ResultChannelError("matching TX_STARTED event is absent")
|
||||
return self.publish(event)
|
||||
|
||||
def publish(
|
||||
self,
|
||||
acknowledgement: TxHandoffAcknowledgement,
|
||||
) -> PublishOutcome:
|
||||
validate_acknowledgement(acknowledgement)
|
||||
remote_before = self.fetch()
|
||||
matching_commit = self._matching_commit(acknowledgement)
|
||||
if matching_commit is not None:
|
||||
return PublishOutcome(
|
||||
"UNCHANGED",
|
||||
matching_commit,
|
||||
remote_before,
|
||||
json_path=TX_RESULT_JSON_PATH,
|
||||
markdown_path=TX_RESULT_MARKDOWN_PATH,
|
||||
)
|
||||
|
||||
tracking_reference = f"refs/remotes/{self.remote}/{self.branch}"
|
||||
with tempfile.TemporaryDirectory(prefix="tx-ack-worktree-") as directory:
|
||||
checkout = Path(directory) / "tx-node"
|
||||
worktree_added = False
|
||||
try:
|
||||
self._run(
|
||||
["worktree", "add", "--detach", str(checkout), tracking_reference]
|
||||
)
|
||||
worktree_added = True
|
||||
existing_text = self._run(
|
||||
["ls-tree", "-r", "--name-only", "HEAD"],
|
||||
cwd=checkout,
|
||||
).stdout
|
||||
existing_paths = {
|
||||
line.strip().replace("\\", "/")
|
||||
for line in existing_text.splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
if not existing_paths.issubset(TX_RESULT_PATHS):
|
||||
raise ResultChannelError(
|
||||
"tx-node contains paths outside the acknowledgement allowlist"
|
||||
)
|
||||
|
||||
json_path = checkout / TX_RESULT_JSON_PATH
|
||||
markdown_path = checkout / TX_RESULT_MARKDOWN_PATH
|
||||
json_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
json_text = canonical_acknowledgement_json(acknowledgement)
|
||||
markdown_text = render_acknowledgement_markdown(acknowledgement)
|
||||
if len(json_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("TX_RESULT.json exceeds the size limit")
|
||||
if len(markdown_text.encode("utf-8")) > MAX_RESULT_BYTES:
|
||||
raise ResultChannelError("TX_RESULT.md exceeds the size limit")
|
||||
json_path.write_text(json_text, encoding="utf-8", newline="\n")
|
||||
markdown_path.write_text(
|
||||
markdown_text,
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
self._run(["add", "--", *sorted(TX_RESULT_PATHS)], cwd=checkout)
|
||||
staged_text = self._run(
|
||||
["diff", "--cached", "--name-only", "--"],
|
||||
cwd=checkout,
|
||||
).stdout
|
||||
staged = {
|
||||
line.strip().replace("\\", "/")
|
||||
for line in staged_text.splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
if staged != TX_RESULT_PATHS:
|
||||
raise ResultChannelError(
|
||||
f"refusing unexpected staged paths: {sorted(staged)}"
|
||||
)
|
||||
self._run(
|
||||
[
|
||||
"-c",
|
||||
"user.name=MAIN TX Watcher",
|
||||
"-c",
|
||||
"user.email=tx-node@local.invalid",
|
||||
"commit",
|
||||
"--quiet",
|
||||
"-m",
|
||||
f"Acknowledge {acknowledgement.task_id} "
|
||||
f"{acknowledgement.state}",
|
||||
],
|
||||
cwd=checkout,
|
||||
)
|
||||
commit = self._run(
|
||||
["rev-parse", "HEAD"],
|
||||
cwd=checkout,
|
||||
).stdout.strip()
|
||||
committed_text = self._run(
|
||||
["ls-tree", "-r", "--name-only", commit],
|
||||
cwd=checkout,
|
||||
).stdout
|
||||
committed_paths = {
|
||||
line.strip().replace("\\", "/")
|
||||
for line in committed_text.splitlines()
|
||||
if line.strip()
|
||||
}
|
||||
if committed_paths != TX_RESULT_PATHS:
|
||||
raise ResultChannelError(
|
||||
"committed tx-node tree violates the path allowlist"
|
||||
)
|
||||
self._push_exact_commit(
|
||||
checkout,
|
||||
commit,
|
||||
remote_before,
|
||||
)
|
||||
return PublishOutcome(
|
||||
"PUBLISHED",
|
||||
commit,
|
||||
commit,
|
||||
json_path=TX_RESULT_JSON_PATH,
|
||||
markdown_path=TX_RESULT_MARKDOWN_PATH,
|
||||
)
|
||||
finally:
|
||||
if worktree_added:
|
||||
removal = self._run(
|
||||
["worktree", "remove", "--force", str(checkout)],
|
||||
check=False,
|
||||
)
|
||||
if removal.returncode != 0:
|
||||
raise ResultChannelError(
|
||||
"temporary acknowledgement worktree cleanup failed: "
|
||||
f"{removal.stderr.strip()}"
|
||||
)
|
||||
self._run(["worktree", "prune"], check=False)
|
||||
|
||||
|
||||
def channel_test_acknowledgements(
|
||||
channel: GitAcknowledgementChannel,
|
||||
*,
|
||||
expected_source_head: str,
|
||||
ready_source_commit: str = CHANNEL_TEST_READY_COMMIT,
|
||||
ready_source_digest: str = CHANNEL_TEST_READY_DIGEST,
|
||||
complete_source_commit: str = CHANNEL_TEST_COMPLETE_COMMIT,
|
||||
complete_source_digest: str = CHANNEL_TEST_COMPLETE_DIGEST,
|
||||
) -> ChannelTestAcknowledgements:
|
||||
"""Find both exact channel-test acknowledgements in chronological history."""
|
||||
|
||||
if not _HEAD_RE.fullmatch(expected_source_head):
|
||||
raise ValueError("expected_source_head must be a full lowercase Git hash")
|
||||
if not _HEAD_RE.fullmatch(ready_source_commit):
|
||||
raise ValueError("ready_source_commit must be a full lowercase Git hash")
|
||||
if not _SHA256_RE.fullmatch(ready_source_digest):
|
||||
raise ValueError("ready_source_digest must be a lowercase SHA256")
|
||||
if not _HEAD_RE.fullmatch(complete_source_commit):
|
||||
raise ValueError("complete_source_commit must be a full lowercase Git hash")
|
||||
if not _SHA256_RE.fullmatch(complete_source_digest):
|
||||
raise ValueError("complete_source_digest must be a lowercase SHA256")
|
||||
commits, _remote_head = channel.commits_since(None)
|
||||
ready_commit: str | None = None
|
||||
complete_commit: str | None = None
|
||||
for commit in commits:
|
||||
acknowledgement, _digest, _head = channel.read_commit(commit)
|
||||
common = (
|
||||
acknowledgement.task_id == CHANNEL_TEST_TASK_ID
|
||||
and acknowledgement.source_head == expected_source_head
|
||||
and not acknowledgement.rf_tx_authorized
|
||||
and not acknowledgement.rf_tx_started
|
||||
and acknowledgement.rf_tx == 0
|
||||
)
|
||||
if (
|
||||
common
|
||||
and acknowledgement.state == CHANNEL_READY_OBSERVED
|
||||
and acknowledgement.source_rx_commit == ready_source_commit
|
||||
and acknowledgement.source_rx_digest == ready_source_digest
|
||||
):
|
||||
ready_commit = commit
|
||||
elif (
|
||||
common
|
||||
and ready_commit is not None
|
||||
and acknowledgement.state == CHANNEL_COMPLETE_OBSERVED
|
||||
and acknowledgement.source_rx_commit == complete_source_commit
|
||||
and acknowledgement.source_rx_digest == complete_source_digest
|
||||
):
|
||||
complete_commit = commit
|
||||
return ChannelTestAcknowledgements(
|
||||
ready_confirmed=ready_commit is not None,
|
||||
complete_confirmed=complete_commit is not None,
|
||||
ready_ack_commit=ready_commit,
|
||||
complete_ack_commit=complete_commit,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CHANNEL_COMPLETE_OBSERVED",
|
||||
"CHANNEL_READY_OBSERVED",
|
||||
"CHANNEL_TEST_COMPLETE_COMMIT",
|
||||
"CHANNEL_TEST_COMPLETE_DIGEST",
|
||||
"CHANNEL_TEST_READY_COMMIT",
|
||||
"CHANNEL_TEST_READY_DIGEST",
|
||||
"ChannelTestAcknowledgements",
|
||||
"GitAcknowledgementChannel",
|
||||
"ResultBranchMissing",
|
||||
"ResultChannelError",
|
||||
"ResultHistoryRewritten",
|
||||
"TX_ACK_SCHEMA_VERSION",
|
||||
"TX_NODE_BRANCH",
|
||||
"TX_RESULT_JSON_PATH",
|
||||
"TX_RESULT_MARKDOWN_PATH",
|
||||
"TX_RESULT_PATHS",
|
||||
"TxHandoffAcknowledgement",
|
||||
"acknowledgement_digest",
|
||||
"acknowledgement_from_mapping",
|
||||
"canonical_acknowledgement_json",
|
||||
"channel_test_acknowledgements",
|
||||
"render_acknowledgement_markdown",
|
||||
"validate_acknowledgement",
|
||||
]
|
||||
14
tools/uninstall_rf001_main_autostart.ps1
Normal file
14
tools/uninstall_rf001_main_autostart.ps1
Normal file
@@ -0,0 +1,14 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$taskName = "SDR Rover HTTP Control Server"
|
||||
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if ($null -ne $task) {
|
||||
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||
"AUTOSTART_REMOVED=YES"
|
||||
}
|
||||
else {
|
||||
"AUTOSTART_REMOVED=NOT_PRESENT"
|
||||
}
|
||||
"RUNTIME_DATA_REMOVED=NO"
|
||||
"RF_TX=0"
|
||||
15
tools/uninstall_rf001_rx_autostart.ps1
Normal file
15
tools/uninstall_rf001_rx_autostart.ps1
Normal file
@@ -0,0 +1,15 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$taskName = "SDR Rover RX Agent"
|
||||
$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
if ($null -ne $task) {
|
||||
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||
"AUTOSTART_REMOVED=YES"
|
||||
}
|
||||
else {
|
||||
"AUTOSTART_REMOVED=NOT_PRESENT"
|
||||
}
|
||||
"INSTALLATION_FILES_REMOVED=NO"
|
||||
"RUNTIME_DATA_REMOVED=NO"
|
||||
"RF_TX=0"
|
||||
Reference in New Issue
Block a user