16 Commits

Author SHA1 Message Date
LittleSam129
0d6fff224e Harden RF node control and agent updates 2026-09-30 11:42:19 +03:00
LittleSam129
a94f2db4ee Harden RF handoff and stream OTA decode 2026-09-22 17:43:58 +03:00
LittleSam129
168e03d9e7 Add MAIN-to-RX acknowledgement channel 2026-09-22 15:09:37 +03:00
LittleSam129
a9317989a2 Process all RX handoff commits in TX watcher 2026-09-22 11:46:53 +03:00
LittleSam129
a516b99015 Fix bidirectional RF-001A coordination 2026-09-21 18:05:28 +03:00
LittleSam129
bc55d0516d Add bidirectional RF handoff coordination 2026-09-21 17:02:49 +03:00
LittleSam129
34f5ace545 Prepare first RF-001A OTA run 2026-09-21 15:55:49 +03:00
LittleSam129
1e3aa25d70 Combine RF-001A clock verification and RX capture 2026-09-21 13:43:31 +03:00
LittleSam129
fa370ccfa2 Prepare RF-001A RX capture task 2026-09-21 12:40:35 +03:00
LittleSam129
442451bdfb Add RX task watcher and approval workflow 2026-09-21 11:32:26 +03:00
LittleSam129
b28befc69d Fix RF-001A common-clock verification 2026-09-21 10:29:06 +03:00
LittleSam129
320adeea4a Prepare coordinated RF-001A OTA test 2026-09-18 15:30:45 +03:00
LittleSam129
7ec8c351bc Fix RX targeted test scope 2026-09-18 13:51:31 +03:00
LittleSam129
fdd58508da Advance RX handoff to hardware preflight 2026-09-18 13:30:04 +03:00
LittleSam129
06fadf6957 Update RX node preflight handoff 2026-09-18 13:09:09 +03:00
LittleSam129
8017849e24 Add RX laptop handoff workflow 2026-09-18 12:20:15 +03:00
70 changed files with 21793 additions and 9 deletions

1
.gitignore vendored
View File

@@ -69,4 +69,5 @@ Desktop.ini
# Локальные настройки и секреты
.env
*.local
.local/

8
INSTALL_RX_AGENT.cmd Normal file
View 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
View 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%

View 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
}

View 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"
}

View 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.

View 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.

View 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` для этого исправления не требуется.

View 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 требуют нового отдельного разрешения;
разрешения, использованные в завершённом сеансе, повторно не применяются.

View 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` в рамках этой фазы не создавался.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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 не запускать.

View File

@@ -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
View 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.

View 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

View 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")]

View 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()

View 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

View 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()

View 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

View 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)

View 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)

View 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()

View 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,
)

View 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 == []

View 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()

View 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

View 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)

View 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

View 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

View 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),
)

View 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

View 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"

View 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

View 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

View 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"

View 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"

View File

@@ -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 читается после вызова рабочего приёмника."""
"""Декодирует 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")

File diff suppressed because it is too large Load Diff

422
tools/rf001_agent_update.py Normal file
View 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)

View 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())

View 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

View 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()

View 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

File diff suppressed because it is too large Load Diff

View 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()

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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()

View 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
View 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
View 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
View 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
View 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
View 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()

View 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%

View 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%

View 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%

View 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
View 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
View 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",
]

View 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"

View 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"