Files
gps-denied-onboard/src/gps_denied_onboard/replay_api/storage.py
T
Oleksandr Bezdieniezhnykh 1d18e25cf4 [AZ-959] replay_api: POST /replay (video,csv) + /static/example-csv
Extend the AZ-701 replay_api POST /replay endpoint so AZ-897 (now
in ../ui repo) can drive the AZ-894 CSV-replay path. The endpoint
keeps full back-compat for tlog clients and adds:

- (video, tlog) OR (video, csv) multipart with strict XOR enforced
  at the API boundary (AC-2 / AC-3 → 400 multipart_missing_field)
- validate_csv_kind: rejects malformed CSV schema at boundary by
  scanning the header line for AZ-896 required tokens; messages
  point at csv_replay_format.md (AC-4)
- ReplayInputs DTO: tlog_path / csv_path are now Path | None with
  XOR re-enforced in __post_init__ for internal callers
- JobStorage reserves both input.tlog and input.csv paths; handler
  writes exactly one
- SubprocessReplayRunner.run dispatches --imu vs --tlog argv (AC-1)
- _maybe_render_report dispatches load_csv_ground_truth vs
  load_tlog_ground_truth; CsvGpsFix and TlogGpsFix have
  field-compatible shapes for the GroundTruthRow adapter (AC-6)
- GET /static/example-csv serves the AZ-896 reference CSV; honours
  REPLAY_API_EXAMPLE_CSV_PATH env, falls back to source-checkout
  layout, returns 503 with example_csv_unavailable when neither
  resolves to a readable file. No auth required (AC-5)

Tests: 27/27 unit tests green:
- 18 pre-existing tlog-path tests unchanged (AC-7)
- 9 new tests covering ACs 1-6 + validate_csv_kind isolation

Deferred (NOT silently fixed; reported to user as end-of-turn
notes for scope discipline):

- gps-denied-render-map only consumes binary tlog truth today, so
  CSV-path jobs return map_html_url=None. Extending render-map to
  dispatch on truth-file extension is AZ-700 follow-up territory.
- ReportContext.tlog_path field is now overloaded as the
  "ground-truth source path"; the rendered report still labels
  the line "Tlog: <csv_path>" which is cosmetically misleading
  for CSV runs. Field rename + label fix is AZ-699 follow-up.

Bookkeeping: AZ-959 spec moved todo/ → done/, dep-table preamble
fifth bump documents what landed + what's deferred, state.md
records batch 5 complete and what comes next.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 12:45:25 +03:00

98 lines
2.9 KiB
Python

"""AZ-701 — per-job temp-file lifecycle.
One ``StorageRoot`` rooted at ``REPLAY_API_STORAGE_ROOT``.
Each job allocates a subdirectory ``<root>/<job_id>/`` containing
the uploaded ``tlog`` + ``video`` + ``calibration`` plus the
estimator's outputs (``emissions.jsonl``, the AZ-699 report, the
AZ-700 map).
The directory is deleted on job completion (``release_job``) and on
service shutdown (``cleanup_all``). The service deliberately does
NOT keep finished-job artefacts forever — invariant 2 in the
contract.
"""
from __future__ import annotations
import logging
import shutil
from dataclasses import dataclass
from pathlib import Path
__all__ = ["JobStorage", "StorageRoot"]
_LOGGER = logging.getLogger("gps_denied_onboard.replay_api.storage")
@dataclass(frozen=True, slots=True)
class JobStorage:
"""The per-job paths the handler hands to the runner.
Both ``tlog_path`` and ``csv_path`` are reserved on disk; the
handler writes to exactly one and leaves the other unused. The
``ReplayInputs`` DTO carries ``None`` for the branch that wasn't
written so downstream consumers know which clock source applies.
"""
root: Path
tlog_path: Path
csv_path: Path
video_path: Path
calibration_path: Path
output_dir: Path
class StorageRoot:
"""Parent of per-job storage directories.
The class is intentionally thin — the registry calls
``allocate_job`` at submit-time and ``release_job`` at terminal
transitions; nothing else owns mutation rights.
"""
def __init__(self, root: Path) -> None:
self._root = root
self._root.mkdir(parents=True, exist_ok=True)
@property
def root(self) -> Path:
return self._root
def allocate_job(self, job_id: str) -> JobStorage:
job_root = self._root / job_id
job_root.mkdir(parents=True, exist_ok=False)
output_dir = job_root / "output"
output_dir.mkdir(parents=True, exist_ok=True)
return JobStorage(
root=job_root,
tlog_path=job_root / "input.tlog",
csv_path=job_root / "input.csv",
video_path=job_root / "input.mp4",
calibration_path=job_root / "calibration.json",
output_dir=output_dir,
)
def release_job(self, job_id: str) -> None:
target = self._root / job_id
if not target.exists():
return
try:
shutil.rmtree(target)
except OSError as exc:
_LOGGER.warning(
"failed to delete per-job storage %s: %s", target, exc
)
def cleanup_all(self) -> None:
for child in self._root.iterdir():
if child.is_dir():
try:
shutil.rmtree(child)
except OSError as exc:
_LOGGER.warning(
"failed to delete per-job storage %s: %s",
child,
exc,
)