[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>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 19:42:46 +03:00
parent 2d88d3d674
commit 5fe67023b2
112 changed files with 3409 additions and 1311 deletions
+20 -20
View File
@@ -5,13 +5,13 @@
**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_tooling (epic AZ-253 / E-C12)
**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_tooling/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/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
@@ -23,7 +23,7 @@ Without a real CLI shell:
- 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-tool` runs and need `$?` to mean something specific per failure category.
- 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.
@@ -31,7 +31,7 @@ This task delivers the CLI shell + the two trivial operator helpers. It does NOT
- 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-tool", 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/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.py``SectorClassificationStore` 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`).
@@ -44,7 +44,7 @@ This task delivers the CLI shell + the two trivial operator helpers. It does NOT
- 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) -> OperatorToolServices` — pure factory that constructs the `SectorClassificationStore` + a logger configured to write to `~/.azaion/onboard/c12-tooling.log`. Returns a frozen dataclass aggregating the operator-tool service handles. Sibling tasks T2..T5 each add their service to this dataclass without renaming or moving it.
- `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`.
@@ -54,7 +54,7 @@ This task delivers the CLI shell + the two trivial operator helpers. It does NOT
- `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-tool` as a console script entry point pointing at `operator_tool.__main__:main`. The `main` function in `__main__.py` calls `app()`.
- `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
@@ -82,8 +82,8 @@ This task delivers the CLI shell + the two trivial operator helpers. It does NOT
## Acceptance Criteria
**AC-1: All six subcommands register and appear in `--help`**
Given the `operator-tool` console script is installed
When the operator runs `operator-tool --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**
@@ -118,12 +118,12 @@ Then a `c12-tooling.log` file exists at `~/.azaion/onboard/`; its lines parse as
**AC-8: Console-script entry point is installed and runnable**
Given the package is installed via `pip install -e .`
When the shell runs `operator-tool --help`
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-tool <subcommand> --help` is run
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**
@@ -133,20 +133,20 @@ Then the on-disk JSON file is byte-identical (or has only timestamp diffs in the
**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-tool build-cache --flight-id 00000000-0000-0000-0000-000000000001 --sector-class stable_rear --calibration-path /tmp/cal.json` runs
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-tool build-cache --flight-file /tmp/flight.json --sector-class stable_rear --calibration-path /tmp/cal.json` runs
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-tool build-cache --flight-id 00000000-0000-0000-0000-000000000001 --flight-file /tmp/flight.json ...` runs
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-tool build-cache --sector-class stable_rear --calibration-path /tmp/cal.json` runs (no flight source)
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`**
@@ -167,7 +167,7 @@ Then exit code is `64`; the stderr message instructs the operator to re-plan in
## Non-Functional Requirements
**Performance**
- CLI cold start (`operator-tool --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.
- 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).
@@ -181,14 +181,14 @@ Then exit code is `64`; the stderr message instructs the operator to re-plan in
| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | `operator-tool --help` output | All 6 subcommands listed |
| 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-tool", "--help"])` after `pip install -e .` | Exit 0, help text printed |
| 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 |
@@ -198,7 +198,7 @@ Then exit code is `64`; the stderr message instructs the operator to re-plan in
| 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-tool --help` × 10 | p99 ≤ 500 ms |
| NFR-perf-cold-start | Microbench `operator-orchestrator --help` × 10 | p99 ≤ 500 ms |
## Constraints
@@ -216,11 +216,11 @@ Then exit code is `64`; the stderr message instructs the operator to re-plan in
- *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-tool tarball; the new version changes the default `store_path`; classifications appear lost.
- *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-tool` is generic; another package on the operator's workstation could shadow it.
- *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**