mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 21:31:13 +00:00
[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:
@@ -5,7 +5,7 @@ lives at the L1 ``_types`` layer so C10 can re-export it without
|
||||
crossing the components.* boundary (architecture rule AC-6).
|
||||
|
||||
The AZ-321 ``EngineCompiler`` plus its DTOs are re-exported here so
|
||||
the composition root and downstream operator-tooling code consume
|
||||
the composition root and downstream operator-orchestrator code consume
|
||||
them through this single contract surface.
|
||||
"""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ a verify failure — callers branch on ``outcome`` (per the contract at
|
||||
|
||||
The Protocol + DTOs live alongside the implementation here; the
|
||||
public re-export surface lives in ``c10_provisioning/__init__.py``.
|
||||
Cross-component consumers (C5 takeoff arming, C12 operator tooling)
|
||||
Cross-component consumers (C5 takeoff arming, C12 operator orchestrator)
|
||||
will import via a future ``_types/manifest_verify.py`` shim if and
|
||||
when they wire up — the AZ-270 lint forbids direct
|
||||
``components.c10_provisioning`` imports from other components.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""C11 Tile Manager component — Public API.
|
||||
|
||||
Re-exports the Protocol surface (``TileDownloader``, ``TileUploader``,
|
||||
``FlightStateSource``), the operator-side services that have landed
|
||||
(``FlightStateGate`` from AZ-317, ``PerFlightKeyManager`` from AZ-318,
|
||||
``HttpTileUploader`` from AZ-319, ``HttpTileDownloader`` from AZ-316),
|
||||
the C11 internal DTOs / enums, the C11 error family, and the
|
||||
Re-exports the Protocol surface (``TileDownloader``, ``TileUploader``),
|
||||
the operator-side services that have landed (``PerFlightKeyManager``
|
||||
from AZ-318, ``HttpTileUploader`` from AZ-319 — flight-state gating is
|
||||
now C12's responsibility per batch 44; ``HttpTileDownloader`` from
|
||||
AZ-316), the C11 internal DTOs / enums, the C11 error family, and the
|
||||
per-component config block.
|
||||
"""
|
||||
|
||||
@@ -12,7 +12,6 @@ from gps_denied_onboard.components.c11_tile_manager._types import (
|
||||
DownloadBatchReport,
|
||||
DownloadOutcome,
|
||||
DownloadRequest,
|
||||
FlightStateSignal,
|
||||
IngestStatus,
|
||||
PerTileStatus,
|
||||
PublicKeyFingerprint,
|
||||
@@ -28,7 +27,6 @@ from gps_denied_onboard.components.c11_tile_manager.config import (
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||
CacheBudgetExceededError,
|
||||
FlightStateNotOnGroundError,
|
||||
RateLimitedError,
|
||||
ResolutionRejectionError,
|
||||
SatelliteProviderError,
|
||||
@@ -36,14 +34,10 @@ from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||
SignatureRejectedError,
|
||||
TileManagerError,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.flight_state_gate import (
|
||||
FlightStateGate,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.idempotent_retry import (
|
||||
IdempotentRetryTileUploader,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.interface import (
|
||||
FlightStateSource,
|
||||
TileDownloader,
|
||||
TileUploader,
|
||||
)
|
||||
@@ -71,10 +65,6 @@ __all__ = [
|
||||
"DownloadBatchReport",
|
||||
"DownloadOutcome",
|
||||
"DownloadRequest",
|
||||
"FlightStateGate",
|
||||
"FlightStateNotOnGroundError",
|
||||
"FlightStateSignal",
|
||||
"FlightStateSource",
|
||||
"HttpTileDownloader",
|
||||
"HttpTileUploader",
|
||||
"IdempotentRetryTileUploader",
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"""C11 internal DTOs (AZ-316, AZ-317, AZ-318, AZ-319).
|
||||
"""C11 internal DTOs (AZ-316, AZ-318, AZ-319).
|
||||
|
||||
* :class:`FlightStateSignal` — five flight-state signals consumed by the
|
||||
upload-side flight-state gate (AZ-317).
|
||||
* :class:`PublicKeyFingerprint` — per-flight Ed25519 keypair fingerprint
|
||||
envelope returned by :meth:`PerFlightKeyManager.start_session` (AZ-318).
|
||||
* :class:`UploadRequest`, :class:`UploadBatchReport`,
|
||||
:class:`PerTileStatus`, :class:`IngestStatus`, :class:`UploadOutcome` —
|
||||
upload-side DTOs and enums consumed and produced by the AZ-319
|
||||
:class:`HttpTileUploader` (contract
|
||||
``_docs/02_document/contracts/c11_tilemanager/tile_uploader.md`` v1.0.0).
|
||||
``_docs/02_document/contracts/c11_tilemanager/tile_uploader.md`` v2.0.0).
|
||||
* :class:`DownloadRequest`, :class:`DownloadBatchReport`,
|
||||
:class:`TileSummary`, :class:`DownloadOutcome`,
|
||||
:class:`SectorClassification` — download-side DTOs and enums consumed
|
||||
@@ -33,7 +31,6 @@ __all__ = [
|
||||
"DownloadBatchReport",
|
||||
"DownloadOutcome",
|
||||
"DownloadRequest",
|
||||
"FlightStateSignal",
|
||||
"IngestStatus",
|
||||
"PerTileStatus",
|
||||
"PublicKeyFingerprint",
|
||||
@@ -45,20 +42,6 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
class FlightStateSignal(str, Enum):
|
||||
"""Five flight-state signals C11's upload-side gate accepts.
|
||||
|
||||
Only :attr:`ON_GROUND` permits an upload; every other value is
|
||||
fail-closed by the AZ-317 gate (AC-2..AC-5).
|
||||
"""
|
||||
|
||||
ON_GROUND = "on_ground"
|
||||
TAKING_OFF = "taking_off"
|
||||
IN_FLIGHT = "in_flight"
|
||||
LANDING = "landing"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicKeyFingerprint:
|
||||
"""Public-key envelope returned by :meth:`PerFlightKeyManager.start_session`.
|
||||
@@ -99,10 +82,9 @@ class UploadOutcome(str, Enum):
|
||||
``DUPLICATE`` / ``SUPERSEDED``.
|
||||
* ``PARTIAL`` — some tiles were ``REJECTED`` while others were
|
||||
acknowledged; the caller may re-invoke for the rejected set.
|
||||
* ``FAILURE`` — the flight-state gate blocked or zero tiles could
|
||||
be POSTed (TLS / 401 / 403 / persistent 5xx surface as raised
|
||||
:class:`SatelliteProviderError`, NOT as ``FAILURE`` in a returned
|
||||
report).
|
||||
* ``FAILURE`` — zero tiles could be POSTed (TLS / 401 / 403 /
|
||||
persistent 5xx surface as raised :class:`SatelliteProviderError`,
|
||||
NOT as ``FAILURE`` in a returned report).
|
||||
"""
|
||||
|
||||
SUCCESS = "success"
|
||||
@@ -292,7 +274,7 @@ class DownloadRequest:
|
||||
class DownloadBatchReport:
|
||||
"""Aggregate report returned by :meth:`TileDownloader.download_tiles_for_area`.
|
||||
|
||||
Per-tile counts let the operator-tooling CLI render the post-run
|
||||
Per-tile counts let the operator-orchestrator CLI render the post-run
|
||||
summary without re-reading the journal:
|
||||
|
||||
* ``tiles_requested`` — total tiles enumerated by
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
"""C11 TileManager error family (AZ-316, AZ-317, AZ-318, AZ-319).
|
||||
"""C11 TileManager error family (AZ-316, AZ-318, AZ-319).
|
||||
|
||||
Rooted at :class:`TileManagerError`. Both the upload (AZ-319) and
|
||||
download (AZ-316) paths share the family parent so cross-path callers
|
||||
can ``except TileManagerError`` to catch any C11-side terminal failure
|
||||
without enumerating subclasses.
|
||||
|
||||
* :class:`FlightStateNotOnGroundError` (AZ-317) — defence-in-depth
|
||||
refusal when the flight controller reports anything other than
|
||||
``ON_GROUND`` at upload entry.
|
||||
* :class:`SessionNotActiveError` (AZ-318) — :meth:`PerFlightKeyManager.sign`
|
||||
/ :meth:`record_signature_rejection` called outside an active session.
|
||||
* :class:`SignatureRejectedError` (AZ-318/AZ-319 envelope) — surfaced
|
||||
@@ -28,17 +25,8 @@ without enumerating subclasses.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.components.c11_tile_manager._types import (
|
||||
FlightStateSignal,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CacheBudgetExceededError",
|
||||
"FlightStateNotOnGroundError",
|
||||
"RateLimitedError",
|
||||
"ResolutionRejectionError",
|
||||
"SatelliteProviderError",
|
||||
@@ -52,27 +40,6 @@ class TileManagerError(Exception):
|
||||
"""Base class for the C11 TileManager error family."""
|
||||
|
||||
|
||||
class FlightStateNotOnGroundError(TileManagerError):
|
||||
"""Upload was attempted when the flight controller is not on ground.
|
||||
|
||||
Carries the observed :class:`FlightStateSignal` and the diagnostic
|
||||
``observed_at`` timestamp. The original source exception (if the
|
||||
refusal was caused by a :class:`FlightStateSource` failure mapped
|
||||
to ``UNKNOWN`` per AC-5) is preserved on ``__cause__``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
observed: FlightStateSignal,
|
||||
observed_at: datetime,
|
||||
) -> None:
|
||||
self.observed: FlightStateSignal = observed
|
||||
self.observed_at: datetime = observed_at
|
||||
super().__init__(
|
||||
f"Upload refused: flight state is {observed.name}"
|
||||
)
|
||||
|
||||
|
||||
class SessionNotActiveError(TileManagerError):
|
||||
""":meth:`PerFlightKeyManager.sign` called without a live session.
|
||||
|
||||
@@ -89,7 +56,7 @@ class SignatureRejectedError(TileManagerError):
|
||||
``TileUploader`` raises the canonical type. The upload-side
|
||||
handler calls :meth:`PerFlightKeyManager.record_signature_rejection`
|
||||
to surface the FDR + ERROR log envelope per AZ-318 AC-8 before
|
||||
re-raising this exception to the operator-tooling layer.
|
||||
re-raising this exception to the operator-orchestrator layer.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"""C11 ``FlightStateGate`` (AZ-317).
|
||||
|
||||
Defence-in-depth ON_GROUND gate for the upload entry point. The
|
||||
primary control is ADR-004 process-level isolation — the airborne
|
||||
binary has the entire ``c11_tile_manager`` source tree excluded at
|
||||
build time. The gate is the runtime backstop: if the operator
|
||||
workstation triggers an upload while the flight controller reports
|
||||
anything other than ``ON_GROUND``, the gate refuses with
|
||||
:class:`FlightStateNotOnGroundError`.
|
||||
|
||||
Fail-closed by design — ``UNKNOWN``, transition states, and source
|
||||
failures all block. AZ-317 acceptance criteria spell out the full
|
||||
matrix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from gps_denied_onboard.components.c11_tile_manager._types import (
|
||||
FlightStateSignal,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||
FlightStateNotOnGroundError,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.interface import (
|
||||
FlightStateSource,
|
||||
)
|
||||
|
||||
__all__ = ["FlightStateGate"]
|
||||
|
||||
|
||||
_LOG_KIND_PASS = "c11.upload.flight_state_confirmed"
|
||||
_LOG_KIND_REFUSED = "c11.upload.refused.flight_state"
|
||||
_COMPONENT = "c11_tile_manager.flight_state_gate"
|
||||
|
||||
|
||||
def _utcnow_second_precision() -> datetime:
|
||||
"""Diagnostic UTC timestamp truncated to seconds (AC-7)."""
|
||||
return datetime.now(timezone.utc).replace(microsecond=0)
|
||||
|
||||
|
||||
class FlightStateGate:
|
||||
"""Single-shot ON_GROUND check called by the upload entry point.
|
||||
|
||||
The gate is constructed once at composition time and called once
|
||||
per :meth:`upload_pending_tiles` invocation by the AZ-319
|
||||
:class:`TileUploader`. It performs no caching, no retries, and no
|
||||
polling — :meth:`current_flight_state` is invoked exactly once per
|
||||
:meth:`confirm_on_ground` call (AC-8).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
source: FlightStateSource,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
self._source = source
|
||||
self._logger = logger
|
||||
|
||||
def confirm_on_ground(self) -> FlightStateSignal:
|
||||
"""Return :attr:`FlightStateSignal.ON_GROUND` or raise.
|
||||
|
||||
Behaviour matrix:
|
||||
|
||||
* ``ON_GROUND`` → return + INFO log (AC-1).
|
||||
* ``IN_FLIGHT`` / ``TAKING_OFF`` / ``LANDING`` / ``UNKNOWN`` →
|
||||
raise :class:`FlightStateNotOnGroundError` + ERROR log
|
||||
(AC-2..AC-4).
|
||||
* Source raises → map to ``UNKNOWN`` + chain the original
|
||||
exception via ``__cause__`` + ERROR log carrying the
|
||||
original message (AC-5).
|
||||
"""
|
||||
|
||||
try:
|
||||
observed = self._source.current_flight_state()
|
||||
except Exception as exc:
|
||||
observed_at = _utcnow_second_precision()
|
||||
error = FlightStateNotOnGroundError(
|
||||
observed=FlightStateSignal.UNKNOWN,
|
||||
observed_at=observed_at,
|
||||
)
|
||||
error.__cause__ = exc
|
||||
self._logger.error(
|
||||
"Upload refused: flight state source failed",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_REFUSED,
|
||||
"kv": {
|
||||
"observed": FlightStateSignal.UNKNOWN.value,
|
||||
"observed_at_iso": observed_at.isoformat(),
|
||||
"source_error": str(exc),
|
||||
},
|
||||
},
|
||||
)
|
||||
raise error
|
||||
|
||||
observed_at = _utcnow_second_precision()
|
||||
if observed is FlightStateSignal.ON_GROUND:
|
||||
self._logger.info(
|
||||
"Upload entry permitted: flight state is ON_GROUND",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_PASS,
|
||||
"kv": {
|
||||
"observed": observed.value,
|
||||
"observed_at_iso": observed_at.isoformat(),
|
||||
},
|
||||
},
|
||||
)
|
||||
return observed
|
||||
|
||||
self._logger.error(
|
||||
f"Upload refused: flight state is {observed.name}",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_REFUSED,
|
||||
"kv": {
|
||||
"observed": observed.value,
|
||||
"observed_at_iso": observed_at.isoformat(),
|
||||
},
|
||||
},
|
||||
)
|
||||
raise FlightStateNotOnGroundError(
|
||||
observed=observed,
|
||||
observed_at=observed_at,
|
||||
)
|
||||
@@ -46,7 +46,6 @@ from uuid import UUID
|
||||
|
||||
from gps_denied_onboard.clock.interface import Clock
|
||||
from gps_denied_onboard.components.c11_tile_manager._types import (
|
||||
FlightStateSignal,
|
||||
IngestStatus,
|
||||
PerTileStatus,
|
||||
UploadBatchReport,
|
||||
@@ -240,11 +239,6 @@ class IdempotentRetryTileUploader:
|
||||
|
||||
return list(self._inner.enumerate_pending_tiles(flight_id))
|
||||
|
||||
def confirm_flight_state(self) -> FlightStateSignal:
|
||||
"""Pass-through to the inner uploader (AC-11)."""
|
||||
|
||||
return self._inner.confirm_flight_state()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""C11 ``TileDownloader`` + ``TileUploader`` + ``FlightStateSource`` Protocols.
|
||||
"""C11 ``TileDownloader`` + ``TileUploader`` Protocols.
|
||||
|
||||
Operator-side ONLY — excluded from airborne via CMake (`BUILD_C11_TILE_MANAGER=OFF`).
|
||||
See `_docs/02_document/components/12_c11_tilemanager/`.
|
||||
@@ -10,13 +10,9 @@ See `_docs/02_document/components/12_c11_tilemanager/`.
|
||||
* :class:`TileUploader` — post-landing upload path (AZ-319) — the
|
||||
authoritative shape lives in
|
||||
``_docs/02_document/contracts/c11_tilemanager/tile_uploader.md``
|
||||
v1.0.0 and is mirrored 1:1 here.
|
||||
* :class:`FlightStateSource` — thin C11-facing adapter the upload-side
|
||||
flight-state gate (AZ-317) calls to read "what is the FC saying right
|
||||
now?". A concrete impl ships with E-C8 (subscribes to the FC adapter's
|
||||
flight-state stream); composition root wires it via the AZ-507
|
||||
consumer-side cut pattern (see `_docs/02_document/module-layout.md`
|
||||
Rule 9). C11 NEVER imports ``components.c8_fc_adapter`` directly.
|
||||
v2.0.0 (post-batch-44 removal of the internal flight-state gate) and
|
||||
is mirrored 1:1 here. Flight-state confirmation is the caller's
|
||||
responsibility (C12 ``PostLandingUploadOrchestrator``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,14 +24,12 @@ from uuid import UUID
|
||||
from gps_denied_onboard.components.c11_tile_manager._types import (
|
||||
DownloadBatchReport,
|
||||
DownloadRequest,
|
||||
FlightStateSignal,
|
||||
TileSummary,
|
||||
UploadBatchReport,
|
||||
UploadRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FlightStateSource",
|
||||
"TileDownloader",
|
||||
"TileUploader",
|
||||
]
|
||||
@@ -69,7 +63,7 @@ class TileUploader(Protocol):
|
||||
"""Post-landing batch upload to ``satellite-provider`` ingest (D-PROJ-2).
|
||||
|
||||
See ``_docs/02_document/contracts/c11_tilemanager/tile_uploader.md``
|
||||
v1.0.0 for invariants I-1 .. I-8 and the per-method error matrix.
|
||||
v2.0.0 for invariants I-1 .. I-7 and the per-method error matrix.
|
||||
The :meth:`enumerate_pending_tiles` return type is the consumer-
|
||||
side structural metadata shape (mirrors c6's ``TileMetadata``;
|
||||
declared as ``Sequence[Any]`` here to keep C11 free of cross-
|
||||
@@ -81,20 +75,3 @@ class TileUploader(Protocol):
|
||||
def enumerate_pending_tiles(
|
||||
self, flight_id: UUID | None = None
|
||||
) -> Sequence[Any]: ...
|
||||
|
||||
def confirm_flight_state(self) -> FlightStateSignal: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FlightStateSource(Protocol):
|
||||
"""Consumer-side cut: "what is the flight controller saying now?".
|
||||
|
||||
The AZ-317 :class:`FlightStateGate` calls
|
||||
:meth:`current_flight_state` once per :meth:`confirm_on_ground`
|
||||
invocation; no polling, no caching. The concrete impl that
|
||||
subscribes to MAVLink heartbeats lives in E-C8 and is wrapped by a
|
||||
composition-root adapter so C11 never imports
|
||||
``components.c8_fc_adapter``.
|
||||
"""
|
||||
|
||||
def current_flight_state(self) -> FlightStateSignal: ...
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""C11 ``HttpTileUploader`` (AZ-319) — concrete :class:`TileUploader`.
|
||||
"""C11 ``HttpTileUploader`` — concrete :class:`TileUploader`.
|
||||
|
||||
Operator-side post-landing upload path. Reads pending mid-flight tiles
|
||||
from C6 (``source = onboard_ingest``, ``uploaded_at IS NULL``), packages
|
||||
each per the D-PROJ-2 multipart contract sketch, signs with the per-flight
|
||||
ephemeral key (AZ-318), POSTs to ``satellite-provider``'s ingest
|
||||
endpoint, and marks acknowledged tiles uploaded. Gates on ``ON_GROUND``
|
||||
(AZ-317) before any C6 read or network egress; zeroes the signing key
|
||||
in a try/finally regardless of outcome.
|
||||
endpoint, and marks acknowledged tiles uploaded. Zeroes the signing key
|
||||
in a try/finally regardless of outcome. Flight-state gating is a C12
|
||||
orchestrator policy (post-landing confirmation via the C13
|
||||
``flight_footer`` FDR record); this uploader is a dumb pipe and trusts
|
||||
its caller.
|
||||
|
||||
Architecture
|
||||
------------
|
||||
@@ -49,9 +51,6 @@ from gps_denied_onboard.components.c11_tile_manager.errors import (
|
||||
SatelliteProviderError,
|
||||
SignatureRejectedError,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.flight_state_gate import (
|
||||
FlightStateGate,
|
||||
)
|
||||
from gps_denied_onboard.components.c11_tile_manager.signing_key import (
|
||||
PerFlightKeyManager,
|
||||
)
|
||||
@@ -245,9 +244,9 @@ class _SessionState:
|
||||
class HttpTileUploader:
|
||||
"""Concrete :class:`TileUploader` against ``satellite-provider``'s ingest endpoint.
|
||||
|
||||
All cross-component dependencies (``flight_state_gate``,
|
||||
``key_manager``, ``tile_store``, ``tile_metadata_store``) are
|
||||
constructor-injected via Protocol cuts. The ``http_client`` is an
|
||||
All cross-component dependencies (``key_manager``, ``tile_store``,
|
||||
``tile_metadata_store``) are constructor-injected via Protocol cuts.
|
||||
The ``http_client`` is an
|
||||
:class:`httpx.Client` the caller owns; ``HttpTileUploader`` does
|
||||
NOT close it — production wiring uses a long-lived client per
|
||||
process; tests inject ``httpx.Client(transport=httpx.MockTransport)``
|
||||
@@ -260,7 +259,6 @@ class HttpTileUploader:
|
||||
http_client: httpx.Client,
|
||||
tile_store: _TileBytesReader,
|
||||
tile_metadata_store: _PendingMetadataReader,
|
||||
flight_state_gate: FlightStateGate,
|
||||
key_manager: PerFlightKeyManager,
|
||||
fdr_client: FdrClient,
|
||||
logger: logging.Logger,
|
||||
@@ -270,7 +268,6 @@ class HttpTileUploader:
|
||||
self._http_client = http_client
|
||||
self._tile_store = tile_store
|
||||
self._metadata_store = tile_metadata_store
|
||||
self._gate = flight_state_gate
|
||||
self._key_manager = key_manager
|
||||
self._fdr = fdr_client
|
||||
self._logger = logger
|
||||
@@ -282,15 +279,15 @@ class HttpTileUploader:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def upload_pending_tiles(self, request: UploadRequest) -> UploadBatchReport:
|
||||
"""Gate → start_session → enumerate → batch loop → finally end_session.
|
||||
"""start_session → enumerate → batch loop → finally end_session.
|
||||
|
||||
Order is FROZEN per Reliability constraint in the task spec —
|
||||
re-ordering is a High Reliability finding at code-review time
|
||||
because it breaks I-1 (gate before any read / network) or I-4
|
||||
(zeroisation guarantee on every exit path).
|
||||
re-ordering would break I-4 (zeroisation guarantee on every exit
|
||||
path). Flight-state confirmation is the caller's responsibility
|
||||
(C12 ``PostLandingUploadOrchestrator``); this uploader is a dumb
|
||||
pipe.
|
||||
"""
|
||||
|
||||
self._gate.confirm_on_ground()
|
||||
flight_id_for_session = request.flight_id or uuid4()
|
||||
fingerprint = self._key_manager.start_session(flight_id_for_session)
|
||||
state = _SessionState(
|
||||
@@ -362,15 +359,10 @@ class HttpTileUploader:
|
||||
def enumerate_pending_tiles(
|
||||
self, flight_id: UUID | None = None
|
||||
) -> list[Any]:
|
||||
"""Read-only enumeration; does NOT call the gate (per contract)."""
|
||||
"""Read-only enumeration."""
|
||||
|
||||
return self._filter_by_flight(self._metadata_store.pending_uploads(), flight_id)
|
||||
|
||||
def confirm_flight_state(self) -> Any:
|
||||
"""Pass-through to :meth:`FlightStateGate.confirm_on_ground`."""
|
||||
|
||||
return self._gate.confirm_on_ground()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
+75
-29
@@ -11,15 +11,15 @@ Re-exports:
|
||||
Protocols, and the production :class:`ParamikoSshSessionFactory`.
|
||||
|
||||
Also registers ``C12Config`` with :func:`register_component_block` so
|
||||
the composition root sees the ``c12_operator_tooling`` slug under
|
||||
the composition root sees the ``c12_operator_orchestrator`` slug under
|
||||
``config.components``.
|
||||
|
||||
NOTE on lazy imports (AZ-326 NFR-perf-cold-start, ≤500 ms p99 for
|
||||
``operator-tool --help``): the heavy adapters
|
||||
``operator-orchestrator --help``): the heavy adapters
|
||||
:class:`ParamikoSshSessionFactory` (pulls in ``paramiko`` + ``cryptography``)
|
||||
and :class:`HttpxFlightsApiClient` (pulls in ``httpx``) are exposed via a
|
||||
PEP 562 :func:`__getattr__` hook rather than top-level imports. Importing
|
||||
them from this module — `from gps_denied_onboard.components.c12_operator_tooling
|
||||
them from this module — `from gps_denied_onboard.components.c12_operator_orchestrator
|
||||
import HttpxFlightsApiClient` — still works for callers, but the heavy
|
||||
``import paramiko`` / ``import httpx`` only fires on first access. The
|
||||
project spec's Constraints section forbids eager-importing these libs
|
||||
@@ -30,7 +30,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
AreaIdentifier,
|
||||
BuildCacheOutcome,
|
||||
BuildCacheRequest,
|
||||
@@ -42,36 +42,59 @@ from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
DownloadRequestCut,
|
||||
FailurePhase,
|
||||
FlightById,
|
||||
FlightFooterRecord,
|
||||
FlightFromFile,
|
||||
FlightResolveReport,
|
||||
FlightResolveSource,
|
||||
FlightSource,
|
||||
IngestStatusCut,
|
||||
PerTileStatusCut,
|
||||
PostLandingUploadRequest,
|
||||
ReadinessOutcome,
|
||||
ReadinessReport,
|
||||
ReLocHint,
|
||||
RemoteBuildOutcome,
|
||||
RemoteBuildReport,
|
||||
SectorClassification,
|
||||
UploadBatchReportCut,
|
||||
UploadOutcomeCut,
|
||||
UploadRequestCut,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.build_cache import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.build_cache import (
|
||||
BuildCacheOrchestrator,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.companion_bringup import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.companion_bringup import (
|
||||
CompanionBringup,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.config import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12BuildCacheConfig,
|
||||
C12CompanionConfig,
|
||||
C12Config,
|
||||
C12PostLandingConfig,
|
||||
HostKeyPolicy,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
BuildLockHeldError,
|
||||
BuildReportParseError,
|
||||
CacheBuildError,
|
||||
CompanionUnreachableError,
|
||||
ContentHashMismatchError,
|
||||
FdrUnreadableError,
|
||||
FlightStateNotConfirmedError,
|
||||
GcsLinkError,
|
||||
NotConfirmedReason,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.exit_codes import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.fdr_footer_reader import (
|
||||
FdrFooterReader,
|
||||
LocalFdrFooterReader,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.post_landing_upload import (
|
||||
PostLandingUploadOrchestrator,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.tile_uploader_cut import (
|
||||
TileUploaderCut,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.exit_codes import (
|
||||
EXIT_BUILD_FAILURE,
|
||||
EXIT_COMPANION_UNREACHABLE,
|
||||
EXIT_CONTENT_HASH_MISMATCH,
|
||||
@@ -89,13 +112,13 @@ from gps_denied_onboard.components.c12_operator_tooling.exit_codes import (
|
||||
EXIT_UPLOAD_FAILURE,
|
||||
EXIT_USAGE,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.file_lock import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.file_lock import (
|
||||
FileLock,
|
||||
FileLockFactory,
|
||||
FilelockFileLockFactory,
|
||||
LockTimeout,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.errors import (
|
||||
EmptyWaypointsError,
|
||||
FlightFileNotFoundError,
|
||||
FlightNotFoundError,
|
||||
@@ -105,59 +128,64 @@ from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors impor
|
||||
FlightsApiUnreachableError,
|
||||
WaypointSchemaError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.file_loader import (
|
||||
load_flight_file,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.interface import (
|
||||
FlightDto,
|
||||
FlightsApiClient,
|
||||
WaypointDto,
|
||||
WaypointObjective,
|
||||
WaypointSource,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.freshness_table import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.freshness_table import (
|
||||
FRESHNESS_TABLE,
|
||||
freshness_threshold_months,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.interface import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.interface import (
|
||||
CacheBuildWorkflow,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.operator_command_transport import (
|
||||
OperatorCommandTransport,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.operator_reloc_service import (
|
||||
OperatorReLocService,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_c10_invoker import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_c10_invoker import (
|
||||
RemoteBuildRequest,
|
||||
RemoteCacheProvisionerInvoker,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_sidecar_verifier import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_sidecar_verifier import (
|
||||
RemoteSidecarResult,
|
||||
RemoteSidecarVerifier,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.sector_classification_store import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.sector_classification_store import (
|
||||
SectorClassificationStore,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
|
||||
RemoteCommandResult,
|
||||
SshSession,
|
||||
SshSessionFactory,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.tile_downloader_cut import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.tile_downloader_cut import (
|
||||
TileDownloaderCut,
|
||||
)
|
||||
from gps_denied_onboard.config.schema import register_component_block
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.bbox import (
|
||||
bbox_from_waypoints,
|
||||
takeoff_origin_from_flight,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.httpx_client import (
|
||||
HttpxFlightsApiClient,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.paramiko_ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.paramiko_ssh_session import (
|
||||
ParamikoSshSession,
|
||||
ParamikoSshSessionFactory,
|
||||
)
|
||||
|
||||
register_component_block("c12_operator_tooling", C12Config)
|
||||
register_component_block("c12_operator_orchestrator", C12Config)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PEP 562 lazy re-exports for heavy adapters
|
||||
@@ -172,23 +200,23 @@ register_component_block("c12_operator_tooling", C12Config)
|
||||
|
||||
_LAZY_NAMES: dict[str, tuple[str, str]] = {
|
||||
"HttpxFlightsApiClient": (
|
||||
"gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client",
|
||||
"gps_denied_onboard.components.c12_operator_orchestrator.flights_api.httpx_client",
|
||||
"HttpxFlightsApiClient",
|
||||
),
|
||||
"ParamikoSshSession": (
|
||||
"gps_denied_onboard.components.c12_operator_tooling.paramiko_ssh_session",
|
||||
"gps_denied_onboard.components.c12_operator_orchestrator.paramiko_ssh_session",
|
||||
"ParamikoSshSession",
|
||||
),
|
||||
"ParamikoSshSessionFactory": (
|
||||
"gps_denied_onboard.components.c12_operator_tooling.paramiko_ssh_session",
|
||||
"gps_denied_onboard.components.c12_operator_orchestrator.paramiko_ssh_session",
|
||||
"ParamikoSshSessionFactory",
|
||||
),
|
||||
"bbox_from_waypoints": (
|
||||
"gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox",
|
||||
"gps_denied_onboard.components.c12_operator_orchestrator.flights_api.bbox",
|
||||
"bbox_from_waypoints",
|
||||
),
|
||||
"takeoff_origin_from_flight": (
|
||||
"gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox",
|
||||
"gps_denied_onboard.components.c12_operator_orchestrator.flights_api.bbox",
|
||||
"takeoff_origin_from_flight",
|
||||
),
|
||||
}
|
||||
@@ -234,6 +262,7 @@ __all__ = [
|
||||
"C12BuildCacheConfig",
|
||||
"C12CompanionConfig",
|
||||
"C12Config",
|
||||
"C12PostLandingConfig",
|
||||
"CacheBuildError",
|
||||
"CacheBuildReport",
|
||||
"CacheBuildWorkflow",
|
||||
@@ -247,28 +276,41 @@ __all__ = [
|
||||
"DownloadRequestCut",
|
||||
"EmptyWaypointsError",
|
||||
"FailurePhase",
|
||||
"FdrFooterReader",
|
||||
"FdrUnreadableError",
|
||||
"FileLock",
|
||||
"FileLockFactory",
|
||||
"FilelockFileLockFactory",
|
||||
"FlightById",
|
||||
"FlightDto",
|
||||
"FlightFileNotFoundError",
|
||||
"FlightFooterRecord",
|
||||
"FlightFromFile",
|
||||
"FlightNotFoundError",
|
||||
"FlightResolveReport",
|
||||
"FlightResolveSource",
|
||||
"FlightSource",
|
||||
"FlightStateNotConfirmedError",
|
||||
"FlightsApiAuthError",
|
||||
"FlightsApiClient",
|
||||
"FlightsApiError",
|
||||
"FlightsApiSchemaError",
|
||||
"FlightsApiUnreachableError",
|
||||
"GcsLinkError",
|
||||
"HostKeyPolicy",
|
||||
"HttpxFlightsApiClient",
|
||||
"IngestStatusCut",
|
||||
"LocalFdrFooterReader",
|
||||
"LockTimeout",
|
||||
"NotConfirmedReason",
|
||||
"OperatorCommandTransport",
|
||||
"OperatorReLocService",
|
||||
"ParamikoSshSession",
|
||||
"ParamikoSshSessionFactory",
|
||||
"PerTileStatusCut",
|
||||
"PostLandingUploadOrchestrator",
|
||||
"PostLandingUploadRequest",
|
||||
"ReLocHint",
|
||||
"ReadinessOutcome",
|
||||
"ReadinessReport",
|
||||
"RemoteBuildOutcome",
|
||||
@@ -283,6 +325,10 @@ __all__ = [
|
||||
"SshSession",
|
||||
"SshSessionFactory",
|
||||
"TileDownloaderCut",
|
||||
"TileUploaderCut",
|
||||
"UploadBatchReportCut",
|
||||
"UploadOutcomeCut",
|
||||
"UploadRequestCut",
|
||||
"WaypointDto",
|
||||
"WaypointObjective",
|
||||
"WaypointSchemaError",
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
"""Module entry point for ``python -m gps_denied_onboard.components.c12_operator_tooling``.
|
||||
"""Module entry point for ``python -m gps_denied_onboard.components.c12_operator_orchestrator``.
|
||||
|
||||
The console script declared in ``pyproject.toml`` (``operator-tool``)
|
||||
The console script declared in ``pyproject.toml`` (``operator-orchestrator``)
|
||||
points at :func:`cli.main` directly; this module is the convenience
|
||||
entry for ``python -m ...`` invocations during development and for
|
||||
operators who prefer the explicit form.
|
||||
@@ -8,7 +8,7 @@ operators who prefer the explicit form.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling.cli import main
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+169
-5
@@ -1,4 +1,4 @@
|
||||
"""C12 operator-tooling shared DTOs / enums (AZ-326, AZ-327, AZ-328).
|
||||
"""C12 operator-orchestrator shared DTOs / enums (AZ-326, AZ-327, AZ-328).
|
||||
|
||||
``SectorClassification`` is declared locally — c12 must not import the
|
||||
c6 / c10 / c11 enums (AZ-507 / module-layout cross-component rule); the
|
||||
@@ -26,7 +26,7 @@ from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.interface import (
|
||||
FlightDto,
|
||||
)
|
||||
|
||||
@@ -42,15 +42,23 @@ __all__ = [
|
||||
"DownloadRequestCut",
|
||||
"FailurePhase",
|
||||
"FlightById",
|
||||
"FlightFooterRecord",
|
||||
"FlightFromFile",
|
||||
"FlightResolveReport",
|
||||
"FlightResolveSource",
|
||||
"FlightSource",
|
||||
"IngestStatusCut",
|
||||
"PerTileStatusCut",
|
||||
"PostLandingUploadRequest",
|
||||
"ReLocHint",
|
||||
"ReadinessOutcome",
|
||||
"ReadinessReport",
|
||||
"RemoteBuildOutcome",
|
||||
"RemoteBuildReport",
|
||||
"SectorClassification",
|
||||
"UploadBatchReportCut",
|
||||
"UploadOutcomeCut",
|
||||
"UploadRequestCut",
|
||||
]
|
||||
|
||||
|
||||
@@ -63,7 +71,7 @@ AreaIdentifier = str
|
||||
class SectorClassification(str, Enum):
|
||||
"""Operator-set classification of a geographic sector (AZ-326).
|
||||
|
||||
Mirrors the c6 enum at the c12 boundary so the operator-tool never
|
||||
Mirrors the c6 enum at the c12 boundary so the operator-orchestrator never
|
||||
imports ``components.c6_tile_cache``. The string values are
|
||||
identical so the composition root can round-trip via ``.value``.
|
||||
"""
|
||||
@@ -83,7 +91,7 @@ class CompanionUnreachableReason(str, Enum):
|
||||
"""SSH-session-open failure category (AZ-327).
|
||||
|
||||
Drives the per-reason ``remediation`` hint on
|
||||
:class:`~gps_denied_onboard.components.c12_operator_tooling.errors.CompanionUnreachableError`.
|
||||
:class:`~gps_denied_onboard.components.c12_operator_orchestrator.errors.CompanionUnreachableError`.
|
||||
"""
|
||||
|
||||
CONNECT_REFUSED = "connect_refused"
|
||||
@@ -226,7 +234,7 @@ class FlightResolveReport:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consumer-side structural cuts of C11 shapes (AZ-507)
|
||||
#
|
||||
# c12_operator_tooling MAY NOT import from c11_tile_manager directly. The
|
||||
# c12_operator_orchestrator MAY NOT import from c11_tile_manager directly. The
|
||||
# composition root maps these local cuts to / from the real c11 DTOs at
|
||||
# the wiring boundary (``runtime_root.c12_factory``).
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -300,6 +308,162 @@ class RemoteBuildReport:
|
||||
elapsed_s: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AZ-329: PostLandingUploadOrchestrator surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostLandingUploadRequest:
|
||||
"""Operator-supplied input to :meth:`PostLandingUploadOrchestrator.trigger_post_landing_upload` (AZ-329).
|
||||
|
||||
The orchestrator inspects the C13 ``flight_footer`` record for
|
||||
``flight_id`` and, if found with ``clean_shutdown=True``, delegates
|
||||
the upload to a c11 :class:`TileUploaderCut` collaborator. ``api_key``
|
||||
is plain :class:`str` for consistency with
|
||||
:class:`BuildCacheRequest.api_key`; the CLI redacts it (``"REDACTED"``)
|
||||
in the ``operator invoked subcommand`` log record and the orchestrator
|
||||
never includes it in any log payload (AC-8).
|
||||
|
||||
``batch_size`` defaults to 50 — the same default the c11
|
||||
``UploadRequest`` carries — and is bounded to ``[1, 200]`` by C11's
|
||||
own ``__post_init__`` validation; this DTO does NOT re-validate.
|
||||
"""
|
||||
|
||||
flight_id: UUID
|
||||
satellite_provider_url: str
|
||||
api_key: str
|
||||
batch_size: int = 50
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FlightFooterRecord:
|
||||
"""C12-local mirror of the C13 ``flight_footer`` payload (AZ-292).
|
||||
|
||||
Owned by C12 to preserve the c12 ↔ c13 cross-component cut — this
|
||||
task does NOT import :class:`c13_fdr.headers.FlightFooter`. Only the
|
||||
fields the orchestrator inspects (``clean_shutdown`` + the four
|
||||
AC-NEW-3 counters) are mirrored; the orchestrator never touches
|
||||
``flight_ended_at_monotonic_ns`` because the operator workstation
|
||||
does not share the airborne monotonic clock.
|
||||
"""
|
||||
|
||||
flight_id: UUID
|
||||
flight_ended_at_iso: str
|
||||
records_written: int
|
||||
records_dropped_overrun: int
|
||||
bytes_written: int
|
||||
rollover_count: int
|
||||
clean_shutdown: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consumer-side structural cuts of C11 TileUploader shapes (AZ-507)
|
||||
#
|
||||
# AZ-329 + AZ-330 forbid importing ``c11_tile_manager`` directly from
|
||||
# c12. The composition root translates between the local cuts and the
|
||||
# real C11 DTOs at the wiring boundary (``runtime_root.c12_factory``).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class IngestStatusCut(str, Enum):
|
||||
"""Mirror of c11 ``IngestStatus`` for C12's consumer-side cut."""
|
||||
|
||||
ACCEPTED = "accepted"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class UploadOutcomeCut(str, Enum):
|
||||
"""Mirror of c11 ``UploadOutcome`` for C12's consumer-side cut."""
|
||||
|
||||
SUCCESS = "success"
|
||||
PARTIAL = "partial"
|
||||
FAILURE = "failure"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadRequestCut:
|
||||
"""C12-local mirror of c11 ``UploadRequest`` (AZ-507 cut).
|
||||
|
||||
``flight_id`` is required here (C12 always issues per-flight
|
||||
uploads); the c11 DTO allows ``None`` for the "all pending across
|
||||
every flight" path used elsewhere. The composition-root mapper
|
||||
forwards this UUID into c11's ``UploadRequest.flight_id``.
|
||||
"""
|
||||
|
||||
flight_id: UUID
|
||||
batch_size: int
|
||||
satellite_provider_url: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PerTileStatusCut:
|
||||
"""C12-local mirror of c11 ``PerTileStatus`` (AZ-507 cut)."""
|
||||
|
||||
tile_id: str
|
||||
status: IngestStatusCut
|
||||
rejection_reason: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AZ-330: OperatorReLocService surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReLocHint:
|
||||
"""Operator-supplied position hint for AC-3.4 re-localization (AZ-330).
|
||||
|
||||
``approximate_position_wgs84`` reuses the shared
|
||||
:class:`gps_denied_onboard._types.geo.LatLonAlt` DTO (per the
|
||||
cross-cutting rule); the shared shape has no range validation, so
|
||||
this DTO validates lat/lon at construction (AC-7).
|
||||
``confidence_radius_m`` must be strictly positive (AC-3);
|
||||
``reason`` must be non-empty (AC-6). The full DTO is persisted to
|
||||
FDR un-redacted; the live log redacts (rounds lat/lon to 5 decimals,
|
||||
truncates ``reason`` to 200 chars) — see AC-9 + AC-4.
|
||||
"""
|
||||
|
||||
approximate_position_wgs84: LatLonAlt
|
||||
confidence_radius_m: float
|
||||
reason: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
lat = self.approximate_position_wgs84.lat_deg
|
||||
lon = self.approximate_position_wgs84.lon_deg
|
||||
if not -90.0 <= lat <= 90.0:
|
||||
raise ValueError(
|
||||
f"approximate_position_wgs84.lat_deg must be in [-90, 90]; got {lat}"
|
||||
)
|
||||
if not -180.0 < lon <= 180.0:
|
||||
raise ValueError(
|
||||
f"approximate_position_wgs84.lon_deg must be in (-180, 180]; got {lon}"
|
||||
)
|
||||
if not self.confidence_radius_m > 0:
|
||||
raise ValueError(
|
||||
f"confidence_radius_m must be > 0; got {self.confidence_radius_m}"
|
||||
)
|
||||
if not self.reason:
|
||||
raise ValueError("reason must be non-empty")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UploadBatchReportCut:
|
||||
"""C12-local mirror of c11 ``UploadBatchReport`` (AZ-507 cut).
|
||||
|
||||
The orchestrator returns this passthrough; the composition root
|
||||
maps c11's real ``UploadBatchReport`` into this cut at the wiring
|
||||
boundary so c12 source never imports from c11.
|
||||
"""
|
||||
|
||||
batch_uuid: UUID
|
||||
per_tile_status: tuple[PerTileStatusCut, ...]
|
||||
retry_count: int
|
||||
next_retry_at_s: int | None
|
||||
outcome: UploadOutcomeCut
|
||||
public_key_fingerprint: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CacheBuildReport:
|
||||
"""Aggregated result of one :meth:`BuildCacheOrchestrator.build_cache` call.
|
||||
+12
-12
@@ -31,7 +31,7 @@ import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
BuildCacheOutcome,
|
||||
BuildCacheRequest,
|
||||
CacheBuildReport,
|
||||
@@ -47,24 +47,24 @@ from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
RemoteBuildReport,
|
||||
SectorClassification,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.companion_bringup import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.companion_bringup import (
|
||||
CompanionBringup,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.config import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12BuildCacheConfig,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
BuildLockHeldError,
|
||||
BuildReportParseError,
|
||||
CacheBuildError,
|
||||
CompanionUnreachableError,
|
||||
ContentHashMismatchError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.file_lock import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.file_lock import (
|
||||
FileLockFactory,
|
||||
LockTimeout,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.errors import (
|
||||
EmptyWaypointsError,
|
||||
FlightFileNotFoundError,
|
||||
FlightNotFoundError,
|
||||
@@ -74,21 +74,21 @@ from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors impor
|
||||
FlightsApiUnreachableError,
|
||||
WaypointSchemaError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.interface import (
|
||||
FlightDto,
|
||||
FlightsApiClient,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.freshness_table import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.freshness_table import (
|
||||
freshness_threshold_months as _default_freshness_threshold,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_c10_invoker import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_c10_invoker import (
|
||||
RemoteBuildRequest,
|
||||
RemoteCacheProvisionerInvoker,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
|
||||
SshSessionFactory,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.tile_downloader_cut import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.tile_downloader_cut import (
|
||||
TileDownloaderCut,
|
||||
)
|
||||
|
||||
@@ -151,7 +151,7 @@ _BUILD_RECOGNISED_NAMES: frozenset[str] = frozenset(
|
||||
class BuildCacheOrchestrator:
|
||||
"""F1 pre-flight cache-build orchestrator (AZ-328).
|
||||
|
||||
Constructed once per ``OperatorToolServices`` from the composition
|
||||
Constructed once per ``OperatorOrchestratorServices`` from the composition
|
||||
root; the CLI ``build-cache`` subcommand resolves it from the
|
||||
services dataclass and calls :meth:`build_cache` exactly once per
|
||||
invocation.
|
||||
+158
-39
@@ -1,4 +1,4 @@
|
||||
"""``operator-tool`` CLI shell — Click app + six subcommands (AZ-326).
|
||||
"""``operator-orchestrator`` CLI shell — Click app + six subcommands (AZ-326).
|
||||
|
||||
The task spec calls for a Typer-based shell. Typer is not pinned by
|
||||
the project (only ``click>=8.1`` is in ``pyproject.toml``); the spec's
|
||||
@@ -37,7 +37,7 @@ from uuid import UUID
|
||||
|
||||
import click
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
BuildCacheOutcome,
|
||||
BuildCacheRequest,
|
||||
CacheBuildReport,
|
||||
@@ -46,18 +46,23 @@ from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
FlightById,
|
||||
FlightFromFile,
|
||||
FlightSource,
|
||||
PostLandingUploadRequest,
|
||||
ReLocHint,
|
||||
SectorClassification,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.config import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12Config,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
BuildLockHeldError,
|
||||
CacheBuildError,
|
||||
CompanionUnreachableError,
|
||||
ContentHashMismatchError,
|
||||
FlightStateNotConfirmedError,
|
||||
GcsLinkError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.exit_codes import (
|
||||
from gps_denied_onboard._types.geo import LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.exit_codes import (
|
||||
EXIT_BUILD_FAILURE,
|
||||
EXIT_COMPANION_UNREACHABLE,
|
||||
EXIT_CONTENT_HASH_MISMATCH,
|
||||
@@ -78,7 +83,7 @@ from gps_denied_onboard.components.c12_operator_tooling.exit_codes import (
|
||||
# Import flights_api types from leaf modules — going through the
|
||||
# ``flights_api`` package ``__init__.py`` would eagerly load ``bbox.py``
|
||||
# which pulls in numpy / pyproj (NFR-perf-cold-start regression).
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.errors import (
|
||||
EmptyWaypointsError,
|
||||
FlightFileNotFoundError,
|
||||
FlightNotFoundError,
|
||||
@@ -87,7 +92,7 @@ from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors impor
|
||||
FlightsApiUnreachableError,
|
||||
WaypointSchemaError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.sector_classification_store import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.sector_classification_store import (
|
||||
SectorClassificationStore,
|
||||
)
|
||||
from gps_denied_onboard.logging import JsonFormatter
|
||||
@@ -97,7 +102,7 @@ __all__ = ["app", "build_app", "main"]
|
||||
|
||||
# Service-collaborator placeholder for sibling tasks. Each subcommand
|
||||
# resolves its concrete collaborator via a factory the test injects;
|
||||
# production wiring lives in runtime_root.c12_factory.OperatorToolServices.
|
||||
# production wiring lives in runtime_root.c12_factory.OperatorOrchestratorServices.
|
||||
ServiceFactory = Callable[[], Any]
|
||||
|
||||
|
||||
@@ -110,7 +115,7 @@ _LOG_KIND_OK = "c12.cli.ok"
|
||||
_LOG_KIND_ERROR = "c12.cli.error"
|
||||
_LOG_KIND_USAGE = "c12.cli.usage"
|
||||
|
||||
_CLI_LOGGER_NAME = "c12_operator_tooling.cli"
|
||||
_CLI_LOGGER_NAME = "c12_operator_orchestrator.cli"
|
||||
_HANDLER_MARKER = "_c12_cli_file_handler"
|
||||
|
||||
|
||||
@@ -254,7 +259,7 @@ _FLIGHTS_API_HINTS: dict[type, tuple[int, str]] = {
|
||||
|
||||
|
||||
@click.group(
|
||||
name="operator-tool",
|
||||
name="operator-orchestrator",
|
||||
help="GPS-denied onboard pre-flight tooling (operator workstation).",
|
||||
)
|
||||
@click.option(
|
||||
@@ -514,22 +519,93 @@ def build_cache(
|
||||
"upload-pending",
|
||||
help="Trigger post-landing upload of pending tiles (AC-NEW-7).",
|
||||
)
|
||||
@click.option(
|
||||
"--flight-id",
|
||||
type=str,
|
||||
required=True,
|
||||
help="UUID of the flight whose pending tiles should be uploaded.",
|
||||
)
|
||||
@click.option(
|
||||
"--satellite-provider-url",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Parent-suite ingest endpoint base URL.",
|
||||
)
|
||||
@click.option(
|
||||
"--api-key",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Parent-suite ingest API key (NEVER logged; AC-8 redaction guarantee).",
|
||||
)
|
||||
@click.option(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=50,
|
||||
show_default=True,
|
||||
help="Tiles per ingest POST (forwarded to C11 UploadRequest).",
|
||||
)
|
||||
@click.pass_context
|
||||
def upload_pending(ctx: click.Context) -> None:
|
||||
"""Delegates to ``post_landing_upload.trigger_post_landing_upload`` (AZ-329)."""
|
||||
def upload_pending(
|
||||
ctx: click.Context,
|
||||
flight_id: str,
|
||||
satellite_provider_url: str,
|
||||
api_key: str,
|
||||
batch_size: int,
|
||||
) -> None:
|
||||
"""Delegate to ``post_landing_upload_orchestrator.trigger_post_landing_upload`` (AZ-329)."""
|
||||
state = ctx.obj
|
||||
logger = state["logger"]
|
||||
_emit_invoked(logger, "upload-pending")
|
||||
_emit_invoked(
|
||||
logger,
|
||||
"upload-pending",
|
||||
{
|
||||
"flight_id": flight_id,
|
||||
"satellite_provider_url": satellite_provider_url,
|
||||
"api_key": "REDACTED",
|
||||
"batch_size": batch_size,
|
||||
},
|
||||
)
|
||||
services = state.get("services")
|
||||
if services is None or not hasattr(services, "post_landing_upload"):
|
||||
if services is None or not hasattr(services, "post_landing_upload_orchestrator"):
|
||||
_emit_ok(
|
||||
logger,
|
||||
"upload-pending",
|
||||
{"note": "no post_landing_upload wired (sibling AZ-329)"},
|
||||
{"note": "no post_landing_upload_orchestrator wired (composition-root pending)"},
|
||||
)
|
||||
ctx.exit(EXIT_OK)
|
||||
orchestrator = services.post_landing_upload_orchestrator
|
||||
if orchestrator is None:
|
||||
_emit_ok(
|
||||
logger,
|
||||
"upload-pending",
|
||||
{"note": "post_landing_upload_orchestrator is None (no tile_uploader wired)"},
|
||||
)
|
||||
ctx.exit(EXIT_OK)
|
||||
request = PostLandingUploadRequest(
|
||||
flight_id=UUID(flight_id),
|
||||
satellite_provider_url=satellite_provider_url,
|
||||
api_key=api_key,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
try:
|
||||
services.post_landing_upload.trigger_post_landing_upload()
|
||||
orchestrator.trigger_post_landing_upload(request)
|
||||
except FlightStateNotConfirmedError as exc:
|
||||
_emit_error(
|
||||
logger,
|
||||
"upload-pending",
|
||||
exit_code=EXIT_FLIGHT_STATE_NOT_CONFIRMED,
|
||||
exception=exc,
|
||||
remediation=exc.remediation,
|
||||
kv={
|
||||
"flight_id": flight_id,
|
||||
"not_confirmed_reason": exc.not_confirmed_reason,
|
||||
},
|
||||
)
|
||||
click.echo(
|
||||
f"upload refused ({exc.not_confirmed_reason}): {exc.remediation}",
|
||||
err=True,
|
||||
)
|
||||
ctx.exit(EXIT_FLIGHT_STATE_NOT_CONFIRMED)
|
||||
except Exception as exc:
|
||||
_handle_known_exception(
|
||||
ctx,
|
||||
@@ -537,10 +613,6 @@ def upload_pending(ctx: click.Context) -> None:
|
||||
"upload-pending",
|
||||
exc,
|
||||
extra_table={
|
||||
"FlightStateNotConfirmedError": (
|
||||
EXIT_FLIGHT_STATE_NOT_CONFIRMED,
|
||||
"Flight state has not been confirmed yet; retry after landing is logged.",
|
||||
),
|
||||
"UploadGateBlockedError": (
|
||||
EXIT_UPLOAD_FAILURE,
|
||||
"Upload gate blocked the request; consult c11 logs for details.",
|
||||
@@ -548,7 +620,7 @@ def upload_pending(ctx: click.Context) -> None:
|
||||
},
|
||||
)
|
||||
return
|
||||
_emit_ok(logger, "upload-pending")
|
||||
_emit_ok(logger, "upload-pending", {"flight_id": flight_id})
|
||||
ctx.exit(EXIT_OK)
|
||||
|
||||
|
||||
@@ -556,37 +628,84 @@ def upload_pending(ctx: click.Context) -> None:
|
||||
"reloc-confirm",
|
||||
help="Request operator-driven re-localization via GCS (AC-3.4, AC-7.3).",
|
||||
)
|
||||
@click.option("--hint", default="", help="Optional textual hint forwarded to the GCS link.")
|
||||
@click.option("--lat", type=float, required=True, help="WGS84 latitude in degrees (-90..90).")
|
||||
@click.option("--lon", type=float, required=True, help="WGS84 longitude in degrees (-180..180].")
|
||||
@click.option("--alt", type=float, required=True, help="WGS84 ellipsoidal altitude in metres.")
|
||||
@click.option(
|
||||
"--radius",
|
||||
type=float,
|
||||
required=True,
|
||||
help="Operator confidence radius in metres (must be > 0).",
|
||||
)
|
||||
@click.option(
|
||||
"--reason",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Free-text operator note explaining the re-loc decision (non-empty).",
|
||||
)
|
||||
@click.pass_context
|
||||
def reloc_confirm(ctx: click.Context, hint: str) -> None:
|
||||
"""Delegates to ``operator_reloc_service.request_relocalization`` (AZ-330)."""
|
||||
def reloc_confirm(
|
||||
ctx: click.Context,
|
||||
lat: float,
|
||||
lon: float,
|
||||
alt: float,
|
||||
radius: float,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Delegates to ``operator_reloc_service.request_reloc`` (AZ-330)."""
|
||||
state = ctx.obj
|
||||
logger = state["logger"]
|
||||
_emit_invoked(logger, "reloc-confirm", {"hint": hint})
|
||||
# AC-4 + AC-9: log-side redaction at the CLI boundary mirrors the
|
||||
# service redaction so the invoked-event line and the sent-event
|
||||
# line agree on what's redacted.
|
||||
_emit_invoked(
|
||||
logger,
|
||||
"reloc-confirm",
|
||||
{
|
||||
"position_lat": round(lat, 5),
|
||||
"position_lon": round(lon, 5),
|
||||
"altitude_m": alt,
|
||||
"confidence_radius_m": radius,
|
||||
"reason": reason[:200],
|
||||
},
|
||||
)
|
||||
services = state.get("services")
|
||||
if services is None or not hasattr(services, "operator_reloc_service"):
|
||||
_emit_ok(
|
||||
logger,
|
||||
"reloc-confirm",
|
||||
{"note": "no operator_reloc_service wired (sibling AZ-330)"},
|
||||
{"note": "no operator_reloc_service wired (composition-root pending)"},
|
||||
)
|
||||
ctx.exit(EXIT_OK)
|
||||
reloc_service = services.operator_reloc_service
|
||||
if reloc_service is None:
|
||||
_emit_ok(
|
||||
logger,
|
||||
"reloc-confirm",
|
||||
{"note": "operator_reloc_service is None (no transport wired)"},
|
||||
)
|
||||
ctx.exit(EXIT_OK)
|
||||
try:
|
||||
services.operator_reloc_service.request_relocalization(hint=hint)
|
||||
except Exception as exc:
|
||||
_handle_known_exception(
|
||||
ctx,
|
||||
hint = ReLocHint(
|
||||
approximate_position_wgs84=LatLonAlt(lat_deg=lat, lon_deg=lon, alt_m=alt),
|
||||
confidence_radius_m=radius,
|
||||
reason=reason,
|
||||
)
|
||||
except ValueError as exc:
|
||||
_exit_with_usage(ctx, logger, "reloc-confirm", str(exc))
|
||||
try:
|
||||
reloc_service.request_reloc(hint)
|
||||
except GcsLinkError as exc:
|
||||
_emit_error(
|
||||
logger,
|
||||
"reloc-confirm",
|
||||
exc,
|
||||
extra_table={
|
||||
"GcsLinkError": (
|
||||
EXIT_GCS_LINK_ERROR,
|
||||
"GCS link unavailable; check pymavlink connectivity and signing key.",
|
||||
),
|
||||
},
|
||||
exit_code=EXIT_GCS_LINK_ERROR,
|
||||
exception=exc,
|
||||
remediation=exc.remediation,
|
||||
kv={"failure_reason": exc.reason},
|
||||
)
|
||||
return
|
||||
click.echo(f"GcsLinkError: {exc.remediation}", err=True)
|
||||
ctx.exit(EXIT_GCS_LINK_ERROR)
|
||||
_emit_ok(logger, "reloc-confirm")
|
||||
ctx.exit(EXIT_OK)
|
||||
|
||||
@@ -600,7 +719,7 @@ def reloc_confirm(ctx: click.Context, hint: str) -> None:
|
||||
@click.pass_context
|
||||
def verify_ready(ctx: click.Context, host: str, port: int) -> None:
|
||||
"""Delegates to :class:`CompanionBringup.verify_companion_ready` (AZ-327)."""
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
CompanionAddress,
|
||||
)
|
||||
|
||||
+5
-5
@@ -28,21 +28,21 @@ from __future__ import annotations
|
||||
import logging
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
CompanionAddress,
|
||||
ReadinessOutcome,
|
||||
ReadinessReport,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.config import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12CompanionConfig,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
ContentHashMismatchError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_sidecar_verifier import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_sidecar_verifier import (
|
||||
RemoteSidecarVerifier,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
|
||||
SshSession,
|
||||
SshSessionFactory,
|
||||
)
|
||||
+27
-4
@@ -1,10 +1,10 @@
|
||||
"""C12 operator-tooling config block (AZ-326, AZ-327).
|
||||
"""C12 operator-orchestrator config block (AZ-326, AZ-327).
|
||||
|
||||
Registered into ``config.components['c12_operator_tooling']`` by the
|
||||
Registered into ``config.components['c12_operator_orchestrator']`` by the
|
||||
package ``__init__.py``. Two composition-root factories read this
|
||||
block:
|
||||
|
||||
* :func:`gps_denied_onboard.runtime_root.c12_factory.build_operator_tool`
|
||||
* :func:`gps_denied_onboard.runtime_root.c12_factory.build_operator_orchestrator`
|
||||
reads the workstation-side service knobs (log path, sector
|
||||
classification store path).
|
||||
* :func:`gps_denied_onboard.runtime_root.c12_factory.build_companion_bringup`
|
||||
@@ -30,6 +30,7 @@ __all__ = [
|
||||
"C12BuildCacheConfig",
|
||||
"C12CompanionConfig",
|
||||
"C12Config",
|
||||
"C12PostLandingConfig",
|
||||
"HostKeyPolicy",
|
||||
]
|
||||
|
||||
@@ -51,6 +52,7 @@ _DEFAULT_LOG_PATH = Path("~/.azaion/onboard/c12-tooling.log").expanduser()
|
||||
_DEFAULT_SECTOR_STORE_PATH = Path("~/.azaion/onboard/sector-classifications.json").expanduser()
|
||||
_DEFAULT_COMPANION_CACHE_ROOT = PurePosixPath("/var/lib/azaion/c10/cache")
|
||||
_DEFAULT_CACHE_STAGING_ROOT = Path("~/.azaion/onboard/cache-staging").expanduser()
|
||||
_DEFAULT_FDR_ROOT = Path("~/.azaion/onboard/fdr").expanduser()
|
||||
_DEFAULT_CONNECT_TIMEOUT_S = 10.0
|
||||
_DEFAULT_SHA256SUM_TIMEOUT_S = 60.0
|
||||
_DEFAULT_LOCK_TIMEOUT_S = 5.0
|
||||
@@ -158,9 +160,22 @@ class C12BuildCacheConfig:
|
||||
raise ConfigError("C12BuildCacheConfig.lock_filename must be non-empty")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C12PostLandingConfig:
|
||||
"""Knobs consumed by :class:`PostLandingUploadOrchestrator` (AZ-329).
|
||||
|
||||
* ``fdr_root`` — workstation-side root directory under which
|
||||
per-flight FDR sub-directories live (``<fdr_root>/<flight_id>/``).
|
||||
``LocalFdrFooterReader`` scans this for the ``flight_footer``
|
||||
record. Defaults to ``~/.azaion/onboard/fdr``.
|
||||
"""
|
||||
|
||||
fdr_root: Path = _DEFAULT_FDR_ROOT
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class C12Config:
|
||||
"""Per-component config for C12 operator tooling.
|
||||
"""Per-component config for C12 operator orchestrator.
|
||||
|
||||
* ``log_path`` — workstation-side rotating log file fed by the
|
||||
AZ-266 :class:`JsonFormatter`. Defaults to
|
||||
@@ -172,12 +187,15 @@ class C12Config:
|
||||
* ``companion`` — nested AZ-327 SSH config block.
|
||||
* ``build_cache`` — nested AZ-328 orchestrator knobs (lockfile,
|
||||
flights service URL/token, bbox buffer).
|
||||
* ``post_landing`` — nested AZ-329 orchestrator knobs
|
||||
(``fdr_root``).
|
||||
"""
|
||||
|
||||
log_path: Path = _DEFAULT_LOG_PATH
|
||||
sector_classification_store_path: Path = _DEFAULT_SECTOR_STORE_PATH
|
||||
companion: C12CompanionConfig = field(default_factory=C12CompanionConfig)
|
||||
build_cache: C12BuildCacheConfig = field(default_factory=C12BuildCacheConfig)
|
||||
post_landing: C12PostLandingConfig = field(default_factory=C12PostLandingConfig)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(self.companion, C12CompanionConfig):
|
||||
@@ -190,3 +208,8 @@ class C12Config:
|
||||
"C12Config.build_cache must be a C12BuildCacheConfig; got "
|
||||
f"{type(self.build_cache).__name__}"
|
||||
)
|
||||
if not isinstance(self.post_landing, C12PostLandingConfig):
|
||||
raise ConfigError(
|
||||
"C12Config.post_landing must be a C12PostLandingConfig; got "
|
||||
f"{type(self.post_landing).__name__}"
|
||||
)
|
||||
+138
-5
@@ -1,7 +1,7 @@
|
||||
"""C12 ``CompanionBringup`` error hierarchy (AZ-327, AZ-328).
|
||||
|
||||
Two failure modes own dedicated exit codes in
|
||||
:mod:`gps_denied_onboard.components.c12_operator_tooling.exit_codes`:
|
||||
:mod:`gps_denied_onboard.components.c12_operator_orchestrator.exit_codes`:
|
||||
|
||||
* :class:`CompanionUnreachableError` — SSH session-open failure.
|
||||
Mapped 1:1 from the underlying paramiko / socket exception via the
|
||||
@@ -25,7 +25,7 @@ AZ-328 adds the ``BuildCacheOrchestrator`` family:
|
||||
``BuildReport`` JSON document; surfaced as ``failure_phase=build``.
|
||||
|
||||
All errors expose a ``remediation`` property the
|
||||
:func:`gps_denied_onboard.components.c12_operator_tooling.cli.main`
|
||||
:func:`gps_denied_onboard.components.c12_operator_orchestrator.cli.main`
|
||||
layer reads to print a one-line operator-friendly hint to stderr.
|
||||
|
||||
The flights-API errors (AZ-489) deliberately do NOT carry a
|
||||
@@ -38,18 +38,30 @@ discipline by keeping the hint table in c12.
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
CompanionUnreachableReason,
|
||||
FailurePhase,
|
||||
)
|
||||
|
||||
NotConfirmedReason = Literal[
|
||||
"flight_id_not_found",
|
||||
"footer_missing",
|
||||
"unclean_shutdown",
|
||||
"fdr_unreadable",
|
||||
]
|
||||
|
||||
__all__ = [
|
||||
"BuildLockHeldError",
|
||||
"BuildReportParseError",
|
||||
"CacheBuildError",
|
||||
"CompanionUnreachableError",
|
||||
"ContentHashMismatchError",
|
||||
"FdrUnreadableError",
|
||||
"FlightStateNotConfirmedError",
|
||||
"GcsLinkError",
|
||||
"NotConfirmedReason",
|
||||
]
|
||||
|
||||
|
||||
@@ -140,7 +152,7 @@ class ContentHashMismatchError(Exception):
|
||||
@property
|
||||
def remediation(self) -> str:
|
||||
return (
|
||||
"Re-run the cache build (`operator-tool build-cache --flight-id ...`) "
|
||||
"Re-run the cache build (`operator-orchestrator build-cache --flight-id ...`) "
|
||||
"to repopulate the affected engine."
|
||||
)
|
||||
|
||||
@@ -227,7 +239,7 @@ class BuildLockHeldError(CacheBuildError):
|
||||
failure_phase=FailurePhase.DOWNLOAD,
|
||||
wrapped_exception_repr=f"LockTimeout(path={lock_path!s}, timeout_s={timeout_s})",
|
||||
message=(
|
||||
f"build-cache lock held: another `operator-tool build-cache` is in "
|
||||
f"build-cache lock held: another `operator-orchestrator build-cache` is in "
|
||||
f"progress (lock={lock_path}, waited {timeout_s:.1f} s)"
|
||||
),
|
||||
remediation=(
|
||||
@@ -239,6 +251,127 @@ class BuildLockHeldError(CacheBuildError):
|
||||
self.timeout_s = timeout_s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AZ-329: PostLandingUploadOrchestrator error family
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_POST_LANDING_REMEDIATIONS: dict[str, str] = {
|
||||
"flight_id_not_found": (
|
||||
"Verify <fdr_root>/<flight_id>/ exists; check "
|
||||
"`config.c12_operator_orchestrator.post_landing.fdr_root` and the "
|
||||
"flight UUID."
|
||||
),
|
||||
"footer_missing": (
|
||||
"No flight_footer record found in any segment — the flight likely "
|
||||
"terminated abnormally (power loss, crash, or close_flight() never "
|
||||
"ran). Inspect FDR manually; upload requires a clean shutdown."
|
||||
),
|
||||
"unclean_shutdown": (
|
||||
"The flight footer reports an unclean shutdown. Operator must "
|
||||
"manually verify the flight outcome before authorising tile upload."
|
||||
),
|
||||
"fdr_unreadable": (
|
||||
"Inspect FDR segment files manually; the parser failed mid-stream. "
|
||||
"The wrapped exception repr is on the error object's `detail` field."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class FdrUnreadableError(Exception):
|
||||
"""Sibling exception raised by :class:`LocalFdrFooterReader` on I/O or parse failure.
|
||||
|
||||
Caught at the :class:`PostLandingUploadOrchestrator` boundary and
|
||||
rewrapped as :class:`FlightStateNotConfirmedError` with
|
||||
``not_confirmed_reason="fdr_unreadable"``. Operators do not see this
|
||||
exception directly; the orchestrator's typed refusal is the
|
||||
operator-facing contract.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class FlightStateNotConfirmedError(Exception):
|
||||
"""Operator-side refusal raised by :class:`PostLandingUploadOrchestrator` (AZ-329).
|
||||
|
||||
The four valid ``not_confirmed_reason`` values form a closed
|
||||
:class:`NotConfirmedReason` ``Literal`` — operators script against
|
||||
these values. Adding a new value requires Plan-cycle approval.
|
||||
|
||||
* ``flight_id_not_found`` — ``<fdr_root>/<flight_id>/`` does not exist
|
||||
* ``footer_missing`` — no ``flight_footer`` record anywhere in the FDR
|
||||
* ``unclean_shutdown`` — footer present but ``clean_shutdown=False``
|
||||
* ``fdr_unreadable`` — I/O or parse error while scanning segments
|
||||
|
||||
``detail`` is reason-specific extra context:
|
||||
* ``unclean_shutdown`` — carries the four AC-NEW-3 counter values
|
||||
* ``fdr_unreadable`` — carries the inner :class:`FdrUnreadableError` repr
|
||||
* Other reasons — empty string.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
flight_id: str,
|
||||
not_confirmed_reason: NotConfirmedReason,
|
||||
detail: str = "",
|
||||
) -> None:
|
||||
super().__init__(
|
||||
f"flight state not confirmed: flight_id={flight_id} "
|
||||
f"reason={not_confirmed_reason}"
|
||||
+ (f" detail={detail}" if detail else "")
|
||||
)
|
||||
self.flight_id = flight_id
|
||||
self.not_confirmed_reason: NotConfirmedReason = not_confirmed_reason
|
||||
self.detail = detail
|
||||
|
||||
@property
|
||||
def remediation(self) -> str:
|
||||
return _POST_LANDING_REMEDIATIONS[self.not_confirmed_reason]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AZ-330: OperatorReLocService error family
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_GCS_LINK_DEFAULT_REMEDIATION: str = (
|
||||
"Check GCS link signal strength; re-issue the re-loc command when "
|
||||
"the link recovers."
|
||||
)
|
||||
|
||||
|
||||
class GcsLinkError(Exception):
|
||||
"""Raised when the GCS link transport cannot send the operator's re-loc hint.
|
||||
|
||||
Producer: the concrete :class:`OperatorCommandTransport` (E-C8's
|
||||
pymavlink-backed implementation, future task). Consumer: C12's
|
||||
:class:`OperatorReLocService.request_reloc`, which catches and
|
||||
re-raises with a ``"C12 reloc-confirm: "`` prefix while preserving
|
||||
the original exception as ``__cause__``. Best-effort semantics —
|
||||
the operator may need to re-issue manually; this layer does NOT
|
||||
auto-retry.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
reason: str,
|
||||
wrapped_exception_repr: str | None = None,
|
||||
remediation: str = _GCS_LINK_DEFAULT_REMEDIATION,
|
||||
) -> None:
|
||||
super().__init__(f"gcs link error: {reason}")
|
||||
self.reason = reason
|
||||
self.wrapped_exception_repr = wrapped_exception_repr
|
||||
self._remediation = remediation
|
||||
|
||||
@property
|
||||
def remediation(self) -> str:
|
||||
return self._remediation
|
||||
|
||||
|
||||
class BuildReportParseError(CacheBuildError):
|
||||
"""C10's companion-side stdout did not contain a parseable BuildReport JSON.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
"""Exit-code constants for the ``operator-tool`` console script (AZ-326).
|
||||
"""Exit-code constants for the ``operator-orchestrator`` console script (AZ-326).
|
||||
|
||||
The CLI shell maps each documented service-collaborator exception family
|
||||
to a specific exit code so operator scripts can branch on ``$?``. The
|
||||
@@ -0,0 +1,195 @@
|
||||
"""C12 FDR footer reader (AZ-329).
|
||||
|
||||
Reads the C13-emitted ``flight_footer`` record (AZ-292) from a flight's
|
||||
FDR segment directory, newest-segment-first, with bounded memory. The
|
||||
reader is the orchestrator's collaborator — the post-landing
|
||||
orchestrator (:class:`PostLandingUploadOrchestrator`) decides what to
|
||||
do with the result (or its absence).
|
||||
|
||||
Segment naming convention (matches C13's
|
||||
:func:`c13_fdr.writer.FileFdrWriter._segment_path`): each closed segment
|
||||
is written to ``<fdr_root>/<flight_id>/segment-NNNN.fdr`` where ``NNNN``
|
||||
is a zero-padded 4-digit integer. The reader sorts by the integer
|
||||
index, not by filesystem mtime, so a concurrent rollover during
|
||||
``close_flight()`` cannot misorder the scan.
|
||||
|
||||
Frame format (matches C13's
|
||||
:func:`c13_fdr.writer.FileFdrWriter._write_record_frame`): each record
|
||||
is a 4-byte little-endian uint32 length prefix followed by the AZ-272
|
||||
``serialise(...)`` body. The reader reads one length, exactly that
|
||||
many body bytes, parses, and either keeps walking or short-circuits on
|
||||
a matching ``kind``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import BinaryIO, Iterator, Protocol, runtime_checkable
|
||||
from uuid import UUID
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
FlightFooterRecord,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
FdrUnreadableError,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client.records import FdrRecord, FdrSchemaError, parse
|
||||
|
||||
__all__ = [
|
||||
"FdrFooterReader",
|
||||
"LocalFdrFooterReader",
|
||||
]
|
||||
|
||||
|
||||
_LENGTH_PREFIX = struct.Struct("<I") # uint32 LE record length prefix (matches C13).
|
||||
_FLIGHT_FOOTER_KIND = "flight_footer"
|
||||
_SEGMENT_FILENAME_RE = re.compile(r"^segment-(\d+)\.fdr$")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class FdrFooterReader(Protocol):
|
||||
"""Operator-side reader of the C13 ``flight_footer`` record for a flight.
|
||||
|
||||
Implementations MUST iterate segments newest-first (descending
|
||||
integer index) and short-circuit on the first matching record so
|
||||
operators don't pay the cost of scanning multi-GB earlier segments
|
||||
for a record that lives at the tail of the last one.
|
||||
|
||||
Raises :class:`FdrUnreadableError` on any I/O or parse failure; the
|
||||
orchestrator rewraps it as a typed refusal.
|
||||
"""
|
||||
|
||||
def read_footer(self, flight_id: UUID) -> FlightFooterRecord | None: ...
|
||||
|
||||
|
||||
class LocalFdrFooterReader:
|
||||
"""On-disk implementation of :class:`FdrFooterReader`.
|
||||
|
||||
Streams length-prefixed records from each segment file in
|
||||
DESCENDING numerical order, parses via AZ-272's
|
||||
:func:`fdr_client.records.parse`, and returns the first record whose
|
||||
``kind == "flight_footer"`` as a :class:`FlightFooterRecord` (the
|
||||
c12-local mirror). The returned ``flight_id`` is asserted to match
|
||||
the requested UUID; a mismatch raises :class:`FdrUnreadableError`.
|
||||
"""
|
||||
|
||||
def __init__(self, fdr_root: Path) -> None:
|
||||
self._fdr_root = fdr_root
|
||||
|
||||
def read_footer(self, flight_id: UUID) -> FlightFooterRecord | None:
|
||||
flight_dir = self._fdr_root / str(flight_id)
|
||||
for segment_path in self._iter_segments_newest_first(flight_dir):
|
||||
footer = self._scan_segment_for_footer(segment_path, flight_id)
|
||||
if footer is not None:
|
||||
return footer
|
||||
return None
|
||||
|
||||
def _iter_segments_newest_first(self, flight_dir: Path) -> list[Path]:
|
||||
# Sort by integer index parsed from `segment-NNNN.fdr`. Filesystem
|
||||
# mtime is NOT reliable — a concurrent rollover during close_flight()
|
||||
# could land the footer in a newer segment whose mtime is older
|
||||
# than an in-progress write to the previous segment.
|
||||
try:
|
||||
entries = list(flight_dir.iterdir())
|
||||
except OSError as exc:
|
||||
raise FdrUnreadableError(
|
||||
f"failed to list FDR segment directory {flight_dir}: {exc!r}"
|
||||
) from exc
|
||||
|
||||
indexed: list[tuple[int, Path]] = []
|
||||
for entry in entries:
|
||||
if not entry.is_file():
|
||||
continue
|
||||
match = _SEGMENT_FILENAME_RE.match(entry.name)
|
||||
if match is None:
|
||||
continue
|
||||
indexed.append((int(match.group(1)), entry))
|
||||
indexed.sort(key=lambda pair: pair[0], reverse=True)
|
||||
return [path for _index, path in indexed]
|
||||
|
||||
def _scan_segment_for_footer(
|
||||
self, segment_path: Path, expected_flight_id: UUID
|
||||
) -> FlightFooterRecord | None:
|
||||
try:
|
||||
handle: BinaryIO = open(segment_path, "rb") # noqa: SIM115 — manual close below
|
||||
except OSError as exc:
|
||||
raise FdrUnreadableError(
|
||||
f"failed to open FDR segment {segment_path}: {exc!r}"
|
||||
) from exc
|
||||
try:
|
||||
for record in self._iter_records(handle, segment_path):
|
||||
if record.kind == _FLIGHT_FOOTER_KIND:
|
||||
return _build_footer_record(record, expected_flight_id)
|
||||
return None
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
def _iter_records(
|
||||
self, handle: BinaryIO, segment_path: Path
|
||||
) -> Iterator[FdrRecord]:
|
||||
prefix_size = _LENGTH_PREFIX.size
|
||||
while True:
|
||||
prefix = handle.read(prefix_size)
|
||||
if not prefix:
|
||||
return
|
||||
if len(prefix) != prefix_size:
|
||||
raise FdrUnreadableError(
|
||||
f"truncated length prefix in {segment_path}: "
|
||||
f"expected {prefix_size} bytes, got {len(prefix)}"
|
||||
)
|
||||
(length,) = _LENGTH_PREFIX.unpack(prefix)
|
||||
body = handle.read(length)
|
||||
if len(body) != length:
|
||||
raise FdrUnreadableError(
|
||||
f"truncated record body in {segment_path}: "
|
||||
f"expected {length} bytes, got {len(body)}"
|
||||
)
|
||||
try:
|
||||
yield parse(body)
|
||||
except FdrSchemaError as exc:
|
||||
raise FdrUnreadableError(
|
||||
f"failed to parse record in {segment_path}: {exc!r}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _build_footer_record(
|
||||
record: FdrRecord, expected_flight_id: UUID
|
||||
) -> FlightFooterRecord:
|
||||
payload = record.payload
|
||||
try:
|
||||
footer_flight_id_str = str(payload["flight_id"])
|
||||
flight_ended_at_iso = str(payload["flight_ended_at_iso"])
|
||||
records_written = int(payload["records_written"])
|
||||
records_dropped_overrun = int(payload["records_dropped_overrun"])
|
||||
bytes_written = int(payload["bytes_written"])
|
||||
rollover_count = int(payload["rollover_count"])
|
||||
clean_shutdown = bool(payload["clean_shutdown"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise FdrUnreadableError(
|
||||
f"flight_footer payload schema violation: {exc!r}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
footer_flight_id = UUID(footer_flight_id_str)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise FdrUnreadableError(
|
||||
f"flight_footer.flight_id is not a UUID: {footer_flight_id_str!r}"
|
||||
) from exc
|
||||
|
||||
if footer_flight_id != expected_flight_id:
|
||||
raise FdrUnreadableError(
|
||||
f"flight_footer.flight_id mismatch: footer={footer_flight_id}, "
|
||||
f"requested={expected_flight_id}"
|
||||
)
|
||||
|
||||
return FlightFooterRecord(
|
||||
flight_id=footer_flight_id,
|
||||
flight_ended_at_iso=flight_ended_at_iso,
|
||||
records_written=records_written,
|
||||
records_dropped_overrun=records_dropped_overrun,
|
||||
bytes_written=bytes_written,
|
||||
rollover_count=rollover_count,
|
||||
clean_shutdown=clean_shutdown,
|
||||
)
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"""Workstation-side file-lock protocols + ``filelock``-backed concrete (AZ-328).
|
||||
|
||||
The C12 ``BuildCacheOrchestrator`` acquires ``cache_staging_root/.c12.lock``
|
||||
to serialise concurrent operator runs of ``operator-tool build-cache``
|
||||
to serialise concurrent operator runs of ``operator-orchestrator build-cache``
|
||||
(description.md § 7). C10's own lockfile lives on the companion under
|
||||
``companion_cache_root/.c10.lock`` (CP-INV-4) — these are independent;
|
||||
the workstation lock prevents two workstation processes from racing on
|
||||
@@ -10,7 +10,7 @@ from racing on the engines+manifest root.
|
||||
|
||||
Why a separate factory rather than reusing c10's: the AZ-507 cross-
|
||||
component rule forbids importing ``c10_provisioning`` from
|
||||
``c12_operator_tooling``. Both factories thinly wrap the same
|
||||
``c12_operator_orchestrator``. Both factories thinly wrap the same
|
||||
``filelock`` library; the contract Protocol below is the consumer-side
|
||||
cut for c12.
|
||||
|
||||
+9
-9
@@ -18,7 +18,7 @@ Two sources produce the same DTO shape:
|
||||
* :meth:`FlightsApiClient.load_flight_file` — JSON on disk (offline path).
|
||||
|
||||
Public surface is frozen by
|
||||
``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md``
|
||||
``_docs/02_document/contracts/c12_operator_orchestrator/flights_api_client.md``
|
||||
v1.0.0.
|
||||
|
||||
NOTE on lazy imports (AZ-326 NFR-perf-cold-start): :class:`HttpxFlightsApiClient`
|
||||
@@ -33,7 +33,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.errors import (
|
||||
EmptyWaypointsError,
|
||||
FlightFileNotFoundError,
|
||||
FlightNotFoundError,
|
||||
@@ -43,10 +43,10 @@ from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors impor
|
||||
FlightsApiUnreachableError,
|
||||
WaypointSchemaError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.file_loader import (
|
||||
load_flight_file,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.interface import (
|
||||
FlightDto,
|
||||
FlightsApiClient,
|
||||
WaypointDto,
|
||||
@@ -55,26 +55,26 @@ from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface im
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.bbox import (
|
||||
bbox_from_waypoints,
|
||||
takeoff_origin_from_flight,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.httpx_client import (
|
||||
HttpxFlightsApiClient,
|
||||
)
|
||||
|
||||
|
||||
_LAZY_NAMES: dict[str, tuple[str, str]] = {
|
||||
"HttpxFlightsApiClient": (
|
||||
"gps_denied_onboard.components.c12_operator_tooling.flights_api.httpx_client",
|
||||
"gps_denied_onboard.components.c12_operator_orchestrator.flights_api.httpx_client",
|
||||
"HttpxFlightsApiClient",
|
||||
),
|
||||
"bbox_from_waypoints": (
|
||||
"gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox",
|
||||
"gps_denied_onboard.components.c12_operator_orchestrator.flights_api.bbox",
|
||||
"bbox_from_waypoints",
|
||||
),
|
||||
"takeoff_origin_from_flight": (
|
||||
"gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox",
|
||||
"gps_denied_onboard.components.c12_operator_orchestrator.flights_api.bbox",
|
||||
"takeoff_origin_from_flight",
|
||||
),
|
||||
}
|
||||
+2
-2
@@ -10,11 +10,11 @@ import math
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.errors import (
|
||||
FlightsApiSchemaError,
|
||||
WaypointSchemaError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.interface import (
|
||||
FlightDto,
|
||||
WaypointDto,
|
||||
WaypointObjective,
|
||||
+2
-2
@@ -11,10 +11,10 @@ import math
|
||||
import numpy as np
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.errors import (
|
||||
EmptyWaypointsError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.interface import (
|
||||
FlightDto,
|
||||
WaypointDto,
|
||||
)
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
"""C12 ``FlightsApiClient`` error hierarchy (AZ-489).
|
||||
|
||||
Mapped 1:1 to the failure modes in the
|
||||
``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md``
|
||||
``_docs/02_document/contracts/c12_operator_orchestrator/flights_api_client.md``
|
||||
exception table.
|
||||
|
||||
FAC-INV-7 (auth-token redaction): ``FlightsApiAuthError`` overrides
|
||||
+3
-3
@@ -11,14 +11,14 @@ from pathlib import Path
|
||||
|
||||
import orjson
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api._parser import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api._parser import (
|
||||
parse_flight_payload,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.errors import (
|
||||
FlightFileNotFoundError,
|
||||
FlightsApiSchemaError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.interface import (
|
||||
FlightDto,
|
||||
)
|
||||
|
||||
+5
-5
@@ -22,23 +22,23 @@ import httpx
|
||||
from gps_denied_onboard.clock.wall_clock import WallClock
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api._parser import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api._parser import (
|
||||
parse_flight_payload,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.bbox import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.bbox import (
|
||||
bbox_from_waypoints,
|
||||
takeoff_origin_from_flight,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.errors import (
|
||||
FlightNotFoundError,
|
||||
FlightsApiAuthError,
|
||||
FlightsApiSchemaError,
|
||||
FlightsApiUnreachableError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.file_loader import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.file_loader import (
|
||||
load_flight_file,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api.interface import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api.interface import (
|
||||
FlightDto,
|
||||
WaypointDto,
|
||||
)
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"""C12 ``FlightsApiClient`` Protocol + DTOs + enums (AZ-489).
|
||||
|
||||
Frozen by ``_docs/02_document/contracts/c12_operator_tooling/flights_api_client.md``
|
||||
Frozen by ``_docs/02_document/contracts/c12_operator_orchestrator/flights_api_client.md``
|
||||
v1.0.0. The DTOs mirror ``suite/flights/Database/Entities/{Flight,Waypoint}.cs``;
|
||||
adding a new field on the parent-suite C# side requires a new minor-version
|
||||
bump here (FAC-INV-1: online + offline produce the same shape).
|
||||
+1
-1
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
SectorClassification,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""C12 ``CacheBuildWorkflow`` Protocol.
|
||||
|
||||
The placeholder :class:`OperatorReLocService` Protocol that used to live
|
||||
here has been superseded by the AZ-330 concrete class in
|
||||
:mod:`operator_reloc_service`. The package re-exports the concrete
|
||||
class under the same public name; consumers continue to import
|
||||
``OperatorReLocService`` from
|
||||
``gps_denied_onboard.components.c12_operator_orchestrator`` unchanged.
|
||||
|
||||
See `_docs/02_document/components/13_c12_operator_orchestrator/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
__all__ = ["CacheBuildWorkflow"]
|
||||
|
||||
|
||||
class CacheBuildWorkflow(Protocol):
|
||||
"""Operator CLI workflow that orchestrates C11 download → C10 provisioning."""
|
||||
|
||||
def run(self, flight_id: str, output_root: Path) -> None: ...
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
"""C12 ``OperatorCommandTransport`` Protocol (AZ-330).
|
||||
|
||||
The C12 ↔ C8 contract for operator-driven commands sent over the GCS
|
||||
link. C12 owns the Protocol shape; E-C8 will own the concrete
|
||||
``MavlinkOperatorCommandTransport`` against pymavlink in a future
|
||||
task. The pattern matches AZ-322's ``BackboneEmbedder`` (C10 owns the
|
||||
Protocol; C2 implements it later).
|
||||
|
||||
The Protocol contract document at
|
||||
``_docs/02_document/contracts/c12_operator_orchestrator/operator_command_transport.md``
|
||||
pins the shape, invariants, and test cases the E-C8 implementer reads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
ReLocHint,
|
||||
)
|
||||
|
||||
__all__ = ["OperatorCommandTransport"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class OperatorCommandTransport(Protocol):
|
||||
"""Send operator-side commands to the airborne companion over the GCS link.
|
||||
|
||||
Implementations MUST raise :class:`GcsLinkError` on any link-level
|
||||
failure (timeout, signal loss, serial-port error). The method is
|
||||
non-blocking with respect to operator-side waiting — the transport
|
||||
may block briefly inside MAVLink serialisation but MUST NOT block
|
||||
waiting for an ack from the companion (best-effort semantics per
|
||||
description.md § 7).
|
||||
"""
|
||||
|
||||
def send_reloc_hint(self, hint: ReLocHint) -> None: ...
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
"""C12 ``OperatorReLocService`` (AZ-330).
|
||||
|
||||
Operator-side surface for AC-3.4 (visual-loss re-localization). The
|
||||
operator workstation issues a position hint; this service validates,
|
||||
forwards to the GCS-link :class:`OperatorCommandTransport` (E-C8 ships
|
||||
the pymavlink-backed concrete impl in a future task), and records the
|
||||
action in FDR so post-flight forensics retains it.
|
||||
|
||||
Best-effort semantics per description.md § 7 — a single attempt; on
|
||||
:class:`GcsLinkError` the failure is logged + FDR-recorded but never
|
||||
auto-retried. The operator decides when to re-issue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
ReLocHint,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
GcsLinkError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.operator_command_transport import (
|
||||
OperatorCommandTransport,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client import EnqueueResult, FdrClient
|
||||
from gps_denied_onboard.fdr_client.records import (
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
FdrRecord,
|
||||
)
|
||||
|
||||
__all__ = ["OperatorReLocService"]
|
||||
|
||||
|
||||
_COMPONENT = "c12_operator_orchestrator"
|
||||
|
||||
_LOG_KIND_SENT = "c12.reloc.sent"
|
||||
_LOG_KIND_FAILED = "c12.reloc.failed"
|
||||
_FDR_KIND_REQUESTED = "c12.reloc.requested"
|
||||
|
||||
# AC-4 + AC-9: live-log redaction tweaks. The FULL hint is persisted
|
||||
# verbatim to FDR (post-flight forensics) and forwarded verbatim to
|
||||
# the transport (operator action is byte-preserving).
|
||||
_REASON_LOG_TRUNCATE_CHARS: int = 200
|
||||
_POSITION_LOG_PRECISION: int = 5
|
||||
|
||||
|
||||
class OperatorReLocService:
|
||||
"""Single-method service: validate → transmit → log → FDR (AC-3.4).
|
||||
|
||||
The flow is intentionally linear and stateless. Construction is
|
||||
cheap (no transport probe, no FDR enqueue) so the composition root
|
||||
can build it eagerly without violating NFR-perf-cold-start. The
|
||||
transport — pymavlink-backed in production — is only touched when
|
||||
the operator hits the CLI's ``reloc-confirm`` subcommand.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
transport: OperatorCommandTransport,
|
||||
fdr_client: FdrClient,
|
||||
logger: logging.Logger,
|
||||
clock: Clock,
|
||||
) -> None:
|
||||
self._transport = transport
|
||||
self._fdr_client = fdr_client
|
||||
self._logger = logger
|
||||
self._clock = clock
|
||||
|
||||
def request_reloc(self, reloc_hint: ReLocHint) -> None:
|
||||
if not reloc_hint.confidence_radius_m > 0:
|
||||
raise ValueError(
|
||||
f"confidence_radius_m must be > 0; got {reloc_hint.confidence_radius_m}"
|
||||
)
|
||||
if not reloc_hint.reason:
|
||||
raise ValueError("reason must be non-empty")
|
||||
|
||||
ts_monotonic_ns = self._clock.monotonic_ns()
|
||||
try:
|
||||
self._transport.send_reloc_hint(reloc_hint)
|
||||
except GcsLinkError as exc:
|
||||
self._log_failure(reloc_hint, exc)
|
||||
self._emit_fdr(
|
||||
reloc_hint,
|
||||
outcome="failed",
|
||||
failure_reason=exc.reason,
|
||||
ts_monotonic_ns=ts_monotonic_ns,
|
||||
)
|
||||
raise GcsLinkError(
|
||||
reason=f"C12 reloc-confirm: {exc.reason}",
|
||||
wrapped_exception_repr=repr(exc),
|
||||
remediation=exc.remediation,
|
||||
) from exc
|
||||
|
||||
self._log_success(reloc_hint)
|
||||
self._emit_fdr(
|
||||
reloc_hint,
|
||||
outcome="sent",
|
||||
failure_reason=None,
|
||||
ts_monotonic_ns=ts_monotonic_ns,
|
||||
)
|
||||
|
||||
def _log_success(self, hint: ReLocHint) -> None:
|
||||
self._logger.info(
|
||||
"operator re-loc hint sent",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_SENT,
|
||||
"kv": _redacted_log_kv(hint),
|
||||
},
|
||||
)
|
||||
|
||||
def _log_failure(self, hint: ReLocHint, exc: GcsLinkError) -> None:
|
||||
kv = _redacted_log_kv(hint)
|
||||
kv["failure_reason"] = exc.reason
|
||||
if exc.wrapped_exception_repr is not None:
|
||||
kv["wrapped_exception_repr"] = exc.wrapped_exception_repr
|
||||
self._logger.error(
|
||||
"operator re-loc hint transmission failed",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_FAILED,
|
||||
"kv": kv,
|
||||
},
|
||||
)
|
||||
|
||||
def _emit_fdr(
|
||||
self,
|
||||
hint: ReLocHint,
|
||||
*,
|
||||
outcome: str,
|
||||
failure_reason: str | None,
|
||||
ts_monotonic_ns: int,
|
||||
) -> None:
|
||||
payload: dict[str, object] = {
|
||||
"hint": _hint_to_payload(hint),
|
||||
"outcome": outcome,
|
||||
"ts_monotonic_ns": ts_monotonic_ns,
|
||||
}
|
||||
if failure_reason is not None:
|
||||
payload["failure_reason"] = failure_reason
|
||||
record = FdrRecord(
|
||||
schema_version=CURRENT_SCHEMA_VERSION,
|
||||
ts=self._iso_ts_from_clock(),
|
||||
producer_id=self._fdr_client.producer_id,
|
||||
kind=_FDR_KIND_REQUESTED,
|
||||
payload=payload,
|
||||
)
|
||||
# AC-8: FDR best-effort. Overrun is observable in tests via spy
|
||||
# but never raises; the operator's transport call already
|
||||
# completed (success or failure) before this point.
|
||||
result = self._fdr_client.enqueue(record)
|
||||
if result == EnqueueResult.OVERRUN:
|
||||
self._logger.warning(
|
||||
"FDR enqueue dropped operator re-loc record (buffer overrun)",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": "c12.reloc.fdr_overrun",
|
||||
"kv": {"outcome": outcome},
|
||||
},
|
||||
)
|
||||
|
||||
def _iso_ts_from_clock(self) -> str:
|
||||
ns = int(self._clock.time_ns())
|
||||
seconds, fraction_ns = divmod(ns, 1_000_000_000)
|
||||
dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
|
||||
return f"{dt.strftime('%Y-%m-%dT%H:%M:%S')}.{fraction_ns:09d}+00:00"
|
||||
|
||||
|
||||
def _hint_to_payload(hint: ReLocHint) -> dict[str, object]:
|
||||
"""Full-precision FDR-side serialisation. No redaction (AC-4 + § 5)."""
|
||||
position = hint.approximate_position_wgs84
|
||||
return {
|
||||
"lat_deg": position.lat_deg,
|
||||
"lon_deg": position.lon_deg,
|
||||
"alt_m": position.alt_m,
|
||||
"confidence_radius_m": hint.confidence_radius_m,
|
||||
"reason": hint.reason,
|
||||
}
|
||||
|
||||
|
||||
def _redacted_log_kv(hint: ReLocHint) -> dict[str, object]:
|
||||
"""Live-log redaction: 5-decimal position + 200-char reason cap (AC-4 + AC-9)."""
|
||||
position = hint.approximate_position_wgs84
|
||||
truncated_reason = hint.reason[:_REASON_LOG_TRUNCATE_CHARS]
|
||||
return {
|
||||
"position_lat": round(position.lat_deg, _POSITION_LOG_PRECISION),
|
||||
"position_lon": round(position.lon_deg, _POSITION_LOG_PRECISION),
|
||||
"altitude_m": position.alt_m,
|
||||
"confidence_radius_m": hint.confidence_radius_m,
|
||||
"reason": truncated_reason,
|
||||
}
|
||||
+4
-4
@@ -23,17 +23,17 @@ from typing import Final
|
||||
|
||||
import paramiko
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
CompanionAddress,
|
||||
CompanionUnreachableReason,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.config import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
HostKeyPolicy,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
CompanionUnreachableError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
|
||||
RemoteCommandResult,
|
||||
SshSession,
|
||||
SshSessionFactory,
|
||||
@@ -0,0 +1,193 @@
|
||||
"""C12 ``PostLandingUploadOrchestrator`` (AZ-329).
|
||||
|
||||
Operator-side gate on the post-landing tile upload. Reads the C13
|
||||
``flight_footer`` record for ``flight_id`` and, when present with
|
||||
``clean_shutdown=True``, delegates the actual upload to a C11
|
||||
:class:`TileUploaderCut` collaborator. Any other state (missing
|
||||
directory, missing footer, ``clean_shutdown=False``, parse error)
|
||||
refuses with :class:`FlightStateNotConfirmedError`.
|
||||
|
||||
C12 does NOT import ``c11_tile_manager`` here — the AZ-507 consumer-side
|
||||
cut pattern enforces structural typing via :class:`TileUploaderCut`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
FlightFooterRecord,
|
||||
PostLandingUploadRequest,
|
||||
UploadBatchReportCut,
|
||||
UploadRequestCut,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12PostLandingConfig,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
FdrUnreadableError,
|
||||
FlightStateNotConfirmedError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.fdr_footer_reader import (
|
||||
FdrFooterReader,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.tile_uploader_cut import (
|
||||
TileUploaderCut,
|
||||
)
|
||||
|
||||
__all__ = ["PostLandingUploadOrchestrator"]
|
||||
|
||||
|
||||
_COMPONENT = "c12_operator_orchestrator"
|
||||
|
||||
_LOG_KIND_CONFIRMED = "c12.upload.confirmed_clean_shutdown"
|
||||
_LOG_KIND_COMPLETE = "c12.upload.complete"
|
||||
_LOG_KIND_REFUSED_FLIGHT_NOT_FOUND = "c12.upload.refused.flight_id_not_found"
|
||||
_LOG_KIND_REFUSED_FOOTER_MISSING = "c12.upload.refused.footer_missing"
|
||||
_LOG_KIND_REFUSED_UNCLEAN_SHUTDOWN = "c12.upload.refused.unclean_shutdown"
|
||||
_LOG_KIND_REFUSED_FDR_UNREADABLE = "c12.upload.refused.fdr_unreadable"
|
||||
|
||||
|
||||
class PostLandingUploadOrchestrator:
|
||||
"""Operator-side gate on the post-landing tile upload (AZ-329).
|
||||
|
||||
Single public method :meth:`trigger_post_landing_upload`. The
|
||||
decision tree is deterministic and exhaustive across the four
|
||||
:class:`NotConfirmedReason` values; the orchestrator never silently
|
||||
proceeds when it cannot positively confirm a clean shutdown.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
tile_uploader: TileUploaderCut,
|
||||
fdr_footer_reader: FdrFooterReader,
|
||||
logger: logging.Logger,
|
||||
config: C12PostLandingConfig,
|
||||
) -> None:
|
||||
self._tile_uploader = tile_uploader
|
||||
self._fdr_footer_reader = fdr_footer_reader
|
||||
self._logger = logger
|
||||
self._config = config
|
||||
|
||||
def trigger_post_landing_upload(
|
||||
self, request: PostLandingUploadRequest
|
||||
) -> UploadBatchReportCut:
|
||||
flight_id_str = str(request.flight_id)
|
||||
flight_dir = self._config.fdr_root / flight_id_str
|
||||
|
||||
if not flight_dir.exists():
|
||||
self._log_refusal(
|
||||
_LOG_KIND_REFUSED_FLIGHT_NOT_FOUND,
|
||||
"flight_id directory not found in FDR root",
|
||||
kv={"flight_id": flight_id_str, "flight_dir": str(flight_dir)},
|
||||
)
|
||||
raise FlightStateNotConfirmedError(
|
||||
flight_id=flight_id_str,
|
||||
not_confirmed_reason="flight_id_not_found",
|
||||
)
|
||||
|
||||
try:
|
||||
footer = self._fdr_footer_reader.read_footer(request.flight_id)
|
||||
except FdrUnreadableError as exc:
|
||||
self._log_refusal(
|
||||
_LOG_KIND_REFUSED_FDR_UNREADABLE,
|
||||
"FDR segment scan failed",
|
||||
kv={"flight_id": flight_id_str, "fdr_unreadable_repr": repr(exc)},
|
||||
)
|
||||
raise FlightStateNotConfirmedError(
|
||||
flight_id=flight_id_str,
|
||||
not_confirmed_reason="fdr_unreadable",
|
||||
detail=repr(exc),
|
||||
) from exc
|
||||
|
||||
if footer is None:
|
||||
self._log_refusal(
|
||||
_LOG_KIND_REFUSED_FOOTER_MISSING,
|
||||
"no flight_footer record found in any FDR segment",
|
||||
kv={"flight_id": flight_id_str},
|
||||
)
|
||||
raise FlightStateNotConfirmedError(
|
||||
flight_id=flight_id_str,
|
||||
not_confirmed_reason="footer_missing",
|
||||
)
|
||||
|
||||
if not footer.clean_shutdown:
|
||||
counters_kv = _footer_counters_kv(footer)
|
||||
self._log_refusal(
|
||||
_LOG_KIND_REFUSED_UNCLEAN_SHUTDOWN,
|
||||
"flight_footer.clean_shutdown is False",
|
||||
kv={"flight_id": flight_id_str, **counters_kv},
|
||||
)
|
||||
detail = (
|
||||
f"records_dropped_overrun={footer.records_dropped_overrun}, "
|
||||
f"bytes_written={footer.bytes_written}, "
|
||||
f"records_written={footer.records_written}, "
|
||||
f"rollover_count={footer.rollover_count}"
|
||||
)
|
||||
raise FlightStateNotConfirmedError(
|
||||
flight_id=flight_id_str,
|
||||
not_confirmed_reason="unclean_shutdown",
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
self._logger.info(
|
||||
"post-landing upload confirmed",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_CONFIRMED,
|
||||
"kv": {
|
||||
"flight_id": flight_id_str,
|
||||
"flight_ended_at_iso": footer.flight_ended_at_iso,
|
||||
"records_written": footer.records_written,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
inner_request = UploadRequestCut(
|
||||
flight_id=request.flight_id,
|
||||
batch_size=request.batch_size,
|
||||
satellite_provider_url=request.satellite_provider_url,
|
||||
)
|
||||
report = self._tile_uploader.upload_pending_tiles(inner_request)
|
||||
|
||||
tiles_acked = sum(
|
||||
1 for tile in report.per_tile_status if tile.status.value == "accepted"
|
||||
)
|
||||
tiles_rejected = sum(
|
||||
1 for tile in report.per_tile_status if tile.status.value == "rejected"
|
||||
)
|
||||
self._logger.info(
|
||||
"post-landing upload complete",
|
||||
extra={
|
||||
"component": _COMPONENT,
|
||||
"kind": _LOG_KIND_COMPLETE,
|
||||
"kv": {
|
||||
"flight_id": flight_id_str,
|
||||
"outcome": report.outcome.value,
|
||||
"tiles_acked": tiles_acked,
|
||||
"tiles_rejected": tiles_rejected,
|
||||
"batch_uuid": str(report.batch_uuid),
|
||||
"public_key_fingerprint": report.public_key_fingerprint,
|
||||
"retry_count": report.retry_count,
|
||||
},
|
||||
},
|
||||
)
|
||||
return report
|
||||
|
||||
def _log_refusal(
|
||||
self, kind: str, message: str, *, kv: dict[str, object]
|
||||
) -> None:
|
||||
self._logger.error(
|
||||
message,
|
||||
extra={"component": _COMPONENT, "kind": kind, "kv": kv},
|
||||
)
|
||||
|
||||
|
||||
def _footer_counters_kv(footer: FlightFooterRecord) -> dict[str, int]:
|
||||
return {
|
||||
"records_written": footer.records_written,
|
||||
"records_dropped_overrun": footer.records_dropped_overrun,
|
||||
"bytes_written": footer.bytes_written,
|
||||
"rollover_count": footer.rollover_count,
|
||||
}
|
||||
+3
-3
@@ -30,15 +30,15 @@ from pathlib import Path, PurePosixPath
|
||||
from uuid import UUID
|
||||
|
||||
from gps_denied_onboard._types.geo import BoundingBox, LatLonAlt
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
RemoteBuildOutcome,
|
||||
RemoteBuildReport,
|
||||
SectorClassification,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.errors import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.errors import (
|
||||
BuildReportParseError,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
|
||||
SshSession,
|
||||
)
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Final
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling.ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.ssh_session import (
|
||||
SshSession,
|
||||
)
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"""Persistent ``{area_id: SectorClassification}`` store (AZ-326).
|
||||
|
||||
Atomic-write JSON file kept in the operator's home directory so a
|
||||
restart of ``operator-tool`` recovers every classification the operator
|
||||
restart of ``operator-orchestrator`` recovers every classification the operator
|
||||
ever ran ``set-sector`` against. The atomic-write pattern uses
|
||||
``tempfile.NamedTemporaryFile(dir=...) + os.replace(...)`` per AC-5;
|
||||
see :mod:`gps_denied_onboard.helpers.sha256_sidecar` for the heavier
|
||||
@@ -22,7 +22,7 @@ import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
AreaIdentifier,
|
||||
SectorClassification,
|
||||
)
|
||||
+1
-1
@@ -17,7 +17,7 @@ from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
CompanionAddress,
|
||||
)
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
"""C12 consumer-side structural cut of c11 ``TileDownloader`` (AZ-507).
|
||||
|
||||
The AZ-507 cross-component rule (see ``_docs/02_document/module-layout.md``
|
||||
line 252) forbids ``c12_operator_tooling/*.py`` from importing
|
||||
line 252) forbids ``c12_operator_orchestrator/*.py`` from importing
|
||||
``components.c11_tile_manager`` directly. The ``BuildCacheOrchestrator``
|
||||
needs the download surface to drive the F1 download phase, so we
|
||||
declare a local Protocol that mirrors the shape of c11's
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_tooling._types import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
DownloadBatchReportCut,
|
||||
DownloadRequestCut,
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""C12 consumer-side structural cut of c11 ``TileUploader`` (AZ-507).
|
||||
|
||||
The AZ-507 cross-component rule (see ``_docs/02_document/module-layout.md``)
|
||||
forbids ``c12_operator_orchestrator/*.py`` from importing
|
||||
``components.c11_tile_manager`` directly. The
|
||||
:class:`PostLandingUploadOrchestrator` needs the upload surface to drive
|
||||
the F10 post-landing upload phase, so we declare a local Protocol that
|
||||
mirrors the shape of c11's
|
||||
:class:`gps_denied_onboard.components.c11_tile_manager.interface.TileUploader.upload_pending_tiles`
|
||||
method.
|
||||
|
||||
The composition root (``runtime_root.c12_factory``'s caller — the
|
||||
suite-level runtime root) wires the concrete c11 strategy in via a thin
|
||||
adapter that maps :class:`UploadRequestCut` to c11's ``UploadRequest``
|
||||
and ``UploadBatchReport`` back to :class:`UploadBatchReportCut`. Tests
|
||||
inject a fake that returns a :class:`UploadBatchReportCut` directly, so
|
||||
they never touch c11 either.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator._types import (
|
||||
UploadBatchReportCut,
|
||||
UploadRequestCut,
|
||||
)
|
||||
|
||||
__all__ = ["TileUploaderCut"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TileUploaderCut(Protocol):
|
||||
"""Single-method consumer-side cut of c11 ``TileUploader``.
|
||||
|
||||
The orchestrator constructs a :class:`UploadRequestCut` and the
|
||||
composition-root wiring translates it into c11's real
|
||||
``UploadRequest`` (and the returned ``UploadBatchReport`` back into
|
||||
a :class:`UploadBatchReportCut`).
|
||||
"""
|
||||
|
||||
def upload_pending_tiles(self, request: UploadRequestCut) -> UploadBatchReportCut: ...
|
||||
@@ -1,21 +0,0 @@
|
||||
"""C12 `CacheBuildWorkflow` + `OperatorReLocService` Protocols.
|
||||
|
||||
See `_docs/02_document/components/13_c12_operator_tooling/`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class CacheBuildWorkflow(Protocol):
|
||||
"""Operator CLI workflow that orchestrates C11 download → C10 provisioning."""
|
||||
|
||||
def run(self, flight_id: str, output_root: Path) -> None: ...
|
||||
|
||||
|
||||
class OperatorReLocService(Protocol):
|
||||
"""Operator-side re-localization request service (GUI deferred per epic)."""
|
||||
|
||||
def request_relocalization(self, flight_id: str, hint: dict) -> None: ...
|
||||
@@ -15,7 +15,7 @@ territory). Runtime selection only.
|
||||
:class:`MatchResult` whose ``reprojection_residual_px <=
|
||||
threshold`` is passed through unchanged; ``>`` invokes the
|
||||
strategy's refinement procedure. Default 2.5 px (the AC-NEW-5 /
|
||||
R10 tunable from operator tooling).
|
||||
R10 tunable from operator orchestrator).
|
||||
|
||||
``invocation_rate_warn_threshold`` is the rolling-60 s
|
||||
invocation-rate ceiling above which a WARN log fires
|
||||
|
||||
@@ -31,7 +31,7 @@ class TilePixelHandle(ABC):
|
||||
def filesystem_path(self) -> Path:
|
||||
"""Absolute path to the JPEG file backing this handle.
|
||||
|
||||
Used only by C12 operator tooling (post-flight inspection)
|
||||
Used only by C12 operator orchestrator (post-flight inspection)
|
||||
and the C11 ``TileUploader`` post-landing copy. In-flight
|
||||
consumers MUST NOT open a second handle to the same path;
|
||||
they MUST use this :class:`TilePixelHandle`.
|
||||
|
||||
@@ -280,6 +280,22 @@ KNOWN_PAYLOAD_KEYS: Final[dict[str, frozenset[str]]] = {
|
||||
"last_rejection_reason",
|
||||
}
|
||||
),
|
||||
# AZ-330 / E-C12: emitted by the C12 OperatorReLocService on every
|
||||
# operator-driven re-loc command (AC-3.4). ``outcome`` is "sent" on
|
||||
# transport success, "failed" when the transport raised
|
||||
# ``GcsLinkError``. ``hint`` carries the FULL ReLocHint (no
|
||||
# redaction — post-flight forensics need the exact action the
|
||||
# operator took). ``failure_reason`` is populated only on
|
||||
# ``outcome="failed"``. ``ts_monotonic_ns`` is the orchestrator-side
|
||||
# ``Clock.monotonic_ns()`` reading at the moment of the call.
|
||||
"c12.reloc.requested": frozenset(
|
||||
{
|
||||
"hint",
|
||||
"outcome",
|
||||
"failure_reason",
|
||||
"ts_monotonic_ns",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
KNOWN_KINDS: Final[frozenset[str]] = frozenset(KNOWN_PAYLOAD_KEYS.keys())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Bootstrap healthcheck callable.
|
||||
|
||||
Used by both `companion-tier1` and `operator-tooling` Dockerfiles via
|
||||
Used by both `companion-tier1` and `operator-orchestrator` Dockerfiles via
|
||||
`HEALTHCHECK CMD python -m gps_denied_onboard.healthcheck`. Returns a non-zero exit
|
||||
code on any failure so Docker's healthcheck loop marks the container unhealthy.
|
||||
|
||||
|
||||
@@ -409,15 +409,15 @@ def compose_root(config: Config) -> RuntimeRoot:
|
||||
|
||||
|
||||
def compose_operator(config: Config) -> OperatorRoot:
|
||||
"""Compose the operator-tooling runtime graph (per contract v1.0.0)."""
|
||||
"""Compose the operator-orchestrator runtime graph (per contract v1.0.0)."""
|
||||
components, order = _compose(
|
||||
config,
|
||||
binary="operator-tooling",
|
||||
binary="operator-orchestrator",
|
||||
allowed_tiers=frozenset({"operator", "shared"}),
|
||||
extra_required_env=("SATELLITE_PROVIDER_URL",),
|
||||
)
|
||||
return OperatorRoot(
|
||||
binary="operator-tooling",
|
||||
binary="operator-orchestrator",
|
||||
profile=os.environ["GPS_DENIED_FC_PROFILE"],
|
||||
components=components,
|
||||
construction_order=order,
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"""C11 TileManager composition-root factories (AZ-316, AZ-317, AZ-318, AZ-319).
|
||||
"""C11 TileManager composition-root factories (AZ-316, AZ-318, AZ-319).
|
||||
|
||||
Wires the operator-side services:
|
||||
|
||||
* :func:`build_flight_state_gate` (AZ-317) — adapts an injected
|
||||
``FlightStateSource`` (typically an E-C8 FC adapter wrapper) into
|
||||
the C11 ``FlightStateGate``.
|
||||
* :func:`build_per_flight_key_manager` (AZ-318) — wires the AZ-273
|
||||
:class:`FdrClient` and the project ``Clock`` strategy into the
|
||||
ephemeral signing-key manager.
|
||||
* :func:`build_tile_uploader` (AZ-319) — composes the gate, the
|
||||
key manager, the c6 storage cuts, an :class:`httpx.Client`, and
|
||||
the :class:`C11Config` block into the production
|
||||
:class:`HttpTileUploader`.
|
||||
* :func:`build_tile_uploader` (AZ-319) — composes the key manager,
|
||||
the c6 storage cuts, an :class:`httpx.Client`, and the
|
||||
:class:`C11Config` block into the production
|
||||
:class:`HttpTileUploader`. Flight-state confirmation is the
|
||||
caller's responsibility (C12 ``PostLandingUploadOrchestrator``).
|
||||
* :func:`build_tile_downloader` (AZ-316) — composes the c6 store +
|
||||
metadata-store + budget-enforcer (wrapped in a single
|
||||
composition-root adapter that hides c6's :class:`TileMetadata`
|
||||
@@ -32,8 +30,6 @@ import httpx
|
||||
|
||||
from gps_denied_onboard.components.c11_tile_manager import (
|
||||
C11Config,
|
||||
FlightStateGate,
|
||||
FlightStateSource,
|
||||
HttpTileDownloader,
|
||||
HttpTileUploader,
|
||||
IdempotentRetryTileUploader,
|
||||
@@ -50,14 +46,12 @@ if TYPE_CHECKING:
|
||||
from gps_denied_onboard.config.schema import Config
|
||||
|
||||
__all__ = [
|
||||
"build_flight_state_gate",
|
||||
"build_per_flight_key_manager",
|
||||
"build_tile_downloader",
|
||||
"build_tile_uploader",
|
||||
]
|
||||
|
||||
|
||||
_C11_GATE_LOGGER = "c11_tile_manager.flight_state_gate"
|
||||
_C11_SIGNING_LOGGER = "c11_tile_manager.signing_key"
|
||||
_C11_SIGNING_PRODUCER_ID = "c11_tile_manager.signing_key"
|
||||
_C11_UPLOADER_LOGGER = "c11_tile_manager.tile_uploader"
|
||||
@@ -65,19 +59,6 @@ _C11_UPLOADER_PRODUCER_ID = "c11_tile_manager.tile_uploader"
|
||||
_C11_DOWNLOADER_LOGGER = "c11_tile_manager.tile_downloader"
|
||||
|
||||
|
||||
def build_flight_state_gate(*, source: FlightStateSource) -> FlightStateGate:
|
||||
"""Construct a wired :class:`FlightStateGate` (AZ-317).
|
||||
|
||||
The ``source`` argument is the consumer-side cut over E-C8's FC
|
||||
adapter; the composition root supplies a concrete adapter wrapping
|
||||
the actual C8 instance once E-C8 ships. Until then operator
|
||||
tooling tests inject a fake source that returns a fixed signal.
|
||||
"""
|
||||
|
||||
logger = get_logger(_C11_GATE_LOGGER)
|
||||
return FlightStateGate(source=source, logger=logger)
|
||||
|
||||
|
||||
def build_per_flight_key_manager(
|
||||
config: Config,
|
||||
*,
|
||||
@@ -108,7 +89,6 @@ def build_tile_uploader(
|
||||
http_client: httpx.Client,
|
||||
tile_store: Any,
|
||||
tile_metadata_store: Any,
|
||||
flight_state_gate: FlightStateGate,
|
||||
key_manager: PerFlightKeyManager,
|
||||
clock: ClockProtocol | None = None,
|
||||
fdr_client: FdrClient | None = None,
|
||||
@@ -162,7 +142,6 @@ def build_tile_uploader(
|
||||
http_client=http_client,
|
||||
tile_store=tile_store,
|
||||
tile_metadata_store=tile_metadata_store,
|
||||
flight_state_gate=flight_state_gate,
|
||||
key_manager=key_manager,
|
||||
fdr_client=fdr_client,
|
||||
logger=logger,
|
||||
@@ -235,7 +214,7 @@ def build_tile_downloader(
|
||||
if not block.service_api_key:
|
||||
raise ConfigError(
|
||||
"build_tile_downloader: C11Config.service_api_key must be "
|
||||
"set; the operator-tooling deploy MUST inject the bearer "
|
||||
"set; the operator-orchestrator deploy MUST inject the bearer "
|
||||
"token via env override"
|
||||
)
|
||||
logger = get_logger(_C11_DOWNLOADER_LOGGER)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Composition-root factories for C12 operator-tooling services.
|
||||
"""Composition-root factories for C12 operator-orchestrator services.
|
||||
|
||||
* :func:`build_flights_api_client` — AZ-489 ``FlightsApiClient`` (online +
|
||||
offline path).
|
||||
@@ -12,14 +12,14 @@
|
||||
AZ-327 / AZ-489 services. The AZ-507 cross-component cut means we
|
||||
translate c11's real ``DownloadRequest`` / ``DownloadBatchReport`` to
|
||||
the local ``DownloadRequestCut`` / ``DownloadBatchReportCut`` here.
|
||||
* :func:`build_operator_tool` — aggregator that returns the
|
||||
:class:`OperatorToolServices` dataclass the AZ-326 CLI consumes.
|
||||
* :func:`build_operator_orchestrator` — aggregator that returns the
|
||||
:class:`OperatorOrchestratorServices` dataclass the AZ-326 CLI consumes.
|
||||
|
||||
Each ``build_*`` function is intentionally tiny — there is one
|
||||
production strategy per service today and the CLI wiring just plugs
|
||||
the concrete instance into the same composition root method. Sibling
|
||||
tasks AZ-329 / AZ-330 will each add a single field to
|
||||
:class:`OperatorToolServices` without renaming or moving the
|
||||
:class:`OperatorOrchestratorServices` without renaming or moving the
|
||||
dataclass.
|
||||
"""
|
||||
|
||||
@@ -30,60 +30,80 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from gps_denied_onboard.clock import Clock
|
||||
from gps_denied_onboard.components.c12_operator_tooling.build_cache import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.build_cache import (
|
||||
BuildCacheOrchestrator,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.companion_bringup import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.companion_bringup import (
|
||||
CompanionBringup,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.file_lock import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.fdr_footer_reader import (
|
||||
LocalFdrFooterReader,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.file_lock import (
|
||||
FilelockFileLockFactory,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.flights_api import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.flights_api import (
|
||||
FlightsApiClient,
|
||||
HttpxFlightsApiClient,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.paramiko_ssh_session import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.operator_command_transport import (
|
||||
OperatorCommandTransport,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.operator_reloc_service import (
|
||||
OperatorReLocService,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.paramiko_ssh_session import (
|
||||
ParamikoSshSessionFactory,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_c10_invoker import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.post_landing_upload import (
|
||||
PostLandingUploadOrchestrator,
|
||||
)
|
||||
from gps_denied_onboard.fdr_client import FdrClient
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_c10_invoker import (
|
||||
RemoteCacheProvisionerInvoker,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.remote_sidecar_verifier import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.remote_sidecar_verifier import (
|
||||
RemoteSidecarVerifier,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.sector_classification_store import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.sector_classification_store import (
|
||||
SectorClassificationStore,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_tooling.tile_downloader_cut import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.tile_downloader_cut import (
|
||||
TileDownloaderCut,
|
||||
)
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.tile_uploader_cut import (
|
||||
TileUploaderCut,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from gps_denied_onboard.components.c12_operator_tooling.config import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12Config,
|
||||
)
|
||||
from gps_denied_onboard.config import Config
|
||||
|
||||
__all__ = [
|
||||
"OperatorToolServices",
|
||||
"OperatorOrchestratorServices",
|
||||
"build_build_cache_orchestrator",
|
||||
"build_companion_bringup",
|
||||
"build_flights_api_client",
|
||||
"build_operator_tool",
|
||||
"build_operator_reloc_service",
|
||||
"build_operator_orchestrator",
|
||||
"build_post_landing_upload_orchestrator",
|
||||
"build_sector_classification_store",
|
||||
]
|
||||
|
||||
|
||||
_C12_LOGGER_NAME = "c12_operator_tooling"
|
||||
_COMPANION_LOGGER_NAME = "c12_operator_tooling.companion_bringup"
|
||||
_BUILD_CACHE_LOGGER_NAME = "c12_operator_tooling.build_cache"
|
||||
_REMOTE_C10_LOGGER_NAME = "c12_operator_tooling.remote_c10_invoker"
|
||||
_C12_LOGGER_NAME = "c12_operator_orchestrator"
|
||||
_COMPANION_LOGGER_NAME = "c12_operator_orchestrator.companion_bringup"
|
||||
_BUILD_CACHE_LOGGER_NAME = "c12_operator_orchestrator.build_cache"
|
||||
_REMOTE_C10_LOGGER_NAME = "c12_operator_orchestrator.remote_c10_invoker"
|
||||
_POST_LANDING_LOGGER_NAME = "c12_operator_orchestrator.post_landing_upload"
|
||||
_OPERATOR_RELOC_LOGGER_NAME = "c12_operator_orchestrator.operator_reloc_service"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OperatorToolServices:
|
||||
"""Aggregated service handles the operator-tool CLI consumes (AZ-326).
|
||||
class OperatorOrchestratorServices:
|
||||
"""Aggregated service handles the operator-orchestrator CLI consumes (AZ-326).
|
||||
|
||||
AZ-326 introduced the dataclass and now owns three services
|
||||
(``flights_api_client``, ``sector_classification_store``,
|
||||
@@ -103,6 +123,8 @@ class OperatorToolServices:
|
||||
sector_classification_store: SectorClassificationStore
|
||||
companion_bringup: CompanionBringup
|
||||
build_cache_orchestrator: BuildCacheOrchestrator | None = None
|
||||
post_landing_upload_orchestrator: PostLandingUploadOrchestrator | None = None
|
||||
operator_reloc_service: OperatorReLocService | None = None
|
||||
|
||||
|
||||
def build_flights_api_client(config: Config) -> FlightsApiClient:
|
||||
@@ -162,7 +184,7 @@ def build_companion_bringup(
|
||||
def build_build_cache_orchestrator(
|
||||
config: Config,
|
||||
*,
|
||||
services: OperatorToolServices,
|
||||
services: OperatorOrchestratorServices,
|
||||
tile_downloader: TileDownloaderCut,
|
||||
clock: Clock,
|
||||
logger: logging.Logger | None = None,
|
||||
@@ -171,7 +193,7 @@ def build_build_cache_orchestrator(
|
||||
|
||||
Caller (production runtime root) is responsible for translating the
|
||||
real c11 ``TileDownloader`` to a :class:`TileDownloaderCut` adapter
|
||||
here — ``c12_operator_tooling`` cannot import c11 directly per
|
||||
here — ``c12_operator_orchestrator`` cannot import c11 directly per
|
||||
AZ-507. The lockfile factory + remote-C10 invoker + SSH factory are
|
||||
constructed in-place; the SSH factory MUST be the same instance as
|
||||
the one wired into ``services.companion_bringup`` (single
|
||||
@@ -207,51 +229,142 @@ def build_build_cache_orchestrator(
|
||||
)
|
||||
|
||||
|
||||
def build_operator_tool(
|
||||
def build_post_landing_upload_orchestrator(
|
||||
config: Config,
|
||||
*,
|
||||
tile_uploader: TileUploaderCut,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> PostLandingUploadOrchestrator:
|
||||
"""Build the AZ-329 :class:`PostLandingUploadOrchestrator` from config + a c11 uploader cut.
|
||||
|
||||
Caller (production suite-level runtime root) is responsible for
|
||||
translating the real c11 ``HttpTileUploader`` to a
|
||||
:class:`TileUploaderCut` adapter here — ``c12_operator_orchestrator``
|
||||
cannot import c11 directly per AZ-507. The adapter maps
|
||||
:class:`UploadRequestCut` ↔ c11's ``UploadRequest`` and
|
||||
:class:`UploadBatchReportCut` ↔ c11's ``UploadBatchReport``.
|
||||
"""
|
||||
c12_config = _resolve_c12_config(config)
|
||||
return PostLandingUploadOrchestrator(
|
||||
tile_uploader=tile_uploader,
|
||||
fdr_footer_reader=LocalFdrFooterReader(c12_config.post_landing.fdr_root),
|
||||
logger=logger or logging.getLogger(_POST_LANDING_LOGGER_NAME),
|
||||
config=c12_config.post_landing,
|
||||
)
|
||||
|
||||
|
||||
def build_operator_reloc_service(
|
||||
config: Config,
|
||||
*,
|
||||
transport: OperatorCommandTransport,
|
||||
fdr_client: FdrClient,
|
||||
clock: Clock,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> OperatorReLocService:
|
||||
"""Build the AZ-330 :class:`OperatorReLocService`.
|
||||
|
||||
The :class:`OperatorCommandTransport` (E-C8's pymavlink-backed
|
||||
``MavlinkOperatorCommandTransport`` in production; a
|
||||
``LoggingOnlyOperatorCommandTransport`` in dev environments without
|
||||
a companion) is resolved by the suite-level runtime root and
|
||||
injected here — c12 cannot import c8 directly per AZ-507. The
|
||||
``fdr_client`` is the shared AZ-273 instance keyed to producer
|
||||
``c12_operator_orchestrator`` so the post-flight FDR captures the
|
||||
operator's re-loc actions chronologically alongside other onboard
|
||||
records.
|
||||
"""
|
||||
_ = config # reserved for future composition-time tuning
|
||||
return OperatorReLocService(
|
||||
transport=transport,
|
||||
fdr_client=fdr_client,
|
||||
logger=logger or logging.getLogger(_OPERATOR_RELOC_LOGGER_NAME),
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
|
||||
def build_operator_orchestrator(
|
||||
config: Config,
|
||||
*,
|
||||
tile_downloader: TileDownloaderCut | None = None,
|
||||
tile_uploader: TileUploaderCut | None = None,
|
||||
clock: Clock | None = None,
|
||||
) -> OperatorToolServices:
|
||||
"""Aggregate the AZ-326 / AZ-327 / AZ-328 / AZ-489 service handles.
|
||||
operator_command_transport: OperatorCommandTransport | None = None,
|
||||
fdr_client: FdrClient | None = None,
|
||||
) -> OperatorOrchestratorServices:
|
||||
"""Aggregate the AZ-326 / AZ-327 / AZ-328 / AZ-329 / AZ-330 / AZ-489 service handles.
|
||||
|
||||
``tile_downloader`` and ``clock`` are optional — without them, the
|
||||
``build_cache_orchestrator`` field is left as ``None`` and the CLI's
|
||||
``build-cache`` subcommand short-circuits gracefully. Production
|
||||
wiring (the suite-level runtime root) supplies real instances.
|
||||
Optional collaborators (each gates one service field):
|
||||
|
||||
* ``tile_downloader`` + ``clock`` → ``build_cache_orchestrator``
|
||||
(AZ-328); CLI ``build-cache`` short-circuits when missing.
|
||||
* ``tile_uploader`` → ``post_landing_upload_orchestrator`` (AZ-329);
|
||||
CLI ``upload-pending`` short-circuits when missing.
|
||||
* ``operator_command_transport`` + ``fdr_client`` + ``clock`` →
|
||||
``operator_reloc_service`` (AZ-330); CLI ``reloc-confirm``
|
||||
short-circuits when missing. AC-10: lazy construction — when the
|
||||
transport is not supplied, no transport instance is created
|
||||
(pymavlink stays unimported).
|
||||
"""
|
||||
base = OperatorToolServices(
|
||||
flights_api_client=build_flights_api_client(config),
|
||||
sector_classification_store=build_sector_classification_store(config),
|
||||
companion_bringup=build_companion_bringup(config),
|
||||
flights_api_client = build_flights_api_client(config)
|
||||
sector_store = build_sector_classification_store(config)
|
||||
companion_bringup = build_companion_bringup(config)
|
||||
|
||||
base_for_build_cache = OperatorOrchestratorServices(
|
||||
flights_api_client=flights_api_client,
|
||||
sector_classification_store=sector_store,
|
||||
companion_bringup=companion_bringup,
|
||||
)
|
||||
if tile_downloader is None or clock is None:
|
||||
return base
|
||||
orchestrator = build_build_cache_orchestrator(
|
||||
config,
|
||||
services=base,
|
||||
tile_downloader=tile_downloader,
|
||||
clock=clock,
|
||||
)
|
||||
return OperatorToolServices(
|
||||
flights_api_client=base.flights_api_client,
|
||||
sector_classification_store=base.sector_classification_store,
|
||||
companion_bringup=base.companion_bringup,
|
||||
build_cache_orchestrator=orchestrator,
|
||||
|
||||
build_cache_orchestrator: BuildCacheOrchestrator | None = None
|
||||
if tile_downloader is not None and clock is not None:
|
||||
build_cache_orchestrator = build_build_cache_orchestrator(
|
||||
config,
|
||||
services=base_for_build_cache,
|
||||
tile_downloader=tile_downloader,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
post_landing_orchestrator: PostLandingUploadOrchestrator | None = None
|
||||
if tile_uploader is not None:
|
||||
post_landing_orchestrator = build_post_landing_upload_orchestrator(
|
||||
config,
|
||||
tile_uploader=tile_uploader,
|
||||
)
|
||||
|
||||
operator_reloc_service: OperatorReLocService | None = None
|
||||
if (
|
||||
operator_command_transport is not None
|
||||
and fdr_client is not None
|
||||
and clock is not None
|
||||
):
|
||||
operator_reloc_service = build_operator_reloc_service(
|
||||
config,
|
||||
transport=operator_command_transport,
|
||||
fdr_client=fdr_client,
|
||||
clock=clock,
|
||||
)
|
||||
|
||||
return OperatorOrchestratorServices(
|
||||
flights_api_client=flights_api_client,
|
||||
sector_classification_store=sector_store,
|
||||
companion_bringup=companion_bringup,
|
||||
build_cache_orchestrator=build_cache_orchestrator,
|
||||
post_landing_upload_orchestrator=post_landing_orchestrator,
|
||||
operator_reloc_service=operator_reloc_service,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_c12_config(config: Config) -> C12Config:
|
||||
from gps_denied_onboard.components.c12_operator_tooling.config import (
|
||||
from gps_denied_onboard.components.c12_operator_orchestrator.config import (
|
||||
C12Config,
|
||||
)
|
||||
|
||||
block = config.components.get("c12_operator_tooling")
|
||||
block = config.components.get("c12_operator_orchestrator")
|
||||
if block is None:
|
||||
return C12Config()
|
||||
if not isinstance(block, C12Config):
|
||||
raise TypeError(
|
||||
"config.components['c12_operator_tooling'] must be a C12Config; got "
|
||||
"config.components['c12_operator_orchestrator'] must be a C12Config; got "
|
||||
f"{type(block).__name__}"
|
||||
)
|
||||
return block
|
||||
|
||||
Reference in New Issue
Block a user