AZ-326 (3pt): operator-tool Click CLI shell at src/gps_denied_onboard/components/c12_operator_tooling/cli.py with six subcommands (download, build-cache, upload-pending, reloc-confirm, verify-ready, set-sector); SectorClassificationStore (atomic-write JSON under ~/.azaion/onboard/sector-classifications.json); freshness-table lookup driving AC-NEW-6; EXIT_* constants; AZ-266 structured-JSON log wiring to a rotating ~/.azaion/onboard/c12-tooling.log handler; operator-tool console-script entry in pyproject.toml. AZ-327 (3pt): CompanionBringup orchestrator at src/gps_denied_onboard/components/c12_operator_tooling/companion_bringup.py that opens an SSH session against the companion (paramiko per project pin), checks the four pre-flight artifacts (Manifest, expected engines, sha256 sidecars, calibration), and returns a ReadinessReport per description.md S2; CompanionUnreachableError + ContentHashMismatchError with operator-friendly remediation hints; ParamikoSshSessionFactory + RemoteSidecarVerifier (sha256sum + cat over SSH, no bytes pulled to the workstation); paramiko>=3.4,<4.0 dep added. NFR-perf-cold-start fix: PEP 562 lazy __getattr__ in c12_operator_tooling/__init__.py and flights_api/__init__.py defers HttpxFlightsApiClient (httpx), ParamikoSshSession[Factory] (paramiko + cryptography), bbox_from_waypoints / takeoff_origin_from_flight (numpy + pyproj). cli.py imports from leaf flights_api modules. operator-tool --help cold start: ~870ms -> <200ms typical, <500ms p99. Includes 73 unit tests (incl. paramiko-version-drift smoke per AZ-327 Risk 1) + console-script integration test. All 1494 repo-wide unit tests pass; 80 skips are pre-existing environment gates. Batch report: _docs/03_implementation/batch_42_cycle1_report.md. Co-authored-by: Cursor <cursoragent@cursor.com>
21 KiB
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_tooling (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_thresholdfromCacheBuildWorkflow), § 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-toolruns 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— Typerapp = 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, andraise typer.Exit(code=N).src/operator_tool/sector_classification_store.py—SectorClassificationStoreclass:- Constructor:
__init__(self, *, store_path: Path, logger: Logger). set_classification(area: AreaIdentifier, sector_class: SectorClassification) -> None— persists{area_id: sector_class}mapping tostore_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 orNoneif not set.list_classifications() -> dict[AreaIdentifier, SectorClassification]— returns all current classifications.- File format:
{"area_id": "active_conflict" | "stable_rear", ...}. - INFO log on every
set_classificationcall (kind="c12.sector.classification.set").
- Constructor:
src/operator_tool/freshness_table.py—freshness_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].
- Pure data:
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 theSectorClassificationStore+ 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.
- Subcommand surface (each subcommand body lives in
cli.py; service implementations live in sibling task files):download— delegates totile_downloader.fetch(...)(AZ-316). MapsSatelliteProviderError → 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 withEXIT_USAGE), plus--sector-class,--calibration-path. Delegates tobuild_cache_orchestrator.build_cache(...)(sibling AZ-328) passing the resolvedFlightDto(the orchestrator computes bbox + takeoff origin from it via AZ-489 helpers). MapsCacheBuildError → EXIT_DOWNLOAD_FAILURE | EXIT_BUILD_FAILURE(perfailure_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 topost_landing_upload.trigger_post_landing_upload(...)(sibling T4). MapsFlightStateNotConfirmedError → EXIT_FLIGHT_STATE_NOT_CONFIRMED;UploadGateBlockedError → EXIT_UPLOAD_FAILURE.reloc-confirm— delegates tooperator_reloc_service.request_reloc(...)(sibling T5). MapsGcsLinkError → EXIT_GCS_LINK_ERROR.verify-ready— delegates tocompanion_bringup.verify_companion_ready(...)(sibling T2). MapsCompanionUnreachableError → EXIT_COMPANION_UNREACHABLE;ContentHashMismatchError → EXIT_CONTENT_HASH_MISMATCH.set-sector— delegates toSectorClassificationStore.set_classification(...).
- Each subcommand's
--helpincludes 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.tomlregistersoperator-toolas a console script entry point pointing atoperator_tool.__main__:main. Themainfunction in__main__.pycallsapp().
Scope
Included
operator_toolpackage 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
--helptext + per-subcommand exception → exit-code mapping. SectorClassificationStorewith atomic-write JSON persistence.freshness_threshold_monthspure-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),
SectorClassificationStoreround-trip (set, get, atomic write resilience),freshness_threshold_monthsfor both enum values, console script invocability viasubprocess.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-tool console script is installed
When the operator runs operator-tool --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-tool --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
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-tool 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
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
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)
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-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.
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
SectorClassificationStorewrite path is atomic across process kill (per AC-5). ~/.azaion/onboard/is created with mode0o700if it does not exist.
Unit Tests
| AC Ref | What to Test | Required Outcome |
|---|---|---|
| AC-1 | operator-tool --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-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-tool --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.pyor__main__.py; they live behind the sibling tasks' service classes that are lazy-resolved. - The CLI is operator-workstation-only —
operator_toolMUST NOT be importable from any airborne entry point. Verified at the SBOM-diff level by E-BOOT (the CI gate already enforces nooperator_toolsymbol inproduction-binary). - Atomic writes use
tempfile.NamedTemporaryFile(dir=store_path.parent) + os.replace. NakedPath.write_text()is NOT acceptable percoderule.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.logper description.md § 9 — config-overrideable viaconfig.c12.log_pathfor 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-tool tarball; the new version changes the default
store_path; classifications appear lost. - Mitigation: The default path is fixed at
~/.azaion/onboard/sector-classifications.jsonand 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-toolis 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.writecould raiseOSErrormid-call; partial tempfile lingers. - Mitigation:
try/finallydeletes the tempfile path on any exception in the write path; AC-5 covers the kill-mid-replace case; the disk-full case surfaces asOSErrorto the caller and the original file remains intact.