Implements the replay-mode CLI dispatcher per ADR-011 (replay-as- configuration): - src/gps_denied_onboard/cli/replay.py: argparse with all 6 required args (--video, --tlog, --output, --camera-calibration, --config, --mavlink-signing-key) plus --pace and --time-offset-ms; path validation, calibration JSON schema-validation, config mutation (mode='replay' + replay sub-block + signing-key hex on dev_static field), dispatch into runtime_root.main(config). - runtime_root.main() now accepts an optional Config (additive, backward-compat). Adds dedicated catch for ReplayInputAdapterError mapping to EXIT_FDR_OPEN_FAILURE (2) so the CLI's exit-code matrix holds end-to-end (AC-9 + epic AZ-265 AC-8). - Signing-key contents stored as hex; redacted in startup banner. - Top-level except logs full traceback via logger.exception + stderr print and exits 1. The CLI does NOT call compose_root directly — it builds a Config and hands it to the shared airborne main, which calls compose_root, which branches on config.mode (AZ-401 / replay protocol Invariant 11). Tests: 22 unit tests covering AC-1..AC-10 + extras (signing-key redaction, file-not-dir validation, dev_static propagation, unhandled exception traceback). Full regression: 2085 passed (+22) green; no new flaky tests. Co-authored-by: Cursor <cursoragent@cursor.com>
9.5 KiB
Code Review Report
Batch: 62 (AZ-402) Date: 2026-05-14 Verdict: PASS
Findings
| # | Severity | Category | File:Line | Title |
|---|---|---|---|---|
| 1 | Low | Maintainability | src/gps_denied_onboard/runtime_root/__init__.py:621-660 | runtime_root.main now accepts an optional Config (additive refactor) — additional surface for callers, but backward-compat |
| 2 | Low | Style | src/gps_denied_onboard/cli/replay.py:235-256 | New shared_main test-injection kwarg added to cli/replay:main (third coordinator with this pattern; pre-flagged in batch 61 review F3) |
Finding Details
F1: runtime_root.main accepts optional Config (Low / Maintainability)
- Location:
src/gps_denied_onboard/runtime_root/__init__.py:621-660(def main(config: Config | None = None) -> int) - Description: AZ-402's task spec calls for the CLI to "dispatch into the same
main()function the livegps-denied-onboardbinary calls". Before this batch,runtime_root.main()was parameterless and loaded theConfigitself fromos.environ; the CLI couldn't pass a mutated config without either callingcompose_rootdirectly (FORBIDDEN per replay protocol Invariant 11) or rewritingos.environ(a fragile workaround). The smallest additive refactor is to acceptConfig | None: whenNonethe live binary's behaviour is preserved (load from env), when supplied the CLI can hand in its mutated config. The function also gains a dedicated catch forReplayInputAdapterErrormapping toEXIT_FDR_OPEN_FAILURE(2) so the CLI's exit-code matrix (AC-9) holds end-to-end. - Suggestion: keep — matches the spec's Excluded section ("This task assumes the shared main exists and is callable with
(config, ...)"). TheRuntimeErrorcatch downstream still handlesReplayInputAdapterErrorif any caller bypasses the new branch — no regression for live mode. - Task: AZ-402
F2: shared_main test-injection kwarg in cli/replay:main (Low / Style)
- Location:
src/gps_denied_onboard/cli/replay.py:235-256(def main(argv, *, shared_main=None)) - Description: A second optional kwarg defaulting to
None(resolved lazily toruntime_root.mainto avoid a circular import + cheap module-load). When provided (tests), the fake replaces the dispatch target. This is the same precedent asreplay_input.tlog_video_adapter.ReplayInputAdapter.__init__'s test factories (batch 60) and AZ-401'scompose_root(replay_components_factory=...)(batch 61). Production callers (the console-script entry point,if __name__ == "__main__"block) pass none of them. - Suggestion: keep. Three coordinators now share this shape; if a fourth adopts it, factor into a shared
_TestFactorieshelper. - Task: AZ-402
Phase Summary
Phase 1 — Context Loading
Read inputs:
_docs/02_tasks/todo/AZ-402_replay_cli.md_docs/02_document/contracts/replay/replay_protocol.md(v2.0.0 — CLI surface + Invariant 11)_docs/02_document/architecture.md(ADR-011)_docs/02_document/module-layout.md(Layer 5 —cli/replay)_docs/02_document/epics.md(E-DEMO-REPLAY ACs 1, 8, 11)
Phase 2 — Spec Compliance
| AC | Verdict | Test |
|---|---|---|
| AC-1 (all 6 required args parsed) | PASS | test_ac1_all_required_args_parsed |
AC-2 (--pace default asap) |
PASS | test_ac2_pace_default_asap |
AC-3 (--pace realtime) |
PASS | test_ac3_pace_realtime |
AC-4 (--time-offset-ms forwarded) |
PASS | test_ac4_time_offset_forwarded + _none_when_absent |
AC-5 (--mavlink-signing-key required, argparse exit 2) |
PASS | test_ac5_missing_signing_key_exits_2 (+ _missing_video_exits_2) |
| AC-6 (malformed JSON → exit 1) | PASS | test_ac6_malformed_calibration_exits_1 |
| AC-7 (missing intrinsics key → schema error) | PASS | test_ac7_missing_intrinsics_key_rejected (+ _top_level_not_object) |
AC-8 (config.mode == "replay") |
PASS | test_ac8_mode_set_to_replay |
AC-9 (exit-code pass-through 0 / 1 / 2; ReplayInputAdapterError → 2) |
PASS | test_ac9_exit_code_pass_through (parametrized × 3) + test_ac9_replay_input_adapter_error_maps_to_2 + test_unhandled_exception_exits_1_with_traceback |
AC-10 (console script registered + --help works) |
PASS | test_ac10_console_script_registered_in_pyproject + test_ac10_console_script_runs_help |
22 unit tests in tests/unit/test_az402_replay_cli.py, all green. Plus extra coverage: signing-key redaction in banner, file-not-dir validation, signing-key propagation to Config.fc.dev_static_signing_key.
Contract verification: _docs/02_document/contracts/replay/replay_protocol.md v2.0.0 §CLI Surface + Invariant 11 (signing key mandatory) match the implementation. module-layout.md §shared/cli/replay description matches the new file's purpose verbatim.
Phase 3 — Code Quality
- SOLID:
cli/replay.pyis a single-responsibility CLI dispatcher. Each helper has one job:_build_argparser,_validate_paths,_load_calibration_json,_build_replay_config,_print_startup_banner.main()orchestrates. - Error handling: explicit, layered.
ReplayCliErrorfor operator-input failures (chains__cause__);ReplayInputAdapterErrorcaught and mapped to exit 2;SystemExitre-raised so argparse's--help/--versionpropagate; everything else logged with full traceback. No bareexcept:and noexcept: pass. - Naming: clear (
_build_replay_config,_print_startup_banner,EXIT_SYNC_IMPOSSIBLE). - Complexity: longest function is
main()at ~35 LOC (linear flow with explicit guards). No cyclomatic-complexity > 10. - Test quality: every test asserts a meaningful behaviour. Parametrised exit-code test exercises 0 / 1 / 2 in one place. The signing-key redaction test asserts the path string itself is NOT in stderr (positive AND negative assertion).
- Dead code: none introduced. The previous
cli/replay.pystub (5-line placeholder returning exit 2) is fully replaced.
Phase 4 — Security Quick-Scan
- Signing key redaction: the startup banner replaces the
mavlink_signing_keyvalue with"<redacted>"before printing. Test enforces (test_signing_key_redacted_in_startup_banner). The path is sanitised; the file contents are also stored as hex inConfig.fc.dev_static_signing_keyand never logged. - No SQL / shell /
eval/exec/pickle: argparse, json.loads, Path operations only. - Calibration JSON: parsed with
json.loads(safe; no schema-injection vector). Schema validation rejects unexpected shapes at top level. - No hardcoded secrets: the signing key is operator-supplied at runtime via a file path.
Phase 5 — Performance Scan
- argparse setup + calibration JSON load + config-mutation are all constant-time on small inputs (calib.json is < 4 KB). The CLI's contribution to cold-start is measured in milliseconds, well within the AZ-402 NFR (
argparse + calibration loading p99 ≤ 100 ms). - The CLI calls
runtime_root.mainexactly once. No retry loop, no polling.
Phase 6 — Cross-Task Consistency
- Only AZ-402 in this batch. The
runtime_root.mainrefactor is additive:main()(no args) still works identically — proven by the 2085-test regression sweep with no failures introduced. - The CLI's
Configmutation usesdataclasses.replacewith the existingConfig,RuntimeConfig,ReplayConfig,FcConfigshapes added in batch 61 (AZ-401). No schema drift. - The exit-code semantic on
ReplayInputAdapterError(2) is consistent withEXIT_FDR_OPEN_FAILURE(2) — both mean "fatal startup hard-fail; operator action required". The shared code makes the airborne binary's exit surface predictable.
Phase 7 — Architecture Compliance
- Layer direction:
cli/replay.pyis Layer 5 permodule-layout.md. It imports from Layer 1 (config,logging), Layer 4 (replay_input.errors), and Layer 5 (runtime_root.main). All Layer-5 → Layer-1/4/5 — correct direction. - Public API respect: the CLI imports
Config,ReplayConfig,load_configfromgps_denied_onboard.config(the package public surface), not deep submodules. It importsReplayInputAdapterErrorfromgps_denied_onboard.replay_input(also a package re-export). It importsruntime_root.mainlazily insidemain()to avoid circular imports. - No new cyclic deps:
cli/replay.pyis leaf-imported only by the console-script entry point; the lazyruntime_root.mainimport insidemain()further insulates it. - Duplicate symbols:
EXIT_SUCCESS,EXIT_GENERIC_FAILURE,EXIT_SYNC_IMPOSSIBLEare the CLI's own constants. They mirrorruntime_root'sEXIT_GENERIC_FAILURE/EXIT_FDR_OPEN_FAILUREby value (1 / 2). The mirror is intentional: each layer documents its own exit semantics. If the values ever drift, the AC-9 parametrised test catches the regression. - Cross-cutting concerns: calibration loading is duplicated in three places (live composition root via env, replay branch in
_replay_branch._load_camera_calibration, and now CLI in_load_calibration_json). The CLI loader is a fail-fast SCHEMA gate, not a parsing layer (the actualCameraCalibrationbuild happens inside_replay_branch). The duplication is small and intentional. Already pre-flagged in batch 61 review F4 as "factor when a third call site appears" — this is the third call site, but with a different responsibility (validation vs. construction); leaving as-is and re-evaluating after AZ-326 / live-CLI work touches the calibration loading path.
Verdict Reasoning
Two Low findings, both deliberate design choices documented in the spec's Excluded / Constraints sections. No Critical, no High. Verdict: PASS.