Files
Oleksandr Bezdieniezhnykh 5fe67023b2 [AZ-329] [AZ-330] [AZ-523] [AZ-524] Batch 44 atomic refactor
Implements two new C12 services and rebalances the C11/C12 boundary
in one atomic commit:

* AZ-329 PostLandingUploadOrchestrator — gates C11 upload on the
  `flight_footer` FDR record's `clean_shutdown` field; 4 refusal
  modes; new FdrFooterReader Protocol + LocalFdrFooterReader.
* AZ-330 OperatorReLocService — AC-3.4 visual-loss re-localization
  hint; reuses shared LatLonAlt; OperatorCommandTransport Protocol
  cut (E-C8 owns the future pymavlink concrete); new FDR record
  kind `c12.reloc.requested`; log redaction (lat/lon 5 decimals,
  reason 200 chars).
* AZ-523 C11 internal flight-state gate removed (SRP refactor):
  `confirm_flight_state` / `FlightStateSignal` use /
  `FlightStateNotOnGroundError` deleted from C11; TileUploader
  contract bumped to v2.0.0 (frozen) with migration note; AZ-317
  superseded.
* AZ-524 Package rename `c12_operator_tooling` →
  `c12_operator_orchestrator` across source, tests, pyproject,
  CMake, Dockerfile, compose, CI, runtime-root services class
  (`OperatorOrchestratorServices`) + factory function
  (`build_operator_orchestrator`), logger namespaces, config slug,
  docs, and the E-C12 epic title.

Tests: 1543 passed, 80 skipped (all environment gates). Targeted
AC suite (AZ-329 + AZ-330 + FdrFooterReader): 37 passed. Cold-start
NFR-perf still ≤ 500 ms p99.

Tracker: AZ-317 → Done (superseded); AZ-319 v2.0.0 contract bump
comment; AZ-329/AZ-330 → In Testing; AZ-253 epic renamed; AZ-523
+ AZ-524 created and closed as audit-trail tickets.

See `_docs/03_implementation/batch_44_cycle1_report.md`.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 19:42:46 +03:00

21 KiB
Raw Permalink Blame History

C12 CLI App — Typer Entry Point + Subcommand Routing + Operator Helpers

Task: AZ-326_c12_cli_app Name: C12 CLI App Description: Implement the operator-tooling CLI shell that operators run on the workstation. Wires Typer (per the Click/Typer project pin) into operator_tool/__main__.py, registers six subcommands (download, build-cache, upload-pending, reloc-confirm, verify-ready, set-sector), wires the E-CC-LOG (AZ-266) logger to a workstation-side structured-JSON log file (~/.azaion/onboard/c12-tooling.log), and ships the two trivial operator-side helpers from description.md § 2 — set_sector_classification(area, sector_class) (persists per-area classification to a local JSON file under the operator workstation's home directory) and apply_freshness_threshold(sector_class) -> int (months) (a pure-data lookup that maps the sector classification enum to the AC-NEW-6 months freshness budget). Each subcommand is a thin shell that resolves its service collaborator (flights_api_client, build_cache, companion_bringup, post_landing_upload, operator_reloc_service — all owned by sibling tasks AZ-489 / AZ-NNN T2..T5) from the composition root and delegates to it; on success returns 0; on a known error type maps to a documented non-zero exit code with a one-line operator-friendly message + remediation hint pulled from the underlying error's remediation attribute. The CLI app does NOT own any workflow logic itself — only command registration, argument parsing, logger wiring, exit-code mapping, and the two simple operator helpers. ADR-010 amendment: the build-cache subcommand accepts a mutually-exclusive pair --flight-id <Guid> | --flight-file <Path> and forwards the resolved FlightDto (via AZ-489 FlightsApiClient) to the orchestrator (AZ-328), which derives the bbox + takeoff origin from it. The legacy --bbox flag is dropped because the bbox is now derived; passing it is an error. Complexity: 3 points Dependencies: AZ-263_initial_structure, AZ-269_config_loader, AZ-266_log_module, AZ-489_c12_flights_api_client (for the FlightsApiClient service collaborator + DTO definitions surfaced via --flight-id / --flight-file) Component: c12_operator_orchestrator (epic AZ-253 / E-C12) Tracker: AZ-326 Epic: AZ-253 (E-C12)

Document Dependencies

  • _docs/02_document/components/13_c12_operator_orchestrator/description.md — § 2 (set_sector_classification, apply_freshness_threshold from CacheBuildWorkflow), § 5 (logging strategy table), § 7 (CLI-only this cycle, GUI deferred).
  • _docs/02_document/contracts/shared_logging/log_record_schema.md — INFO/WARN/ERROR log shapes for operator events.

Problem

Without a real CLI shell:

  • F1 (pre-flight cache build) and F10 (post-landing upload) have no operator entry point — every workflow function in this epic is unreachable from the workstation.
  • AC-NEW-6 (freshness pipeline) collapses partially — sibling tasks have no canonical place to call apply_freshness_threshold(sector_class) so each invents its own table or hard-codes months.
  • Sector classification (active-conflict vs stable-rear) per description.md § 1 has no persistent surface; operator restarts lose all classifications.
  • Logging from C12 is silent — without the wiring of E-CC-LOG to the workstation-side log file, every operator action is invisible during incident review.
  • Sibling tasks T2..T5 have no consumer; their service classes ship but no end-to-end CLI flow exercises them.
  • Exit codes are inconsistent across subcommands — operators script operator-orchestrator runs and need $? to mean something specific per failure category.

This task delivers the CLI shell + the two trivial operator helpers. It does NOT own build_cache, verify_companion_ready, trigger_post_landing_upload, or OperatorReLocService — those are sibling tasks invoked through the CLI.

Outcome

  • A Typer-based CLI app at src/operator_tool/:
    • src/operator_tool/__main__.py — module entry point: from operator_tool.cli import app; app().
    • src/operator_tool/cli.py — Typer app = typer.Typer(name="operator-orchestrator", help="GPS-denied onboard pre-flight tooling (operator workstation)"). Registers six subcommands via @app.command(...). Each subcommand opens a logging context, calls into its service collaborator, catches the documented exception family for that command, maps to the documented exit code, and raise typer.Exit(code=N).
    • src/operator_tool/sector_classification_store.pySectorClassificationStore class:
      • Constructor: __init__(self, *, store_path: Path, logger: Logger).
      • set_classification(area: AreaIdentifier, sector_class: SectorClassification) -> None — persists {area_id: sector_class} mapping to store_path (default: ~/.azaion/onboard/sector-classifications.json) using atomic write (tempfile + os.replace).
      • get_classification(area: AreaIdentifier) -> SectorClassification | None — reads the JSON file; returns the classification for the given area or None if not set.
      • list_classifications() -> dict[AreaIdentifier, SectorClassification] — returns all current classifications.
      • File format: {"area_id": "active_conflict" | "stable_rear", ...}.
      • INFO log on every set_classification call (kind="c12.sector.classification.set").
    • src/operator_tool/freshness_table.pyfreshness_threshold_months(sector_class: SectorClassification) -> int:
      • Pure data: active_conflict → 1 month; stable_rear → 12 months. Documented inline as the AC-NEW-6 freshness budget per description.md § 1 + Plan-phase intent.
      • Module-level constant: FRESHNESS_TABLE: dict[SectorClassification, int].
    • src/operator_tool/exit_codes.py — module-level constants: EXIT_OK = 0, EXIT_GENERIC_ERROR = 1, EXIT_USAGE = 2, EXIT_COMPANION_UNREACHABLE = 10, EXIT_CONTENT_HASH_MISMATCH = 11, EXIT_DOWNLOAD_FAILURE = 20, EXIT_BUILD_FAILURE = 21, EXIT_FLIGHT_STATE_NOT_CONFIRMED = 30, EXIT_UPLOAD_FAILURE = 31, EXIT_GCS_LINK_ERROR = 40, EXIT_LOCK_HELD = 50, EXIT_FLIGHTS_API_UNREACHABLE = 60, EXIT_FLIGHTS_API_AUTH = 61, EXIT_FLIGHT_NOT_FOUND = 62, EXIT_FLIGHT_SCHEMA = 63, EXIT_EMPTY_WAYPOINTS = 64. Sibling tasks may extend with documented additions.
  • A composition root entry at src/gps_denied_onboard/runtime_root/c12_factory.py:
    • build_operator_tool(config: Config) -> OperatorOrchestratorServices — pure factory that constructs the SectorClassificationStore + a logger configured to write to ~/.azaion/onboard/c12-tooling.log. Returns a frozen dataclass aggregating the operator-orchestrator service handles. Sibling tasks T2..T5 each add their service to this dataclass without renaming or moving it.
  • Subcommand surface (each subcommand body lives in cli.py; service implementations live in sibling task files):
    • download — delegates to tile_downloader.fetch(...) (AZ-316). Maps SatelliteProviderError → EXIT_DOWNLOAD_FAILURE.
    • build-cache — accepts a mutually-exclusive pair --flight-id <Guid> | --flight-file <Path> (Typer-enforced via a callback that rejects both-set / neither-set with EXIT_USAGE), plus --sector-class, --calibration-path. Delegates to build_cache_orchestrator.build_cache(...) (sibling AZ-328) passing the resolved FlightDto (the orchestrator computes bbox + takeoff origin from it via AZ-489 helpers). Maps CacheBuildError → EXIT_DOWNLOAD_FAILURE | EXIT_BUILD_FAILURE (per failure_phase); BuildLockHeldError → EXIT_LOCK_HELD; FlightsApiUnreachableError → EXIT_FLIGHTS_API_UNREACHABLE; FlightsApiAuthError → EXIT_FLIGHTS_API_AUTH; FlightNotFoundError → EXIT_FLIGHT_NOT_FOUND; FlightsApiSchemaError | FlightFileNotFoundError | WaypointSchemaError → EXIT_FLIGHT_SCHEMA; EmptyWaypointsError → EXIT_EMPTY_WAYPOINTS.
    • upload-pending — delegates to post_landing_upload.trigger_post_landing_upload(...) (sibling T4). Maps FlightStateNotConfirmedError → EXIT_FLIGHT_STATE_NOT_CONFIRMED; UploadGateBlockedError → EXIT_UPLOAD_FAILURE.
    • reloc-confirm — delegates to operator_reloc_service.request_reloc(...) (sibling T5). Maps GcsLinkError → EXIT_GCS_LINK_ERROR.
    • verify-ready — delegates to companion_bringup.verify_companion_ready(...) (sibling T2). Maps CompanionUnreachableError → EXIT_COMPANION_UNREACHABLE; ContentHashMismatchError → EXIT_CONTENT_HASH_MISMATCH.
    • set-sector — delegates to SectorClassificationStore.set_classification(...).
  • Each subcommand's --help includes a one-line summary + the AC IDs it supports (e.g. build-cache: orchestrate F1 (AC-8.3, AC-NEW-1)).
  • Logging is wired at app startup: a single rotating file handler at ~/.azaion/onboard/c12-tooling.log, structured JSON formatter from E-CC-LOG (AZ-266). Console (stderr) handler at WARN level for operator visibility.
  • pyproject.toml registers operator-orchestrator as a console script entry point pointing at operator_tool.__main__:main. The main function in __main__.py calls app().

Scope

Included

  • operator_tool package layout (__init__.py, __main__.py, cli.py, sector_classification_store.py, freshness_table.py, exit_codes.py).
  • The composition-root factory build_operator_tool.
  • Six subcommand registrations + per-subcommand --help text + per-subcommand exception → exit-code mapping.
  • SectorClassificationStore with atomic-write JSON persistence.
  • freshness_threshold_months pure-data lookup.
  • The exit-code constants module.
  • Logger wiring for the workstation-side log file (rotating file handler + structured JSON via AZ-266).
  • Console-script entry-point declaration in pyproject.toml.
  • Unit tests covering: subcommand registration, exception → exit-code mapping (using fakes for service collaborators), SectorClassificationStore round-trip (set, get, atomic write resilience), freshness_threshold_months for both enum values, console script invocability via subprocess.run.

Excluded

  • The actual workflows for build-cache, upload-pending, reloc-confirm, verify-ready — owned by sibling tasks T2..T5.
  • The download workflow body — owned by AZ-316.
  • The MAVLink encoding for reloc-confirm — owned by sibling T5.
  • A GUI surface — Plan-phase carryforward, deferred per description.md § 7.
  • Anything that runs on the airborne companion (this entire package is operator-workstation-only per ADR-004).
  • Per-subcommand integration tests against real satellite-provider — those live in C12-AT-01 (test decompose).

Acceptance Criteria

AC-1: All six subcommands register and appear in --help Given the operator-orchestrator console script is installed When the operator runs operator-orchestrator --help Then the listed subcommands include exactly download, build-cache, upload-pending, reloc-confirm, verify-ready, set-sector; no extras

AC-2: Successful subcommand exits 0 Given a subcommand whose service collaborator returns successfully When the subcommand is invoked through the CLI Then the process exit code is 0; no error message is printed to stderr; an INFO log entry is written

AC-3: Each documented exception maps to its documented exit code Given a service collaborator raises one of the documented exception types in this task's outcome list When the subcommand is invoked Then the process exit code matches the constant in exit_codes.py; a one-line operator-friendly message is printed to stderr; an ERROR log entry is written with the exception type and the remediation hint

AC-4: SectorClassificationStore round-trips via atomic write Given an empty store When set_classification(area="Derkachi", sector_class=SectorClassification.active_conflict) is called, then a fresh SectorClassificationStore is constructed pointing at the same path Then get_classification("Derkachi") returns SectorClassification.active_conflict; the on-disk JSON file matches the expected shape; the file's parent directory was created if missing

AC-5: SectorClassificationStore set is atomic under crash Given an existing JSON file with one classification When the process is killed (SIGKILL) mid-write of a second classification (simulated via a Path.replace patch that raises after tempfile.write but before os.replace) Then the original JSON file remains intact and parseable; no *.tmp lingers

AC-6: freshness_threshold_months returns the documented values Given the two enum values of SectorClassification When freshness_threshold_months(...) is called for each Then active_conflict → 1, stable_rear → 12

AC-7: Logging writes structured JSON to the workstation log file Given a fresh CLI invocation with ~/.azaion/onboard/ empty When any subcommand runs to completion Then a c12-tooling.log file exists at ~/.azaion/onboard/; its lines parse as JSON; each line carries timestamp, level, kind, plus subcommand-specific fields per AZ-266's record schema

AC-8: Console-script entry point is installed and runnable Given the package is installed via pip install -e . When the shell runs operator-orchestrator --help Then the help text is printed; the exit code is 0; the binary resolves through the entry-point declared in pyproject.toml

AC-9: Subcommand --help references the relevant AC IDs Given any subcommand When operator-orchestrator <subcommand> --help is run Then the help text body includes the AC IDs the subcommand supports (e.g. build-cache mentions AC-8.3, AC-NEW-1); operators reading --help can cross-reference to acceptance_criteria.md

AC-10: set-sector is idempotent for the same input Given set-sector --area Derkachi --class active_conflict was just run When the same command is run again Then the on-disk JSON file is byte-identical (or has only timestamp diffs in the log, not in the data file); the operator sees the same exit code 0 and the same INFO log line

AC-11: build-cache --flight-id happy path delegates to orchestrator with FlightDto (ADR-010) Given a fake FlightsApiClient.fetch_flight returns a 3-waypoint FlightDto When operator-orchestrator build-cache --flight-id 00000000-0000-0000-0000-000000000001 --sector-class stable_rear --calibration-path /tmp/cal.json runs Then build_cache_orchestrator.build_cache(...) is called once with the resolved FlightDto (or its (flight_id, bbox, takeoff_origin) projection per AZ-328 signature); ZERO calls to --bbox legacy parsing

AC-12: build-cache --flight-file happy path uses offline loader Given a local JSON file in the documented schema is on disk When operator-orchestrator build-cache --flight-file /tmp/flight.json --sector-class stable_rear --calibration-path /tmp/cal.json runs Then FlightsApiClient.load_flight_file(/tmp/flight.json) is called once; fetch_flight is NOT called; the orchestrator receives the same DTO shape

AC-13: build-cache with both --flight-id and --flight-file errors out When operator-orchestrator build-cache --flight-id 00000000-0000-0000-0000-000000000001 --flight-file /tmp/flight.json ... runs Then exit code is EXIT_USAGE = 2; stderr names the conflict; ZERO calls to either client method

AC-14: build-cache with neither --flight-id nor --flight-file errors out When operator-orchestrator build-cache --sector-class stable_rear --calibration-path /tmp/cal.json runs (no flight source) Then exit code is EXIT_USAGE = 2; stderr lists which flag must be supplied

AC-15: FlightNotFoundError maps to EXIT_FLIGHT_NOT_FOUND Given fetch_flight raises FlightNotFoundError When build-cache --flight-id <unknown> runs Then exit code is 62; ERROR log carries the offending flight_id; ZERO calls to C11/C10

AC-16: FlightsApiAuthError maps to EXIT_FLIGHTS_API_AUTH (and never logs the auth token) Given fetch_flight raises FlightsApiAuthError When build-cache --flight-id <id> runs Then exit code is 61; the structured log entry does NOT contain the auth_token value

AC-17: EmptyWaypointsError maps to EXIT_EMPTY_WAYPOINTS Given the fetched FlightDto has zero waypoints When build-cache --flight-id <id> runs (and the orchestrator calls bbox_from_waypoints → raises) Then exit code is 64; the stderr message instructs the operator to re-plan in the Mission Planner UI

Non-Functional Requirements

Performance

  • CLI cold start (operator-orchestrator --help) ≤ 500 ms on a developer laptop. The Typer app must avoid eager-importing heavy dependencies (httpx, pymavlink, paramiko) — sibling tasks expose lazy-import accessors used by their respective subcommands, not at module load time.

Compatibility

  • Click/Typer per the project pin (no version override).
  • The structured JSON log format matches AZ-266's record schema exactly; this task adds no new top-level field.

Reliability

  • The SectorClassificationStore write path is atomic across process kill (per AC-5).
  • ~/.azaion/onboard/ is created with mode 0o700 if it does not exist.

Unit Tests

AC Ref What to Test Required Outcome
AC-1 operator-orchestrator --help output All 6 subcommands listed
AC-2 Subcommand with success-returning fake service Exit 0, INFO log, no stderr
AC-3 Subcommand with raising fake (each documented exception family) Exit code matches exit_codes.py; ERROR log; one-line stderr
AC-4 Round-trip SectorClassificationStore set → read Matches input
AC-5 Patched os.replace to raise mid-write Original file intact, no *.tmp lingers
AC-6 freshness_threshold_months for both enums active_conflict → 1, stable_rear → 12
AC-7 Subcommand run, then read log file Each line parses as JSON; required fields present
AC-8 subprocess.run(["operator-orchestrator", "--help"]) after pip install -e . Exit 0, help text printed
AC-9 Per-subcommand --help text Includes documented AC IDs
AC-10 Repeated set-sector for same area/class On-disk JSON byte-identical
AC-11 build-cache --flight-id happy path Orchestrator called once with resolved DTO
AC-12 build-cache --flight-file happy path load_flight_file called; fetch_flight NOT called
AC-13 Both --flight-id and --flight-file Exit 2; conflict message
AC-14 Neither flight source supplied Exit 2; usage hint
AC-15 FlightNotFoundError Exit 62; flight_id in log
AC-16 FlightsApiAuthError Exit 61; auth_token NOT in log
AC-17 EmptyWaypointsError Exit 64; Mission Planner UI hint
NFR-perf-cold-start Microbench operator-orchestrator --help × 10 p99 ≤ 500 ms

Constraints

  • This task introduces NO new third-party dependencies — Click/Typer is already pinned by the project per description.md § 5.
  • Heavy dependencies (httpx, pymavlink, paramiko) MUST NOT be eager-imported in cli.py or __main__.py; they live behind the sibling tasks' service classes that are lazy-resolved.
  • The CLI is operator-workstation-only — operator_tool MUST NOT be importable from any airborne entry point. Verified at the SBOM-diff level by E-BOOT (the CI gate already enforces no operator_tool symbol in production-binary).
  • Atomic writes use tempfile.NamedTemporaryFile(dir=store_path.parent) + os.replace. Naked Path.write_text() is NOT acceptable per coderule.mdc "follow established project patterns" (see AZ-280's atomic-write pattern for the established convention; this task uses the simpler stdlib version since there is no SHA-256 sidecar requirement here).
  • Log file location is fixed at ~/.azaion/onboard/c12-tooling.log per description.md § 9 — config-overrideable via config.c12.log_path for tests but the default MUST match the spec.
  • Subcommand naming is the source of truth for operators; renaming a subcommand requires a Plan-cycle change.

Risks & Mitigation

Risk 1: Heavy imports leak into CLI startup

  • Risk: A future sibling task lazily-imports a heavy dependency at the wrong scope (module level instead of function level), violating NFR-perf-cold-start.
  • Mitigation: AC-NFR-perf-cold-start microbenches startup; CI hooks the test. If a regression appears, the offending import is surfaced by python -X importtime.

Risk 2: Operator runs set-sector against a stale store path after upgrade

  • Risk: An operator upgrades the operator-orchestrator tarball; the new version changes the default store_path; classifications appear lost.
  • Mitigation: The default path is fixed at ~/.azaion/onboard/sector-classifications.json and treated as a stable contract. A future cycle that needs to migrate runs an explicit migration; this cycle does NOT change the path.

Risk 3: Console script collides with another tool

  • Risk: The name operator-orchestrator is generic; another package on the operator's workstation could shadow it.
  • Mitigation: The package is shipped as part of the operator-tooling tarball with its own venv; no global install. README documents the tarball install procedure.

Risk 4: Atomic-write corner case — disk full mid-tempfile

  • Risk: tempfile.NamedTemporaryFile.write could raise OSError mid-call; partial tempfile lingers.
  • Mitigation: try/finally deletes the tempfile path on any exception in the write path; AC-5 covers the kill-mid-replace case; the disk-full case surfaces as OSError to the caller and the original file remains intact.