feat(sessions): add durable observation archive and replay API
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user