feat(sessions): add durable observation archive and replay API

This commit is contained in:
DCCONSTRUCTIONS 2026-07-17 17:50:54 +03:00
parent aa2df560b7
commit 656f0c524d
23 changed files with 12492 additions and 5 deletions

3
.gitignore vendored
View File

@ -26,6 +26,9 @@ private/
# Real laboratory captures and decoded artifacts are sensitive and large.
captures/
sessions/
# The host runtime package shares the domain name but is source, not evidence.
!src/k1link/sessions/
!src/k1link/sessions/**/*.py
artifacts/raw/
artifacts/decoded/
*.pcap

View File

@ -33,6 +33,9 @@ MAX_TOPIC_BYTES = 65_535
CONNECT_TIMEOUT_SECONDS = 10.0
KEEPALIVE_SECONDS = 30
LOOP_INTERVAL_SECONDS = 0.25
GROUP_COMMIT_INTERVAL_SECONDS = 0.5
GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024
GROUP_COMMIT_MAX_MESSAGES = 32
# Eight-byte file signature followed by repeated >IQ, topic UTF-8 bytes, payload bytes.
RAW_MAGIC = b"K1MQTT\x00\x01"
@ -166,6 +169,9 @@ class _CaptureWriter:
self.topic_counts: dict[str, int] = {}
self._raw: IO[bytes] | None = None
self._metadata: IO[str] | None = None
self._pending_metadata: list[str] = []
self._pending_raw_bytes = 0
self._last_commit_monotonic = time.monotonic()
def open(self) -> None:
self.out_dir.mkdir(parents=True, exist_ok=True)
@ -179,6 +185,7 @@ class _CaptureWriter:
self._raw = _open_binary_exclusive(self.raw_path)
self._raw.write(RAW_MAGIC)
self._metadata = _open_text_exclusive(self.metadata_path)
_fsync_directory(self.out_dir)
except BaseException:
with suppress(OSError):
self.close()
@ -212,7 +219,9 @@ class _CaptureWriter:
"max_message_bytes": self.max_message_bytes,
"reason": "message_too_large",
}
self._commit_pending()
self._write_metadata(metadata, record)
os.fsync(metadata.fileno())
raise MessageTooLargeError(
f"message on {topic!r} is {len(payload)} bytes; "
f"limit is {self.max_message_bytes} bytes"
@ -246,7 +255,17 @@ class _CaptureWriter:
"raw_payload_offset": offset + len(header) + len(topic_bytes),
"raw_frame_bytes": frame_bytes,
}
self._write_metadata(metadata, record)
self._pending_metadata.append(
json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
)
self._pending_raw_bytes += frame_bytes
if (
len(self._pending_metadata) >= GROUP_COMMIT_MAX_MESSAGES
or self._pending_raw_bytes >= GROUP_COMMIT_MAX_BYTES
or time.monotonic() - self._last_commit_monotonic
>= GROUP_COMMIT_INTERVAL_SECONDS
):
self._commit_pending()
return CapturedMqttMessage(
sequence=self.message_count,
topic=topic,
@ -261,7 +280,12 @@ class _CaptureWriter:
def close(self) -> None:
first_error: OSError | None = None
# Make raw frames durable before making their JSONL references durable.
try:
self._commit_pending()
except OSError as exc:
first_error = exc
# Make the magic (including the zero-message case) and any last stream
# buffers durable before the handles are released.
for stream in (self._raw, self._metadata):
if stream is None or stream.closed:
continue
@ -280,6 +304,13 @@ class _CaptureWriter:
if first_error is not None:
raise first_error
def maybe_commit(self, now_monotonic: float | None = None) -> None:
if not self._pending_metadata:
return
now = time.monotonic() if now_monotonic is None else now_monotonic
if now - self._last_commit_monotonic >= GROUP_COMMIT_INTERVAL_SECONDS:
self._commit_pending(now_monotonic=now)
@property
def raw_bytes(self) -> int:
if self.raw_path.exists():
@ -301,6 +332,26 @@ class _CaptureWriter:
stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
stream.flush()
def _commit_pending(self, *, now_monotonic: float | None = None) -> None:
if not self._pending_metadata:
return
raw = self._require_raw()
metadata = self._require_metadata()
# Group commit invariant: durable raw bytes always precede durable
# metadata references. A crash can therefore lose only the bounded
# in-memory group, never expose metadata pointing past durable raw.
raw.flush()
os.fsync(raw.fileno())
payload = "".join(self._pending_metadata)
self._pending_metadata.clear()
self._pending_raw_bytes = 0
metadata.write(payload)
metadata.flush()
os.fsync(metadata.fileno())
self._last_commit_monotonic = (
time.monotonic() if now_monotonic is None else now_monotonic
)
def validate_private_ipv4(value: str) -> str:
"""Require a literal RFC1918 address so capture cannot target arbitrary hosts."""
@ -536,6 +587,11 @@ def capture_mqtt(
break
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
try:
writer.maybe_commit()
except OSError as exc:
fail("capture_error", f"group commit failed: {type(exc).__name__}: {exc}")
break
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
fail(
"connection_lost",
@ -681,3 +737,12 @@ def _write_summary_exclusive(path: Path, summary: CaptureSummary) -> None:
stream.write(serialized)
stream.flush()
os.fsync(stream.fileno())
_fsync_directory(path.parent)
def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)

View File

@ -0,0 +1,65 @@
"""Host-owned observation session catalog and durable layout persistence."""
from .active import (
ActiveSessionLease,
ActiveSessionLeaseError,
recover_stale_active_session_marker,
)
from .media import (
RECORDED_MEDIA_MANIFEST_SCHEMA,
RecordedMediaFile,
RecordedMediaInspector,
RecordedMediaManifest,
validate_recorded_media_timeline,
)
from .models import (
LayoutConflictError,
RecordedMediaArtifact,
ReplayCommand,
SessionIntegrityError,
SessionNotFoundError,
SessionNotReplayableError,
)
from .preparation import (
RecordingPreparationQueueFull,
RecordingPreparationSnapshot,
SessionRecordingPreparationManager,
)
from .recording import (
MaterializedRecording,
RecordingMaterializationCancelled,
RecordingMaterializationError,
SessionRecordingMaterializer,
)
from .store import (
SessionStore,
resolve_missioncore_data_dir,
resolve_missioncore_evidence_dir,
)
__all__ = [
"LayoutConflictError",
"ActiveSessionLease",
"ActiveSessionLeaseError",
"MaterializedRecording",
"RecordingMaterializationCancelled",
"RecordedMediaArtifact",
"RECORDED_MEDIA_MANIFEST_SCHEMA",
"RecordedMediaFile",
"RecordedMediaInspector",
"RecordedMediaManifest",
"ReplayCommand",
"RecordingMaterializationError",
"RecordingPreparationQueueFull",
"RecordingPreparationSnapshot",
"SessionIntegrityError",
"SessionNotFoundError",
"SessionNotReplayableError",
"SessionRecordingMaterializer",
"SessionRecordingPreparationManager",
"SessionStore",
"recover_stale_active_session_marker",
"resolve_missioncore_data_dir",
"resolve_missioncore_evidence_dir",
"validate_recorded_media_timeline",
]

View File

@ -0,0 +1,172 @@
from __future__ import annotations
import importlib
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
ACTIVE_SESSION_MARKER = ".current_session"
class ActiveSessionLeaseError(RuntimeError):
"""The evidence root already has an active writer or cannot be leased."""
@dataclass(slots=True)
class ActiveSessionLease:
"""Cross-process lease that keeps an in-progress session out of replay.
The marker is intentionally written before the session directory is
created. Discovery therefore observes either no candidate yet or a
candidate protected by an already locked marker. A process crash releases
the OS lock; startup recovery can then remove the stale marker while
preserving the interrupted evidence directory.
"""
sessions_root: Path
session_root: Path
_descriptor: int
_marker_identity: tuple[int, int]
_released: bool = False
@classmethod
def acquire(cls, sessions_root: Path, session_root: Path) -> ActiveSessionLease:
root = sessions_root.expanduser().resolve()
root.mkdir(mode=0o700, parents=True, exist_ok=True)
target = session_root.expanduser().absolute()
if target.parent.resolve() != root or not target.name:
raise ActiveSessionLeaseError("active session must be a direct child of evidence root")
marker = root / ACTIVE_SESSION_MARKER
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(marker, flags, 0o600)
except FileExistsError as exc:
raise ActiveSessionLeaseError(
"another observation session owns the evidence root"
) from exc
try:
payload = f"{target.name}\n".encode()
os.write(descriptor, payload)
os.fsync(descriptor)
_lock_descriptor(descriptor, blocking=False)
_fsync_directory(root)
metadata = os.fstat(descriptor)
return cls(
sessions_root=root,
session_root=target,
_descriptor=descriptor,
_marker_identity=(metadata.st_dev, metadata.st_ino),
)
except BaseException:
try:
marker.unlink(missing_ok=True)
_fsync_directory(root)
finally:
os.close(descriptor)
raise
def release(self) -> None:
if self._released:
return
marker = self.sessions_root / ACTIVE_SESSION_MARKER
try:
try:
metadata = marker.lstat()
except FileNotFoundError:
metadata = None
if metadata is not None and (metadata.st_dev, metadata.st_ino) == self._marker_identity:
marker.unlink()
_fsync_directory(self.sessions_root)
finally:
_unlock_descriptor(self._descriptor)
os.close(self._descriptor)
self._released = True
def __enter__(self) -> ActiveSessionLease:
return self
def __exit__(self, *_: object) -> None:
self.release()
def recover_stale_active_session_marker(sessions_root: Path) -> bool:
"""Remove only an unlocked marker left by a terminated writer."""
root = sessions_root.expanduser().resolve()
marker = root / ACTIVE_SESSION_MARKER
try:
marker_stat = marker.lstat()
except FileNotFoundError:
return False
except OSError:
return False
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
return False
try:
descriptor = os.open(marker, os.O_RDWR | getattr(os, "O_NOFOLLOW", 0))
except OSError:
return False
locked = False
try:
try:
_lock_descriptor(descriptor, blocking=False)
locked = True
except OSError:
return False
opened = os.fstat(descriptor)
try:
current = marker.lstat()
except OSError:
return False
if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):
return False
marker.unlink()
_fsync_directory(root)
return True
finally:
if locked:
_unlock_descriptor(descriptor)
os.close(descriptor)
def _lock_descriptor(descriptor: int, *, blocking: bool) -> None:
if os.name == "nt":
msvcrt = cast(Any, importlib.import_module("msvcrt"))
os.lseek(descriptor, 0, os.SEEK_SET)
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
msvcrt.locking(descriptor, mode, 1)
return
import fcntl
operation = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
fcntl.flock(descriptor, operation)
def _unlock_descriptor(descriptor: int) -> None:
try:
if os.name == "nt":
msvcrt = cast(Any, importlib.import_module("msvcrt"))
os.lseek(descriptor, 0, os.SEEK_SET)
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
return
import fcntl
fcntl.flock(descriptor, fcntl.LOCK_UN)
except OSError:
return
def _fsync_directory(path: Path) -> None:
try:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
except OSError:
return
try:
os.fsync(descriptor)
finally:
os.close(descriptor)

View File

@ -0,0 +1,818 @@
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import stat
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import lru_cache
from pathlib import Path
from typing import IO, Any
from k1link.mqtt.capture import (
FRAME_HEADER,
GROUP_COMMIT_MAX_BYTES,
GROUP_COMMIT_MAX_MESSAGES,
MAX_CONFIGURABLE_MESSAGE_BYTES,
MAX_TOPIC_BYTES,
RAW_MAGIC,
)
from .models import (
LegacyMediaSourceCandidate,
LegacySessionCandidate,
SessionModality,
SessionStatus,
)
MAX_LEGACY_JSON_BYTES = 2 * 1024 * 1024
LEGACY_SESSION_PATTERN = re.compile(r"^[A-Za-z0-9._-]+_viewer_live(?:_[0-9]+)?$")
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
MEDIA_SOURCE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
MEDIA_EPOCH_PATTERN = re.compile(r"^epoch-(?!0+$)[0-9]+$")
MEDIA_SEGMENT_PATTERN = re.compile(r"^[0-9]+\.m4s$")
MAX_MEDIA_INDEX_BYTES = 32 * 1024 * 1024
MAX_RECOVERY_METADATA_BYTES = 64 * 1024 * 1024
MAX_RECOVERY_METADATA_LINE_BYTES = 64 * 1024
MAX_RECOVERY_MESSAGES = 500_000
@dataclass(frozen=True, slots=True)
class _RecoveredCapture:
message_count: int
topic_counts: dict[str, int]
started_at_utc: str
completed_at_utc: str
duration_seconds: float
raw_committed_bytes: int
metadata_committed_bytes: int
def discover_legacy_viewer_sessions(root: Path) -> tuple[LegacySessionCandidate, ...]:
"""Describe legacy live sessions without copying or decoding their payloads."""
allowed_root = root.expanduser().resolve()
if not allowed_root.is_dir():
return ()
active_session_root = _active_session_root(allowed_root)
candidates: list[LegacySessionCandidate] = []
for entry in sorted(allowed_root.iterdir()):
if not entry.is_dir() or not LEGACY_SESSION_PATTERN.fullmatch(entry.name):
continue
resolved = entry.resolve()
if not resolved.is_relative_to(allowed_root):
continue
# The saved-session catalog deliberately omits the writer-owned run.
# Besides avoiding a misleading interrupted/error row, this prevents
# the two-second reconciler from repeatedly scanning and hashing a
# capture that is still growing. Marker release makes it discoverable
# on the next reconciliation pass.
if active_session_root == resolved:
continue
candidates.append(
_describe_session(
allowed_root,
resolved,
active=False,
)
)
return tuple(candidates)
def _describe_session(
allowed_root: Path,
session_root: Path,
*,
active: bool,
) -> LegacySessionCandidate:
capture_root = session_root / "captures" / "mqtt_live"
raw_path = capture_root / "mqtt.raw.k1mqtt"
summary = _read_json_object(capture_root / "mqtt.summary.json")
manifest = _read_json_object(session_root / "manifest.redacted.json")
raw_bytes = raw_path.stat().st_size if _confined_file(raw_path, session_root) else 0
raw_magic_ok = False
if raw_bytes >= len(RAW_MAGIC):
with raw_path.open("rb") as stream:
raw_magic_ok = stream.read(len(RAW_MAGIC)) == RAW_MAGIC
has_completed_summary = _is_completed_summary(summary)
completed = (
_validate_completed_capture(capture_root, session_root, raw_path, summary)
if has_completed_summary and raw_magic_ok
else None
)
recovered = (
_recover_interrupted_capture(capture_root, session_root, raw_path)
if raw_magic_ok
and completed is None
and (not has_completed_summary or summary.get("error") is not None)
else None
)
message_count = (
_non_negative_int(summary.get("message_count"))
if completed is not None
else recovered.message_count if recovered is not None else 0
)
summary_topic_counts = summary.get("topic_counts")
topic_counts = (
_normalized_topic_counts(summary_topic_counts)
if completed is not None
else recovered.topic_counts if recovered is not None else {}
)
media_sources = _discover_media_sources(session_root)
modalities = list(_modalities(topic_counts))
if media_sources:
modalities.append("video")
replayable = bool(
not active
and
raw_magic_ok
and raw_bytes > len(RAW_MAGIC)
and message_count > 0
and any(modality in {"point-cloud", "trajectory"} for modality in modalities)
)
if completed is not None:
status = (
"interrupted"
if active
else _status(replayable, summary.get("error"), summary.get("stop_reason"))
)
started_at = (
_safe_timestamp(summary.get("created_at_utc"))
or _safe_timestamp(manifest.get("started_at_utc"))
or _timestamp_from_session_name(session_root.name)
)
completed_at = _safe_timestamp(summary.get("completed_at_utc")) or _safe_timestamp(
manifest.get("completed_at_utc")
)
duration = _duration(summary.get("capture_elapsed_seconds"))
declared_hash = _declared_raw_hash(summary)
integrity_status = "verified" if declared_hash is not None else "validated-structure"
else:
status = "interrupted" if recovered is not None or active else "failed"
started_at = (
recovered.started_at_utc
if recovered is not None
else _safe_timestamp(manifest.get("started_at_utc"))
or _timestamp_from_session_name(session_root.name)
)
completed_at = recovered.completed_at_utc if recovered is not None else None
duration = recovered.duration_seconds if recovered is not None else None
declared_hash = None
integrity_status = "validated-prefix" if recovered is not None else "unverified"
replay_raw_bytes = (
completed.raw_committed_bytes
if completed is not None
else recovered.raw_committed_bytes if recovered is not None else 0
)
replay_metadata_bytes = (
completed.metadata_committed_bytes
if completed is not None
else recovered.metadata_committed_bytes if recovered is not None else 0
)
return LegacySessionCandidate(
session_id=session_root.name,
display_name=session_root.name,
status=status,
started_at_utc=started_at,
completed_at_utc=completed_at,
duration_seconds=duration,
modalities=tuple(modalities),
replayable=replayable,
total_bytes=raw_bytes + sum(source.byte_length for source in media_sources),
allowed_root=allowed_root,
session_root=session_root,
raw_path=raw_path.resolve(strict=False),
raw_byte_length=raw_bytes,
replay_raw_byte_length=replay_raw_bytes,
replay_metadata_byte_length=replay_metadata_bytes,
raw_sha256=declared_hash,
raw_integrity_status=integrity_status,
media_sources=media_sources,
)
def _is_completed_summary(summary: dict[str, Any]) -> bool:
return (
"message_count" in summary
and isinstance(summary.get("message_count"), int)
and not isinstance(summary.get("message_count"), bool)
and isinstance(summary.get("topic_counts"), dict)
and isinstance(summary.get("stop_reason"), str)
)
def _validate_completed_capture(
capture_root: Path,
session_root: Path,
raw_path: Path,
summary: dict[str, Any],
) -> _RecoveredCapture | None:
metadata_path = capture_root / "mqtt.metadata.jsonl"
try:
raw_stat = raw_path.lstat()
metadata_stat = metadata_path.lstat()
except OSError:
return None
if not stat.S_ISREG(raw_stat.st_mode) or not stat.S_ISREG(metadata_stat.st_mode):
return None
fingerprint = json.dumps(
{
"message_count": summary.get("message_count"),
"topic_counts": summary.get("topic_counts"),
"raw_bytes": summary.get("raw_bytes"),
"artifact_hashes": summary.get("artifact_hashes"),
},
sort_keys=True,
separators=(",", ":"),
)
return _validate_completed_capture_cached(
str(capture_root),
str(session_root),
str(raw_path),
_stat_identity(raw_stat),
_stat_identity(metadata_stat),
fingerprint,
)
@lru_cache(maxsize=128)
def _validate_completed_capture_cached(
capture_root_text: str,
session_root_text: str,
raw_path_text: str,
_raw_identity: tuple[int, int, int, int, int],
_metadata_identity: tuple[int, int, int, int, int],
summary_fingerprint: str,
) -> _RecoveredCapture | None:
capture_root = Path(capture_root_text)
session_root = Path(session_root_text)
raw_path = Path(raw_path_text)
summary = json.loads(summary_fingerprint)
validated = _scan_capture_prefix(
capture_root,
session_root,
raw_path,
tolerate_incomplete_metadata_tail=False,
tolerate_raw_crash_tail=False,
)
if validated is None:
return None
if validated.message_count != _non_negative_int(summary.get("message_count")):
return None
if validated.topic_counts != _normalized_topic_counts(summary.get("topic_counts")):
return None
declared_raw_bytes = summary.get("raw_bytes")
if declared_raw_bytes is not None and (
not isinstance(declared_raw_bytes, int)
or isinstance(declared_raw_bytes, bool)
or declared_raw_bytes != validated.raw_committed_bytes
):
return None
raw_hash = _declared_raw_hash(summary)
if raw_hash is not None and _sha256_stable(raw_path) != raw_hash:
return None
metadata_hash = _declared_metadata_hash(summary)
metadata_path = capture_root / "mqtt.metadata.jsonl"
if metadata_hash is not None and _sha256_stable(metadata_path) != metadata_hash:
return None
return validated
def _recover_interrupted_capture(
capture_root: Path,
session_root: Path,
raw_path: Path,
) -> _RecoveredCapture | None:
return _scan_capture_prefix(
capture_root,
session_root,
raw_path,
tolerate_incomplete_metadata_tail=True,
tolerate_raw_crash_tail=True,
)
def _scan_capture_prefix(
capture_root: Path,
session_root: Path,
raw_path: Path,
*,
tolerate_incomplete_metadata_tail: bool,
tolerate_raw_crash_tail: bool,
) -> _RecoveredCapture | None:
metadata_path = capture_root / "mqtt.metadata.jsonl"
if not _confined_file(raw_path, session_root) or not _confined_file(
metadata_path, session_root
):
return None
try:
raw_size = raw_path.stat().st_size
with raw_path.open("rb") as raw_stream, metadata_path.open("rb") as metadata_stream:
if raw_stream.read(len(RAW_MAGIC)) != RAW_MAGIC:
return None
consumed_metadata_bytes = 0
message_count = 0
topic_counts: dict[str, int] = {}
first_epoch_ns: int | None = None
last_epoch_ns: int | None = None
first_monotonic_ns: int | None = None
last_monotonic_ns: int | None = None
first_timestamp: str | None = None
last_timestamp: str | None = None
metadata_committed_bytes = 0
while (
consumed_metadata_bytes < MAX_RECOVERY_METADATA_BYTES
and message_count < MAX_RECOVERY_MESSAGES
):
remaining = MAX_RECOVERY_METADATA_BYTES - consumed_metadata_bytes
read_limit = min(MAX_RECOVERY_METADATA_LINE_BYTES + 1, remaining + 1)
line = metadata_stream.readline(read_limit)
if not line:
break
consumed_metadata_bytes += len(line)
if (
len(line) > MAX_RECOVERY_METADATA_LINE_BYTES
or consumed_metadata_bytes > MAX_RECOVERY_METADATA_BYTES
):
return None
if not line.endswith(b"\n"):
if tolerate_incomplete_metadata_tail:
break
return None
record = _metadata_record(line)
if record is None:
return None
metadata_committed_bytes = consumed_metadata_bytes
if record.get("record_type") != "message":
continue
expected_sequence = message_count + 1
validated = _validate_recovery_frame(
raw_stream,
raw_size=raw_size,
record=record,
expected_sequence=expected_sequence,
)
if validated is None:
return None
topic, epoch_ns, monotonic_ns, timestamp = validated
if last_monotonic_ns is not None and monotonic_ns < last_monotonic_ns:
return None
message_count = expected_sequence
topic_counts[topic] = topic_counts.get(topic, 0) + 1
if first_epoch_ns is None:
first_epoch_ns = epoch_ns
first_monotonic_ns = monotonic_ns
first_timestamp = timestamp
last_epoch_ns = epoch_ns
last_monotonic_ns = monotonic_ns
last_timestamp = timestamp
if metadata_stream.read(1):
return None
raw_committed_bytes = raw_stream.tell()
if raw_committed_bytes != raw_size and not (
tolerate_raw_crash_tail
and _tolerable_raw_crash_tail(raw_stream, raw_size=raw_size)
):
return None
except OSError:
return None
if (
message_count == 0
or first_epoch_ns is None
or last_epoch_ns is None
or first_timestamp is None
or last_timestamp is None
or first_monotonic_ns is None
or last_monotonic_ns is None
):
return None
return _RecoveredCapture(
message_count=message_count,
topic_counts=topic_counts,
started_at_utc=first_timestamp,
completed_at_utc=last_timestamp,
duration_seconds=(last_monotonic_ns - first_monotonic_ns) / 1_000_000_000,
raw_committed_bytes=raw_committed_bytes,
metadata_committed_bytes=metadata_committed_bytes,
)
def _metadata_record(line: bytes) -> dict[str, Any] | None:
try:
value = json.loads(line.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None
def _tolerable_raw_crash_tail(raw_stream: IO[bytes], *, raw_size: int) -> bool:
"""Accept at most one raw frame which was not committed by metadata.
The capture writer flushes raw before metadata and does not begin the next
message until the current metadata record is written. Consequently a
process crash can leave only a prefix (or all) of one additional frame.
Anything beyond that boundary is corruption, not a recoverable tail.
"""
tail_offset = raw_stream.tell()
tail_bytes = raw_size - tail_offset
if tail_bytes <= 0:
return True
if tail_bytes > GROUP_COMMIT_MAX_BYTES + MAX_CONFIGURABLE_MESSAGE_BYTES:
return False
frames = 0
while raw_stream.tell() < raw_size:
remaining = raw_size - raw_stream.tell()
header = raw_stream.read(min(remaining, FRAME_HEADER.size))
if len(header) < FRAME_HEADER.size:
return frames < GROUP_COMMIT_MAX_MESSAGES
topic_length, payload_length = FRAME_HEADER.unpack(header)
if not 1 <= topic_length <= MAX_TOPIC_BYTES:
return False
if payload_length > MAX_CONFIGURABLE_MESSAGE_BYTES:
return False
frame_bytes = FRAME_HEADER.size + topic_length + payload_length
frame_remaining = raw_size - (raw_stream.tell() - FRAME_HEADER.size)
available_topic_bytes = min(
topic_length,
max(0, frame_remaining - FRAME_HEADER.size),
)
topic_prefix = raw_stream.read(available_topic_bytes)
if available_topic_bytes < topic_length:
return frames < GROUP_COMMIT_MAX_MESSAGES
try:
topic = topic_prefix.decode("utf-8")
except UnicodeDecodeError:
return False
if not topic:
return False
payload_available = frame_remaining - FRAME_HEADER.size - topic_length
if payload_available < payload_length:
return frames < GROUP_COMMIT_MAX_MESSAGES
raw_stream.seek(payload_length, 1)
frames += 1
if frames > GROUP_COMMIT_MAX_MESSAGES or frame_remaining < frame_bytes:
return False
return frames <= GROUP_COMMIT_MAX_MESSAGES
def _validate_recovery_frame(
raw_stream: IO[bytes],
*,
raw_size: int,
record: dict[str, Any],
expected_sequence: int,
) -> tuple[str, int, int, str] | None:
sequence = record.get("sequence")
topic = record.get("topic")
payload_bytes = record.get("payload_bytes")
frame_offset = record.get("raw_frame_offset")
payload_offset = record.get("raw_payload_offset")
frame_bytes = record.get("raw_frame_bytes")
epoch_ns = record.get("received_at_epoch_ns")
monotonic_ns = record.get("received_monotonic_ns")
if (
sequence != expected_sequence
or not isinstance(topic, str)
or not topic
or not isinstance(payload_bytes, int)
or isinstance(payload_bytes, bool)
or not 0 <= payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES
or not isinstance(frame_offset, int)
or isinstance(frame_offset, bool)
or frame_offset != raw_stream.tell()
or not isinstance(payload_offset, int)
or isinstance(payload_offset, bool)
or not isinstance(frame_bytes, int)
or isinstance(frame_bytes, bool)
or not isinstance(epoch_ns, int)
or isinstance(epoch_ns, bool)
or epoch_ns < 0
or not isinstance(monotonic_ns, int)
or isinstance(monotonic_ns, bool)
or monotonic_ns < 0
):
return None
header = raw_stream.read(FRAME_HEADER.size)
if len(header) != FRAME_HEADER.size:
return None
topic_length, raw_payload_bytes = FRAME_HEADER.unpack(header)
if (
not 1 <= topic_length <= MAX_TOPIC_BYTES
or raw_payload_bytes != payload_bytes
or payload_offset != frame_offset + FRAME_HEADER.size + topic_length
or frame_bytes != FRAME_HEADER.size + topic_length + payload_bytes
or frame_offset + frame_bytes > raw_size
):
return None
raw_topic = raw_stream.read(topic_length)
try:
decoded_topic = raw_topic.decode("utf-8")
except UnicodeDecodeError:
return None
if decoded_topic != topic:
return None
raw_stream.seek(payload_bytes, 1)
timestamp = _safe_timestamp(record.get("received_at_utc"))
if timestamp is None:
try:
timestamp = datetime.fromtimestamp(epoch_ns / 1_000_000_000, tz=UTC).isoformat()
except (OSError, OverflowError, ValueError):
return None
return topic, epoch_ns, monotonic_ns, timestamp
def _read_json_object(path: Path) -> dict[str, Any]:
try:
if not path.is_file() or path.stat().st_size > MAX_LEGACY_JSON_BYTES:
return {}
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _confined_file(path: Path, session_root: Path) -> bool:
try:
resolved = path.resolve(strict=True)
except OSError:
return False
return resolved.is_file() and resolved.is_relative_to(session_root)
def _non_negative_int(value: object) -> int:
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
def _normalized_topic_counts(value: object) -> dict[str, int]:
if not isinstance(value, dict):
return {}
result: dict[str, int] = {}
for topic, raw_count in value.items():
count = _non_negative_int(raw_count)
if isinstance(topic, str) and count > 0:
result[topic] = count
return result
def _duration(value: object) -> float | None:
if not isinstance(value, (int, float)) or isinstance(value, bool):
return None
duration = float(value)
return duration if math.isfinite(duration) and duration >= 0 else None
def _safe_timestamp(value: object) -> str | None:
if not isinstance(value, str) or len(value) > 64:
return None
candidate = value.strip()
try:
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return None
return candidate
def _timestamp_from_session_name(session_id: str) -> str | None:
prefix = session_id.split("_", 1)[0]
try:
parsed = datetime.strptime(prefix, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
except ValueError:
return None
return parsed.isoformat().replace("+00:00", "Z")
def _declared_raw_hash(summary: dict[str, Any]) -> str | None:
hashes = summary.get("artifact_hashes")
if not isinstance(hashes, dict):
return None
value = hashes.get("raw_sha256")
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
def _declared_metadata_hash(summary: dict[str, Any]) -> str | None:
hashes = summary.get("artifact_hashes")
if not isinstance(hashes, dict):
return None
value = hashes.get("metadata_jsonl_sha256")
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
def _sha256_stable(path: Path) -> str:
try:
current = path.lstat()
except OSError:
return ""
if not stat.S_ISREG(current.st_mode):
return ""
return _sha256_cached(str(path), _stat_identity(current))
@lru_cache(maxsize=128)
def _sha256_cached(
path_text: str,
expected_identity: tuple[int, int, int, int, int],
) -> str:
path = Path(path_text)
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError:
return ""
try:
before = os.fstat(descriptor)
if not stat.S_ISREG(before.st_mode) or _stat_identity(before) != expected_identity:
return ""
digest = hashlib.sha256()
while chunk := os.read(descriptor, 1024 * 1024):
digest.update(chunk)
after = os.fstat(descriptor)
try:
current = os.lstat(path)
except OSError:
return ""
if _stat_identity(before) != _stat_identity(after) or (
current.st_dev,
current.st_ino,
) != (before.st_dev, before.st_ino):
return ""
return digest.hexdigest()
finally:
os.close(descriptor)
def _stat_identity(value: os.stat_result) -> tuple[int, int, int, int, int]:
return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)
def _active_session_root(root: Path) -> Path | None:
marker = root / ".current_session"
try:
marker_stat = marker.lstat()
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
return None
value = marker.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return None
if not value:
return None
raw = Path(value).expanduser()
if raw.is_absolute():
candidate = raw
elif raw.parts and raw.parts[0] == root.name:
candidate = root.parent / raw
else:
candidate = root / raw
try:
resolved = candidate.resolve(strict=True)
except OSError:
return None
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
def _modalities(topic_counts: Mapping[str, int]) -> tuple[SessionModality, ...]:
topics = {
topic
for topic, count in topic_counts.items()
if isinstance(topic, str) and _non_negative_int(count) > 0
}
result: list[SessionModality] = []
if any(topic == "RealtimePointcloud" or topic.endswith("/lio_pcl") for topic in topics):
result.append("point-cloud")
if any(topic == "RealtimePath" or topic.endswith("/lio_pose") for topic in topics):
result.append("trajectory")
return tuple(result)
def _status(replayable: bool, error: object, stop_reason: object) -> SessionStatus:
if error is None and stop_reason in {
"duration_elapsed",
"external_stop",
"keyboard_interrupt",
}:
return "ready" if replayable else "failed"
return "interrupted" if replayable else "failed"
def _discover_media_sources(session_root: Path) -> tuple[LegacyMediaSourceCandidate, ...]:
media_root = session_root / "media"
try:
resolved_media_root = media_root.resolve(strict=True)
except OSError:
return ()
if not resolved_media_root.is_dir() or not resolved_media_root.is_relative_to(session_root):
return ()
candidates: list[LegacyMediaSourceCandidate] = []
for source_root in sorted(resolved_media_root.iterdir()):
if not source_root.is_dir() or not MEDIA_SOURCE_PATTERN.fullmatch(source_root.name):
continue
try:
resolved_source_root = source_root.resolve(strict=True)
except OSError:
continue
if not resolved_source_root.is_relative_to(resolved_media_root):
continue
epochs = [
epoch
for epoch in sorted(resolved_source_root.iterdir())
if epoch.is_dir()
and MEDIA_EPOCH_PATTERN.fullmatch(epoch.name)
and _validated_media_epoch(epoch, resolved_source_root.name)
]
if not epochs:
continue
byte_length = sum(_media_epoch_bytes(epoch) for epoch in epochs)
artifact_suffix = hashlib.sha256(resolved_source_root.name.encode()).hexdigest()[:16]
candidates.append(
LegacyMediaSourceCandidate(
source_id=resolved_source_root.name,
artifact_id=f"recorded-video-{artifact_suffix}",
locator=resolved_source_root,
byte_length=byte_length,
epoch_count=len(epochs),
)
)
return tuple(candidates)
def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool:
try:
resolved_epoch = epoch.resolve(strict=True)
init_path = (epoch / "init.mp4").resolve(strict=True)
segments_root = (epoch / "segments").resolve(strict=True)
index_path = (epoch / "index.jsonl").resolve(strict=True)
summary_path = (epoch / "summary.json").resolve(strict=True)
except OSError:
return False
if not all(
path.is_relative_to(resolved_epoch)
for path in (init_path, segments_root, index_path, summary_path)
):
return False
if (
not init_path.is_file()
or init_path.stat().st_size <= 0
or not segments_root.is_dir()
or not index_path.is_file()
or not 0 < index_path.stat().st_size <= MAX_MEDIA_INDEX_BYTES
or not summary_path.is_file()
):
return False
summary = _read_json_object(summary_path)
if summary.get("schema_version") not in {1, "missioncore.camera-recording/v1"}:
return False
if summary.get("source_id") != expected_source_id:
return False
segment_count = _non_negative_int(summary.get("segment_count"))
if segment_count < 1:
return False
segments = [
path.resolve()
for path in sorted(segments_root.iterdir())
if path.is_file() and MEDIA_SEGMENT_PATTERN.fullmatch(path.name)
]
if len(segments) != segment_count or any(
not path.is_relative_to(segments_root) or path.stat().st_size <= 0 for path in segments
):
return False
try:
index_records = [
json.loads(line)
for line in index_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return False
if len(index_records) != segment_count or not all(
isinstance(record, dict)
and _non_negative_int(record.get("sequence")) > 0
for record in index_records
):
return False
sequences = [int(record["sequence"]) for record in index_records]
return len(sequences) == len(set(sequences))
def _media_epoch_bytes(epoch: Path) -> int:
try:
resolved_epoch = epoch.resolve(strict=True)
except OSError:
return 0
total = 0
for path in resolved_epoch.rglob("*"):
try:
resolved = path.resolve(strict=True)
except OSError:
continue
if resolved.is_file() and resolved.is_relative_to(resolved_epoch):
total += resolved.stat().st_size
return total

1460
src/k1link/sessions/media.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,221 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
SessionStatus = Literal["ready", "interrupted", "failed"]
SessionModality = Literal["point-cloud", "trajectory", "video"]
class SessionStoreError(RuntimeError):
"""Base error for the host-owned observation session store."""
class SessionNotFoundError(SessionStoreError):
"""The requested opaque session identifier is not registered."""
class SessionNotReplayableError(SessionStoreError):
"""The session has no reviewed replay source."""
class SessionIntegrityError(SessionStoreError):
"""A stored artifact no longer satisfies its confinement/integrity boundary."""
class LayoutConflictError(SessionStoreError):
"""A workspace layout revision changed since the caller loaded it."""
@dataclass(frozen=True, slots=True)
class SessionSource:
source_id: str
semantic_channel_id: str
modality: SessionModality
status: str
seekable: bool
artifact_id: str
def as_dict(self) -> dict[str, Any]:
return {
"source_id": self.source_id,
"semantic_channel_id": self.semantic_channel_id,
"modality": self.modality,
"status": self.status,
"seekable": self.seekable,
"artifact_id": self.artifact_id,
}
@dataclass(frozen=True, slots=True)
class SessionArtifact:
artifact_id: str
kind: str
media_type: str
byte_length: int
sha256: str | None
integrity_status: str
def as_dict(self) -> dict[str, Any]:
return {
"artifact_id": self.artifact_id,
"kind": self.kind,
"media_type": self.media_type,
"byte_length": self.byte_length,
"sha256": self.sha256,
"integrity_status": self.integrity_status,
}
@dataclass(frozen=True, slots=True)
class SessionSummary:
session_id: str
display_name: str
status: SessionStatus
started_at_utc: str | None
completed_at_utc: str | None
duration_seconds: float | None
modalities: tuple[SessionModality, ...]
source_count: int
total_bytes: int
replayable: bool
origin: str
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.observation-session-summary/v1",
"session_id": self.session_id,
"display_name": self.display_name,
"status": self.status,
"started_at_utc": self.started_at_utc,
"completed_at_utc": self.completed_at_utc,
"duration_seconds": self.duration_seconds,
"modalities": list(self.modalities),
"source_count": self.source_count,
"total_bytes": self.total_bytes,
"replayable": self.replayable,
"origin": self.origin,
}
@dataclass(frozen=True, slots=True)
class SessionDetail:
summary: SessionSummary
sources: tuple[SessionSource, ...]
artifacts: tuple[SessionArtifact, ...]
def as_dict(self) -> dict[str, Any]:
duration = self.summary.duration_seconds
return {
"schema_version": "missioncore.observation-session/v1",
**{
key: value
for key, value in self.summary.as_dict().items()
if key != "schema_version"
},
"timeline": {
"mode": "recorded" if self.summary.replayable else "unavailable",
"seekable": self.summary.replayable,
"start_seconds": 0.0 if self.summary.replayable else None,
"end_seconds": duration if self.summary.replayable else None,
"synchronization": "host-arrival-best-effort",
},
"sources": [source.as_dict() for source in self.sources],
"artifacts": [artifact.as_dict() for artifact in self.artifacts],
}
@dataclass(frozen=True, slots=True)
class SessionPage:
items: tuple[SessionSummary, ...]
next_cursor: str | None
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.observation-session-list/v1",
"items": [item.as_dict() for item in self.items],
"next_cursor": self.next_cursor,
}
@dataclass(frozen=True, slots=True)
class WorkspaceLayout:
workspace_id: str
schema_version: int
revision: int
name: str
layout: dict[str, Any]
updated_at_utc: str
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.workspace-layout/v1",
"workspace_id": self.workspace_id,
"layout_schema_version": self.schema_version,
"revision": self.revision,
"name": self.name,
"layout": self.layout,
"updated_at_utc": self.updated_at_utc,
}
@dataclass(frozen=True, slots=True)
class ReplayCommand:
"""Internal-only replay command. ``source_path`` never enters an API DTO."""
session_id: str
source_path: Path
allowed_root: Path
session_root: Path
replay_byte_length: int
metadata_byte_length: int
expected_source_sha256: str | None
speed: float
loop: bool
@dataclass(frozen=True, slots=True)
class RecordedMediaArtifact:
"""Internal handle for one confined archived camera source.
The physical source id and filesystem locator never enter the public API.
``public_source_id`` and ``artifact_id`` are opaque catalog identifiers.
"""
session_id: str
public_source_id: str
artifact_id: str
source_path: Path
byte_length: int
@dataclass(frozen=True, slots=True)
class LegacySessionCandidate:
session_id: str
display_name: str
status: SessionStatus
started_at_utc: str | None
completed_at_utc: str | None
duration_seconds: float | None
modalities: tuple[SessionModality, ...]
replayable: bool
total_bytes: int
allowed_root: Path
session_root: Path
raw_path: Path
raw_byte_length: int
replay_raw_byte_length: int
replay_metadata_byte_length: int
raw_sha256: str | None
raw_integrity_status: str
media_sources: tuple[LegacyMediaSourceCandidate, ...]
@dataclass(frozen=True, slots=True)
class LegacyMediaSourceCandidate:
source_id: str
artifact_id: str
locator: Path
byte_length: int
epoch_count: int

View File

@ -0,0 +1,617 @@
from __future__ import annotations
import os
import queue
import threading
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from functools import partial
from pathlib import Path
from time import monotonic
from typing import Literal, cast
from uuid import uuid4
from .media import RecordedMediaManifest, validate_recorded_media_timeline
from .models import ReplayCommand
from .recording import (
MaterializedRecording,
RecordingMaterializationCancelled,
SessionRecordingMaterializer,
)
PreparationState = Literal[
"queued",
"validating",
"exporting",
"finalizing",
"ready",
"failed",
"cancelled",
]
ACTIVE_PREPARATION_STATES = frozenset({"queued", "validating", "exporting", "finalizing"})
_PREPARATION_PHASE: dict[PreparationState, int] = {
"queued": 0,
"validating": 1,
"exporting": 2,
"finalizing": 3,
"ready": 4,
"failed": 4,
"cancelled": 4,
}
class RecordingPreparationQueueFull(RuntimeError):
"""The bounded conversion queue cannot accept another recording."""
@dataclass(frozen=True, slots=True)
class RecordingPreparationSnapshot:
preparation_id: str
session_id: str
state: PreparationState
progress: float
updated_at_utc: str
cancellable: bool
retryable: bool
error: str | None
command: ReplayCommand
recording: MaterializedRecording | None
recorded_media: tuple[RecordedMediaManifest, ...] | None
@dataclass(slots=True)
class _PreparationJob:
preparation_id: str
source_identity: tuple[object, ...]
# This is an immutable source-preparation command. Per-browser playback
# policy (speed/loop) never belongs to a shared conversion job.
command: ReplayCommand
state: PreparationState = "queued"
progress: float = 0.0
updated_at_utc: str = field(default_factory=lambda: _utc_now_iso())
error: str | None = None
recording: MaterializedRecording | None = None
recorded_media: tuple[RecordedMediaManifest, ...] | None = None
cancel_event: threading.Event = field(default_factory=threading.Event)
last_activity_monotonic: float = field(default_factory=monotonic)
interrupted_by_restart: bool = False
cancelled_by_operator: bool = False
@dataclass(slots=True)
class _WorkerGeneration:
generation_id: int
work_queue: queue.Queue[_PreparationJob]
stop_event: threading.Event = field(default_factory=threading.Event)
worker: threading.Thread | None = None
class SessionRecordingPreparationManager:
"""One bounded, process-owned conversion worker for durable recordings.
Jobs are keyed by the session plus an inexpensive source identity. They
intentionally outlive HTTP requests and browser tabs. Conversion stays
single-worker to bound Rerun's CPU, memory and temporary-disk pressure.
Worker generations make lifespan restart safe even if a third-party
exporter ignores cancellation longer than ``close(timeout=...)``. New
jobs can queue immediately, but a successor worker starts only after the
previous generation has actually exited, so two writers never overlap.
"""
def __init__(
self,
materializer: SessionRecordingMaterializer,
*,
queue_capacity: int = 128,
heartbeat_interval_seconds: float = 5.0,
ready_preparer: Callable[
[ReplayCommand, MaterializedRecording],
tuple[RecordedMediaManifest, ...],
]
| None = None,
) -> None:
if queue_capacity < 1:
raise ValueError("recording preparation queue capacity must be positive")
if heartbeat_interval_seconds <= 0:
raise ValueError("recording preparation heartbeat interval must be positive")
self.materializer = materializer
self._queue_capacity = queue_capacity
# Activity callbacks from the real exporter are rate-limited to this
# interval. There is deliberately no independent fake heartbeat: a
# hung exporter must become observable to the browser stall detector.
self._heartbeat_interval_seconds = heartbeat_interval_seconds
self._ready_preparer = ready_preparer
self._guard = threading.RLock()
self._current_by_session: dict[str, _PreparationJob] = {}
self._closed = True
self._generation_counter = 0
self._active_generation: _WorkerGeneration | None = None
self._pending_generation: _WorkerGeneration | None = None
self.start()
def start(self) -> None:
"""Start or restart the single worker for an application lifespan."""
with self._guard:
if not self._closed:
return
self._current_by_session = {
session_id: job
for session_id, job in self._current_by_session.items()
if job.state != "cancelled"
}
self._closed = False
active = self._active_generation
if active is not None and active.worker is not None and active.worker.is_alive():
self._pending_generation = self._new_generation_locked()
return
self._active_generation = None
generation = self._pending_generation or self._new_generation_locked()
self._pending_generation = None
self._start_generation_locked(generation)
def enqueue(
self,
command: ReplayCommand,
*,
retry_failed: bool = False,
retry_interrupted: bool = False,
) -> RecordingPreparationSnapshot:
source_command = _source_command(command)
identity = _source_identity(source_command)
with self._guard:
if self._closed:
raise RuntimeError("recording preparation manager is closed")
current = self._current_by_session.get(command.session_id)
if current is not None and current.source_identity == identity:
retry_terminal = retry_failed and current.state in {"failed", "cancelled"}
retry_restart = (
retry_interrupted
and current.state == "cancelled"
and current.interrupted_by_restart
)
if not retry_terminal and not retry_restart:
return self._snapshot_locked(current)
job = _PreparationJob(
preparation_id=uuid4().hex,
source_identity=identity,
command=source_command,
)
self._current_by_session[command.session_id] = job
try:
self._intake_queue_locked().put_nowait(job)
except queue.Full as exc:
if self._current_by_session.get(command.session_id) is job:
if current is None:
self._current_by_session.pop(command.session_id, None)
else:
self._current_by_session[command.session_id] = current
raise RecordingPreparationQueueFull("recording preparation queue is full") from exc
return self._snapshot_locked(job)
def resolve_cached(
self,
command: ReplayCommand,
) -> RecordingPreparationSnapshot | None:
"""Validate a published cache and register it as a ready job."""
# When launch preparation includes camera manifests, a disk-only RRD
# is not a complete ready result. The worker must validate both parts
# in one background transaction before publishing ``ready``.
if self._ready_preparer is not None:
return None
source_command = _source_command(command)
identity = _source_identity(source_command)
recording = self.materializer.get_cached(source_command)
if recording is None:
with self._guard:
current = self._current_by_session.get(command.session_id)
if current is not None and current.state == "ready":
self._current_by_session.pop(command.session_id, None)
return None
with self._guard:
current = self._current_by_session.get(command.session_id)
if (
current is not None
and current.source_identity == identity
and current.state in ACTIVE_PREPARATION_STATES | {"ready"}
):
# A worker may have started between the disk lookup and this
# transaction. Let that single job publish its own result.
if current.state == "ready":
current.recording = recording
return self._snapshot_locked(current)
ready = _PreparationJob(
preparation_id=uuid4().hex,
source_identity=identity,
command=source_command,
state="ready",
progress=1.0,
recording=recording,
recorded_media=(),
)
self._current_by_session[command.session_id] = ready
return self._snapshot_locked(ready)
def resolve_cached_pinned(
self,
command: ReplayCommand,
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
"""Validate and lease a published cache without starting conversion."""
if self._ready_preparer is not None:
return None
source_command = _source_command(command)
result = self.materializer.get_cached_pinned(source_command)
if result is None:
with self._guard:
current = self._current_by_session.get(command.session_id)
if current is not None and current.state == "ready":
self._current_by_session.pop(command.session_id, None)
return None
recording, release = result
identity = _source_identity(source_command)
with self._guard:
current = self._current_by_session.get(command.session_id)
if (
current is None
or current.source_identity != identity
or current.state not in ACTIVE_PREPARATION_STATES | {"ready"}
):
current = _PreparationJob(
preparation_id=uuid4().hex,
source_identity=identity,
command=source_command,
state="ready",
progress=1.0,
recording=recording,
recorded_media=(),
)
self._current_by_session[command.session_id] = current
snapshot = self._snapshot_locked(current)
return snapshot, release
def status(self, session_id: str) -> RecordingPreparationSnapshot | None:
with self._guard:
job = self._current_by_session.get(session_id)
if (
job is not None
and job.state == "ready"
and (
job.recording is None
or not self.materializer.is_recording_available(job.recording)
)
):
self._current_by_session.pop(session_id, None)
return None
return None if job is None else self._snapshot_locked(job)
def reserve_cached(
self,
command: ReplayCommand,
*,
lease_seconds: float = 120.0,
) -> RecordingPreparationSnapshot | None:
"""Pin a ready artifact across the launch-document to file-GET gap."""
if lease_seconds <= 0:
raise ValueError("recording launch lease must be positive")
pinned = self.resolve_cached_pinned(command)
if pinned is None:
return None
snapshot, release = pinned
timer = threading.Timer(lease_seconds, release)
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
timer.daemon = True
timer.start()
return snapshot
def pin_ready(
self,
session_id: str,
*,
preparation_id: str | None = None,
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
"""Cheaply lease the exact already-validated ready generation."""
with self._guard:
job = self._current_by_session.get(session_id)
if (
job is None
or job.state != "ready"
or job.recording is None
or (
preparation_id is not None
and job.preparation_id != preparation_id
)
):
return None
recording = job.recording
pinned = self.materializer.pin_recording(recording)
if pinned is None:
if self._current_by_session.get(session_id) is job:
self._current_by_session.pop(session_id, None)
return None
snapshot = self._snapshot_locked(job)
return snapshot, pinned
def reserve_ready(
self,
session_id: str,
*,
preparation_id: str | None = None,
lease_seconds: float = 120.0,
) -> RecordingPreparationSnapshot | None:
"""Hold a cheap launch lease without reopening or hashing the cache."""
if lease_seconds <= 0:
raise ValueError("recording launch lease must be positive")
pinned = self.pin_ready(session_id, preparation_id=preparation_id)
if pinned is None:
return None
snapshot, release = pinned
timer = threading.Timer(lease_seconds, release)
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
timer.daemon = True
timer.start()
return snapshot
def cancel(self, session_id: str, *, preparation_id: str | None = None) -> bool:
with self._guard:
job = self._current_by_session.get(session_id)
if (
job is None
or job.state not in ACTIVE_PREPARATION_STATES
or (preparation_id is not None and job.preparation_id != preparation_id)
):
return False
job.cancelled_by_operator = True
job.cancel_event.set()
if job.state == "queued":
self._transition_locked(job, "cancelled", job.progress)
return True
def close(self, *, timeout: float = 5.0) -> None:
with self._guard:
if self._closed:
return
self._closed = True
for job in self._current_by_session.values():
if job.state in ACTIVE_PREPARATION_STATES:
job.interrupted_by_restart = not job.cancelled_by_operator
job.cancel_event.set()
if job.state == "queued":
self._transition_locked(job, "cancelled", job.progress)
active = self._active_generation
if active is not None:
active.stop_event.set()
pending = self._pending_generation
if pending is not None:
pending.stop_event.set()
self._pending_generation = None
worker = None if active is None else active.worker
if worker is not None:
worker.join(timeout=max(0.0, timeout))
def _run_generation(self, generation: _WorkerGeneration) -> None:
work_queue = generation.work_queue
try:
while not generation.stop_event.is_set():
try:
job = work_queue.get(timeout=0.1)
except queue.Empty:
continue
try:
if job.state == "cancelled" or job.cancel_event.is_set():
with self._guard:
self._transition_locked(job, "cancelled", job.progress)
continue
with self._guard:
self._transition_locked(job, "validating", 0.05)
try:
recording = self.materializer.materialize(
job.command,
progress_callback=partial(self._progress, job),
cancel_event=job.cancel_event,
)
except RecordingMaterializationCancelled:
with self._guard:
self._transition_locked(job, "cancelled", job.progress)
except Exception:
# The public status intentionally does not expose paths,
# broker payloads or exporter internals.
with self._guard:
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
self._transition_locked(job, "failed", job.progress)
job.error = "Не удалось подготовить запись сессии."
job.updated_at_utc = _utc_now_iso()
else:
with self._guard:
# Cancellation may race the final materializer
# return. Never publish ``ready`` after accepting a
# cancel request for this exact job.
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
self._transition_locked(job, "finalizing", 0.95)
if job.cancel_event.is_set():
with self._guard:
self._transition_locked(job, "cancelled", job.progress)
continue
try:
recorded_media = (
()
if self._ready_preparer is None
else self._ready_preparer(job.command, recording)
)
validate_recorded_media_timeline(
recorded_media,
recording_start_seconds=(
recording.timeline_start_ns / 1_000_000_000
),
recording_end_seconds=(
recording.timeline_end_ns / 1_000_000_000
),
)
except Exception:
with self._guard:
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
self._transition_locked(job, "failed", job.progress)
job.error = "Не удалось подготовить запись сессии."
job.updated_at_utc = _utc_now_iso()
continue
with self._guard:
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
job.recording = recording
job.recorded_media = recorded_media
self._transition_locked(job, "ready", 1.0)
finally:
work_queue.task_done()
finally:
self._generation_exited(generation)
def _new_generation_locked(self) -> _WorkerGeneration:
self._generation_counter += 1
return _WorkerGeneration(
generation_id=self._generation_counter,
work_queue=queue.Queue(maxsize=self._queue_capacity),
)
def _start_generation_locked(self, generation: _WorkerGeneration) -> None:
self._active_generation = generation
worker = threading.Thread(
target=self._run_generation,
args=(generation,),
name=f"missioncore-recording-preparation-{generation.generation_id}",
daemon=True,
)
generation.worker = worker
worker.start()
def _generation_exited(self, generation: _WorkerGeneration) -> None:
with self._guard:
if self._active_generation is not generation:
return
self._active_generation = None
if self._closed:
return
successor = self._pending_generation or self._new_generation_locked()
self._pending_generation = None
self._start_generation_locked(successor)
def _intake_queue_locked(self) -> queue.Queue[_PreparationJob]:
pending = self._pending_generation
if pending is not None:
return pending.work_queue
active = self._active_generation
if active is None or active.stop_event.is_set():
pending = self._new_generation_locked()
self._pending_generation = pending
return pending.work_queue
return active.work_queue
def _progress(self, job: _PreparationJob, state: str, progress: float) -> None:
# Only the worker publishes ``ready`` after it owns the verified
# recording handle; accepting the final callback would expose a short
# ready-without-launch race to status polling.
if state not in {"validating", "exporting", "finalizing"}:
return
next_state = cast(PreparationState, state)
with self._guard:
if job.state in {"cancelled", "failed", "ready"}:
return
if _PREPARATION_PHASE[next_state] < _PREPARATION_PHASE[job.state]:
return
now = monotonic()
if (
job.state == next_state
and progress <= job.progress
and now - job.last_activity_monotonic < self._heartbeat_interval_seconds
):
return
self._transition_locked(job, next_state, progress)
def _transition_locked(
self,
job: _PreparationJob,
state: PreparationState,
progress: float,
) -> None:
if job.state not in ACTIVE_PREPARATION_STATES and state != job.state:
return
if _PREPARATION_PHASE[state] < _PREPARATION_PHASE[job.state]:
return
job.state = state
job.progress = max(job.progress, min(1.0, max(0.0, progress)))
job.updated_at_utc = _utc_now_iso()
job.last_activity_monotonic = monotonic()
def _snapshot_locked(self, job: _PreparationJob) -> RecordingPreparationSnapshot:
return RecordingPreparationSnapshot(
preparation_id=job.preparation_id,
session_id=job.command.session_id,
state=job.state,
progress=job.progress,
updated_at_utc=job.updated_at_utc,
cancellable=(
job.state == "queued"
or (
job.state in {"validating", "exporting", "finalizing"}
and self.materializer.supports_cooperative_cancellation
)
),
retryable=job.state in {"failed", "cancelled"},
error=job.error,
command=job.command,
recording=job.recording,
recorded_media=job.recorded_media,
)
def _source_identity(command: ReplayCommand) -> tuple[object, ...]:
"""Build a non-blocking identity; full validation belongs to the worker."""
identities: list[object] = [
command.session_id,
str(command.source_path),
command.replay_byte_length,
command.metadata_byte_length,
command.expected_source_sha256,
]
for path in (command.source_path, command.source_path.with_name("mqtt.metadata.jsonl")):
try:
value = os.lstat(path)
except OSError:
identities.extend((str(Path(path)), None, None, None, None))
else:
identities.extend(
(
str(Path(path)),
value.st_size,
value.st_mtime_ns,
value.st_ctime_ns,
value.st_ino,
)
)
return tuple(identities)
def _source_command(command: ReplayCommand) -> ReplayCommand:
"""Remove per-viewer launch policy from a shared preparation job."""
return replace(command, speed=1.0, loop=False)
def _utc_now_iso() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,715 @@
from __future__ import annotations
import json
import os
import re
import sqlite3
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any, cast
from k1link.artifacts import utc_now_iso
from .legacy import discover_legacy_viewer_sessions
from .models import (
LayoutConflictError,
LegacySessionCandidate,
RecordedMediaArtifact,
ReplayCommand,
SessionArtifact,
SessionDetail,
SessionIntegrityError,
SessionModality,
SessionNotFoundError,
SessionNotReplayableError,
SessionPage,
SessionSource,
SessionStatus,
SessionSummary,
WorkspaceLayout,
)
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
MAX_LAYOUT_BYTES = 256 * 1024
DATABASE_NAME = "mission-core.sqlite3"
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS observation_sessions (
session_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ready', 'interrupted', 'failed')),
started_at_utc TEXT,
completed_at_utc TEXT,
duration_seconds REAL,
modalities_json TEXT NOT NULL,
replayable INTEGER NOT NULL CHECK (replayable IN (0, 1)),
origin TEXT NOT NULL,
source_count INTEGER NOT NULL,
total_bytes INTEGER NOT NULL,
replay_raw_bytes INTEGER NOT NULL DEFAULT 0,
replay_metadata_bytes INTEGER NOT NULL DEFAULT 0,
allowed_root TEXT NOT NULL,
session_root TEXT NOT NULL,
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS observation_sessions_recent
ON observation_sessions(started_at_utc DESC, session_id DESC);
CREATE TABLE IF NOT EXISTS observation_session_artifacts (
session_id TEXT NOT NULL REFERENCES observation_sessions(session_id) ON DELETE CASCADE,
artifact_id TEXT NOT NULL,
kind TEXT NOT NULL,
media_type TEXT NOT NULL,
byte_length INTEGER NOT NULL,
sha256 TEXT,
integrity_status TEXT NOT NULL,
locator TEXT NOT NULL,
PRIMARY KEY (session_id, artifact_id)
);
CREATE TABLE IF NOT EXISTS observation_session_sources (
session_id TEXT NOT NULL REFERENCES observation_sessions(session_id) ON DELETE CASCADE,
source_id TEXT NOT NULL,
semantic_channel_id TEXT NOT NULL,
modality TEXT NOT NULL,
status TEXT NOT NULL,
seekable INTEGER NOT NULL CHECK (seekable IN (0, 1)),
artifact_id TEXT NOT NULL,
PRIMARY KEY (session_id, source_id),
FOREIGN KEY (session_id, artifact_id)
REFERENCES observation_session_artifacts(session_id, artifact_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS workspace_layouts (
workspace_id TEXT PRIMARY KEY,
layout_schema_version INTEGER NOT NULL,
revision INTEGER NOT NULL,
name TEXT NOT NULL,
layout_json TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
"""
def resolve_missioncore_data_dir(repository_root: Path) -> Path:
configured = os.environ.get("MISSIONCORE_DATA_DIR", "").strip()
if configured:
return Path(configured).expanduser().resolve()
return (repository_root.expanduser().resolve() / ".runtime" / "mission-core").resolve()
def resolve_missioncore_evidence_dir(repository_root: Path) -> Path:
"""Return the private source-of-record root for new observation sessions."""
configured = os.environ.get("MISSIONCORE_EVIDENCE_DIR", "").strip()
if configured:
return Path(configured).expanduser().resolve()
return (resolve_missioncore_data_dir(repository_root) / "evidence" / "sessions").resolve()
class SessionStore:
"""SQLite catalog plus confined filesystem references for host observation sessions."""
def __init__(self, repository_root: Path, *, data_dir: Path | None = None) -> None:
self.repository_root = repository_root.expanduser().resolve()
self.data_dir = (
data_dir.expanduser().resolve()
if data_dir is not None
else resolve_missioncore_data_dir(self.repository_root)
)
self.data_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
with _ignore_os_error():
self.data_dir.chmod(0o700)
self.database_path = self.data_dir / DATABASE_NAME
self._lock = threading.RLock()
self._initialize()
def import_legacy_viewer_live(self, root: Path) -> tuple[str, ...]:
allowed_root = root.expanduser().resolve()
candidates = discover_legacy_viewer_sessions(allowed_root)
imported: list[str] = []
for candidate in candidates:
self._upsert_legacy(candidate)
imported.append(candidate.session_id)
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
discovered = set(imported)
indexed = connection.execute(
"SELECT session_id FROM observation_sessions "
"WHERE origin = 'legacy-viewer-live' AND allowed_root = ?",
(str(allowed_root),),
).fetchall()
stale = [row["session_id"] for row in indexed if row["session_id"] not in discovered]
connection.executemany(
"DELETE FROM observation_sessions WHERE session_id = ?",
((session_id,) for session_id in stale),
)
connection.commit()
return tuple(imported)
def list_recent(self, *, limit: int = 20, cursor: str | None = None) -> SessionPage:
if not 1 <= limit <= 100:
raise ValueError("limit must be within 1..100")
parameters: list[object] = []
where = ""
with self._connect() as connection:
if cursor is not None:
_validate_identifier(cursor, "session cursor")
cursor_row = connection.execute(
"SELECT started_at_utc, session_id FROM observation_sessions "
"WHERE session_id = ?",
(cursor,),
).fetchone()
if cursor_row is None:
raise SessionNotFoundError("observation session cursor was not found")
where = (
"WHERE (COALESCE(started_at_utc, ''), session_id) < "
"(COALESCE(?, ''), ?)"
)
parameters.extend((cursor_row["started_at_utc"], cursor_row["session_id"]))
parameters.append(limit + 1)
rows = connection.execute(
f"SELECT * FROM observation_sessions {where} " # noqa: S608 - static clause
"ORDER BY COALESCE(started_at_utc, '') DESC, session_id DESC LIMIT ?",
parameters,
).fetchall()
has_more = len(rows) > limit
selected = rows[:limit]
items = tuple(_summary_from_row(row) for row in selected)
next_cursor = items[-1].session_id if has_more and items else None
return SessionPage(items=items, next_cursor=next_cursor)
def get_session(self, session_id: str) -> SessionDetail:
_validate_identifier(session_id, "session id")
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM observation_sessions WHERE session_id = ?",
(session_id,),
).fetchone()
if row is None:
raise SessionNotFoundError("observation session was not found")
source_rows = connection.execute(
"SELECT source_id, semantic_channel_id, modality, status, seekable, artifact_id "
"FROM observation_session_sources WHERE session_id = ? ORDER BY source_id",
(session_id,),
).fetchall()
artifact_rows = connection.execute(
"SELECT artifact_id, kind, media_type, byte_length, sha256, integrity_status "
"FROM observation_session_artifacts WHERE session_id = ? ORDER BY artifact_id",
(session_id,),
).fetchall()
sources = tuple(
SessionSource(
source_id=source["source_id"],
semantic_channel_id=source["semantic_channel_id"],
modality=cast(SessionModality, source["modality"]),
status=source["status"],
seekable=bool(source["seekable"]),
artifact_id=source["artifact_id"],
)
for source in source_rows
)
artifacts = tuple(
SessionArtifact(
artifact_id=artifact["artifact_id"],
kind=artifact["kind"],
media_type=artifact["media_type"],
byte_length=artifact["byte_length"],
sha256=artifact["sha256"],
integrity_status=artifact["integrity_status"],
)
for artifact in artifact_rows
)
return SessionDetail(summary=_summary_from_row(row), sources=sources, artifacts=artifacts)
def prepare_replay(
self,
session_id: str,
*,
speed: float = 1.0,
loop: bool = False,
) -> ReplayCommand:
detail = self.get_session(session_id)
if not detail.summary.replayable:
raise SessionNotReplayableError("observation session has no replayable spatial source")
if not 0 <= speed <= 100:
raise ValueError("speed must be within 0..100")
with self._connect() as connection:
row = connection.execute(
"SELECT s.allowed_root, s.session_root, s.replay_raw_bytes, "
"s.replay_metadata_bytes, a.locator, a.sha256 "
"FROM observation_sessions AS s "
"JOIN observation_session_artifacts AS a ON a.session_id = s.session_id "
"WHERE s.session_id = ? AND a.artifact_id = 'raw-mqtt'",
(session_id,),
).fetchone()
if row is None:
raise SessionNotReplayableError("observation session replay artifact is unavailable")
source_path = _resolve_confined_artifact(
Path(row["allowed_root"]),
Path(row["session_root"]),
Path(row["locator"]),
)
return ReplayCommand(
session_id=session_id,
source_path=source_path,
allowed_root=Path(row["allowed_root"]),
session_root=Path(row["session_root"]),
replay_byte_length=int(row["replay_raw_bytes"]),
metadata_byte_length=int(row["replay_metadata_bytes"]),
expected_source_sha256=row["sha256"],
speed=float(speed),
loop=loop,
)
def list_recorded_media(self, session_id: str) -> tuple[RecordedMediaArtifact, ...]:
"""Return confined archived-video handles without exposing device ids."""
_validate_identifier(session_id, "session id")
with self._connect() as connection:
session = connection.execute(
"SELECT allowed_root, session_root FROM observation_sessions "
"WHERE session_id = ?",
(session_id,),
).fetchone()
if session is None:
raise SessionNotFoundError("observation session was not found")
rows = connection.execute(
"SELECT DISTINCT a.artifact_id, a.locator, a.byte_length "
"FROM observation_session_artifacts AS a "
"JOIN observation_session_sources AS source "
"ON source.session_id = a.session_id AND source.artifact_id = a.artifact_id "
"WHERE a.session_id = ? AND a.kind = 'recorded-video' "
"AND source.modality = 'video' ORDER BY a.artifact_id",
(session_id,),
).fetchall()
return tuple(
_recorded_media_from_row(
session_id=session_id,
allowed_root=Path(session["allowed_root"]),
session_root=Path(session["session_root"]),
artifact_id=row["artifact_id"],
locator=Path(row["locator"]),
byte_length=row["byte_length"],
)
for row in rows
)
def get_recorded_media(
self,
session_id: str,
artifact_id: str,
) -> RecordedMediaArtifact:
_validate_identifier(artifact_id, "recorded media artifact id")
matches = tuple(
artifact
for artifact in self.list_recorded_media(session_id)
if artifact.artifact_id == artifact_id
)
if len(matches) != 1:
raise SessionNotFoundError("recorded media source was not found")
return matches[0]
def get_layout(self, workspace_id: str) -> WorkspaceLayout:
_validate_identifier(workspace_id, "workspace id")
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM workspace_layouts WHERE workspace_id = ?",
(workspace_id,),
).fetchone()
if row is None:
raise SessionNotFoundError("workspace layout was not found")
return _layout_from_row(row)
def save_layout(
self,
workspace_id: str,
*,
schema_version: int,
expected_revision: int,
name: str,
layout: dict[str, Any],
) -> WorkspaceLayout:
_validate_identifier(workspace_id, "workspace id")
if schema_version != 1:
raise ValueError("only workspace layout schema version 1 is supported")
if expected_revision < 0:
raise ValueError("expected revision must be non-negative")
normalized_name = name.strip()
if not 1 <= len(normalized_name) <= 160:
raise ValueError("layout name must contain 1..160 characters")
serialized = _serialize_layout(layout)
updated_at = utc_now_iso()
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
current = connection.execute(
"SELECT revision FROM workspace_layouts WHERE workspace_id = ?",
(workspace_id,),
).fetchone()
current_revision = 0 if current is None else int(current["revision"])
if current_revision != expected_revision:
connection.rollback()
raise LayoutConflictError("workspace layout revision changed")
revision = current_revision + 1
connection.execute(
"INSERT INTO workspace_layouts "
"(workspace_id, layout_schema_version, revision, name, layout_json, "
"updated_at_utc) VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(workspace_id) DO UPDATE SET "
"layout_schema_version = excluded.layout_schema_version, "
"revision = excluded.revision, name = excluded.name, "
"layout_json = excluded.layout_json, updated_at_utc = excluded.updated_at_utc",
(
workspace_id,
schema_version,
revision,
normalized_name,
serialized,
updated_at,
),
)
connection.commit()
return WorkspaceLayout(
workspace_id=workspace_id,
schema_version=schema_version,
revision=revision,
name=normalized_name,
layout=json.loads(serialized),
updated_at_utc=updated_at,
)
def _initialize(self) -> None:
with self._connect() as connection:
connection.executescript(SCHEMA_SQL)
columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(observation_sessions)")
}
if "replay_raw_bytes" not in columns:
connection.execute(
"ALTER TABLE observation_sessions "
"ADD COLUMN replay_raw_bytes INTEGER NOT NULL DEFAULT 0"
)
if "replay_metadata_bytes" not in columns:
connection.execute(
"ALTER TABLE observation_sessions "
"ADD COLUMN replay_metadata_bytes INTEGER NOT NULL DEFAULT 0"
)
connection.commit()
with _ignore_os_error():
self.database_path.chmod(0o600)
def _upsert_legacy(self, candidate: LegacySessionCandidate) -> None:
_validate_identifier(candidate.session_id, "legacy session id")
allowed_root = candidate.allowed_root.resolve()
session_root = candidate.session_root.resolve()
if not session_root.is_relative_to(allowed_root):
raise SessionIntegrityError("legacy session root escapes its allowed root")
sources = _legacy_sources(candidate)
artifacts = _legacy_artifacts(candidate, session_root)
now = utc_now_iso()
modalities_json = json.dumps(list(candidate.modalities), separators=(",", ":"))
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT origin, allowed_root, session_root, created_at_utc "
"FROM observation_sessions WHERE session_id = ?",
(candidate.session_id,),
).fetchone()
if existing is not None and (
existing["origin"] != "legacy-viewer-live"
or Path(existing["allowed_root"]).resolve() != allowed_root
or Path(existing["session_root"]).resolve() != session_root
):
connection.rollback()
raise SessionIntegrityError("session id is already bound to another origin")
created_at = existing["created_at_utc"] if existing is not None else now
connection.execute(
"INSERT INTO observation_sessions "
"(session_id, display_name, status, started_at_utc, completed_at_utc, "
"duration_seconds, modalities_json, replayable, origin, source_count, "
"total_bytes, replay_raw_bytes, replay_metadata_bytes, allowed_root, "
"session_root, created_at_utc, updated_at_utc) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(session_id) DO UPDATE SET "
"display_name = excluded.display_name, status = excluded.status, "
"started_at_utc = excluded.started_at_utc, "
"completed_at_utc = excluded.completed_at_utc, "
"duration_seconds = excluded.duration_seconds, "
"modalities_json = excluded.modalities_json, "
"replayable = excluded.replayable, source_count = excluded.source_count, "
"total_bytes = excluded.total_bytes, "
"replay_raw_bytes = excluded.replay_raw_bytes, "
"replay_metadata_bytes = excluded.replay_metadata_bytes, "
"updated_at_utc = excluded.updated_at_utc",
(
candidate.session_id,
candidate.display_name,
candidate.status,
candidate.started_at_utc,
candidate.completed_at_utc,
candidate.duration_seconds,
modalities_json,
int(candidate.replayable),
"legacy-viewer-live",
len(sources),
candidate.total_bytes,
candidate.replay_raw_byte_length,
candidate.replay_metadata_byte_length,
str(allowed_root),
str(session_root),
created_at,
now,
),
)
connection.execute(
"DELETE FROM observation_session_sources WHERE session_id = ?",
(candidate.session_id,),
)
connection.execute(
"DELETE FROM observation_session_artifacts WHERE session_id = ?",
(candidate.session_id,),
)
connection.executemany(
"INSERT INTO observation_session_artifacts "
"(session_id, artifact_id, kind, media_type, byte_length, sha256, "
"integrity_status, locator) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[
(candidate.session_id, *artifact)
for artifact in artifacts
],
)
connection.executemany(
"INSERT INTO observation_session_sources "
"(session_id, source_id, semantic_channel_id, modality, status, seekable, "
"artifact_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
[
(
candidate.session_id,
source.source_id,
source.semantic_channel_id,
source.modality,
source.status,
int(source.seekable),
source.artifact_id,
)
for source in sources
],
)
connection.commit()
@contextmanager
def _connect(self) -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(self.database_path, timeout=5.0)
connection.row_factory = sqlite3.Row
try:
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA synchronous = FULL")
connection.execute("PRAGMA busy_timeout = 5000")
connection.execute("PRAGMA journal_mode = WAL")
yield connection
finally:
connection.close()
def _legacy_sources(candidate: LegacySessionCandidate) -> tuple[SessionSource, ...]:
rows: list[SessionSource] = []
if "point-cloud" in candidate.modalities:
rows.append(
SessionSource(
source_id="sensor.lidar.primary",
semantic_channel_id="spatial.point-cloud.recorded",
modality="point-cloud",
status="recorded",
seekable=candidate.replayable,
artifact_id="raw-mqtt",
)
)
if "trajectory" in candidate.modalities:
rows.append(
SessionSource(
source_id="spatial.trajectory",
semantic_channel_id="spatial.pose.recorded",
modality="trajectory",
status="recorded",
seekable=candidate.replayable,
artifact_id="raw-mqtt",
)
)
rows.extend(
SessionSource(
source_id=media.source_id,
semantic_channel_id="camera.video.recorded",
modality="video",
status="recorded",
seekable=True,
artifact_id=media.artifact_id,
)
for media in candidate.media_sources
)
if len({source.source_id for source in rows}) != len(rows):
raise SessionIntegrityError("legacy session contains duplicate source identifiers")
return tuple(rows)
def _legacy_artifacts(
candidate: LegacySessionCandidate,
session_root: Path,
) -> tuple[tuple[str, str, str, int, str | None, str, str], ...]:
artifacts: list[tuple[str, str, str, int, str | None, str, str]] = [
(
"raw-mqtt",
"raw-transport",
"application/x-nodedc-k1mqtt",
candidate.raw_byte_length,
candidate.raw_sha256,
candidate.raw_integrity_status,
str(candidate.raw_path),
)
]
for media in candidate.media_sources:
try:
locator = media.locator.resolve(strict=True)
except OSError as exc:
raise SessionIntegrityError("legacy video artifact is missing") from exc
if not locator.is_dir() or not locator.is_relative_to(session_root):
raise SessionIntegrityError("legacy video artifact escapes its session root")
artifacts.append(
(
media.artifact_id,
"recorded-video",
"video/mp4",
media.byte_length,
None,
"validated-structure",
str(locator),
)
)
if len({artifact[0] for artifact in artifacts}) != len(artifacts):
raise SessionIntegrityError("legacy session contains duplicate artifact identifiers")
return tuple(artifacts)
def _summary_from_row(row: sqlite3.Row) -> SessionSummary:
raw_modalities = json.loads(row["modalities_json"])
modalities = tuple(cast(SessionModality, value) for value in raw_modalities)
return SessionSummary(
session_id=row["session_id"],
display_name=row["display_name"],
status=cast(SessionStatus, row["status"]),
started_at_utc=row["started_at_utc"],
completed_at_utc=row["completed_at_utc"],
duration_seconds=row["duration_seconds"],
modalities=modalities,
source_count=row["source_count"],
total_bytes=row["total_bytes"],
replayable=bool(row["replayable"]),
origin=row["origin"],
)
def _layout_from_row(row: sqlite3.Row) -> WorkspaceLayout:
payload = json.loads(row["layout_json"])
if not isinstance(payload, dict):
raise SessionIntegrityError("stored workspace layout is not a JSON object")
return WorkspaceLayout(
workspace_id=row["workspace_id"],
schema_version=row["layout_schema_version"],
revision=row["revision"],
name=row["name"],
layout=payload,
updated_at_utc=row["updated_at_utc"],
)
def _resolve_confined_artifact(
allowed_root: Path,
session_root: Path,
locator: Path,
) -> Path:
try:
allowed = allowed_root.resolve(strict=True)
session = session_root.resolve(strict=True)
artifact = locator.resolve(strict=True)
except OSError as exc:
raise SessionIntegrityError("session replay artifact is missing") from exc
if (
not allowed.is_dir()
or not session.is_dir()
or not artifact.is_file()
or not session.is_relative_to(allowed)
or not artifact.is_relative_to(session)
):
raise SessionIntegrityError("session replay artifact escapes its allowed root")
return artifact
def _recorded_media_from_row(
*,
session_id: str,
allowed_root: Path,
session_root: Path,
artifact_id: str,
locator: Path,
byte_length: int,
) -> RecordedMediaArtifact:
_validate_identifier(artifact_id, "recorded media artifact id")
try:
allowed = allowed_root.resolve(strict=True)
session = session_root.resolve(strict=True)
media_root = (session / "media").resolve(strict=True)
artifact = locator.resolve(strict=True)
except OSError as exc:
raise SessionIntegrityError("recorded media artifact is missing") from exc
if (
not allowed.is_dir()
or not session.is_dir()
or not media_root.is_dir()
or not artifact.is_dir()
or not session.is_relative_to(allowed)
or not media_root.is_relative_to(session)
or artifact.parent != media_root
):
raise SessionIntegrityError("recorded media artifact escapes its session root")
if not isinstance(byte_length, int) or isinstance(byte_length, bool) or byte_length < 1:
raise SessionIntegrityError("recorded media artifact has an invalid byte length")
public_suffix = artifact_id.removeprefix("recorded-video-")
public_source_id = f"recorded.camera.{public_suffix}"
_validate_identifier(public_source_id, "recorded media source id")
return RecordedMediaArtifact(
session_id=session_id,
public_source_id=public_source_id,
artifact_id=artifact_id,
source_path=artifact,
byte_length=byte_length,
)
def _serialize_layout(layout: dict[str, Any]) -> str:
try:
serialized = json.dumps(layout, ensure_ascii=False, separators=(",", ":"))
except (TypeError, ValueError) as exc:
raise ValueError("layout must be a JSON object") from exc
if len(serialized.encode("utf-8")) > MAX_LAYOUT_BYTES:
raise ValueError("layout exceeds the 256 KiB storage boundary")
return serialized
def _validate_identifier(value: str, label: str) -> None:
if not IDENTIFIER_PATTERN.fullmatch(value):
raise ValueError(f"{label} has an invalid shape")
@contextmanager
def _ignore_os_error() -> Iterator[None]:
try:
yield
except OSError:
return

View File

@ -53,11 +53,17 @@ def _iter_native_capture(path: Path) -> Iterator[StreamMessage]:
epoch_ns = fallback_epoch_ns + frame.sequence - 1
monotonic_ns: int | None = None
if metadata_stream is not None:
epoch_ns, monotonic_ns = _read_native_timing(
timing = _read_native_timing(
metadata_stream,
expected_sequence=frame.sequence,
fallback_epoch_ns=epoch_ns,
)
if timing is None:
# A crash may leave one raw frame ahead of the last fully
# committed metadata line. Only the aligned prefix has a
# trustworthy source timeline and is safe to replay.
return
epoch_ns, monotonic_ns = timing
yield StreamMessage(
sequence=frame.sequence,
topic=frame.topic,
@ -78,15 +84,19 @@ def _read_native_timing(
*,
expected_sequence: int,
fallback_epoch_ns: int,
) -> tuple[int, int | None]:
) -> tuple[int, int | None] | None:
line = stream.readline(MAX_METADATA_LINE_CHARS + 1)
if not line:
return fallback_epoch_ns, None
return None
if len(line) > MAX_METADATA_LINE_CHARS:
raise ReplayFormatError("native metadata line exceeds the reviewed bound")
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
if not line.endswith(("\n", "\r")):
# A non-newline final tail is the only tolerated corruption: the
# writer may have crashed between raw and metadata group commits.
return None
raise ReplayFormatError(
f"native metadata line {expected_sequence} is not valid JSON"
) from exc

View File

@ -0,0 +1,595 @@
from __future__ import annotations
import hashlib
import math
import os
import threading
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import TypedDict
from uuid import UUID, uuid4
import numpy as np
import rerun as rr
from rerun import blueprint as rrb
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
from k1link.protocol.normalizer import normalize_k1_message
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages
from k1link.viewer.rerun_bridge import (
MAX_TRAJECTORY_POSES,
TRAJECTORY_APPEND_INTERVAL_NS,
TRAJECTORY_FORCE_APPEND_NS,
TRAJECTORY_MIN_DISTANCE_METERS,
TRAJECTORY_PUBLISH_INTERVAL_NS,
RerunSceneSettings,
_parse_hex_color,
_point_colors,
)
APPLICATION_ID = "nodedc_mission_core_recorded"
SESSION_TIMELINE = "session_time"
CAPTURE_TIMELINE = "capture_time"
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
# blueprint update makes the update overwrite the existing scene instead of
# creating a fresh view/container (which would also reset the operator's eye
# position and layout).
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
class RrdExportSummary(TypedDict):
schema_version: int
input_path: str
output_path: str
recording_id: str
timeline: str
capture_timeline: str
source_messages: int
decoded_messages: int
point_frames: int
pose_frames: int
ignored_messages: int
points: int
trajectory_poses: int
trajectory_updates: int
session_origin_monotonic_ns: int
timeline_start_ns: int
timeline_end_ns: int
timeline_span_ns: int
first_decoded_time_ns: int
last_decoded_time_ns: int
source_sha256: str
rrd_sha256: str
rrd_bytes: int
class RrdExportError(RuntimeError):
"""A raw capture could not be converted into a complete durable RRD."""
class RrdExportCancelled(RrdExportError):
"""A background RRD export was cooperatively cancelled."""
@dataclass(slots=True)
class _ExportCounters:
source_messages: int = 0
point_frames: int = 0
pose_frames: int = 0
ignored_messages: int = 0
points: int = 0
first_decoded_time_ns: int | None = None
last_decoded_time_ns: int | None = None
@property
def decoded_messages(self) -> int:
return self.point_frames + self.pose_frames
def observe_decoded(self, session_time_ns: int) -> None:
if self.first_decoded_time_ns is None:
self.first_decoded_time_ns = session_time_ns
self.last_decoded_time_ns = session_time_ns
@dataclass(slots=True)
class _TrajectoryBuffer:
positions: list[tuple[float, float, float]]
last_append_time_ns: int | None = None
last_publish_time_ns: int | None = None
updates: int = 0
@classmethod
def empty(cls) -> _TrajectoryBuffer:
return cls(positions=[])
def process(
self,
recording: rr.RecordingStream,
position: tuple[float, float, float],
session_time_ns: int,
) -> None:
if not self._append(position, session_time_ns):
return
if (
self.last_publish_time_ns is not None
and session_time_ns - self.last_publish_time_ns < TRAJECTORY_PUBLISH_INTERVAL_NS
):
return
self.last_publish_time_ns = session_time_ns
self.updates += 1
recording.log(
"/world/trajectory",
rr.LineStrips3D(
[list(self.positions)],
colors=[247, 248, 244, 255],
radii=rr.Radius.ui_points(2.0),
),
)
def _append(self, position: tuple[float, float, float], session_time_ns: int) -> bool:
if not self.positions:
self.positions.append(position)
self.last_append_time_ns = session_time_ns
return True
assert self.last_append_time_ns is not None
elapsed_ns = session_time_ns - self.last_append_time_ns
if elapsed_ns < TRAJECTORY_APPEND_INTERVAL_NS:
return False
if (
math.dist(self.positions[-1], position) < TRAJECTORY_MIN_DISTANCE_METERS
and elapsed_ns < TRAJECTORY_FORCE_APPEND_NS
):
return False
self.positions.append(position)
self.last_append_time_ns = session_time_ns
if len(self.positions) > MAX_TRAJECTORY_POSES:
last = self.positions[-1]
self.positions = self.positions[::2]
if self.positions[-1] != last:
self.positions.append(last)
return True
def export_k1mqtt_to_rrd(
input_path: Path,
output_path: Path,
*,
cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None,
) -> RrdExportSummary:
"""Losslessly project every decodable K1 data-plane frame into one RRD.
The raw capture remains the source of record. The derived RRD uses a
recording-local duration timeline whose zero is the first raw message's
receive-monotonic timestamp. It never traverses the bounded live-preview
queue, so export throughput cannot drop point or pose frames.
The destination is replaced only after the temporary RRD has been closed,
flushed and fsynced. Any decode, timing, sink or rename failure therefore
leaves an existing destination artifact untouched.
"""
_raise_if_cancelled(cancel_event)
source = input_path.expanduser().resolve()
destination = output_path.expanduser().resolve()
_validate_paths(source, destination)
destination.parent.mkdir(parents=True, exist_ok=True)
recording_id = str(uuid4())
temporary = destination.with_name(f".{destination.name}.{recording_id}.tmp")
source_sha256 = _sha256_file(
source,
cancel_event=cancel_event,
activity_callback=activity_callback,
)
settings = RerunSceneSettings()
blueprint = _recorded_blueprint(settings)
recording: rr.RecordingStream | None = None
recording_closed = False
published = False
counters = _ExportCounters()
trajectory = _TrajectoryBuffer.empty()
session_origin_ns: int | None = None
previous_monotonic_ns: int | None = None
try:
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
recording.set_sinks(
rr.FileSink(temporary, write_footer=True),
default_blueprint=blueprint,
)
_log_static_scene(recording)
_log_session_origin(recording)
for message in iter_replay_messages(source):
_raise_if_cancelled(cancel_event)
_notify_activity(activity_callback)
counters.source_messages += 1
monotonic_ns = message.received_monotonic_ns
if monotonic_ns is None:
raise RrdExportError(
"native capture metadata must provide received_monotonic_ns "
f"for message {message.sequence}"
)
if previous_monotonic_ns is not None and monotonic_ns < previous_monotonic_ns:
raise RrdExportError(
f"native capture monotonic time decreases at message {message.sequence}"
)
if session_origin_ns is None:
session_origin_ns = monotonic_ns
session_time_ns = monotonic_ns - session_origin_ns
if session_time_ns > JS_MAX_SAFE_INTEGER:
raise RrdExportError(
"session duration exceeds the exact JavaScript nanosecond range"
)
previous_monotonic_ns = monotonic_ns
try:
decoded = normalize_k1_message(
message,
processing_started_monotonic_ns=monotonic_ns,
)
except NormalizationError as exc:
raise RrdExportError(
f"known K1 frame {message.sequence} failed normalization"
) from exc
if decoded is None:
counters.ignored_messages += 1
continue
_set_frame_time(
recording,
decoded.context.sequence,
session_time_ns,
decoded.context.captured_at_epoch_ns,
)
counters.observe_decoded(session_time_ns)
if isinstance(decoded, DecodedPointCloudView):
_log_points(recording, decoded, settings)
counters.point_frames += 1
counters.points += decoded.point_count
elif isinstance(decoded, DecodedPoseView):
position = (
float(decoded.position_xyz[0]),
float(decoded.position_xyz[1]),
float(decoded.position_xyz[2]),
)
_log_pose(recording, decoded, position)
trajectory.process(recording, position, session_time_ns)
counters.pose_frames += 1
else:
counters.ignored_messages += 1
if session_origin_ns is None:
raise RrdExportError("native capture contains no messages")
if counters.decoded_messages == 0:
raise RrdExportError("native capture contains no decodable point or pose frames")
assert counters.first_decoded_time_ns is not None
assert counters.last_decoded_time_ns is not None
_raise_if_cancelled(cancel_event)
recording.flush(timeout_sec=30.0)
recording.disconnect()
recording_closed = True
_raise_if_cancelled(cancel_event)
_fsync_file(temporary)
rrd_bytes = temporary.stat().st_size
if rrd_bytes <= 0:
raise RrdExportError("Rerun produced an empty recording")
rrd_sha256 = _sha256_file(
temporary,
cancel_event=cancel_event,
activity_callback=activity_callback,
)
summary = RrdExportSummary(
schema_version=1,
input_path=str(source),
output_path=str(destination),
recording_id=recording_id,
timeline=SESSION_TIMELINE,
capture_timeline=CAPTURE_TIMELINE,
source_messages=counters.source_messages,
decoded_messages=counters.decoded_messages,
point_frames=counters.point_frames,
pose_frames=counters.pose_frames,
ignored_messages=counters.ignored_messages,
points=counters.points,
trajectory_poses=len(trajectory.positions),
trajectory_updates=trajectory.updates,
session_origin_monotonic_ns=session_origin_ns,
timeline_start_ns=0,
# Playback completeness is defined by data actually written to
# the RRD. K1 status/heartbeat packets may continue long after the
# final point or pose frame; advertising that raw tail as the RRD
# end makes a strict browser buffering gate wait forever.
timeline_end_ns=counters.last_decoded_time_ns,
timeline_span_ns=counters.last_decoded_time_ns,
first_decoded_time_ns=counters.first_decoded_time_ns,
last_decoded_time_ns=counters.last_decoded_time_ns,
source_sha256=source_sha256,
rrd_sha256=rrd_sha256,
rrd_bytes=rrd_bytes,
)
_raise_if_cancelled(cancel_event)
os.replace(temporary, destination)
_fsync_directory(destination.parent)
published = True
return summary
except (ReplayFormatError, OSError) as exc:
raise RrdExportError(f"RRD export failed: {exc}") from exc
finally:
if recording is not None and not recording_closed:
with suppress(BaseException):
recording.disconnect()
if not published:
temporary.unlink(missing_ok=True)
def _validate_paths(source: Path, destination: Path) -> None:
if not source.is_file():
raise RrdExportError(f"raw capture does not exist: {source}")
if source.suffix.casefold() != ".k1mqtt":
raise RrdExportError("RRD export accepts only native .k1mqtt captures")
if destination.suffix.casefold() != ".rrd":
raise RrdExportError("RRD destination must use the .rrd suffix")
if source == destination:
raise RrdExportError("raw capture and RRD destination must be different files")
try:
replay_format = detect_replay_format(source)
except ReplayFormatError as exc:
raise RrdExportError(str(exc)) from exc
if replay_format != "k1mqtt":
raise RrdExportError("RRD export accepts only native K1MQTT captures")
def _recorded_blueprint(
settings: RerunSceneSettings,
*,
include_initial_playback_state: bool = True,
) -> rrb.Blueprint:
accumulation = max(0.0, settings.accumulation_seconds)
# An omitted range means Rerun's native latest-at query: the most recent
# LiDAR frame at the cursor. A zero-width range is *not* equivalent; it
# only matches rows stamped at the cursor's exact nanosecond and therefore
# makes an ordinary recorded scan appear empty.
time_ranges: list[rr.VisibleTimeRange] | None = None
if accumulation > 0:
time_ranges = [
rr.VisibleTimeRange(
SESSION_TIMELINE,
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
end=rr.TimeRangeBoundary.cursor_relative(),
)
]
point_visualizer = rr.Points3D.from_fields(
# A negative Radius value is Rerun's serialized representation
# for UI points. Blueprint overrides broadcast this singleton
# value across every recorded point without rewriting the store.
radii=rr.Radius.ui_points(settings.point_size),
# Recorded height/intensity/distance/RGB palettes are baked into
# each Points3D row. A uniform custom color is the one color mode
# that can be replaced safely by a singleton blueprint override.
colors=(
[_parse_hex_color(settings.custom_color)]
if settings.palette == "custom"
else None
),
).visualizer()
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
spatial_view = rrb.Spatial3DView(
origin="/world",
name="Пространственная сцена",
background=[7, 8, 10, 255],
line_grid=rrb.LineGrid3D(
visible=settings.show_grid,
color=[86, 91, 99, 110],
stroke_width=0.75,
),
overrides={
# EntityBehavior is evaluated from the blueprint store and can
# therefore hide/reveal already-recorded entities without
# rewriting the data RRD. Keep the point visualizer alongside it
# so size/color overrides remain active for the same entity.
"/world/points": [
rrb.EntityBehavior(visible=settings.show_points),
point_visualizer,
],
"/world/trajectory": rrb.EntityBehavior(
visible=settings.show_trajectory,
),
},
# A positive window accumulates historical frames. With no
# window, latest-at deliberately keeps one current LiDAR frame.
time_ranges=time_ranges,
)
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
root_container = rrb.Tabs(spatial_view)
root_container.id = RECORDED_ROOT_CONTAINER_ID
if include_initial_playback_state:
# This state is appropriate only while opening a newly exported RRD.
# Settings-only blueprint messages must not pause an already playing
# recording or mutate the host-owned panel state.
return rrb.Blueprint(
root_container,
rrb.TimePanel(
timeline=SESSION_TIMELINE,
play_state="paused",
state="hidden",
),
auto_layout=False,
auto_views=False,
collapse_panels=True,
)
return rrb.Blueprint(
root_container,
auto_layout=False,
auto_views=False,
collapse_panels=False,
)
def recorded_blueprint_rrd(
settings: RerunSceneSettings,
*,
application_id: str = APPLICATION_ID,
recording_id: str,
) -> bytes:
"""Serialize a small active blueprint update for an already-open recording.
The returned RRD contains blueprint-store messages only; it never copies the
recorded data store and is therefore safe to push through a WebViewer log
channel when operator display settings change.
"""
recording = rr.RecordingStream(
application_id,
recording_id=recording_id,
send_properties=False,
)
stream = rr.binary_stream(recording)
try:
recording.send_blueprint(
_recorded_blueprint(
settings,
include_initial_playback_state=False,
),
make_active=True,
make_default=False,
)
payload = stream.read(flush=True, flush_timeout_sec=5.0)
except Exception as exc:
raise RrdExportError("failed to serialize recorded blueprint") from exc
finally:
with suppress(Exception):
recording.disconnect()
if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576:
raise RrdExportError("serialized recorded blueprint is invalid")
return payload
def _log_static_scene(recording: rr.RecordingStream) -> None:
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
recording.log(
"/world/sensor_pose",
rr.TransformAxes3D(axis_length=0.45, show_frame=False),
static=True,
)
def _log_session_origin(recording: rr.RecordingStream) -> None:
"""Materialize the declared zero of ``session_time`` outside the 3D scene."""
recording.set_time(
SESSION_TIMELINE,
duration=np.timedelta64(0, "ns"),
)
recording.log(
"/__mission_core/session_origin",
rr.AnyValues(session_origin=True),
)
def _set_frame_time(
recording: rr.RecordingStream,
sequence: int,
session_time_ns: int,
capture_time_ns: int,
) -> None:
recording.set_time(
SESSION_TIMELINE,
duration=np.timedelta64(session_time_ns, "ns"),
)
recording.set_time(
CAPTURE_TIMELINE,
timestamp=np.datetime64(capture_time_ns, "ns"),
)
recording.set_time("message_sequence", sequence=sequence)
def _log_points(
recording: rr.RecordingStream,
frame: DecodedPointCloudView,
settings: RerunSceneSettings,
) -> None:
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
if frame.intensities is None:
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
else:
intensities = np.frombuffer(frame.intensities, dtype=np.uint8)
rgb = (
None
if frame.colors_rgb is None
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
)
recording.log(
"/world/points",
rr.Points3D(
positions,
colors=_point_colors(positions, intensities, rgb, settings),
radii=rr.Radius.ui_points(settings.point_size),
),
)
def _log_pose(
recording: rr.RecordingStream,
frame: DecodedPoseView,
position: tuple[float, float, float],
) -> None:
recording.log(
"/world/sensor_pose",
rr.Transform3D(
translation=position,
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
),
)
def _fsync_file(path: Path) -> None:
with path.open("rb") as stream:
os.fsync(stream.fileno())
def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _sha256_file(
path: Path,
*,
cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None,
) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
_raise_if_cancelled(cancel_event)
_notify_activity(activity_callback)
digest.update(chunk)
return digest.hexdigest()
def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
if cancel_event is not None and cancel_event.is_set():
raise RrdExportCancelled("RRD export was cancelled")
def _notify_activity(callback: Callable[[], None] | None) -> None:
if callback is not None:
callback()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
from __future__ import annotations
from pathlib import Path
from k1link.sessions import ActiveSessionLease, recover_stale_active_session_marker
from k1link.sessions.legacy import discover_legacy_viewer_sessions
def test_active_session_lease_hides_live_evidence_until_release(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = sessions / "20260717T011050Z_viewer_live"
lease = ActiveSessionLease.acquire(sessions, session)
try:
session.mkdir()
capture = session / "captures" / "mqtt_live"
capture.mkdir(parents=True)
(capture / "mqtt.raw.k1mqtt").write_bytes(b"K1MQTT\x00unfinished")
candidates = discover_legacy_viewer_sessions(sessions)
assert candidates == ()
assert recover_stale_active_session_marker(sessions) is False
finally:
lease.release()
assert not (sessions / ".current_session").exists()
candidates = discover_legacy_viewer_sessions(sessions)
assert len(candidates) == 1
assert candidates[0].session_id == session.name
assert candidates[0].replayable is False
def test_startup_recovery_removes_only_an_unlocked_stale_marker(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
sessions.mkdir()
marker = sessions / ".current_session"
marker.write_text("20260717T011050Z_viewer_live\n", encoding="utf-8")
assert recover_stale_active_session_marker(sessions) is True
assert not marker.exists()
assert recover_stale_active_session_marker(sessions) is False

View File

@ -0,0 +1,338 @@
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
import time
from pathlib import Path
import pytest
from k1link.sessions.legacy import discover_legacy_viewer_sessions
from k1link.web.camera_archive import (
CAMERA_ARCHIVE_SCHEMA,
CAMERA_COMMIT_POLICY,
CameraArchiveError,
CameraArchiveWriter,
recover_incomplete_camera_archives,
)
def test_camera_archive_writes_canonical_segments_and_seals_summary(tmp_path: Path) -> None:
session = tmp_path / "session"
session.mkdir()
writer = CameraArchiveWriter(
session,
"sensor.camera.left",
7,
commit_interval_seconds=60,
commit_bytes=1024,
)
init = b"init-segment"
first = b"first-fragment"
second = b"second-fragment"
writer.append("init", init, host_epoch_ns=100, host_monotonic_ns=200)
writer.append("media", first, host_epoch_ns=110, host_monotonic_ns=210)
writer.append("media", second, host_epoch_ns=120, host_monotonic_ns=220)
# The tuning values cannot weaken the segment RPO: every append has already
# published its fragment, index record, and interrupted crash checkpoint.
checkpoint = json.loads(writer.summary_path.read_text(encoding="utf-8"))
assert checkpoint["status"] == "interrupted"
assert checkpoint["segment_count"] == 2
assert checkpoint["commit_policy"] == CAMERA_COMMIT_POLICY
summary = writer.close()
entries = [json.loads(line) for line in writer.index_path.read_text().splitlines()]
assert writer.archive_dir.name == "epoch-7"
assert writer.init_path.read_bytes() == init
assert [path.name for path in sorted(writer.segments_dir.iterdir())] == [
"1.m4s",
"2.m4s",
]
assert [entry["kind"] for entry in entries] == ["media", "media"]
assert [entry["sequence"] for entry in entries] == [1, 2]
assert [entry["path"] for entry in entries] == [
"segments/1.m4s",
"segments/2.m4s",
]
for entry, expected in zip(entries, (first, second), strict=True):
payload = (writer.archive_dir / entry["path"]).read_bytes()
assert payload == expected
assert entry["length"] == len(expected)
assert entry["sha256"] == hashlib.sha256(expected).hexdigest()
assert summary["schema_version"] == CAMERA_ARCHIVE_SCHEMA
assert summary["status"] == "complete"
assert summary["segment_count"] == 2
assert summary["entry_count"] == 2
assert summary["media_segment_count"] == 2
assert summary["valid_bytes"] == len(init) + len(first) + len(second)
assert summary["stream_sha256"] == hashlib.sha256(init + first + second).hexdigest()
assert summary["synchronization"] == "host-arrival-best-effort"
assert summary["artifacts"] == {
"init": "init.mp4",
"segments": "segments",
"index": "index.jsonl",
}
assert "192.168" not in json.dumps(summary)
assert writer.close() == summary
def test_camera_archive_rejects_media_without_init_and_existing_epoch(tmp_path: Path) -> None:
session = tmp_path / "session"
session.mkdir()
writer = CameraArchiveWriter(session, "sensor.camera.right", 1)
with pytest.raises(CameraArchiveError, match="before"):
writer.append("media", b"fragment")
writer.append("init", b"init")
writer.close(status="interrupted", failure_code="source-ended")
with pytest.raises(FileExistsError):
CameraArchiveWriter(session, "sensor.camera.right", 1)
with pytest.raises(ValueError, match="safe storage"):
CameraArchiveWriter(session, "../camera", 2)
@pytest.mark.parametrize("symlink_component", ["media", "source"])
def test_camera_writer_never_follows_precreated_storage_symlinks(
tmp_path: Path,
symlink_component: str,
) -> None:
session = tmp_path / "session"
session.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
if symlink_component == "media":
(session / "media").symlink_to(outside, target_is_directory=True)
else:
media = session / "media"
media.mkdir()
(media / "sensor.camera.left").symlink_to(outside, target_is_directory=True)
with pytest.raises(CameraArchiveError, match="no-follow"):
CameraArchiveWriter(session, "sensor.camera.left", 1)
assert list(outside.iterdir()) == []
def test_camera_writer_keeps_directory_fds_across_path_swap(tmp_path: Path) -> None:
session = tmp_path / "session"
session.mkdir()
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
writer.append("init", b"init")
original_epoch = writer.archive_dir
moved_epoch = original_epoch.with_name("epoch-1-moved")
original_epoch.rename(moved_epoch)
outside_epoch = tmp_path / "outside-epoch"
outside_epoch.mkdir()
original_epoch.symlink_to(outside_epoch, target_is_directory=True)
original_segments = moved_epoch / "segments"
moved_segments = moved_epoch / "segments-held-by-fd"
original_segments.rename(moved_segments)
outside_segments = tmp_path / "outside-segments"
outside_segments.mkdir()
original_segments.symlink_to(outside_segments, target_is_directory=True)
writer.append("media", b"frame-after-path-swap")
summary = writer.close()
assert (moved_segments / "1.m4s").read_bytes() == b"frame-after-path-swap"
assert json.loads((moved_epoch / "summary.json").read_text(encoding="utf-8")) == summary
assert list(outside_epoch.iterdir()) == []
assert list(outside_segments.iterdir()) == []
def test_recovery_seals_unindexed_durable_tail_and_preserves_orphans(tmp_path: Path) -> None:
sessions_root = tmp_path / "sessions"
session = sessions_root / "20260717T011050Z_viewer_live"
session.mkdir(parents=True)
writer = CameraArchiveWriter(session, "sensor.camera.left", 3)
writer.append("init", b"init")
writer.append("media", b"frame-1", host_epoch_ns=100, host_monotonic_ns=200)
# Catalog refresh must not hash or rewrite a writer that is still active in
# this server process.
assert recover_incomplete_camera_archives(sessions_root) == ()
writer.close(status="interrupted", failure_code="synthetic-process-crash")
epoch = writer.archive_dir
writer.summary_path.unlink()
# Simulate a process dying after atomically publishing the next fragment but
# before its JSONL entry, plus a non-contiguous fragment that cannot be put on
# the trusted timeline. Recovery salvages #2 and quarantines (never deletes) #4.
(writer.segments_dir / "2.m4s").write_bytes(b"frame-2")
(writer.segments_dir / "4.m4s").write_bytes(b"orphan-frame")
recovered = recover_incomplete_camera_archives(sessions_root)
assert len(recovered) == 1
summary = recovered[0]
assert summary["status"] == "interrupted"
assert summary["failure_code"] == "server-process-interrupted"
assert summary["segment_count"] == 2
entries = [
json.loads(line)
for line in writer.index_path.read_text(encoding="utf-8").splitlines()
]
assert [entry["sequence"] for entry in entries] == [1, 2]
assert entries[0]["host_epoch_ns"] == 100
assert entries[1]["recovered"] is True
assert (writer.segments_dir / "2.m4s").read_bytes() == b"frame-2"
assert not (writer.segments_dir / "4.m4s").exists()
assert any(
path.read_bytes() == b"orphan-frame"
for path in (epoch / "recovery-orphans").iterdir()
if path.is_file()
)
# Recovery output is exactly the layout consumed by legacy media discovery,
# and a second scan is an idempotent no-op.
assert recover_incomplete_camera_archives(sessions_root) == ()
candidates = discover_legacy_viewer_sessions(sessions_root)
assert len(candidates) == 1
assert [source.source_id for source in candidates[0].media_sources] == [
"sensor.camera.left"
]
def test_recovery_replaces_index_and_summary_symlinks_without_following_targets(
tmp_path: Path,
) -> None:
sessions_root = tmp_path / "sessions"
session = sessions_root / "20260717T011051Z_viewer_live"
session.mkdir(parents=True)
writer = CameraArchiveWriter(session, "sensor.camera.right", 1)
writer.append("init", b"init")
writer.append("media", b"frame")
writer.close(status="interrupted")
outside_index = tmp_path / "outside-index"
outside_summary = tmp_path / "outside-summary"
outside_index.write_bytes(b"do-not-read-or-overwrite-index")
outside_summary.write_bytes(b"do-not-read-or-overwrite-summary")
writer.index_path.unlink()
writer.summary_path.unlink()
writer.index_path.symlink_to(outside_index)
writer.summary_path.symlink_to(outside_summary)
recovered = recover_incomplete_camera_archives(sessions_root)
assert len(recovered) == 1
assert recovered[0]["segment_count"] == 1
assert outside_index.read_bytes() == b"do-not-read-or-overwrite-index"
assert outside_summary.read_bytes() == b"do-not-read-or-overwrite-summary"
assert writer.index_path.is_symlink() is False
assert writer.summary_path.is_symlink() is False
assert json.loads(writer.index_path.read_text(encoding="utf-8"))["recovered"] is True
def test_recovery_fails_closed_on_symlinked_quarantine_directory(tmp_path: Path) -> None:
sessions_root = tmp_path / "sessions"
session = sessions_root / "20260717T011052Z_viewer_live"
session.mkdir(parents=True)
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
writer.append("init", b"init")
writer.append("media", b"frame-1")
writer.close(status="interrupted")
writer.summary_path.unlink()
orphan = writer.segments_dir / "3.m4s"
orphan.write_bytes(b"orphan")
outside = tmp_path / "outside-quarantine"
outside.mkdir()
(writer.archive_dir / "recovery-orphans").symlink_to(outside, target_is_directory=True)
with pytest.raises(CameraArchiveError, match="real directory"):
recover_incomplete_camera_archives(sessions_root)
assert list(outside.iterdir()) == []
assert orphan.read_bytes() == b"orphan"
def test_recovery_quarantines_segment_symlink_without_reading_target(tmp_path: Path) -> None:
sessions_root = tmp_path / "sessions"
session = sessions_root / "20260717T011053Z_viewer_live"
session.mkdir(parents=True)
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
writer.append("init", b"init")
writer.append("media", b"frame-1")
writer.close(status="interrupted")
writer.summary_path.unlink()
outside = tmp_path / "outside-segment"
outside.write_bytes(b"external-evidence-must-not-be-read-or-moved")
(writer.segments_dir / "2.m4s").symlink_to(outside)
recovered = recover_incomplete_camera_archives(sessions_root)
assert len(recovered) == 1
assert recovered[0]["segment_count"] == 1
assert outside.read_bytes() == b"external-evidence-must-not-be-read-or-moved"
quarantined = list((writer.archive_dir / "recovery-orphans").glob("2.m4s*"))
assert len(quarantined) == 1
assert quarantined[0].is_symlink()
assert quarantined[0].resolve() == outside.resolve()
def test_recovery_lock_symlink_is_rejected_without_touching_target(tmp_path: Path) -> None:
sessions_root = tmp_path / "sessions"
sessions_root.mkdir()
outside = tmp_path / "outside-lock"
outside.write_bytes(b"external-lock-target")
(sessions_root / ".camera-recovery.lock").symlink_to(outside)
with pytest.raises(CameraArchiveError, match="lock failed no-follow"):
recover_incomplete_camera_archives(sessions_root)
assert outside.read_bytes() == b"external-lock-target"
@pytest.mark.skipif(os.name != "posix", reason="uses POSIX flock to assert worker serialization")
def test_recovery_startup_lease_serializes_another_process(tmp_path: Path) -> None:
import fcntl
sessions_root = tmp_path / "sessions"
sessions_root.mkdir()
lock_path = sessions_root / ".camera-recovery.lock"
lock_path.touch(mode=0o600)
descriptor = os.open(lock_path, os.O_RDWR)
process: subprocess.Popen[str] | None = None
locked = False
try:
fcntl.flock(descriptor, fcntl.LOCK_EX)
locked = True
process = subprocess.Popen(
[
sys.executable,
"-c",
(
"from pathlib import Path; "
"from k1link.web.camera_archive import "
"recover_incomplete_camera_archives; "
"recover_incomplete_camera_archives(Path(__import__('sys').argv[1])); "
"print('recovered')"
),
str(sessions_root),
],
cwd=Path(__file__).parents[1],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
time.sleep(0.15)
assert process.poll() is None
fcntl.flock(descriptor, fcntl.LOCK_UN)
locked = False
stdout, stderr = process.communicate(timeout=5)
assert process.returncode == 0, stderr
assert stdout.strip() == "recovered"
finally:
if locked:
fcntl.flock(descriptor, fcntl.LOCK_UN)
os.close(descriptor)
if process is not None and process.poll() is None:
process.kill()
process.wait(timeout=5)

View File

@ -10,6 +10,7 @@ import pytest
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.reasoncodes import ReasonCode
import k1link.mqtt.capture as capture_module
from k1link.mqtt.capture import (
FRAME_HEADER,
RAW_MAGIC,
@ -256,3 +257,85 @@ def test_capture_reader_rejects_invalid_or_unbounded_frames(
with pytest.raises(CaptureFormatError, match=message):
list(iter_capture_frames(path, max_payload_bytes=4))
def _mqtt_message(topic: str, payload: bytes) -> mqtt.MQTTMessage:
message = mqtt.MQTTMessage(topic=topic.encode("utf-8"))
message.payload = payload
message.qos = 0
message.retain = False
message.dup = False
return message
def test_group_commit_fsyncs_raw_before_publishing_metadata(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
writer.open()
assert writer._raw is not None # noqa: SLF001
assert writer._metadata is not None # noqa: SLF001
raw_fd = writer._raw.fileno() # noqa: SLF001
metadata_fd = writer._metadata.fileno() # noqa: SLF001
fsync_calls: list[int] = []
real_fsync = capture_module.os.fsync
def observe_fsync(descriptor: int) -> None:
fsync_calls.append(descriptor)
real_fsync(descriptor)
monkeypatch.setattr(capture_module.os, "fsync", observe_fsync)
monkeypatch.setattr(capture_module, "GROUP_COMMIT_MAX_MESSAGES", 2)
writer.record(_mqtt_message("RealtimePath", b"one"))
assert writer.metadata_path.read_bytes() == b""
writer.record(_mqtt_message("RealtimePath", b"two"))
assert fsync_calls[:2] == [raw_fd, metadata_fd]
assert len(writer.metadata_path.read_text(encoding="utf-8").splitlines()) == 2
writer.close()
def test_group_commit_timer_bounds_quiet_stream_rpo(
tmp_path: Path,
) -> None:
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
writer.open()
writer.record(_mqtt_message("RealtimePath", b"one"))
assert writer.metadata_path.read_bytes() == b""
writer.maybe_commit(
writer._last_commit_monotonic # noqa: SLF001
+ capture_module.GROUP_COMMIT_INTERVAL_SECONDS
)
assert len(writer.metadata_path.read_text(encoding="utf-8").splitlines()) == 1
writer.close()
def test_raw_fsync_failure_never_publishes_metadata_ahead_of_raw(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
writer.open()
writer.record(_mqtt_message("RealtimePath", b"one"))
assert writer._raw is not None # noqa: SLF001
raw_fd = writer._raw.fileno() # noqa: SLF001
real_fsync = capture_module.os.fsync
def fail_raw_fsync(descriptor: int) -> None:
if descriptor == raw_fd:
raise OSError("synthetic raw fsync failure")
real_fsync(descriptor)
monkeypatch.setattr(capture_module.os, "fsync", fail_raw_fsync)
with pytest.raises(OSError, match="synthetic raw fsync failure"):
writer._commit_pending() # noqa: SLF001
assert writer.metadata_path.read_bytes() == b""
# Restore durability primitive so the fixture can close normally.
monkeypatch.setattr(capture_module.os, "fsync", real_fsync)
writer.close()

324
tests/test_rrd_export.py Normal file
View File

@ -0,0 +1,324 @@
from __future__ import annotations
import hashlib
import json
import struct
import subprocess
import sys
import threading
from pathlib import Path
import pytest
import k1link.viewer.rrd_export as export_module
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.viewer.rerun_bridge import RerunSceneSettings
from k1link.viewer.rrd_export import (
RECORDED_POINTS_VISUALIZER_ID,
RECORDED_ROOT_CONTAINER_ID,
RECORDED_SPATIAL_VIEW_ID,
SESSION_TIMELINE,
RrdExportCancelled,
RrdExportError,
_recorded_blueprint,
export_k1mqtt_to_rrd,
recorded_blueprint_rrd,
)
def _point_payload(x: float) -> bytes:
return struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB",
x,
-2.0,
3.0,
10,
20,
30,
40,
)
def _pose_payload(x: float) -> bytes:
return struct.pack("<ffffffff", x, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
def _write_capture(
directory: Path,
frames: list[tuple[str, bytes, int]],
) -> Path:
capture = directory / "mqtt.raw.k1mqtt"
raw = bytearray(RAW_MAGIC)
metadata: list[dict[str, object]] = []
epoch_origin_ns = 1_784_124_315_000_000_000
monotonic_origin_ns = 9_000_000_000
for sequence, (topic, payload, session_time_ns) in enumerate(frames, start=1):
topic_raw = topic.encode("utf-8")
raw.extend(FRAME_HEADER.pack(len(topic_raw), len(payload)))
raw.extend(topic_raw)
raw.extend(payload)
metadata.append(
{
"record_type": "message",
"sequence": sequence,
"received_at_epoch_ns": epoch_origin_ns + session_time_ns,
"received_monotonic_ns": monotonic_origin_ns + session_time_ns,
}
)
capture.write_bytes(raw)
(directory / "mqtt.metadata.jsonl").write_text(
"".join(json.dumps(item) + "\n" for item in metadata),
encoding="utf-8",
)
return capture
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _print_rrd_entity(path: Path, entity: str) -> str:
completed = subprocess.run(
[
sys.executable,
"-m",
"rerun_cli",
"rrd",
"print",
"-vvv",
"--entity",
entity,
str(path),
],
check=True,
capture_output=True,
text=True,
)
return completed.stdout
def test_recorded_blueprint_accumulates_point_frames_on_session_timeline() -> None:
blueprint = _recorded_blueprint(
RerunSceneSettings(
point_size=7.5,
palette="custom",
custom_color="#112233",
accumulation_seconds=18.5,
show_points=False,
show_trajectory=True,
show_grid=False,
)
)
spatial_view = blueprint.root_container.contents[0]
visible_ranges = spatial_view.properties["VisibleTimeRanges"]
line_grid = spatial_view.properties["LineGrid3D"]
assert visible_ranges.ranges.as_arrow_array().to_pylist() == [
{
"timeline": SESSION_TIMELINE,
"range": {
"start": -18_500_000_000,
"end": 0,
},
}
]
assert line_grid.visible.as_arrow_array().to_pylist() == [False]
point_behavior, point_visualizer = spatial_view.visualizer_overrides["/world/points"]
trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"]
assert point_behavior.visible.as_arrow_array().to_pylist() == [False]
assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True]
point_overrides = {
str(batch.component_descriptor()): batch.as_arrow_array().to_pylist()
for batch in point_visualizer.overrides
}
assert point_overrides == {
"Points3D:colors": [0x112233FF],
# Negative values are Rerun's encoding for screen-space UI points.
"Points3D:radii": [-7.5],
}
def test_zero_accumulation_uses_latest_frame_instead_of_empty_time_range() -> None:
blueprint = _recorded_blueprint(
RerunSceneSettings(accumulation_seconds=0.0, show_grid=True)
)
spatial_view = blueprint.root_container.contents[0]
assert "VisibleTimeRanges" not in spatial_view.properties
point_behavior, point_visualizer = spatial_view.visualizer_overrides["/world/points"]
trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"]
assert point_behavior.visible.as_arrow_array().to_pylist() == [True]
assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True]
point_components = {
str(batch.component_descriptor()) for batch in point_visualizer.overrides
}
assert point_components == {"Points3D:radii"}
def test_dynamic_blueprint_reuses_scene_ids_without_playback_mutation() -> None:
first = _recorded_blueprint(
RerunSceneSettings(show_points=False, show_trajectory=True),
include_initial_playback_state=False,
)
second = _recorded_blueprint(
RerunSceneSettings(show_points=True, show_trajectory=False),
include_initial_playback_state=False,
)
assert not hasattr(first, "time_panel")
assert not hasattr(second, "time_panel")
assert first.root_container.id == second.root_container.id == RECORDED_ROOT_CONTAINER_ID
first_view = first.root_container.contents[0]
second_view = second.root_container.contents[0]
assert first_view.id == second_view.id == RECORDED_SPATIAL_VIEW_ID
first_point_behavior, first_point_visualizer = first_view.visualizer_overrides[
"/world/points"
]
second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[
"/world/points"
]
first_trajectory = first_view.visualizer_overrides["/world/trajectory"]
second_trajectory = second_view.visualizer_overrides["/world/trajectory"]
assert first_point_visualizer.id == RECORDED_POINTS_VISUALIZER_ID
assert second_point_visualizer.id == RECORDED_POINTS_VISUALIZER_ID
assert first_point_behavior.visible.as_arrow_array().to_pylist() == [False]
assert second_point_behavior.visible.as_arrow_array().to_pylist() == [True]
assert first_trajectory.visible.as_arrow_array().to_pylist() == [True]
assert second_trajectory.visible.as_arrow_array().to_pylist() == [False]
payload = recorded_blueprint_rrd(
RerunSceneSettings(show_points=False, show_trajectory=False),
recording_id="stable-recording",
)
assert b"PlayState" not in payload
assert b"play_state" not in payload
assert str(RECORDED_ROOT_CONTAINER_ID).encode() in payload
assert str(RECORDED_SPATIAL_VIEW_ID).encode() in payload
assert str(RECORDED_POINTS_VISUALIZER_ID).encode() in payload
def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[
("lixel/application/report/heartbeat", b"opaque", 0),
("RealtimePointcloud", _point_payload(1.0), 100_000_000),
("RealtimePath", _pose_payload(1.0), 600_000_000),
("RealtimePointcloud", _point_payload(2.0), 900_000_000),
("RealtimePath", _pose_payload(1.2), 1_200_000_000),
("lixel/application/report/heartbeat", b"opaque-tail", 9_500_000_000),
],
)
output = tmp_path / "session.rrd"
summary = export_k1mqtt_to_rrd(capture, output)
assert summary["source_messages"] == 6
assert summary["decoded_messages"] == 4
assert summary["point_frames"] == 2
assert summary["pose_frames"] == 2
assert summary["ignored_messages"] == 2
assert summary["points"] == 2
assert summary["trajectory_poses"] == 2
assert summary["trajectory_updates"] == 2
assert summary["session_origin_monotonic_ns"] == 9_000_000_000
assert summary["timeline"] == "session_time"
assert summary["timeline_start_ns"] == 0
assert summary["timeline_end_ns"] == 1_200_000_000
assert summary["timeline_span_ns"] == 1_200_000_000
assert summary["first_decoded_time_ns"] == 100_000_000
assert summary["last_decoded_time_ns"] == 1_200_000_000
assert summary["source_sha256"] == _sha256(capture)
assert summary["rrd_bytes"] == output.stat().st_size
assert summary["rrd_sha256"] == _sha256(output)
assert output.stat().st_size > 0
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
origin_table = _print_rrd_entity(output, "/__mission_core/session_origin")
assert "/__mission_core/session_origin" in origin_table
assert "session_time" in origin_table
assert "P0D" in origin_table
assert "[true]" in origin_table
def test_atomic_rename_failure_preserves_existing_recording(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _write_capture(
tmp_path,
[("RealtimePointcloud", _point_payload(1.0), 0)],
)
output = tmp_path / "session.rrd"
trusted = b"existing-good-recording"
output.write_bytes(trusted)
def fail_replace(_source: Path, _destination: Path) -> None:
raise OSError("synthetic rename failure")
monkeypatch.setattr(export_module.os, "replace", fail_replace)
with pytest.raises(RrdExportError, match="synthetic rename failure"):
export_k1mqtt_to_rrd(capture, output)
assert output.read_bytes() == trusted
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
def test_missing_monotonic_metadata_never_replaces_existing_recording(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[("RealtimePointcloud", _point_payload(1.0), 0)],
)
(tmp_path / "mqtt.metadata.jsonl").unlink()
output = tmp_path / "session.rrd"
output.write_bytes(b"existing-good-recording")
with pytest.raises(RrdExportError, match="received_monotonic_ns"):
export_k1mqtt_to_rrd(capture, output)
assert output.read_bytes() == b"existing-good-recording"
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
def test_export_preserves_valid_prefix_before_crash_metadata_tail(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[
("RealtimePointcloud", _point_payload(1.0), 100_000_000),
("RealtimePointcloud", _point_payload(2.0), 200_000_000),
],
)
metadata_path = tmp_path / "mqtt.metadata.jsonl"
first_line = metadata_path.read_text(encoding="utf-8").splitlines(keepends=True)[0]
metadata_path.write_text(
first_line + '{"record_type":"message"',
encoding="utf-8",
)
output = tmp_path / "session.rrd"
summary = export_k1mqtt_to_rrd(capture, output)
assert summary["source_messages"] == 1
assert summary["decoded_messages"] == 1
assert summary["point_frames"] == 1
assert summary["timeline_end_ns"] == 0
assert output.stat().st_size > 0
def test_export_honors_cooperative_cancellation_without_publishing(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[("RealtimePointcloud", _point_payload(1.0), 0)],
)
output = tmp_path / "session.rrd"
output.write_bytes(b"trusted-existing-recording")
cancelled = threading.Event()
cancelled.set()
with pytest.raises(RrdExportCancelled):
export_k1mqtt_to_rrd(capture, output, cancel_event=cancelled)
assert output.read_bytes() == b"trusted-existing-recording"
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []

1659
tests/test_session_api.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,377 @@
from __future__ import annotations
import hashlib
import threading
import time
from collections.abc import Callable
from dataclasses import replace
from pathlib import Path
import pytest
from k1link.sessions.models import ReplayCommand
from k1link.sessions.preparation import (
RecordingPreparationSnapshot,
SessionRecordingPreparationManager,
)
from k1link.sessions.recording import RecordingMaterializationError, SessionRecordingMaterializer
def _command(root: Path, session_id: str = "20260717T100000Z_viewer_live") -> ReplayCommand:
root.mkdir(parents=True)
source = root / "mqtt.raw.k1mqtt"
metadata = root / "mqtt.metadata.jsonl"
source.write_bytes(b"native-session-source")
metadata.write_text('{"record_type":"message","sequence":1}\n', encoding="utf-8")
return ReplayCommand(
session_id=session_id,
source_path=source,
allowed_root=root,
session_root=root,
replay_byte_length=source.stat().st_size,
metadata_byte_length=metadata.stat().st_size,
expected_source_sha256=None,
speed=1.0,
loop=False,
)
def _summary(source: Path, destination: Path, payload: bytes) -> dict[str, object]:
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_000_000_000,
}
def _wait_for_state(
manager: SessionRecordingPreparationManager,
session_id: str,
states: set[str],
timeout: float = 2.0,
) -> RecordingPreparationSnapshot:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
snapshot = manager.status(session_id)
if snapshot is not None and snapshot.state in states:
return snapshot
time.sleep(0.005)
raise AssertionError(f"preparation did not reach {states}")
def test_manager_returns_quick_job_deduplicates_and_reports_monotonic_progress(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "session")
started = threading.Event()
release = threading.Event()
calls = 0
def exporter(
source: Path,
destination: Path,
*,
cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None,
) -> dict[str, object]:
nonlocal calls
calls += 1
started.set()
deadline = time.monotonic() + 2
while not release.wait(timeout=0.005):
assert cancel_event is None or not cancel_event.is_set()
if activity_callback is not None:
activity_callback()
assert time.monotonic() < deadline
return _summary(source, destination, b"prepared-recording")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter),
heartbeat_interval_seconds=0.01,
)
try:
before = time.monotonic()
first = manager.enqueue(command)
elapsed = time.monotonic() - before
duplicate = manager.enqueue(command)
assert elapsed < 0.1
assert duplicate.preparation_id == first.preparation_id
assert started.wait(timeout=1)
exporting = _wait_for_state(manager, command.session_id, {"exporting"})
time.sleep(0.03)
heartbeat = manager.status(command.session_id)
assert heartbeat is not None
assert heartbeat.updated_at_utc > exporting.updated_at_utc
assert heartbeat.progress >= exporting.progress
assert heartbeat.cancellable is True
release.set()
ready = _wait_for_state(manager, command.session_id, {"ready"})
assert calls == 1
assert exporting.progress <= ready.progress == 1.0
assert ready.recording is not None
assert ready.updated_at_utc.endswith("Z")
finally:
release.set()
manager.close()
def test_manager_failure_is_retryable_and_retry_publishes_new_job(tmp_path: Path) -> None:
command = _command(tmp_path / "session")
calls = 0
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
if calls == 1:
raise OSError("simulated converter failure")
return _summary(source, destination, b"retry-recording")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
)
try:
first = manager.enqueue(command)
failed = _wait_for_state(manager, command.session_id, {"failed"})
retry = manager.enqueue(command, retry_failed=True)
ready = _wait_for_state(manager, command.session_id, {"ready"})
assert failed.retryable is True
assert failed.error == "Не удалось подготовить запись сессии."
assert retry.preparation_id != first.preparation_id
assert ready.preparation_id == retry.preparation_id
assert calls == 2
finally:
manager.close()
def test_manager_cancels_queued_job_without_exporting_it(tmp_path: Path) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260717T100001Z_viewer_live",
)
started = threading.Event()
release = threading.Event()
calls = 0
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
started.set()
assert release.wait(timeout=2)
return _summary(source, destination, b"recording")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
)
try:
manager.enqueue(first)
assert started.wait(timeout=1)
manager.enqueue(second)
assert manager.cancel(second.session_id) is True
cancelled = _wait_for_state(manager, second.session_id, {"cancelled"})
release.set()
_wait_for_state(manager, first.session_id, {"ready"})
time.sleep(0.02)
assert cancelled.cancellable is False
assert calls == 1
finally:
release.set()
manager.close()
def test_manager_can_restart_across_repeated_application_lifespans(tmp_path: Path) -> None:
command = _command(tmp_path / "session")
calls = 0
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
return _summary(source, destination, b"restartable-recording")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
)
try:
manager.enqueue(command)
_wait_for_state(manager, command.session_id, {"ready"})
manager.close()
manager.start()
resumed = manager.enqueue(command)
assert resumed.state == "ready"
assert calls == 1
finally:
manager.close()
def test_noncooperative_exporter_has_no_fake_heartbeat_and_restart_waits_for_old_writer(
tmp_path: Path,
) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260717T100002Z_viewer_live",
)
first_started = threading.Event()
release_first = threading.Event()
active = 0
max_active = 0
calls = 0
guard = threading.Lock()
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal active, calls, max_active
with guard:
calls += 1
active += 1
max_active = max(max_active, active)
call = calls
try:
if call == 1:
first_started.set()
assert release_first.wait(timeout=2)
return _summary(source, destination, f"recording-{call}".encode())
finally:
with guard:
active -= 1
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter),
heartbeat_interval_seconds=0.01,
)
try:
manager.enqueue(first)
assert first_started.wait(timeout=1)
blocked = _wait_for_state(manager, first.session_id, {"exporting"})
time.sleep(0.03)
unchanged = manager.status(first.session_id)
assert unchanged is not None
assert unchanged.updated_at_utc == blocked.updated_at_utc
assert unchanged.cancellable is False
manager.close(timeout=0.001)
manager.start()
queued = manager.enqueue(second)
assert queued.state == "queued"
time.sleep(0.03)
assert calls == 1
release_first.set()
ready = _wait_for_state(manager, second.session_id, {"ready"})
assert ready.recording is not None
assert calls == 2
assert max_active == 1
finally:
release_first.set()
manager.close()
def test_reconciler_retry_does_not_replace_operator_cancel_across_restart(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "session")
started = threading.Event()
release = threading.Event()
calls = 0
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
started.set()
assert release.wait(timeout=2)
return _summary(source, destination, b"operator-cancelled")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
)
try:
original = manager.enqueue(command)
assert started.wait(timeout=1)
assert manager.cancel(
command.session_id,
preparation_id=original.preparation_id,
)
manager.close(timeout=0.001)
manager.start()
release.set()
cancelled = _wait_for_state(manager, command.session_id, {"cancelled"})
reconciled = manager.enqueue(command, retry_interrupted=True)
assert reconciled.preparation_id == cancelled.preparation_id
assert reconciled.state == "cancelled"
assert calls == 1
finally:
release.set()
manager.close()
def test_shared_preparation_command_ignores_per_request_playback_policy(tmp_path: Path) -> None:
command = _command(tmp_path / "session")
started = threading.Event()
release = threading.Event()
def exporter(source: Path, destination: Path) -> dict[str, object]:
started.set()
assert release.wait(timeout=2)
return _summary(source, destination, b"recording")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
)
try:
first = manager.enqueue(replace(command, speed=2.0, loop=True))
assert started.wait(timeout=1)
duplicate = manager.enqueue(replace(command, speed=7.0, loop=False))
assert duplicate.preparation_id == first.preparation_id
assert duplicate.command.speed == 1.0
assert duplicate.command.loop is False
finally:
release.set()
manager.close()
def test_launch_reservation_blocks_eviction_until_lease_expires(tmp_path: Path) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260717T100003Z_viewer_live",
)
def exporter(source: Path, destination: Path) -> dict[str, object]:
return _summary(source, destination, b"R" * (40 * 1024))
materializer = SessionRecordingMaterializer(
tmp_path / "private",
exporter=exporter,
cache_max_bytes=50 * 1024,
free_space_reserve_bytes=0,
)
manager = SessionRecordingPreparationManager(materializer)
try:
first_recording = materializer.materialize(first)
resolved = manager.resolve_cached(first)
assert resolved is not None and resolved.state == "ready"
reserved = manager.reserve_cached(first, lease_seconds=0.05)
assert reserved is not None and reserved.recording is not None
with pytest.raises(RecordingMaterializationError, match="cache quota"):
materializer.materialize(second)
assert first_recording.path.exists()
time.sleep(0.08)
second_recording = materializer.materialize(second)
assert second_recording.path.exists()
assert not first_recording.path.exists()
finally:
manager.close()

View File

@ -0,0 +1,522 @@
from __future__ import annotations
import hashlib
import json
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import pytest
import k1link.sessions.recording as recording_module
from k1link.sessions.models import ReplayCommand
from k1link.sessions.recording import (
CACHE_SCHEMA,
RERUN_RECORDING_MEDIA_TYPE,
RecordingMaterializationError,
SessionRecordingMaterializer,
)
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
class FakeExporter:
def __init__(self, *, delay_seconds: float = 0.0) -> None:
self.calls = 0
self.delay_seconds = delay_seconds
self._lock = threading.Lock()
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
self.calls += 1
sequence = self.calls
if self.delay_seconds:
time.sleep(self.delay_seconds)
destination.write_bytes(b"RRD" + sequence.to_bytes(2, "big") + source.read_bytes())
return {
"source_sha256": _sha256(source),
"rrd_sha256": _sha256(destination),
"rrd_bytes": destination.stat().st_size,
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 2_500_000_000,
}
def _command(tmp_path: Path) -> ReplayCommand:
tmp_path.mkdir(parents=True, exist_ok=True)
source = tmp_path / "mqtt.raw.k1mqtt"
source.write_bytes(b"native-source-recording")
metadata = tmp_path / "mqtt.metadata.jsonl"
metadata.write_text('{"record_type":"message","sequence":1}\n', encoding="utf-8")
return ReplayCommand(
session_id="20260716T205632Z_viewer_live",
source_path=source,
allowed_root=tmp_path,
session_root=tmp_path,
replay_byte_length=source.stat().st_size,
metadata_byte_length=metadata.stat().st_size,
expected_source_sha256=None,
speed=1.0,
loop=False,
)
def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Path) -> None:
command = _command(tmp_path)
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
first = materializer.materialize(command)
second = materializer.materialize(command)
assert first == second
assert exporter.calls == 1
assert first.media_type == RERUN_RECORDING_MEDIA_TYPE
assert first.timeline == "session_time"
assert first.timeline_start_ns == 0
assert first.timeline_end_ns == 2_500_000_000
assert first.path.is_relative_to(materializer.recordings_root)
assert first.path.stat().st_mode & 0o777 == 0o600
sidecar = first.path.with_name("scene.rrd.cache.json")
assert sidecar.stat().st_mode & 0o777 == 0o600
document = json.loads(sidecar.read_text(encoding="utf-8"))
assert document["schema_version"] == CACHE_SCHEMA
assert "source_path" not in document
assert "recording_path" not in document
assert str(tmp_path) not in sidecar.read_text(encoding="utf-8")
after_restart = SessionRecordingMaterializer(
tmp_path / "private",
exporter=exporter,
).materialize(command)
assert after_restart == first
assert exporter.calls == 1
@pytest.mark.parametrize(
"obsolete_schema",
[
"missioncore.derived-rerun-recording-cache/v1",
"missioncore.derived-rerun-recording-cache/v2",
"missioncore.derived-rerun-recording-cache/v4",
"missioncore.derived-rerun-recording-cache/v5",
],
)
def test_materializer_rebuilds_incompatible_recording_cache_schema(
tmp_path: Path,
obsolete_schema: str,
) -> None:
command = _command(tmp_path)
exporter = FakeExporter()
private_root = tmp_path / "private"
first = SessionRecordingMaterializer(private_root, exporter=exporter).materialize(command)
sidecar = first.path.with_name("scene.rrd.cache.json")
document = json.loads(sidecar.read_text(encoding="utf-8"))
document["schema_version"] = obsolete_schema
sidecar.write_text(json.dumps(document), encoding="utf-8")
rebuilt = SessionRecordingMaterializer(private_root, exporter=exporter).materialize(command)
assert exporter.calls == 2
assert rebuilt.path == first.path
assert json.loads(sidecar.read_text(encoding="utf-8"))["schema_version"] == CACHE_SCHEMA
def test_failed_rebuild_preserves_previously_published_cache(tmp_path: Path) -> None:
command = _command(tmp_path)
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
first = materializer.materialize(command)
published_bytes = first.path.read_bytes()
sidecar = first.path.with_name("scene.rrd.cache.json")
published_sidecar = sidecar.read_bytes()
command.source_path.write_bytes(b"new-source-that-requires-rebuild")
changed = replace(command, replay_byte_length=command.source_path.stat().st_size)
def fail_export(_source: Path, destination: Path) -> dict[str, object]:
destination.write_bytes(b"incomplete-candidate")
raise OSError("simulated export failure")
failing = SessionRecordingMaterializer(tmp_path / "private", exporter=fail_export)
with pytest.raises(RecordingMaterializationError):
failing.materialize(changed)
assert first.path.read_bytes() == published_bytes
assert sidecar.read_bytes() == published_sidecar
assert not tuple(first.path.parent.glob(".scene.*.candidate.rrd"))
def test_materializer_rebuilds_when_source_or_recording_changes(tmp_path: Path) -> None:
command = _command(tmp_path)
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
first = materializer.materialize(command)
original_recording_stat = first.path.stat()
corrupted = bytearray(first.path.read_bytes())
corrupted[-1] ^= 0x01
first.path.write_bytes(corrupted)
os.utime(
first.path,
ns=(original_recording_stat.st_atime_ns, original_recording_stat.st_mtime_ns),
)
repaired = materializer.materialize(command)
assert exporter.calls == 2
assert repaired.sha256 == _sha256(repaired.path)
assert repaired.sha256 != hashlib.sha256(corrupted).hexdigest()
command.source_path.write_bytes(b"new-native-source")
command = replace(command, replay_byte_length=command.source_path.stat().st_size)
updated = materializer.materialize(command)
assert exporter.calls == 3
assert updated.source_sha256 == _sha256(command.source_path)
def test_concurrent_materialization_exports_one_recording(tmp_path: Path) -> None:
command = _command(tmp_path)
exporter = FakeExporter(delay_seconds=0.05)
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
with ThreadPoolExecutor(max_workers=8) as executor:
recordings = tuple(executor.map(materializer.materialize, [command] * 8))
assert exporter.calls == 1
assert len({recording.sha256 for recording in recordings}) == 1
assert len({recording.path for recording in recordings}) == 1
def test_materializer_never_follows_cache_symlinks_outside_private_root(
tmp_path: Path,
) -> None:
command = _command(tmp_path)
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
outside = tmp_path / "outside"
outside.mkdir()
session_cache = materializer.recordings_root / command.session_id
session_cache.symlink_to(outside, target_is_directory=True)
with pytest.raises(RecordingMaterializationError, match="must not be a symlink"):
materializer.materialize(command)
assert list(outside.iterdir()) == []
session_cache.unlink()
session_cache.mkdir()
outside_recording = outside / "scene.rrd"
outside_recording.write_bytes(b"do-not-touch")
(session_cache / "scene.rrd").symlink_to(outside_recording)
recording = materializer.materialize(command)
assert recording.path.read_bytes().startswith(b"RRD")
assert outside_recording.read_bytes() == b"do-not-touch"
def test_materializer_revalidates_prepared_source_without_following_replaced_symlink(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "session")
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
outside = tmp_path / "outside.k1mqtt"
outside.write_bytes(command.source_path.read_bytes())
command.source_path.unlink()
command.source_path.symlink_to(outside)
with pytest.raises(RecordingMaterializationError, match="missing or unsafe"):
materializer.materialize(command)
assert exporter.calls == 0
assert outside.read_bytes() == b"native-source-recording"
def test_materializer_exports_only_validated_prefix_before_crash_tail(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "session")
committed_raw_bytes = command.replay_byte_length
committed_metadata_bytes = command.metadata_byte_length
with command.source_path.open("ab") as stream:
stream.write(b"uncommitted-raw-tail")
metadata = command.source_path.with_name("mqtt.metadata.jsonl")
with metadata.open("ab") as stream:
stream.write(b'{"record_type":"message"')
observed: list[tuple[int, int]] = []
class PrefixExporter(FakeExporter):
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
observed.append(
(source.stat().st_size, source.with_name("mqtt.metadata.jsonl").stat().st_size)
)
return super().__call__(source, destination)
exporter = PrefixExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
recording = materializer.materialize(command)
assert observed == [(committed_raw_bytes, committed_metadata_bytes)]
assert recording.source_sha256 == hashlib.sha256(b"native-source-recording").hexdigest()
assert command.source_path.read_bytes().endswith(b"uncommitted-raw-tail")
def test_global_singleflight_bounds_exports_across_different_sessions(tmp_path: Path) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260716T205633Z_viewer_live",
)
class ConcurrencyExporter(FakeExporter):
def __init__(self) -> None:
super().__init__()
self.active = 0
self.max_active = 0
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
time.sleep(0.03)
return super().__call__(source, destination)
finally:
with self._lock:
self.active -= 1
exporter = ConcurrencyExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
with ThreadPoolExecutor(max_workers=2) as executor:
tuple(executor.map(materializer.materialize, (first, second)))
assert exporter.calls == 2
assert exporter.max_active == 1
def test_cross_process_file_lock_serializes_independent_materializer_instances(
tmp_path: Path,
) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260716T205634Z_viewer_live",
)
class ConcurrencyExporter(FakeExporter):
def __init__(self) -> None:
super().__init__()
self.active = 0
self.max_active = 0
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
time.sleep(0.03)
return super().__call__(source, destination)
finally:
with self._lock:
self.active -= 1
exporter = ConcurrencyExporter()
private_root = tmp_path / "private"
first_materializer = SessionRecordingMaterializer(private_root, exporter=exporter)
second_materializer = SessionRecordingMaterializer(private_root, exporter=exporter)
with ThreadPoolExecutor(max_workers=2) as executor:
first_future = executor.submit(first_materializer.materialize, first)
second_future = executor.submit(second_materializer.materialize, second)
assert first_future.result(timeout=2).path.exists()
assert second_future.result(timeout=2).path.exists()
assert exporter.calls == 2
assert exporter.max_active == 1
def test_export_scavenges_confined_crash_candidates_before_quota_check(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "session")
private_root = tmp_path / "private"
materializer = SessionRecordingMaterializer(
private_root,
exporter=FakeExporter(),
cache_max_bytes=32 * 1024,
free_space_reserve_bytes=0,
)
session_cache = materializer.recordings_root / command.session_id
session_cache.mkdir()
stale_candidate = session_cache / ".scene.deadbeef.candidate.rrd"
stale_candidate.write_bytes(b"x" * (24 * 1024))
stale_export_temp = session_cache / "..scene.deadbeef.candidate.rrd.uuid.tmp"
stale_export_temp.write_bytes(b"x" * 1024)
stale_stage = session_cache / ".source.deadbeef.tmp"
stale_stage.mkdir()
(stale_stage / "mqtt.raw.k1mqtt").write_bytes(b"x" * 1024)
recording = materializer.materialize(command)
assert recording.path.exists()
assert not stale_candidate.exists()
assert not stale_export_temp.exists()
assert not stale_stage.exists()
def test_cached_recording_and_response_lease_do_not_wait_for_other_export(
tmp_path: Path,
) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260716T205633Z_viewer_live",
)
class BlockingSecondExporter(FakeExporter):
def __init__(self) -> None:
super().__init__()
self.second_started = threading.Event()
self.release_second = threading.Event()
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
next_call = self.calls + 1
if next_call == 2:
self.second_started.set()
if not self.release_second.wait(timeout=2):
raise AssertionError("test did not release the blocked export")
return super().__call__(source, destination)
exporter = BlockingSecondExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
first_recording = materializer.materialize(first)
with ThreadPoolExecutor(max_workers=3) as executor:
exporting = executor.submit(materializer.materialize, second)
assert exporter.second_started.wait(timeout=1)
cached = executor.submit(materializer.materialize, first).result(timeout=0.2)
pinned, release = executor.submit(
materializer.materialize_pinned,
first,
).result(timeout=0.2)
assert cached == first_recording
assert pinned == first_recording
release()
exporter.release_second.set()
assert exporting.result(timeout=1).session_id == second.session_id
def test_cache_quota_evicts_lru_derived_recording_without_deleting_raw(
tmp_path: Path,
) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260716T205633Z_viewer_live",
)
class SizedExporter(FakeExporter):
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
self.calls += 1
destination.write_bytes(b"R" * (40 * 1024))
return {
"source_sha256": _sha256(source),
"rrd_sha256": _sha256(destination),
"rrd_bytes": destination.stat().st_size,
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1,
}
materializer = SessionRecordingMaterializer(
tmp_path / "private",
exporter=SizedExporter(),
cache_max_bytes=50 * 1024,
free_space_reserve_bytes=0,
)
first_recording = materializer.materialize(first)
first_source_bytes = first.source_path.read_bytes()
second_recording = materializer.materialize(second)
assert not first_recording.path.exists()
assert second_recording.path.exists()
assert first.source_path.read_bytes() == first_source_bytes
assert second.source_path.exists()
def test_pinned_cache_entry_survives_eviction_until_release(tmp_path: Path) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260716T205633Z_viewer_live",
)
class SizedExporter(FakeExporter):
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
self.calls += 1
destination.write_bytes(b"R" * (40 * 1024))
return {
"source_sha256": _sha256(source),
"rrd_sha256": _sha256(destination),
"rrd_bytes": destination.stat().st_size,
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1,
}
materializer = SessionRecordingMaterializer(
tmp_path / "private",
exporter=SizedExporter(),
cache_max_bytes=50 * 1024,
free_space_reserve_bytes=0,
)
first_recording, release = materializer.materialize_pinned(first)
with pytest.raises(RecordingMaterializationError, match="cache quota"):
materializer.materialize(second)
assert first_recording.path.exists()
release()
second_recording = materializer.materialize(second)
assert not first_recording.path.exists()
assert second_recording.path.exists()
def test_cache_free_space_reserve_refuses_export_without_touching_raw(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
command = _command(tmp_path / "session")
source_bytes = command.source_path.read_bytes()
materializer = SessionRecordingMaterializer(
tmp_path / "private",
exporter=FakeExporter(),
cache_max_bytes=1024 * 1024,
free_space_reserve_bytes=1024,
)
monkeypatch.setattr(
recording_module.shutil,
"disk_usage",
lambda _path: SimpleNamespace(free=1023),
)
with pytest.raises(RecordingMaterializationError, match="free-space reserve"):
materializer.materialize(command)
assert command.source_path.read_bytes() == source_bytes

573
tests/test_session_store.py Normal file
View File

@ -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"] == []

View File

@ -16,6 +16,14 @@ def _write_native(path: Path, topic: str, payload: bytes) -> None:
)
def _append_native(path: Path, topic: str, payload: bytes) -> None:
topic_raw = topic.encode()
with path.open("ab") as stream:
stream.write(FRAME_HEADER.pack(len(topic_raw), len(payload)))
stream.write(topic_raw)
stream.write(payload)
def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
_write_native(capture, "lixel/application/report/lio_pose", b"pose")
@ -39,6 +47,35 @@ def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None:
assert message.received_monotonic_ns == 456
def test_native_replay_stops_at_crash_truncated_metadata_tail(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
_write_native(capture, "RealtimePointcloud", b"first")
_append_native(capture, "RealtimePointcloud", b"uncommitted-tail")
first_record = {
"record_type": "message",
"sequence": 1,
"received_at_epoch_ns": 123_000_000_000,
"received_monotonic_ns": 456,
}
(tmp_path / "mqtt.metadata.jsonl").write_text(
json.dumps(first_record) + "\n" + '{"record_type":"message"',
encoding="utf-8",
)
messages = list(iter_replay_messages(capture))
assert [message.payload for message in messages] == [b"first"]
def test_native_replay_rejects_newline_terminated_metadata_corruption(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
_write_native(capture, "RealtimePointcloud", b"frame")
(tmp_path / "mqtt.metadata.jsonl").write_text("{corrupt\n", encoding="utf-8")
with pytest.raises(ReplayFormatError, match="not valid JSON"):
list(iter_replay_messages(capture))
def test_legacy_tsv_replay_validates_and_decodes_payload(tmp_path: Path) -> None:
capture = tmp_path / "payloads.tsv"
capture.write_bytes(b"1784124315.186225000\tRealtimePath\t4\t0001aaff\n")