feat(k1): integrate durable evidence with acquisition lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 17:51:05 +03:00
parent 656f0c524d
commit 007ff9fff2
6 changed files with 1638 additions and 242 deletions
+357
View File
@@ -0,0 +1,357 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from fastapi import APIRouter, HTTPException
from fastapi.routing import APIRoute
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.sessions import (
SessionRecordingMaterializer,
SessionRecordingPreparationManager,
SessionStore,
)
from k1link.web import app as app_module
from k1link.web.session_api import build_session_router
def _make_completed_session(root: Path, session_id: str) -> Path:
capture = root / session_id / "captures" / "mqtt_live"
capture.mkdir(parents=True)
topics = (
"lixel/application/report/lio_pcl",
"lixel/application/report/lio_pose",
)
raw = bytearray(RAW_MAGIC)
metadata: list[dict[str, object]] = []
for sequence, topic in enumerate(topics, start=1):
encoded_topic = topic.encode("utf-8")
payload = b"fixture"
frame_offset = len(raw)
raw.extend(FRAME_HEADER.pack(len(encoded_topic), len(payload)))
raw.extend(encoded_topic)
raw.extend(payload)
metadata.append(
{
"record_type": "message",
"sequence": sequence,
"received_at_epoch_ns": 1_000_000_000 + sequence,
"received_monotonic_ns": 2_000_000_000 + sequence,
"topic": topic,
"payload_bytes": len(payload),
"raw_frame_offset": frame_offset,
"raw_payload_offset": frame_offset + FRAME_HEADER.size + len(encoded_topic),
"raw_frame_bytes": FRAME_HEADER.size + len(encoded_topic) + len(payload),
}
)
(capture / "mqtt.raw.k1mqtt").write_bytes(raw)
metadata_path = capture / "mqtt.metadata.jsonl"
metadata_path.write_text(
"".join(json.dumps(record) + "\n" for record in metadata),
encoding="utf-8",
)
(capture / "mqtt.summary.json").write_text(
json.dumps(
{
"created_at_utc": "2026-07-16T20:56:32.699Z",
"completed_at_utc": "2026-07-16T20:57:02.699Z",
"capture_elapsed_seconds": 30.0,
"stop_reason": "external_stop",
"error": None,
"message_count": 2,
"raw_bytes": len(raw),
"topic_counts": {topic: 1 for topic in topics},
"artifact_hashes": {
"raw_sha256": hashlib.sha256(raw).hexdigest(),
"metadata_jsonl_sha256": hashlib.sha256(metadata_path.read_bytes()).hexdigest(),
},
}
),
encoding="utf-8",
)
return capture.parents[1]
def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
return next(
route.endpoint
for route in router.routes
if isinstance(route, APIRoute) and route.path == path and method in route.methods
)
def test_list_refresh_discovers_new_completed_session_without_server_restart(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
store = SessionStore(repository, data_dir=tmp_path / "data")
refresh_calls = 0
def refresh() -> tuple[str, ...]:
nonlocal refresh_calls
refresh_calls += 1
return store.import_legacy_viewer_live(sessions)
router = build_session_router(store, catalog_refresher=refresh)
list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")
assert list_route(limit=20, cursor=None) == {"items": []}
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
listing = list_route(limit=20, cursor=None)
assert refresh_calls == 2
assert [item["id"] for item in listing["items"]] == [session.name]
def test_detail_and_replay_refresh_catalog_before_lookup(tmp_path: Path) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
refresh_calls = 0
def refresh() -> tuple[str, ...]:
nonlocal refresh_calls
refresh_calls += 1
return store.import_legacy_viewer_live(sessions)
router = build_session_router(store, catalog_refresher=refresh)
detail_route = _endpoint(
router,
"/api/v1/observation-sessions/{session_id}",
"GET",
)
replay_route = _endpoint(
router,
"/api/v1/observation-sessions/{session_id}/replay",
"POST",
)
detail = detail_route(session_id=session.name)
replay = asyncio.run(replay_route(session_id=session.name, request=None))
assert detail["session_id"] == session.name
assert replay["launch"]["session_id"] == session.name
assert refresh_calls == 2
def test_catalog_refresh_failure_is_a_stable_service_error(tmp_path: Path) -> None:
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
def fail_refresh() -> None:
raise OSError("private local path must not escape")
router = build_session_router(store, catalog_refresher=fail_refresh)
list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")
with pytest.raises(HTTPException) as error:
list_route(limit=20, cursor=None)
assert error.value.status_code == 503
assert "private local path" not in str(error.value.detail)
def test_startup_warmup_enqueues_every_finalized_replayable_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
def exporter(source: Path, destination: Path) -> dict[str, object]:
payload = b"startup-prepared-recording"
destination.write_bytes(payload)
return {
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
"rrd_bytes": len(payload),
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1,
}
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
)
monkeypatch.setattr(app_module, "session_store", store)
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
try:
enqueued = app_module.enqueue_replayable_recordings()
deadline = time.monotonic() + 2
snapshot = manager.status(session.name)
while snapshot is not None and snapshot.state != "ready" and time.monotonic() < deadline:
time.sleep(0.005)
snapshot = manager.status(session.name)
assert enqueued == (session.name,)
assert snapshot is not None
assert snapshot.state == "ready"
assert snapshot.recording is not None
finally:
manager.close()
def test_reconciliation_skips_one_stale_session_and_prepares_later_valid_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
good = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
stale = _make_completed_session(sessions, "20260716T205633Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
(stale / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt").unlink()
def exporter(source: Path, destination: Path) -> dict[str, object]:
payload = b"prepared-after-stale-row"
destination.write_bytes(payload)
return {
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
"rrd_bytes": len(payload),
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1,
}
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
)
monkeypatch.setattr(app_module, "session_store", store)
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
try:
enqueued = app_module.enqueue_replayable_recordings()
deadline = time.monotonic() + 2
snapshot = manager.status(good.name)
while snapshot is not None and snapshot.state != "ready" and time.monotonic() < deadline:
time.sleep(0.005)
snapshot = manager.status(good.name)
assert enqueued == (good.name,)
assert manager.status(stale.name) is None
assert snapshot is not None and snapshot.state == "ready"
finally:
manager.close()
def test_reconciler_requeues_only_a_restart_interrupted_job(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
started = threading.Event()
release = threading.Event()
calls = 0
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
if calls == 1:
started.set()
assert release.wait(timeout=2)
payload = f"restart-{calls}".encode()
destination.write_bytes(payload)
return {
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
"rrd_bytes": len(payload),
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1,
}
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
)
monkeypatch.setattr(app_module, "session_store", store)
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
try:
app_module.enqueue_replayable_recordings()
assert started.wait(timeout=1)
manager.close(timeout=0.001)
manager.start()
release.set()
deadline = time.monotonic() + 2
snapshot = manager.status(session.name)
while (
snapshot is None or snapshot.state != "cancelled"
) and time.monotonic() < deadline:
time.sleep(0.005)
snapshot = manager.status(session.name)
assert snapshot is not None and snapshot.state == "cancelled"
interrupted_id = snapshot.preparation_id
app_module.enqueue_replayable_recordings()
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
snapshot = manager.status(session.name)
if snapshot is not None and snapshot.state == "ready":
break
time.sleep(0.005)
assert snapshot is not None and snapshot.state == "ready"
assert snapshot.preparation_id != interrupted_id
assert calls == 2
finally:
release.set()
manager.close()
def test_reconciler_does_not_loop_retry_a_genuine_failed_job(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
calls = 0
def exporter(_source: Path, _destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
raise OSError("genuine export failure")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
)
monkeypatch.setattr(app_module, "session_store", store)
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
try:
app_module.enqueue_replayable_recordings()
deadline = time.monotonic() + 2
snapshot = manager.status(session.name)
while (
snapshot is None or snapshot.state != "failed"
) and time.monotonic() < deadline:
time.sleep(0.005)
snapshot = manager.status(session.name)
assert snapshot is not None and snapshot.state == "failed"
failed_id = snapshot.preparation_id
app_module.enqueue_replayable_recordings()
time.sleep(0.05)
unchanged = manager.status(session.name)
assert unchanged is not None
assert unchanged.preparation_id == failed_id
assert unchanged.state == "failed"
assert calls == 1
finally:
manager.close()
+344 -3
View File
@@ -1,12 +1,15 @@
from __future__ import annotations
import hashlib
import json
import sys
import time
from io import BytesIO
from pathlib import Path
import pytest
import k1link.web.xgrids_k1_camera as camera_module
from k1link.web.xgrids_k1_camera import (
CAMERA_MEDIA_TYPE,
XgridsK1CameraGateway,
@@ -40,6 +43,63 @@ def _fake_ffmpeg(tmp_path: Path) -> Path:
return executable
def _burst_ffmpeg(tmp_path: Path) -> Path:
executable = tmp_path / "burst-ffmpeg"
executable.write_text(
f"#!{sys.executable}\n"
"import sys, time\n"
"def box(kind, payload=b''):\n"
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
"sys.stdout.buffer.flush()\n"
"time.sleep(0.2)\n"
"for index in range(10):\n"
" sys.stdout.buffer.write(box(b'moof') + box(b'mdat', bytes([index])))\n"
" sys.stdout.buffer.flush()\n"
" time.sleep(0.01)\n"
"time.sleep(10)\n",
encoding="utf-8",
)
executable.chmod(0o700)
return executable
def _buffered_tail_ffmpeg(tmp_path: Path, sentinel: Path, *, fragments: int) -> Path:
executable = tmp_path / "buffered-tail-ffmpeg"
executable.write_text(
f"#!{sys.executable}\n"
"import pathlib, sys, time\n"
"def box(kind, payload=b''):\n"
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
f"for index in range({fragments}):\n"
" sys.stdout.buffer.write(box(b'moof') + box(b'mdat', index.to_bytes(2, 'big')))\n"
"sys.stdout.buffer.flush()\n"
f"pathlib.Path({str(sentinel)!r}).write_text('ready')\n"
"time.sleep(10)\n",
encoding="utf-8",
)
executable.chmod(0o700)
return executable
def _incomplete_tail_ffmpeg(tmp_path: Path, sentinel: Path) -> Path:
executable = tmp_path / "incomplete-tail-ffmpeg"
executable.write_text(
f"#!{sys.executable}\n"
"import pathlib, sys, time\n"
"def box(kind, payload=b''):\n"
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov') + box(b'moof'))\n"
"sys.stdout.buffer.flush()\n"
f"pathlib.Path({str(sentinel)!r}).write_text('ready')\n"
"time.sleep(10)\n",
encoding="utf-8",
)
executable.chmod(0o700)
return executable
def _gateway(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@@ -48,6 +108,16 @@ def _gateway(
return XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
def _wait_until(predicate: object, *, timeout: float = 3.0) -> None:
assert callable(predicate)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.01)
raise AssertionError("condition was not reached before timeout")
def test_camera_selection_is_exclusive_and_hides_device_transport(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@@ -90,9 +160,7 @@ def test_camera_ffmpeg_command_is_allowlisted_copy_remux() -> None:
)
assert argv[0] == "/trusted/ffmpeg"
assert argv[argv.index("-i") + 1] == (
"rtsp://10.0.0.24:8554/live/chn_left_main"
)
assert argv[argv.index("-i") + 1] == ("rtsp://10.0.0.24:8554/live/chn_left_main")
assert argv[argv.index("-c:v") + 1] == "copy"
assert argv[argv.index("-allowed_media_types") + 1] == "video"
assert argv[argv.index("-flush_packets") + 1] == "1"
@@ -145,6 +213,279 @@ def test_gateway_emits_init_and_complete_media_segments_without_transcoding(
gateway.close()
def test_acquisition_records_without_browser_and_source_switch_seals_epochs(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gateway = _gateway(tmp_path, monkeypatch)
session = tmp_path / "sessions" / "camera-acquisition"
try:
left = gateway.select("sensor.camera.left", "192.168.1.20")
with pytest.raises(ValueError, match="does not exist"):
gateway.start_recording(session)
assert session.exists() is False
session.mkdir(parents=True)
gateway.start_recording(session)
left_epoch = session / "media" / "sensor.camera.left" / f"epoch-{left['generation']}"
# No open_delivery/WebSocket exists: acquisition ownership alone starts
# FFmpeg and commits the init segment before any preview consumer.
_wait_until(
lambda: (
(left_epoch / "init.mp4").is_file()
and len(list((left_epoch / "segments").glob("*.m4s"))) == 1
)
)
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
right = gateway.select("sensor.camera.right", "192.168.1.20")
right_epoch = session / "media" / "sensor.camera.right" / f"epoch-{right['generation']}"
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
_wait_until(lambda: len(list((right_epoch / "segments").glob("*.m4s"))) == 1)
lease = gateway.open_delivery(right["generation"])
init_segment = lease.segments.get(timeout=1)
assert init_segment is not None and init_segment[0] == "init"
gateway.release_delivery(lease, client_closed=True)
# Browser disposal is not producer disposal while recording is active.
assert lease.process.poll() is None
assert gateway.snapshot()["recording"]["active"] is True
assert gateway.snapshot()["phase"] == "streaming"
stopped = gateway.stop_recording(status="complete")
assert stopped["recording"]["active"] is False
assert stopped["recording"]["completed_epochs"] == 2
summaries = []
for epoch in (left_epoch, right_epoch):
init = (epoch / "init.mp4").read_bytes()
entries = [
json.loads(line)
for line in (epoch / "index.jsonl").read_text(encoding="utf-8").splitlines()
]
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
summaries.append(summary)
assert [entry["kind"] for entry in entries] == ["media"]
assert all(
entry["length"] == len((epoch / entry["path"]).read_bytes())
and hashlib.sha256((epoch / entry["path"]).read_bytes()).hexdigest()
== entry["sha256"]
for entry in entries
)
assert summary["status"] == "complete"
assert summary["segment_count"] == 1
assert summary["entry_count"] == 1
assert summary["valid_bytes"] == len(init) + sum(
entry["length"] for entry in entries
)
assert summaries[0]["failure_code"] == "source-switch"
assert summaries[1]["failure_code"] is None
serialized = json.dumps(stopped)
assert "192.168.1.20" not in serialized
assert "rtsp://" not in serialized
finally:
gateway.close()
def test_camera_storage_open_failure_is_loud_and_never_starts_ffmpeg(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gateway = _gateway(tmp_path, monkeypatch)
session = tmp_path / "session"
session.mkdir()
popen_called = False
class FailingArchive:
def __init__(self, *_: object, **__: object) -> None:
raise camera_module.CameraArchiveError("synthetic storage failure")
def forbidden_popen(*_: object, **__: object) -> object:
nonlocal popen_called
popen_called = True
raise AssertionError("FFmpeg must not start without durable storage")
monkeypatch.setattr(camera_module, "CameraArchiveWriter", FailingArchive)
monkeypatch.setattr(camera_module.subprocess, "Popen", forbidden_popen)
try:
gateway.select("sensor.camera.left", "192.168.1.20")
with pytest.raises(RuntimeError, match="хранилище"):
gateway.start_recording(session)
state = gateway.snapshot()
assert popen_called is False
assert state["phase"] == "error"
assert state["error"]["code"] == "camera-storage-failed"
assert "192.168.1.20" not in json.dumps(state)
finally:
gateway.close()
def test_camera_storage_append_failure_fails_producer_and_epoch_loudly(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
original_append = camera_module.CameraArchiveWriter.append
def failing_append(
writer: object,
kind: object,
payload: bytes,
**timestamps: object,
) -> dict[str, object]:
if kind == "media":
raise camera_module.CameraArchiveError("synthetic media commit failure")
return original_append(writer, kind, payload, **timestamps) # type: ignore[arg-type]
monkeypatch.setattr(camera_module.CameraArchiveWriter, "append", failing_append)
gateway = _gateway(tmp_path, monkeypatch)
session = tmp_path / "session"
session.mkdir()
try:
selected = gateway.select("sensor.camera.left", "192.168.1.20")
gateway.start_recording(session)
_wait_until(lambda: gateway.snapshot()["phase"] == "error")
state = gateway.snapshot()
assert state["error"]["code"] == "camera-storage-failed"
summary_path = (
session
/ "media"
/ "sensor.camera.left"
/ f"epoch-{selected['generation']}"
/ "summary.json"
)
_wait_until(
lambda: (
summary_path.is_file()
and json.loads(summary_path.read_text(encoding="utf-8"))["status"]
== "failed"
)
)
summary = json.loads(summary_path.read_text(encoding="utf-8"))
assert summary["status"] == "failed"
assert summary["failure_code"] == "camera-storage-failed"
assert "192.168.1.20" not in json.dumps(state)
finally:
gateway.close()
def test_slow_browser_is_dropped_without_stopping_archive_producer(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("MISSIONCORE_FFMPEG_BINARY", str(_burst_ffmpeg(tmp_path)))
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
session = tmp_path / "session"
session.mkdir()
try:
selected = gateway.select("sensor.camera.left", "192.168.1.20")
gateway.start_recording(session)
lease = gateway.open_delivery(selected["generation"])
# Deliberately never drain the bounded queue.
_wait_until(lambda: lease.failure_code == "consumer-too-slow")
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
assert lease.process.poll() is None
assert gateway.snapshot()["recording"]["active"] is True
gateway.release_delivery(lease, client_closed=False)
epoch = (
session
/ "media"
/ "sensor.camera.left"
/ f"epoch-{selected['generation']}"
)
_wait_until(
lambda: len(list((epoch / "segments").glob("*.m4s"))) == 10,
)
gateway.stop_recording(status="complete")
summary_path = epoch / "summary.json"
summary = json.loads(summary_path.read_text(encoding="utf-8"))
assert summary["status"] == "complete"
assert summary["segment_count"] == 10
assert summary["media_segment_count"] == 10
finally:
gateway.close()
def test_clean_stop_drains_ffmpeg_stdout_before_sealing_archive(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
fragment_count = 100
sentinel = tmp_path / "ffmpeg-wrote-buffered-tail"
monkeypatch.setenv(
"MISSIONCORE_FFMPEG_BINARY",
str(_buffered_tail_ffmpeg(tmp_path, sentinel, fragments=fragment_count)),
)
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
session = tmp_path / "session"
session.mkdir()
try:
selected = gateway.select("sensor.camera.left", "192.168.1.20")
gateway.start_recording(session)
_wait_until(sentinel.is_file)
gateway.stop_recording(status="complete")
epoch = (
session
/ "media"
/ "sensor.camera.left"
/ f"epoch-{selected['generation']}"
)
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
assert summary["status"] == "complete"
assert summary["segment_count"] == fragment_count
assert len(list((epoch / "segments").glob("*.m4s"))) == fragment_count
assert len((epoch / "index.jsonl").read_text(encoding="utf-8").splitlines()) == (
fragment_count
)
finally:
gateway.close()
def test_clean_stop_marks_incomplete_fragment_tail_interrupted(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
sentinel = tmp_path / "ffmpeg-wrote-incomplete-tail"
monkeypatch.setenv(
"MISSIONCORE_FFMPEG_BINARY",
str(_incomplete_tail_ffmpeg(tmp_path, sentinel)),
)
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
session = tmp_path / "session"
session.mkdir()
try:
selected = gateway.select("sensor.camera.left", "192.168.1.20")
gateway.start_recording(session)
_wait_until(sentinel.is_file)
gateway.stop_recording(status="complete")
summary_path = (
session
/ "media"
/ "sensor.camera.left"
/ f"epoch-{selected['generation']}"
/ "summary.json"
)
summary = json.loads(summary_path.read_text(encoding="utf-8"))
assert summary["status"] == "interrupted"
assert summary["failure_code"] == "incomplete-fmp4-fragment"
assert summary["segment_count"] == 0
state = gateway.snapshot()
assert state["phase"] == "error"
assert state["error"]["code"] == "incomplete-fmp4-fragment"
finally:
gateway.close()
def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,