Files
Oleksandr Bezdieniezhnykh 2c31cc094f [AZ-402] Replay — gps-denied-replay console-script + shared main(config)
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>
2026-05-14 20:04:37 +03:00

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:

  1. Parses arguments + validates file existence (video, tlog, calib, config, signing key).
  2. Loads config.yaml via the existing config/ loader.
  3. Loads the camera-calibration JSON (small dedicated loader; pinhole + distortion-coefficients schema).
  4. 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 — ReplayInputAdapter will auto-detect via AZ-405).
  5. Calls the SAME main(config, camera_calibration, signing_key_path) function the live gps-denied-onboard binary already calls. The shared main wires everything via compose_root(config) and runs the per-frame loop.
  6. 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).
  7. Top-level try/except logs the FULL traceback via logger.exception and 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.mdADR-011 (replay-as-configuration) + § 5 (binary topology; replay runs from the airborne image).
  • _docs/02_document/module-layout.mdcli/replay cross-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.pymain() 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] registers gps-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-key value 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 --video is 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 missing intrinsicsReplayCliError("camera-calibration schema invalid: missing 'intrinsics'").
    • test_config_mode_set_to_replay: parse args + invoke the CLI; capture the Config passed to the shared main; assert config.mode == "replay" + config.replay.video_path etc. 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 inside cli/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 via logger.exception mock) and the CLI exits 1.
    • test_console_script_registered: install the package in a fresh venv (via tox or pytest-virtualenv); assert gps-denied-replay --help runs 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 ReplayInputAdapter inside replay_input/ consumes --time-offset-ms from config OR auto-detects when None).
  • The compose_root branch + 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_root itself).

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_root directly — it mutates the config and dispatches into the shared main, which calls compose_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 2Mitigation: 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 a replay.auto_sync.ac8_validation_failed structured log line with the auto-detected offset + match percentage. Operators distinguish via stderr inspection.
  • Risk: calibration JSON schema driftMitigation: schema-validate at load time; AC-7 enforces.
  • Risk: top-level error swallowing makes debugging hardMitigation: 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 logicMitigation: AC-8 (config.mode == "replay" set) + dispatch-to-shared-main test together prevent any composition logic from sneaking into cli/replay.py. Code-review checklist on the PR.

Runtime Completeness

  • Named capability: gps-denied-replay console-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_root directly 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.