feat(sessions): add durable observation archive and replay API
This commit is contained in:
@@ -0,0 +1,573 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC, iter_capture_frames
|
||||
from k1link.sessions import (
|
||||
LayoutConflictError,
|
||||
SessionIntegrityError,
|
||||
SessionNotFoundError,
|
||||
SessionStore,
|
||||
resolve_missioncore_evidence_dir,
|
||||
)
|
||||
|
||||
|
||||
def make_legacy_session(
|
||||
sessions_root: Path,
|
||||
session_id: str,
|
||||
*,
|
||||
created_at: str = "2026-07-16T20:56:32.699Z",
|
||||
) -> Path:
|
||||
session = sessions_root / session_id
|
||||
capture = session / "captures" / "mqtt_live"
|
||||
capture.mkdir(parents=True)
|
||||
frames = [
|
||||
("lixel/application/report/lio_pcl", b"point-frame"),
|
||||
("lixel/application/report/lio_pose", b"pose-frame"),
|
||||
]
|
||||
raw = bytearray(RAW_MAGIC)
|
||||
metadata: list[dict[str, object]] = []
|
||||
for sequence, (topic, payload) in enumerate(frames, start=1):
|
||||
topic_bytes = topic.encode()
|
||||
frame_offset = len(raw)
|
||||
raw.extend(FRAME_HEADER.pack(len(topic_bytes), len(payload)))
|
||||
raw.extend(topic_bytes)
|
||||
raw.extend(payload)
|
||||
metadata.append(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"record_type": "message",
|
||||
"sequence": sequence,
|
||||
"received_at_utc": f"2026-07-16T20:56:{31 + sequence:02d}.699Z",
|
||||
"received_at_epoch_ns": 1_784_235_391_699_000_000 + sequence * 1_000_000_000,
|
||||
"received_monotonic_ns": 9_000_000_000 + sequence * 1_000_000_000,
|
||||
"topic": topic,
|
||||
"payload_bytes": len(payload),
|
||||
"raw_frame_offset": frame_offset,
|
||||
"raw_payload_offset": frame_offset + FRAME_HEADER.size + len(topic_bytes),
|
||||
"raw_frame_bytes": FRAME_HEADER.size + len(topic_bytes) + len(payload),
|
||||
}
|
||||
)
|
||||
raw_path = capture / "mqtt.raw.k1mqtt"
|
||||
raw_path.write_bytes(raw)
|
||||
raw_hash = hashlib.sha256(raw).hexdigest()
|
||||
metadata_path = capture / "mqtt.metadata.jsonl"
|
||||
metadata_path.write_text(
|
||||
"".join(json.dumps(record, separators=(",", ":")) + "\n" for record in metadata),
|
||||
encoding="utf-8",
|
||||
)
|
||||
metadata_hash = hashlib.sha256(metadata_path.read_bytes()).hexdigest()
|
||||
(capture / "mqtt.summary.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"created_at_utc": created_at,
|
||||
"completed_at_utc": "2026-07-16T21:20:43.018Z",
|
||||
"capture_elapsed_seconds": 1440.1,
|
||||
"stop_reason": "external_stop",
|
||||
"error": None,
|
||||
"message_count": 2,
|
||||
"raw_bytes": len(raw),
|
||||
"payload_bytes": sum(len(payload) for _, payload in frames),
|
||||
"topic_counts": {
|
||||
"lixel/application/report/lio_pcl": 1,
|
||||
"lixel/application/report/lio_pose": 1,
|
||||
},
|
||||
"artifact_hashes": {
|
||||
"raw_sha256": raw_hash,
|
||||
"metadata_jsonl_sha256": metadata_hash,
|
||||
},
|
||||
# This is deliberately sensitive and must not enter API DTOs.
|
||||
"target_ipv4": "192.168.99.77",
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(session / "manifest.redacted.json").write_text(
|
||||
json.dumps({"started_at_utc": created_at, "target": "redacted"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
def test_evidence_root_is_private_and_configurable(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
assert resolve_missioncore_evidence_dir(repository) == (
|
||||
repository / ".runtime" / "mission-core" / "evidence" / "sessions"
|
||||
).resolve()
|
||||
|
||||
configured = tmp_path / "external-evidence"
|
||||
monkeypatch.setenv("MISSIONCORE_EVIDENCE_DIR", str(configured))
|
||||
assert resolve_missioncore_evidence_dir(repository) == configured.resolve()
|
||||
|
||||
|
||||
def test_catalog_reconciles_sessions_removed_from_one_evidence_root(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
assert store.import_legacy_viewer_live(sessions) == (session.name,)
|
||||
shutil.rmtree(session)
|
||||
|
||||
assert store.import_legacy_viewer_live(sessions) == ()
|
||||
assert store.list_recent().items == ()
|
||||
|
||||
|
||||
def make_recorded_camera_source(
|
||||
session: Path,
|
||||
source_id: str = "sensor.camera.left",
|
||||
*,
|
||||
complete: bool = True,
|
||||
) -> Path:
|
||||
epoch = session / "media" / source_id / "epoch-1"
|
||||
segments = epoch / "segments"
|
||||
segments.mkdir(parents=True)
|
||||
(epoch / "init.mp4").write_bytes(b"ftyp-mission-core")
|
||||
(segments / "1.m4s").write_bytes(b"moof-camera-frame")
|
||||
(epoch / "index.jsonl").write_text(
|
||||
json.dumps({"sequence": 1, "started_at_seconds": 0.0}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
if complete:
|
||||
(epoch / "summary.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "missioncore.camera-recording/v1",
|
||||
"source_id": source_id,
|
||||
"segment_count": 1,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return epoch
|
||||
|
||||
|
||||
def replace_summary_with_recovery_metadata(
|
||||
session: Path,
|
||||
*,
|
||||
corrupt_trailing_line: bool = False,
|
||||
) -> None:
|
||||
capture = session / "captures" / "mqtt_live"
|
||||
raw_path = capture / "mqtt.raw.k1mqtt"
|
||||
timestamps = (
|
||||
("2026-07-16T20:56:32.699Z", 1_784_235_392_699_000_000, 10_000_000_000),
|
||||
("2026-07-16T20:56:35.199Z", 1_784_235_395_199_000_000, 12_500_000_000),
|
||||
)
|
||||
records = []
|
||||
for frame, (timestamp, epoch_ns, monotonic_ns) in zip(
|
||||
iter_capture_frames(raw_path),
|
||||
timestamps,
|
||||
strict=True,
|
||||
):
|
||||
records.append(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"record_type": "message",
|
||||
"sequence": frame.sequence,
|
||||
"received_at_utc": timestamp,
|
||||
"received_at_epoch_ns": epoch_ns,
|
||||
"received_monotonic_ns": monotonic_ns,
|
||||
"topic": frame.topic,
|
||||
"payload_bytes": frame.raw_frame_bytes
|
||||
- FRAME_HEADER.size
|
||||
- len(frame.topic.encode("utf-8")),
|
||||
"raw_frame_offset": frame.raw_frame_offset,
|
||||
"raw_payload_offset": frame.raw_payload_offset,
|
||||
"raw_frame_bytes": frame.raw_frame_bytes,
|
||||
}
|
||||
)
|
||||
metadata = b"".join(
|
||||
(json.dumps(record, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
for record in records
|
||||
)
|
||||
if corrupt_trailing_line:
|
||||
metadata += b'{"record_type":"message"'
|
||||
(capture / "mqtt.metadata.jsonl").write_bytes(metadata)
|
||||
(capture / "mqtt.summary.json").write_text("{corrupt", encoding="utf-8")
|
||||
|
||||
|
||||
def serialized(value: object) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def test_store_uses_private_wal_database_and_idempotently_imports_legacy_session(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
first = store.import_legacy_viewer_live(sessions)
|
||||
second = store.import_legacy_viewer_live(sessions)
|
||||
|
||||
assert first == second == ("20260716T205632Z_viewer_live",)
|
||||
page = store.list_recent()
|
||||
assert len(page.items) == 1
|
||||
summary = page.items[0]
|
||||
assert summary.status == "ready"
|
||||
assert summary.modalities == ("point-cloud", "trajectory")
|
||||
assert summary.replayable is True
|
||||
assert summary.source_count == 2
|
||||
assert "192.168.99.77" not in serialized(page.as_dict())
|
||||
assert str(repository) not in serialized(page.as_dict())
|
||||
|
||||
detail = store.get_session(summary.session_id)
|
||||
assert [source.source_id for source in detail.sources] == [
|
||||
"sensor.lidar.primary",
|
||||
"spatial.trajectory",
|
||||
]
|
||||
assert all(source.seekable for source in detail.sources)
|
||||
assert str(repository) not in serialized(detail.as_dict())
|
||||
command = store.prepare_replay(summary.session_id, speed=2.0, loop=True)
|
||||
assert command.source_path.name == "mqtt.raw.k1mqtt"
|
||||
assert command.speed == 2.0
|
||||
assert command.loop is True
|
||||
|
||||
with sqlite3.connect(store.database_path) as connection:
|
||||
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
|
||||
assert store.database_path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_recent_sessions_are_sorted_and_cursor_paginated(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
make_legacy_session(
|
||||
sessions,
|
||||
"20260716T191025Z_viewer_live",
|
||||
created_at="2026-07-16T19:10:25.352Z",
|
||||
)
|
||||
make_legacy_session(
|
||||
sessions,
|
||||
"20260716T205632Z_viewer_live",
|
||||
created_at="2026-07-16T20:56:32.699Z",
|
||||
)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
first = store.list_recent(limit=1)
|
||||
assert first.items[0].session_id == "20260716T205632Z_viewer_live"
|
||||
assert first.next_cursor == "20260716T205632Z_viewer_live"
|
||||
second = store.list_recent(limit=1, cursor=first.next_cursor)
|
||||
assert second.items[0].session_id == "20260716T191025Z_viewer_live"
|
||||
assert second.next_cursor is None
|
||||
|
||||
|
||||
def test_legacy_import_adds_video_only_for_validated_recording_tree(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
complete = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
incomplete = make_legacy_session(sessions, "20260716T205632Z_viewer_live_2")
|
||||
make_recorded_camera_source(complete)
|
||||
make_recorded_camera_source(incomplete, complete=False)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
complete_detail = store.get_session(complete.name)
|
||||
assert complete_detail.summary.modalities == ("point-cloud", "trajectory", "video")
|
||||
assert complete_detail.summary.source_count == 3
|
||||
camera = next(
|
||||
source for source in complete_detail.sources if source.source_id == "sensor.camera.left"
|
||||
)
|
||||
assert camera.modality == "video"
|
||||
assert camera.semantic_channel_id == "camera.video.recorded"
|
||||
assert camera.seekable is True
|
||||
video_artifact = next(
|
||||
artifact
|
||||
for artifact in complete_detail.artifacts
|
||||
if artifact.artifact_id == camera.artifact_id
|
||||
)
|
||||
assert video_artifact.kind == "recorded-video"
|
||||
assert video_artifact.integrity_status == "validated-structure"
|
||||
assert str(repository) not in serialized(complete_detail.as_dict())
|
||||
|
||||
incomplete_detail = store.get_session(incomplete.name)
|
||||
assert incomplete_detail.summary.modalities == ("point-cloud", "trajectory")
|
||||
assert all(source.modality != "video" for source in incomplete_detail.sources)
|
||||
|
||||
|
||||
def test_interrupted_capture_is_recovered_from_aligned_raw_and_metadata_prefix(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session, corrupt_trailing_line=True)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
assert detail.summary.status == "interrupted"
|
||||
assert detail.summary.replayable is True
|
||||
assert detail.summary.modalities == ("point-cloud", "trajectory")
|
||||
assert detail.summary.started_at_utc == "2026-07-16T20:56:32.699Z"
|
||||
assert detail.summary.completed_at_utc == "2026-07-16T20:56:35.199Z"
|
||||
assert detail.summary.duration_seconds == 2.5
|
||||
assert detail.artifacts[0].integrity_status == "validated-prefix"
|
||||
assert store.prepare_replay(session.name).source_path.name == "mqtt.raw.k1mqtt"
|
||||
|
||||
|
||||
def test_catalog_upsert_promotes_recovered_session_after_summary_is_completed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
summary_path = session / "captures" / "mqtt_live" / "mqtt.summary.json"
|
||||
metadata_path = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
|
||||
completed_summary = summary_path.read_text(encoding="utf-8")
|
||||
completed_metadata = metadata_path.read_text(encoding="utf-8")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
assert store.get_session(session.name).summary.status == "interrupted"
|
||||
|
||||
summary_path.write_text(completed_summary, encoding="utf-8")
|
||||
metadata_path.write_text(completed_metadata, encoding="utf-8")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
promoted = store.get_session(session.name).summary
|
||||
assert promoted.status == "ready"
|
||||
assert promoted.duration_seconds == 1440.1
|
||||
assert promoted.replayable is True
|
||||
|
||||
|
||||
def test_interrupted_capture_does_not_trust_metadata_outside_session(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
metadata = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
|
||||
outside = tmp_path / "outside.metadata.jsonl"
|
||||
outside.write_bytes(metadata.read_bytes())
|
||||
metadata.unlink()
|
||||
metadata.symlink_to(outside)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
assert detail.summary.status == "failed"
|
||||
assert detail.summary.replayable is False
|
||||
assert detail.summary.modalities == ()
|
||||
|
||||
|
||||
def test_interrupted_capture_rejects_newline_terminated_metadata_corruption(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
metadata = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
|
||||
with metadata.open("ab") as stream:
|
||||
stream.write(b"{corrupt\n")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
assert detail.summary.status == "failed"
|
||||
assert detail.summary.replayable is False
|
||||
|
||||
|
||||
def test_replay_resolution_rejects_artifact_symlink_escape(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
outside = tmp_path / "outside.k1mqtt"
|
||||
outside.write_bytes(raw.read_bytes())
|
||||
raw.unlink()
|
||||
raw.symlink_to(outside)
|
||||
|
||||
with pytest.raises(SessionIntegrityError, match="escapes"):
|
||||
store.prepare_replay(session.name)
|
||||
|
||||
|
||||
def test_completed_capture_is_not_replayable_when_declared_integrity_fails(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
corrupted = bytearray(raw.read_bytes())
|
||||
corrupted[-1] ^= 0x01
|
||||
raw.write_bytes(corrupted)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
assert detail.summary.status == "failed"
|
||||
assert detail.summary.replayable is False
|
||||
assert detail.summary.modalities == ()
|
||||
|
||||
|
||||
def test_completed_capture_is_not_replayable_when_declared_count_is_wrong(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
summary_path = session / "captures" / "mqtt_live" / "mqtt.summary.json"
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
summary["message_count"] = 3
|
||||
summary_path.write_text(json.dumps(summary), encoding="utf-8")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
assert store.get_session(session.name).summary.replayable is False
|
||||
|
||||
|
||||
def test_current_session_marker_keeps_active_capture_out_of_replay(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
(sessions / ".current_session").write_text(
|
||||
f"sessions/{session.name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
store.get_session(session.name)
|
||||
|
||||
(sessions / ".current_session").unlink()
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
assert store.get_session(session.name).summary.replayable is True
|
||||
|
||||
|
||||
def test_interrupted_capture_replays_only_committed_prefix_before_partial_raw_tail(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
committed_bytes = raw.stat().st_size
|
||||
with raw.open("ab") as stream:
|
||||
stream.write(FRAME_HEADER.pack(12, 100)[:7])
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
command = store.prepare_replay(session.name)
|
||||
assert detail.summary.status == "interrupted"
|
||||
assert detail.summary.replayable is True
|
||||
assert command.replay_byte_length == committed_bytes
|
||||
assert command.replay_byte_length < command.source_path.stat().st_size
|
||||
|
||||
|
||||
def test_interrupted_capture_rejects_non_frame_raw_tail(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
with raw.open("ab") as stream:
|
||||
stream.write(FRAME_HEADER.pack(0, 0))
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
assert store.get_session(session.name).summary.replayable is False
|
||||
|
||||
|
||||
def test_interrupted_capture_tolerates_bounded_group_commit_raw_tail(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
replace_summary_with_recovery_metadata(session)
|
||||
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
|
||||
committed_bytes = raw.stat().st_size
|
||||
with raw.open("ab") as stream:
|
||||
for payload in (b"pending-one", b"pending-two"):
|
||||
topic = b"RealtimePath"
|
||||
stream.write(FRAME_HEADER.pack(len(topic), len(payload)))
|
||||
stream.write(topic)
|
||||
stream.write(payload)
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
command = store.prepare_replay(session.name)
|
||||
assert store.get_session(session.name).summary.status == "interrupted"
|
||||
assert command.replay_byte_length == committed_bytes
|
||||
|
||||
|
||||
def test_failed_final_summary_falls_back_to_last_committed_prefix(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
capture = session / "captures" / "mqtt_live"
|
||||
metadata = capture / "mqtt.metadata.jsonl"
|
||||
first_record = metadata.read_text(encoding="utf-8").splitlines(keepends=True)[0]
|
||||
metadata.write_text(first_record, encoding="utf-8")
|
||||
summary_path = capture / "mqtt.summary.json"
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
summary["error"] = "synthetic metadata fsync failure"
|
||||
summary_path.write_text(json.dumps(summary), encoding="utf-8")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
store.import_legacy_viewer_live(sessions)
|
||||
|
||||
detail = store.get_session(session.name)
|
||||
assert detail.summary.status == "interrupted"
|
||||
assert detail.summary.replayable is True
|
||||
assert detail.summary.modalities == ("point-cloud",)
|
||||
|
||||
|
||||
def test_layout_save_is_atomic_and_revision_checked(tmp_path: Path) -> None:
|
||||
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
|
||||
|
||||
first = store.save_layout(
|
||||
"observation.spatial",
|
||||
schema_version=1,
|
||||
expected_revision=0,
|
||||
name="Операторская сцена",
|
||||
layout={"visible_source_ids": ["sensor.lidar.primary"], "windows": []},
|
||||
)
|
||||
assert first.revision == 1
|
||||
assert store.get_layout("observation.spatial") == first
|
||||
|
||||
with pytest.raises(LayoutConflictError, match="revision changed"):
|
||||
store.save_layout(
|
||||
"observation.spatial",
|
||||
schema_version=1,
|
||||
expected_revision=0,
|
||||
name="Устаревшая запись",
|
||||
layout={},
|
||||
)
|
||||
|
||||
second = store.save_layout(
|
||||
"observation.spatial",
|
||||
schema_version=1,
|
||||
expected_revision=1,
|
||||
name="Операторская сцена",
|
||||
layout={"visible_source_ids": [], "windows": []},
|
||||
)
|
||||
assert second.revision == 2
|
||||
assert store.get_layout("observation.spatial").layout["visible_source_ids"] == []
|
||||
Reference in New Issue
Block a user