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>
12 KiB
Replay — gps-denied-replay console-script wrapper (mode-config dispatcher)
Task: AZ-402_replay_cli
Name: gps-denied-replay console-script — thin mode-config wrapper that builds a replay-mode Config and dispatches into the shared airborne entry point
Description: Implement the gps-denied-replay console-script in src/gps_denied_onboard/cli/replay.py. Per ADR-011, this is not a standalone CLI with its own composition root — it is a thin wrapper around the live airborne entry point that loads config.yaml, sets config.mode = "replay", applies the replay-specific CLI args (--video, --tlog, --output, --time-offset-ms, --pace, --mavlink-signing-key), and calls the same main() function the live gps-denied-onboard binary calls. The shared main entry point calls compose_root(config) (which branches on config.mode per AZ-401) and runs the per-frame loop; the runtime loop is unchanged between live and replay.
CLI surface (argparse):
gps-denied-replay
--video PATH # required
--tlog PATH # required
--output results.jsonl # required
--camera-calibration calib.json # required
--config config.yaml # required (same schema as airborne)
--mavlink-signing-key PATH # required (operator supplies a dummy key for replay; signing handshake still runs)
[--pace {realtime,asap}] # default asap
[--time-offset-ms N] # overrides AZ-405 auto-sync inside replay_input/
The CLI:
- Parses arguments + validates file existence (video, tlog, calib, config, signing key).
- Loads
config.yamlvia the existingconfig/loader. - Loads the camera-calibration JSON (small dedicated loader; pinhole + distortion-coefficients schema).
- Mutates the loaded config:
config.mode = "replay",config.replay.video_path = ...,config.replay.tlog_path = ...,config.replay.output_path = ...,config.replay.pace = ...,config.replay.time_offset_ms = ...(None if not provided —ReplayInputAdapterwill auto-detect via AZ-405). - Calls the SAME
main(config, camera_calibration, signing_key_path)function the livegps-denied-onboardbinary already calls. The shared main wires everything viacompose_root(config)and runs the per-frame loop. - Maps the runtime exit code to the process exit code (0 = success; 2 =
ReplayInputAdapter.open()auto-sync hard-fail per AC-8 of the epic; 1 = any other error). - Top-level try/except logs the FULL traceback via
logger.exceptionand exits 1 on any unhandled exception.
Complexity: 3 points (unchanged from v1.0.0 — the CLI shape is the same; what changed is that the CLI does NOT host the composition logic; it just builds a config and dispatches).
Dependencies: AZ-401 (compose_root extension with config.mode == "replay" branch + the Config.mode + Config.replay schema additions); AZ-269 / AZ-270 (config loader); AZ-263 (the shared airborne main() entry point); AZ-266 (logging); AZ-272 (FDR record schema); AZ-273 (FdrClient).
Component: replay-cli (epic AZ-265 / E-DEMO-REPLAY) — src/gps_denied_onboard/cli/replay.py.
Tracker: AZ-402
Epic: AZ-265 (E-DEMO-REPLAY)
Document Dependencies
_docs/02_document/contracts/replay/replay_protocol.md(v2.0.0) — CLI surface specification + Invariant 11 (signing key mandatory in replay)._docs/02_document/architecture.md— ADR-011 (replay-as-configuration) + § 5 (binary topology; replay runs from the airborne image)._docs/02_document/module-layout.md—cli/replaycross-cutting entry (the console-script wrapper, not a standalone CLI).
Problem
Without this task, the operator has no entry point to invoke config.mode == "replay" against an arbitrary (video, tlog) pair — they would need to manually edit a config file with the replay-mode flag and the per-file paths, then invoke the airborne entry point. The CLI is the user-facing surface (and CI-test surface) for the replay mode.
Outcome
src/gps_denied_onboard/cli/replay.py—main()entrypoint:- argparse setup with all 6 required args + the 2 optional ones.
- File-existence validation for all required-file args (video, tlog, calib, config, signing key); fails fast with
ReplayCliError+ exit 1 on missing files. - Calibration loader (small JSON loader; pinhole + distortion-coefficients schema) — module-internal helper.
- Config loader invocation (re-use AZ-269 / AZ-270 plumbing).
- Mode-config mutation:
config.mode = "replay"+config.replay.{video_path, tlog_path, output_path, pace, time_offset_ms}populated from CLI args. - Dispatch into the shared airborne
main(config, camera_calibration, signing_key_path)entry point. - Exit-code mapping: shared main returns 0 / 1 / 2 → CLI exits with the same code.
- Structured logging setup + FDR client setup happen inside the shared main (NOT duplicated here).
- Top-level try/except: logs the FULL traceback via
logger.exception+ exits 1 on any unhandled exception.
pyproject.toml[project.scripts]registersgps-denied-replay = "gps_denied_onboard.cli.replay:main".- INFO log at CLI startup (BEFORE config load, since logging is not yet bootstrapped): a single
print(f"gps-denied-replay starting with args: {sanitised_args}")via stderr; the shared main then bootstraps structured logging properly.--mavlink-signing-keyvalue is replaced by<redacted>in the printed args. - Unit tests:
test_argparse_all_args: all 6 required + 2 optional args parsed correctly; defaults applied.test_argparse_missing_video_exits_2: argparse exits 2 when--videois omitted (stdlib argparse default).test_argparse_missing_signing_key_exits_2: same for--mavlink-signing-key.test_calibration_loader_malformed: corrupt calib.json →ReplayCliError("camera-calibration JSON malformed: <details>")+ exit 1.test_calibration_loader_schema: calib.json missingintrinsics→ReplayCliError("camera-calibration schema invalid: missing 'intrinsics'").test_config_mode_set_to_replay: parse args + invoke the CLI; capture theConfigpassed to the shared main; assertconfig.mode == "replay"+config.replay.video_pathetc. populated.test_dispatch_to_shared_main: assert the shared main is called exactly once with the mutated config; assert no separate composition logic is invoked insidecli/replay.py.test_exit_code_pass_through: with a FakeMain returning 0 / 1 / 2, the CLI exits 0 / 1 / 2 respectively.test_top_level_exception_logged_and_exits_1: an unhandled exception inside the shared main is logged with full traceback (verified vialogger.exceptionmock) and the CLI exits 1.test_console_script_registered: install the package in a fresh venv (viatoxorpytest-virtualenv); assertgps-denied-replay --helpruns and prints the argparse usage.
Scope
Included
- argparse + arg-validation (file existence).
- camera-calibration JSON loader + schema validation (module-internal helper).
- Config-mode mutation (
config.mode = "replay"+ replay sub-config population). - Dispatch into the shared airborne
main()entry point. - Exit-code mapping (pass-through).
- Top-level error handling.
- Console-script registration in pyproject.toml.
- All unit tests listed above.
Excluded
- Auto-sync IMU take-off detection — owned by AZ-405 (the
ReplayInputAdapterinsidereplay_input/consumes--time-offset-msfrom config OR auto-detects when None). - The
compose_rootbranch + the JSONL sink + the NoopMavlinkTransport wiring — owned by AZ-401. - E2E replay fixture test — owned by AZ-404.
- The shared airborne
main()function itself — owned by AZ-263 / the existing airborne entry-point task. This task assumes the shared main exists and is callable with(config, camera_calibration, signing_key_path).
Acceptance Criteria
AC-1: All required args parsed — invoke with --video v.mp4 --tlog t.tlog --output o.jsonl --camera-calibration c.json --config conf.yaml --mavlink-signing-key key.bin; assert all six values reach the shared main (or the Config mutation phase) intact.
AC-2: --pace default ASAP — invoke without --pace; assert config.replay.pace == "asap".
AC-3: --pace realtime — invoke with --pace realtime; assert config.replay.pace == "realtime".
AC-4: --time-offset-ms forwarded — invoke with --time-offset-ms 5000; assert config.replay.time_offset_ms == 5000. Without --time-offset-ms, assert config.replay.time_offset_ms is None (and ReplayInputAdapter will auto-detect).
AC-5: --mavlink-signing-key required — invoke without --mavlink-signing-key; assert argparse exits 2 with stderr message naming the missing arg. Per replay protocol Invariant 11.
AC-6: Calibration loader rejects malformed JSON — pass a corrupt calib.json; assert ReplayCliError("camera-calibration JSON malformed: <details>") + exit 1.
AC-7: Calibration schema validation — pass a calib.json missing intrinsics key; assert ReplayCliError("camera-calibration schema invalid: missing 'intrinsics'").
AC-8: Mode set to replay — capture the Config object passed to the shared main; assert config.mode == "replay".
AC-9: Exit-code pass-through — wire a FakeMain returning 0 / 1 / 2; assert the CLI exits 0 / 1 / 2 respectively. Exit code 2 is reserved for ReplayInputAdapter.open() auto-sync hard-fail (set by the shared main / compose_root), NOT for argparse missing-arg (which uses argparse's default exit 2 but with a distinguishable stderr message).
AC-10: Console script registered — install the package in a fresh venv; assert gps-denied-replay --help runs and prints the argparse usage.
Non-Functional Requirements
- CLI startup p99 ≤ 5 s (cold-start NFT from the epic, including config + calibration loading).
- argparse + calibration loading p99 ≤ 100 ms (excluding
compose_rootitself).
Constraints
- argparse (stdlib) — no new CLI framework.
- JSON for calibration (already the project convention).
- Exit codes: 0 = success; 2 = AC-8 sync-impossible (set by the shared main from
ReplayInputAdapter) OR argparse missing-arg (stdlib default); 1 = any other error. - Console-script registration in pyproject.toml
[project.scripts]. - The CLI MUST NOT call
compose_rootdirectly — it mutates the config and dispatches into the shared main, which callscompose_root. This keeps the live and replay code paths converged at the same entry point per ADR-011.
Risks & Mitigation
- Risk: argparse exit code 2 conflicts with epic AC-8 exit code 2 — Mitigation: documented; the argparse path emits a
usage:line + a "the following arguments are required: …" line to stderr (stdlib default), whereas the AC-8 path emits areplay.auto_sync.ac8_validation_failedstructured log line with the auto-detected offset + match percentage. Operators distinguish via stderr inspection. - Risk: calibration JSON schema drift — Mitigation: schema-validate at load time; AC-7 enforces.
- Risk: top-level error swallowing makes debugging hard — Mitigation: top-level except logs the FULL traceback (via
logger.exception); the exit code is 1 but the operator sees the traceback in stderr. - Risk: the CLI accidentally re-implements composition logic — Mitigation: AC-8 (
config.mode == "replay"set) + dispatch-to-shared-main test together prevent any composition logic from sneaking intocli/replay.py. Code-review checklist on the PR.
Runtime Completeness
- Named capability:
gps-denied-replayconsole-script that activates replay mode on the airborne binary. - Production code: real argparse, real calibration loader, real config-mode mutation, real dispatch to the shared main, real exit-code pass-through.
- Allowed external stubs: test fakes only.
- Unacceptable substitutes: a click-based or typer-based CLI (adds a dependency for no gain over stdlib argparse); calling
compose_rootdirectly from the CLI (bypasses the shared main and defeats ADR-011's "same entry point for both modes" property).
Contract
Implements _docs/02_document/contracts/replay/replay_protocol.md (v2.0.0) — CLI surface + Invariant 11 (signing key mandatory). Operationalises ADR-011.