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

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 17:50:54 +03:00
parent aa2df560b7
commit 656f0c524d
23 changed files with 12492 additions and 5 deletions
+67 -2
View File
@@ -33,6 +33,9 @@ MAX_TOPIC_BYTES = 65_535
CONNECT_TIMEOUT_SECONDS = 10.0
KEEPALIVE_SECONDS = 30
LOOP_INTERVAL_SECONDS = 0.25
GROUP_COMMIT_INTERVAL_SECONDS = 0.5
GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024
GROUP_COMMIT_MAX_MESSAGES = 32
# Eight-byte file signature followed by repeated >IQ, topic UTF-8 bytes, payload bytes.
RAW_MAGIC = b"K1MQTT\x00\x01"
@@ -166,6 +169,9 @@ class _CaptureWriter:
self.topic_counts: dict[str, int] = {}
self._raw: IO[bytes] | None = None
self._metadata: IO[str] | None = None
self._pending_metadata: list[str] = []
self._pending_raw_bytes = 0
self._last_commit_monotonic = time.monotonic()
def open(self) -> None:
self.out_dir.mkdir(parents=True, exist_ok=True)
@@ -179,6 +185,7 @@ class _CaptureWriter:
self._raw = _open_binary_exclusive(self.raw_path)
self._raw.write(RAW_MAGIC)
self._metadata = _open_text_exclusive(self.metadata_path)
_fsync_directory(self.out_dir)
except BaseException:
with suppress(OSError):
self.close()
@@ -212,7 +219,9 @@ class _CaptureWriter:
"max_message_bytes": self.max_message_bytes,
"reason": "message_too_large",
}
self._commit_pending()
self._write_metadata(metadata, record)
os.fsync(metadata.fileno())
raise MessageTooLargeError(
f"message on {topic!r} is {len(payload)} bytes; "
f"limit is {self.max_message_bytes} bytes"
@@ -246,7 +255,17 @@ class _CaptureWriter:
"raw_payload_offset": offset + len(header) + len(topic_bytes),
"raw_frame_bytes": frame_bytes,
}
self._write_metadata(metadata, record)
self._pending_metadata.append(
json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
)
self._pending_raw_bytes += frame_bytes
if (
len(self._pending_metadata) >= GROUP_COMMIT_MAX_MESSAGES
or self._pending_raw_bytes >= GROUP_COMMIT_MAX_BYTES
or time.monotonic() - self._last_commit_monotonic
>= GROUP_COMMIT_INTERVAL_SECONDS
):
self._commit_pending()
return CapturedMqttMessage(
sequence=self.message_count,
topic=topic,
@@ -261,7 +280,12 @@ class _CaptureWriter:
def close(self) -> None:
first_error: OSError | None = None
# Make raw frames durable before making their JSONL references durable.
try:
self._commit_pending()
except OSError as exc:
first_error = exc
# Make the magic (including the zero-message case) and any last stream
# buffers durable before the handles are released.
for stream in (self._raw, self._metadata):
if stream is None or stream.closed:
continue
@@ -280,6 +304,13 @@ class _CaptureWriter:
if first_error is not None:
raise first_error
def maybe_commit(self, now_monotonic: float | None = None) -> None:
if not self._pending_metadata:
return
now = time.monotonic() if now_monotonic is None else now_monotonic
if now - self._last_commit_monotonic >= GROUP_COMMIT_INTERVAL_SECONDS:
self._commit_pending(now_monotonic=now)
@property
def raw_bytes(self) -> int:
if self.raw_path.exists():
@@ -301,6 +332,26 @@ class _CaptureWriter:
stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
stream.flush()
def _commit_pending(self, *, now_monotonic: float | None = None) -> None:
if not self._pending_metadata:
return
raw = self._require_raw()
metadata = self._require_metadata()
# Group commit invariant: durable raw bytes always precede durable
# metadata references. A crash can therefore lose only the bounded
# in-memory group, never expose metadata pointing past durable raw.
raw.flush()
os.fsync(raw.fileno())
payload = "".join(self._pending_metadata)
self._pending_metadata.clear()
self._pending_raw_bytes = 0
metadata.write(payload)
metadata.flush()
os.fsync(metadata.fileno())
self._last_commit_monotonic = (
time.monotonic() if now_monotonic is None else now_monotonic
)
def validate_private_ipv4(value: str) -> str:
"""Require a literal RFC1918 address so capture cannot target arbitrary hosts."""
@@ -536,6 +587,11 @@ def capture_mqtt(
break
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
try:
writer.maybe_commit()
except OSError as exc:
fail("capture_error", f"group commit failed: {type(exc).__name__}: {exc}")
break
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
fail(
"connection_lost",
@@ -681,3 +737,12 @@ def _write_summary_exclusive(path: Path, summary: CaptureSummary) -> None:
stream.write(serialized)
stream.flush()
os.fsync(stream.fileno())
_fsync_directory(path.parent)
def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
+65
View File
@@ -0,0 +1,65 @@
"""Host-owned observation session catalog and durable layout persistence."""
from .active import (
ActiveSessionLease,
ActiveSessionLeaseError,
recover_stale_active_session_marker,
)
from .media import (
RECORDED_MEDIA_MANIFEST_SCHEMA,
RecordedMediaFile,
RecordedMediaInspector,
RecordedMediaManifest,
validate_recorded_media_timeline,
)
from .models import (
LayoutConflictError,
RecordedMediaArtifact,
ReplayCommand,
SessionIntegrityError,
SessionNotFoundError,
SessionNotReplayableError,
)
from .preparation import (
RecordingPreparationQueueFull,
RecordingPreparationSnapshot,
SessionRecordingPreparationManager,
)
from .recording import (
MaterializedRecording,
RecordingMaterializationCancelled,
RecordingMaterializationError,
SessionRecordingMaterializer,
)
from .store import (
SessionStore,
resolve_missioncore_data_dir,
resolve_missioncore_evidence_dir,
)
__all__ = [
"LayoutConflictError",
"ActiveSessionLease",
"ActiveSessionLeaseError",
"MaterializedRecording",
"RecordingMaterializationCancelled",
"RecordedMediaArtifact",
"RECORDED_MEDIA_MANIFEST_SCHEMA",
"RecordedMediaFile",
"RecordedMediaInspector",
"RecordedMediaManifest",
"ReplayCommand",
"RecordingMaterializationError",
"RecordingPreparationQueueFull",
"RecordingPreparationSnapshot",
"SessionIntegrityError",
"SessionNotFoundError",
"SessionNotReplayableError",
"SessionRecordingMaterializer",
"SessionRecordingPreparationManager",
"SessionStore",
"recover_stale_active_session_marker",
"resolve_missioncore_data_dir",
"resolve_missioncore_evidence_dir",
"validate_recorded_media_timeline",
]
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import importlib
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
ACTIVE_SESSION_MARKER = ".current_session"
class ActiveSessionLeaseError(RuntimeError):
"""The evidence root already has an active writer or cannot be leased."""
@dataclass(slots=True)
class ActiveSessionLease:
"""Cross-process lease that keeps an in-progress session out of replay.
The marker is intentionally written before the session directory is
created. Discovery therefore observes either no candidate yet or a
candidate protected by an already locked marker. A process crash releases
the OS lock; startup recovery can then remove the stale marker while
preserving the interrupted evidence directory.
"""
sessions_root: Path
session_root: Path
_descriptor: int
_marker_identity: tuple[int, int]
_released: bool = False
@classmethod
def acquire(cls, sessions_root: Path, session_root: Path) -> ActiveSessionLease:
root = sessions_root.expanduser().resolve()
root.mkdir(mode=0o700, parents=True, exist_ok=True)
target = session_root.expanduser().absolute()
if target.parent.resolve() != root or not target.name:
raise ActiveSessionLeaseError("active session must be a direct child of evidence root")
marker = root / ACTIVE_SESSION_MARKER
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(marker, flags, 0o600)
except FileExistsError as exc:
raise ActiveSessionLeaseError(
"another observation session owns the evidence root"
) from exc
try:
payload = f"{target.name}\n".encode()
os.write(descriptor, payload)
os.fsync(descriptor)
_lock_descriptor(descriptor, blocking=False)
_fsync_directory(root)
metadata = os.fstat(descriptor)
return cls(
sessions_root=root,
session_root=target,
_descriptor=descriptor,
_marker_identity=(metadata.st_dev, metadata.st_ino),
)
except BaseException:
try:
marker.unlink(missing_ok=True)
_fsync_directory(root)
finally:
os.close(descriptor)
raise
def release(self) -> None:
if self._released:
return
marker = self.sessions_root / ACTIVE_SESSION_MARKER
try:
try:
metadata = marker.lstat()
except FileNotFoundError:
metadata = None
if metadata is not None and (metadata.st_dev, metadata.st_ino) == self._marker_identity:
marker.unlink()
_fsync_directory(self.sessions_root)
finally:
_unlock_descriptor(self._descriptor)
os.close(self._descriptor)
self._released = True
def __enter__(self) -> ActiveSessionLease:
return self
def __exit__(self, *_: object) -> None:
self.release()
def recover_stale_active_session_marker(sessions_root: Path) -> bool:
"""Remove only an unlocked marker left by a terminated writer."""
root = sessions_root.expanduser().resolve()
marker = root / ACTIVE_SESSION_MARKER
try:
marker_stat = marker.lstat()
except FileNotFoundError:
return False
except OSError:
return False
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
return False
try:
descriptor = os.open(marker, os.O_RDWR | getattr(os, "O_NOFOLLOW", 0))
except OSError:
return False
locked = False
try:
try:
_lock_descriptor(descriptor, blocking=False)
locked = True
except OSError:
return False
opened = os.fstat(descriptor)
try:
current = marker.lstat()
except OSError:
return False
if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):
return False
marker.unlink()
_fsync_directory(root)
return True
finally:
if locked:
_unlock_descriptor(descriptor)
os.close(descriptor)
def _lock_descriptor(descriptor: int, *, blocking: bool) -> None:
if os.name == "nt":
msvcrt = cast(Any, importlib.import_module("msvcrt"))
os.lseek(descriptor, 0, os.SEEK_SET)
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
msvcrt.locking(descriptor, mode, 1)
return
import fcntl
operation = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
fcntl.flock(descriptor, operation)
def _unlock_descriptor(descriptor: int) -> None:
try:
if os.name == "nt":
msvcrt = cast(Any, importlib.import_module("msvcrt"))
os.lseek(descriptor, 0, os.SEEK_SET)
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
return
import fcntl
fcntl.flock(descriptor, fcntl.LOCK_UN)
except OSError:
return
def _fsync_directory(path: Path) -> None:
try:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
except OSError:
return
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
+818
View File
@@ -0,0 +1,818 @@
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import stat
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import lru_cache
from pathlib import Path
from typing import IO, Any
from k1link.mqtt.capture import (
FRAME_HEADER,
GROUP_COMMIT_MAX_BYTES,
GROUP_COMMIT_MAX_MESSAGES,
MAX_CONFIGURABLE_MESSAGE_BYTES,
MAX_TOPIC_BYTES,
RAW_MAGIC,
)
from .models import (
LegacyMediaSourceCandidate,
LegacySessionCandidate,
SessionModality,
SessionStatus,
)
MAX_LEGACY_JSON_BYTES = 2 * 1024 * 1024
LEGACY_SESSION_PATTERN = re.compile(r"^[A-Za-z0-9._-]+_viewer_live(?:_[0-9]+)?$")
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
MEDIA_SOURCE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
MEDIA_EPOCH_PATTERN = re.compile(r"^epoch-(?!0+$)[0-9]+$")
MEDIA_SEGMENT_PATTERN = re.compile(r"^[0-9]+\.m4s$")
MAX_MEDIA_INDEX_BYTES = 32 * 1024 * 1024
MAX_RECOVERY_METADATA_BYTES = 64 * 1024 * 1024
MAX_RECOVERY_METADATA_LINE_BYTES = 64 * 1024
MAX_RECOVERY_MESSAGES = 500_000
@dataclass(frozen=True, slots=True)
class _RecoveredCapture:
message_count: int
topic_counts: dict[str, int]
started_at_utc: str
completed_at_utc: str
duration_seconds: float
raw_committed_bytes: int
metadata_committed_bytes: int
def discover_legacy_viewer_sessions(root: Path) -> tuple[LegacySessionCandidate, ...]:
"""Describe legacy live sessions without copying or decoding their payloads."""
allowed_root = root.expanduser().resolve()
if not allowed_root.is_dir():
return ()
active_session_root = _active_session_root(allowed_root)
candidates: list[LegacySessionCandidate] = []
for entry in sorted(allowed_root.iterdir()):
if not entry.is_dir() or not LEGACY_SESSION_PATTERN.fullmatch(entry.name):
continue
resolved = entry.resolve()
if not resolved.is_relative_to(allowed_root):
continue
# The saved-session catalog deliberately omits the writer-owned run.
# Besides avoiding a misleading interrupted/error row, this prevents
# the two-second reconciler from repeatedly scanning and hashing a
# capture that is still growing. Marker release makes it discoverable
# on the next reconciliation pass.
if active_session_root == resolved:
continue
candidates.append(
_describe_session(
allowed_root,
resolved,
active=False,
)
)
return tuple(candidates)
def _describe_session(
allowed_root: Path,
session_root: Path,
*,
active: bool,
) -> LegacySessionCandidate:
capture_root = session_root / "captures" / "mqtt_live"
raw_path = capture_root / "mqtt.raw.k1mqtt"
summary = _read_json_object(capture_root / "mqtt.summary.json")
manifest = _read_json_object(session_root / "manifest.redacted.json")
raw_bytes = raw_path.stat().st_size if _confined_file(raw_path, session_root) else 0
raw_magic_ok = False
if raw_bytes >= len(RAW_MAGIC):
with raw_path.open("rb") as stream:
raw_magic_ok = stream.read(len(RAW_MAGIC)) == RAW_MAGIC
has_completed_summary = _is_completed_summary(summary)
completed = (
_validate_completed_capture(capture_root, session_root, raw_path, summary)
if has_completed_summary and raw_magic_ok
else None
)
recovered = (
_recover_interrupted_capture(capture_root, session_root, raw_path)
if raw_magic_ok
and completed is None
and (not has_completed_summary or summary.get("error") is not None)
else None
)
message_count = (
_non_negative_int(summary.get("message_count"))
if completed is not None
else recovered.message_count if recovered is not None else 0
)
summary_topic_counts = summary.get("topic_counts")
topic_counts = (
_normalized_topic_counts(summary_topic_counts)
if completed is not None
else recovered.topic_counts if recovered is not None else {}
)
media_sources = _discover_media_sources(session_root)
modalities = list(_modalities(topic_counts))
if media_sources:
modalities.append("video")
replayable = bool(
not active
and
raw_magic_ok
and raw_bytes > len(RAW_MAGIC)
and message_count > 0
and any(modality in {"point-cloud", "trajectory"} for modality in modalities)
)
if completed is not None:
status = (
"interrupted"
if active
else _status(replayable, summary.get("error"), summary.get("stop_reason"))
)
started_at = (
_safe_timestamp(summary.get("created_at_utc"))
or _safe_timestamp(manifest.get("started_at_utc"))
or _timestamp_from_session_name(session_root.name)
)
completed_at = _safe_timestamp(summary.get("completed_at_utc")) or _safe_timestamp(
manifest.get("completed_at_utc")
)
duration = _duration(summary.get("capture_elapsed_seconds"))
declared_hash = _declared_raw_hash(summary)
integrity_status = "verified" if declared_hash is not None else "validated-structure"
else:
status = "interrupted" if recovered is not None or active else "failed"
started_at = (
recovered.started_at_utc
if recovered is not None
else _safe_timestamp(manifest.get("started_at_utc"))
or _timestamp_from_session_name(session_root.name)
)
completed_at = recovered.completed_at_utc if recovered is not None else None
duration = recovered.duration_seconds if recovered is not None else None
declared_hash = None
integrity_status = "validated-prefix" if recovered is not None else "unverified"
replay_raw_bytes = (
completed.raw_committed_bytes
if completed is not None
else recovered.raw_committed_bytes if recovered is not None else 0
)
replay_metadata_bytes = (
completed.metadata_committed_bytes
if completed is not None
else recovered.metadata_committed_bytes if recovered is not None else 0
)
return LegacySessionCandidate(
session_id=session_root.name,
display_name=session_root.name,
status=status,
started_at_utc=started_at,
completed_at_utc=completed_at,
duration_seconds=duration,
modalities=tuple(modalities),
replayable=replayable,
total_bytes=raw_bytes + sum(source.byte_length for source in media_sources),
allowed_root=allowed_root,
session_root=session_root,
raw_path=raw_path.resolve(strict=False),
raw_byte_length=raw_bytes,
replay_raw_byte_length=replay_raw_bytes,
replay_metadata_byte_length=replay_metadata_bytes,
raw_sha256=declared_hash,
raw_integrity_status=integrity_status,
media_sources=media_sources,
)
def _is_completed_summary(summary: dict[str, Any]) -> bool:
return (
"message_count" in summary
and isinstance(summary.get("message_count"), int)
and not isinstance(summary.get("message_count"), bool)
and isinstance(summary.get("topic_counts"), dict)
and isinstance(summary.get("stop_reason"), str)
)
def _validate_completed_capture(
capture_root: Path,
session_root: Path,
raw_path: Path,
summary: dict[str, Any],
) -> _RecoveredCapture | None:
metadata_path = capture_root / "mqtt.metadata.jsonl"
try:
raw_stat = raw_path.lstat()
metadata_stat = metadata_path.lstat()
except OSError:
return None
if not stat.S_ISREG(raw_stat.st_mode) or not stat.S_ISREG(metadata_stat.st_mode):
return None
fingerprint = json.dumps(
{
"message_count": summary.get("message_count"),
"topic_counts": summary.get("topic_counts"),
"raw_bytes": summary.get("raw_bytes"),
"artifact_hashes": summary.get("artifact_hashes"),
},
sort_keys=True,
separators=(",", ":"),
)
return _validate_completed_capture_cached(
str(capture_root),
str(session_root),
str(raw_path),
_stat_identity(raw_stat),
_stat_identity(metadata_stat),
fingerprint,
)
@lru_cache(maxsize=128)
def _validate_completed_capture_cached(
capture_root_text: str,
session_root_text: str,
raw_path_text: str,
_raw_identity: tuple[int, int, int, int, int],
_metadata_identity: tuple[int, int, int, int, int],
summary_fingerprint: str,
) -> _RecoveredCapture | None:
capture_root = Path(capture_root_text)
session_root = Path(session_root_text)
raw_path = Path(raw_path_text)
summary = json.loads(summary_fingerprint)
validated = _scan_capture_prefix(
capture_root,
session_root,
raw_path,
tolerate_incomplete_metadata_tail=False,
tolerate_raw_crash_tail=False,
)
if validated is None:
return None
if validated.message_count != _non_negative_int(summary.get("message_count")):
return None
if validated.topic_counts != _normalized_topic_counts(summary.get("topic_counts")):
return None
declared_raw_bytes = summary.get("raw_bytes")
if declared_raw_bytes is not None and (
not isinstance(declared_raw_bytes, int)
or isinstance(declared_raw_bytes, bool)
or declared_raw_bytes != validated.raw_committed_bytes
):
return None
raw_hash = _declared_raw_hash(summary)
if raw_hash is not None and _sha256_stable(raw_path) != raw_hash:
return None
metadata_hash = _declared_metadata_hash(summary)
metadata_path = capture_root / "mqtt.metadata.jsonl"
if metadata_hash is not None and _sha256_stable(metadata_path) != metadata_hash:
return None
return validated
def _recover_interrupted_capture(
capture_root: Path,
session_root: Path,
raw_path: Path,
) -> _RecoveredCapture | None:
return _scan_capture_prefix(
capture_root,
session_root,
raw_path,
tolerate_incomplete_metadata_tail=True,
tolerate_raw_crash_tail=True,
)
def _scan_capture_prefix(
capture_root: Path,
session_root: Path,
raw_path: Path,
*,
tolerate_incomplete_metadata_tail: bool,
tolerate_raw_crash_tail: bool,
) -> _RecoveredCapture | None:
metadata_path = capture_root / "mqtt.metadata.jsonl"
if not _confined_file(raw_path, session_root) or not _confined_file(
metadata_path, session_root
):
return None
try:
raw_size = raw_path.stat().st_size
with raw_path.open("rb") as raw_stream, metadata_path.open("rb") as metadata_stream:
if raw_stream.read(len(RAW_MAGIC)) != RAW_MAGIC:
return None
consumed_metadata_bytes = 0
message_count = 0
topic_counts: dict[str, int] = {}
first_epoch_ns: int | None = None
last_epoch_ns: int | None = None
first_monotonic_ns: int | None = None
last_monotonic_ns: int | None = None
first_timestamp: str | None = None
last_timestamp: str | None = None
metadata_committed_bytes = 0
while (
consumed_metadata_bytes < MAX_RECOVERY_METADATA_BYTES
and message_count < MAX_RECOVERY_MESSAGES
):
remaining = MAX_RECOVERY_METADATA_BYTES - consumed_metadata_bytes
read_limit = min(MAX_RECOVERY_METADATA_LINE_BYTES + 1, remaining + 1)
line = metadata_stream.readline(read_limit)
if not line:
break
consumed_metadata_bytes += len(line)
if (
len(line) > MAX_RECOVERY_METADATA_LINE_BYTES
or consumed_metadata_bytes > MAX_RECOVERY_METADATA_BYTES
):
return None
if not line.endswith(b"\n"):
if tolerate_incomplete_metadata_tail:
break
return None
record = _metadata_record(line)
if record is None:
return None
metadata_committed_bytes = consumed_metadata_bytes
if record.get("record_type") != "message":
continue
expected_sequence = message_count + 1
validated = _validate_recovery_frame(
raw_stream,
raw_size=raw_size,
record=record,
expected_sequence=expected_sequence,
)
if validated is None:
return None
topic, epoch_ns, monotonic_ns, timestamp = validated
if last_monotonic_ns is not None and monotonic_ns < last_monotonic_ns:
return None
message_count = expected_sequence
topic_counts[topic] = topic_counts.get(topic, 0) + 1
if first_epoch_ns is None:
first_epoch_ns = epoch_ns
first_monotonic_ns = monotonic_ns
first_timestamp = timestamp
last_epoch_ns = epoch_ns
last_monotonic_ns = monotonic_ns
last_timestamp = timestamp
if metadata_stream.read(1):
return None
raw_committed_bytes = raw_stream.tell()
if raw_committed_bytes != raw_size and not (
tolerate_raw_crash_tail
and _tolerable_raw_crash_tail(raw_stream, raw_size=raw_size)
):
return None
except OSError:
return None
if (
message_count == 0
or first_epoch_ns is None
or last_epoch_ns is None
or first_timestamp is None
or last_timestamp is None
or first_monotonic_ns is None
or last_monotonic_ns is None
):
return None
return _RecoveredCapture(
message_count=message_count,
topic_counts=topic_counts,
started_at_utc=first_timestamp,
completed_at_utc=last_timestamp,
duration_seconds=(last_monotonic_ns - first_monotonic_ns) / 1_000_000_000,
raw_committed_bytes=raw_committed_bytes,
metadata_committed_bytes=metadata_committed_bytes,
)
def _metadata_record(line: bytes) -> dict[str, Any] | None:
try:
value = json.loads(line.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None
def _tolerable_raw_crash_tail(raw_stream: IO[bytes], *, raw_size: int) -> bool:
"""Accept at most one raw frame which was not committed by metadata.
The capture writer flushes raw before metadata and does not begin the next
message until the current metadata record is written. Consequently a
process crash can leave only a prefix (or all) of one additional frame.
Anything beyond that boundary is corruption, not a recoverable tail.
"""
tail_offset = raw_stream.tell()
tail_bytes = raw_size - tail_offset
if tail_bytes <= 0:
return True
if tail_bytes > GROUP_COMMIT_MAX_BYTES + MAX_CONFIGURABLE_MESSAGE_BYTES:
return False
frames = 0
while raw_stream.tell() < raw_size:
remaining = raw_size - raw_stream.tell()
header = raw_stream.read(min(remaining, FRAME_HEADER.size))
if len(header) < FRAME_HEADER.size:
return frames < GROUP_COMMIT_MAX_MESSAGES
topic_length, payload_length = FRAME_HEADER.unpack(header)
if not 1 <= topic_length <= MAX_TOPIC_BYTES:
return False
if payload_length > MAX_CONFIGURABLE_MESSAGE_BYTES:
return False
frame_bytes = FRAME_HEADER.size + topic_length + payload_length
frame_remaining = raw_size - (raw_stream.tell() - FRAME_HEADER.size)
available_topic_bytes = min(
topic_length,
max(0, frame_remaining - FRAME_HEADER.size),
)
topic_prefix = raw_stream.read(available_topic_bytes)
if available_topic_bytes < topic_length:
return frames < GROUP_COMMIT_MAX_MESSAGES
try:
topic = topic_prefix.decode("utf-8")
except UnicodeDecodeError:
return False
if not topic:
return False
payload_available = frame_remaining - FRAME_HEADER.size - topic_length
if payload_available < payload_length:
return frames < GROUP_COMMIT_MAX_MESSAGES
raw_stream.seek(payload_length, 1)
frames += 1
if frames > GROUP_COMMIT_MAX_MESSAGES or frame_remaining < frame_bytes:
return False
return frames <= GROUP_COMMIT_MAX_MESSAGES
def _validate_recovery_frame(
raw_stream: IO[bytes],
*,
raw_size: int,
record: dict[str, Any],
expected_sequence: int,
) -> tuple[str, int, int, str] | None:
sequence = record.get("sequence")
topic = record.get("topic")
payload_bytes = record.get("payload_bytes")
frame_offset = record.get("raw_frame_offset")
payload_offset = record.get("raw_payload_offset")
frame_bytes = record.get("raw_frame_bytes")
epoch_ns = record.get("received_at_epoch_ns")
monotonic_ns = record.get("received_monotonic_ns")
if (
sequence != expected_sequence
or not isinstance(topic, str)
or not topic
or not isinstance(payload_bytes, int)
or isinstance(payload_bytes, bool)
or not 0 <= payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES
or not isinstance(frame_offset, int)
or isinstance(frame_offset, bool)
or frame_offset != raw_stream.tell()
or not isinstance(payload_offset, int)
or isinstance(payload_offset, bool)
or not isinstance(frame_bytes, int)
or isinstance(frame_bytes, bool)
or not isinstance(epoch_ns, int)
or isinstance(epoch_ns, bool)
or epoch_ns < 0
or not isinstance(monotonic_ns, int)
or isinstance(monotonic_ns, bool)
or monotonic_ns < 0
):
return None
header = raw_stream.read(FRAME_HEADER.size)
if len(header) != FRAME_HEADER.size:
return None
topic_length, raw_payload_bytes = FRAME_HEADER.unpack(header)
if (
not 1 <= topic_length <= MAX_TOPIC_BYTES
or raw_payload_bytes != payload_bytes
or payload_offset != frame_offset + FRAME_HEADER.size + topic_length
or frame_bytes != FRAME_HEADER.size + topic_length + payload_bytes
or frame_offset + frame_bytes > raw_size
):
return None
raw_topic = raw_stream.read(topic_length)
try:
decoded_topic = raw_topic.decode("utf-8")
except UnicodeDecodeError:
return None
if decoded_topic != topic:
return None
raw_stream.seek(payload_bytes, 1)
timestamp = _safe_timestamp(record.get("received_at_utc"))
if timestamp is None:
try:
timestamp = datetime.fromtimestamp(epoch_ns / 1_000_000_000, tz=UTC).isoformat()
except (OSError, OverflowError, ValueError):
return None
return topic, epoch_ns, monotonic_ns, timestamp
def _read_json_object(path: Path) -> dict[str, Any]:
try:
if not path.is_file() or path.stat().st_size > MAX_LEGACY_JSON_BYTES:
return {}
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _confined_file(path: Path, session_root: Path) -> bool:
try:
resolved = path.resolve(strict=True)
except OSError:
return False
return resolved.is_file() and resolved.is_relative_to(session_root)
def _non_negative_int(value: object) -> int:
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
def _normalized_topic_counts(value: object) -> dict[str, int]:
if not isinstance(value, dict):
return {}
result: dict[str, int] = {}
for topic, raw_count in value.items():
count = _non_negative_int(raw_count)
if isinstance(topic, str) and count > 0:
result[topic] = count
return result
def _duration(value: object) -> float | None:
if not isinstance(value, (int, float)) or isinstance(value, bool):
return None
duration = float(value)
return duration if math.isfinite(duration) and duration >= 0 else None
def _safe_timestamp(value: object) -> str | None:
if not isinstance(value, str) or len(value) > 64:
return None
candidate = value.strip()
try:
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return None
return candidate
def _timestamp_from_session_name(session_id: str) -> str | None:
prefix = session_id.split("_", 1)[0]
try:
parsed = datetime.strptime(prefix, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
except ValueError:
return None
return parsed.isoformat().replace("+00:00", "Z")
def _declared_raw_hash(summary: dict[str, Any]) -> str | None:
hashes = summary.get("artifact_hashes")
if not isinstance(hashes, dict):
return None
value = hashes.get("raw_sha256")
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
def _declared_metadata_hash(summary: dict[str, Any]) -> str | None:
hashes = summary.get("artifact_hashes")
if not isinstance(hashes, dict):
return None
value = hashes.get("metadata_jsonl_sha256")
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
def _sha256_stable(path: Path) -> str:
try:
current = path.lstat()
except OSError:
return ""
if not stat.S_ISREG(current.st_mode):
return ""
return _sha256_cached(str(path), _stat_identity(current))
@lru_cache(maxsize=128)
def _sha256_cached(
path_text: str,
expected_identity: tuple[int, int, int, int, int],
) -> str:
path = Path(path_text)
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError:
return ""
try:
before = os.fstat(descriptor)
if not stat.S_ISREG(before.st_mode) or _stat_identity(before) != expected_identity:
return ""
digest = hashlib.sha256()
while chunk := os.read(descriptor, 1024 * 1024):
digest.update(chunk)
after = os.fstat(descriptor)
try:
current = os.lstat(path)
except OSError:
return ""
if _stat_identity(before) != _stat_identity(after) or (
current.st_dev,
current.st_ino,
) != (before.st_dev, before.st_ino):
return ""
return digest.hexdigest()
finally:
os.close(descriptor)
def _stat_identity(value: os.stat_result) -> tuple[int, int, int, int, int]:
return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)
def _active_session_root(root: Path) -> Path | None:
marker = root / ".current_session"
try:
marker_stat = marker.lstat()
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
return None
value = marker.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return None
if not value:
return None
raw = Path(value).expanduser()
if raw.is_absolute():
candidate = raw
elif raw.parts and raw.parts[0] == root.name:
candidate = root.parent / raw
else:
candidate = root / raw
try:
resolved = candidate.resolve(strict=True)
except OSError:
return None
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
def _modalities(topic_counts: Mapping[str, int]) -> tuple[SessionModality, ...]:
topics = {
topic
for topic, count in topic_counts.items()
if isinstance(topic, str) and _non_negative_int(count) > 0
}
result: list[SessionModality] = []
if any(topic == "RealtimePointcloud" or topic.endswith("/lio_pcl") for topic in topics):
result.append("point-cloud")
if any(topic == "RealtimePath" or topic.endswith("/lio_pose") for topic in topics):
result.append("trajectory")
return tuple(result)
def _status(replayable: bool, error: object, stop_reason: object) -> SessionStatus:
if error is None and stop_reason in {
"duration_elapsed",
"external_stop",
"keyboard_interrupt",
}:
return "ready" if replayable else "failed"
return "interrupted" if replayable else "failed"
def _discover_media_sources(session_root: Path) -> tuple[LegacyMediaSourceCandidate, ...]:
media_root = session_root / "media"
try:
resolved_media_root = media_root.resolve(strict=True)
except OSError:
return ()
if not resolved_media_root.is_dir() or not resolved_media_root.is_relative_to(session_root):
return ()
candidates: list[LegacyMediaSourceCandidate] = []
for source_root in sorted(resolved_media_root.iterdir()):
if not source_root.is_dir() or not MEDIA_SOURCE_PATTERN.fullmatch(source_root.name):
continue
try:
resolved_source_root = source_root.resolve(strict=True)
except OSError:
continue
if not resolved_source_root.is_relative_to(resolved_media_root):
continue
epochs = [
epoch
for epoch in sorted(resolved_source_root.iterdir())
if epoch.is_dir()
and MEDIA_EPOCH_PATTERN.fullmatch(epoch.name)
and _validated_media_epoch(epoch, resolved_source_root.name)
]
if not epochs:
continue
byte_length = sum(_media_epoch_bytes(epoch) for epoch in epochs)
artifact_suffix = hashlib.sha256(resolved_source_root.name.encode()).hexdigest()[:16]
candidates.append(
LegacyMediaSourceCandidate(
source_id=resolved_source_root.name,
artifact_id=f"recorded-video-{artifact_suffix}",
locator=resolved_source_root,
byte_length=byte_length,
epoch_count=len(epochs),
)
)
return tuple(candidates)
def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool:
try:
resolved_epoch = epoch.resolve(strict=True)
init_path = (epoch / "init.mp4").resolve(strict=True)
segments_root = (epoch / "segments").resolve(strict=True)
index_path = (epoch / "index.jsonl").resolve(strict=True)
summary_path = (epoch / "summary.json").resolve(strict=True)
except OSError:
return False
if not all(
path.is_relative_to(resolved_epoch)
for path in (init_path, segments_root, index_path, summary_path)
):
return False
if (
not init_path.is_file()
or init_path.stat().st_size <= 0
or not segments_root.is_dir()
or not index_path.is_file()
or not 0 < index_path.stat().st_size <= MAX_MEDIA_INDEX_BYTES
or not summary_path.is_file()
):
return False
summary = _read_json_object(summary_path)
if summary.get("schema_version") not in {1, "missioncore.camera-recording/v1"}:
return False
if summary.get("source_id") != expected_source_id:
return False
segment_count = _non_negative_int(summary.get("segment_count"))
if segment_count < 1:
return False
segments = [
path.resolve()
for path in sorted(segments_root.iterdir())
if path.is_file() and MEDIA_SEGMENT_PATTERN.fullmatch(path.name)
]
if len(segments) != segment_count or any(
not path.is_relative_to(segments_root) or path.stat().st_size <= 0 for path in segments
):
return False
try:
index_records = [
json.loads(line)
for line in index_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return False
if len(index_records) != segment_count or not all(
isinstance(record, dict)
and _non_negative_int(record.get("sequence")) > 0
for record in index_records
):
return False
sequences = [int(record["sequence"]) for record in index_records]
return len(sequences) == len(set(sequences))
def _media_epoch_bytes(epoch: Path) -> int:
try:
resolved_epoch = epoch.resolve(strict=True)
except OSError:
return 0
total = 0
for path in resolved_epoch.rglob("*"):
try:
resolved = path.resolve(strict=True)
except OSError:
continue
if resolved.is_file() and resolved.is_relative_to(resolved_epoch):
total += resolved.stat().st_size
return total
File diff suppressed because it is too large Load Diff
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
SessionStatus = Literal["ready", "interrupted", "failed"]
SessionModality = Literal["point-cloud", "trajectory", "video"]
class SessionStoreError(RuntimeError):
"""Base error for the host-owned observation session store."""
class SessionNotFoundError(SessionStoreError):
"""The requested opaque session identifier is not registered."""
class SessionNotReplayableError(SessionStoreError):
"""The session has no reviewed replay source."""
class SessionIntegrityError(SessionStoreError):
"""A stored artifact no longer satisfies its confinement/integrity boundary."""
class LayoutConflictError(SessionStoreError):
"""A workspace layout revision changed since the caller loaded it."""
@dataclass(frozen=True, slots=True)
class SessionSource:
source_id: str
semantic_channel_id: str
modality: SessionModality
status: str
seekable: bool
artifact_id: str
def as_dict(self) -> dict[str, Any]:
return {
"source_id": self.source_id,
"semantic_channel_id": self.semantic_channel_id,
"modality": self.modality,
"status": self.status,
"seekable": self.seekable,
"artifact_id": self.artifact_id,
}
@dataclass(frozen=True, slots=True)
class SessionArtifact:
artifact_id: str
kind: str
media_type: str
byte_length: int
sha256: str | None
integrity_status: str
def as_dict(self) -> dict[str, Any]:
return {
"artifact_id": self.artifact_id,
"kind": self.kind,
"media_type": self.media_type,
"byte_length": self.byte_length,
"sha256": self.sha256,
"integrity_status": self.integrity_status,
}
@dataclass(frozen=True, slots=True)
class SessionSummary:
session_id: str
display_name: str
status: SessionStatus
started_at_utc: str | None
completed_at_utc: str | None
duration_seconds: float | None
modalities: tuple[SessionModality, ...]
source_count: int
total_bytes: int
replayable: bool
origin: str
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.observation-session-summary/v1",
"session_id": self.session_id,
"display_name": self.display_name,
"status": self.status,
"started_at_utc": self.started_at_utc,
"completed_at_utc": self.completed_at_utc,
"duration_seconds": self.duration_seconds,
"modalities": list(self.modalities),
"source_count": self.source_count,
"total_bytes": self.total_bytes,
"replayable": self.replayable,
"origin": self.origin,
}
@dataclass(frozen=True, slots=True)
class SessionDetail:
summary: SessionSummary
sources: tuple[SessionSource, ...]
artifacts: tuple[SessionArtifact, ...]
def as_dict(self) -> dict[str, Any]:
duration = self.summary.duration_seconds
return {
"schema_version": "missioncore.observation-session/v1",
**{
key: value
for key, value in self.summary.as_dict().items()
if key != "schema_version"
},
"timeline": {
"mode": "recorded" if self.summary.replayable else "unavailable",
"seekable": self.summary.replayable,
"start_seconds": 0.0 if self.summary.replayable else None,
"end_seconds": duration if self.summary.replayable else None,
"synchronization": "host-arrival-best-effort",
},
"sources": [source.as_dict() for source in self.sources],
"artifacts": [artifact.as_dict() for artifact in self.artifacts],
}
@dataclass(frozen=True, slots=True)
class SessionPage:
items: tuple[SessionSummary, ...]
next_cursor: str | None
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.observation-session-list/v1",
"items": [item.as_dict() for item in self.items],
"next_cursor": self.next_cursor,
}
@dataclass(frozen=True, slots=True)
class WorkspaceLayout:
workspace_id: str
schema_version: int
revision: int
name: str
layout: dict[str, Any]
updated_at_utc: str
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.workspace-layout/v1",
"workspace_id": self.workspace_id,
"layout_schema_version": self.schema_version,
"revision": self.revision,
"name": self.name,
"layout": self.layout,
"updated_at_utc": self.updated_at_utc,
}
@dataclass(frozen=True, slots=True)
class ReplayCommand:
"""Internal-only replay command. ``source_path`` never enters an API DTO."""
session_id: str
source_path: Path
allowed_root: Path
session_root: Path
replay_byte_length: int
metadata_byte_length: int
expected_source_sha256: str | None
speed: float
loop: bool
@dataclass(frozen=True, slots=True)
class RecordedMediaArtifact:
"""Internal handle for one confined archived camera source.
The physical source id and filesystem locator never enter the public API.
``public_source_id`` and ``artifact_id`` are opaque catalog identifiers.
"""
session_id: str
public_source_id: str
artifact_id: str
source_path: Path
byte_length: int
@dataclass(frozen=True, slots=True)
class LegacySessionCandidate:
session_id: str
display_name: str
status: SessionStatus
started_at_utc: str | None
completed_at_utc: str | None
duration_seconds: float | None
modalities: tuple[SessionModality, ...]
replayable: bool
total_bytes: int
allowed_root: Path
session_root: Path
raw_path: Path
raw_byte_length: int
replay_raw_byte_length: int
replay_metadata_byte_length: int
raw_sha256: str | None
raw_integrity_status: str
media_sources: tuple[LegacyMediaSourceCandidate, ...]
@dataclass(frozen=True, slots=True)
class LegacyMediaSourceCandidate:
source_id: str
artifact_id: str
locator: Path
byte_length: int
epoch_count: int
+617
View File
@@ -0,0 +1,617 @@
from __future__ import annotations
import os
import queue
import threading
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from functools import partial
from pathlib import Path
from time import monotonic
from typing import Literal, cast
from uuid import uuid4
from .media import RecordedMediaManifest, validate_recorded_media_timeline
from .models import ReplayCommand
from .recording import (
MaterializedRecording,
RecordingMaterializationCancelled,
SessionRecordingMaterializer,
)
PreparationState = Literal[
"queued",
"validating",
"exporting",
"finalizing",
"ready",
"failed",
"cancelled",
]
ACTIVE_PREPARATION_STATES = frozenset({"queued", "validating", "exporting", "finalizing"})
_PREPARATION_PHASE: dict[PreparationState, int] = {
"queued": 0,
"validating": 1,
"exporting": 2,
"finalizing": 3,
"ready": 4,
"failed": 4,
"cancelled": 4,
}
class RecordingPreparationQueueFull(RuntimeError):
"""The bounded conversion queue cannot accept another recording."""
@dataclass(frozen=True, slots=True)
class RecordingPreparationSnapshot:
preparation_id: str
session_id: str
state: PreparationState
progress: float
updated_at_utc: str
cancellable: bool
retryable: bool
error: str | None
command: ReplayCommand
recording: MaterializedRecording | None
recorded_media: tuple[RecordedMediaManifest, ...] | None
@dataclass(slots=True)
class _PreparationJob:
preparation_id: str
source_identity: tuple[object, ...]
# This is an immutable source-preparation command. Per-browser playback
# policy (speed/loop) never belongs to a shared conversion job.
command: ReplayCommand
state: PreparationState = "queued"
progress: float = 0.0
updated_at_utc: str = field(default_factory=lambda: _utc_now_iso())
error: str | None = None
recording: MaterializedRecording | None = None
recorded_media: tuple[RecordedMediaManifest, ...] | None = None
cancel_event: threading.Event = field(default_factory=threading.Event)
last_activity_monotonic: float = field(default_factory=monotonic)
interrupted_by_restart: bool = False
cancelled_by_operator: bool = False
@dataclass(slots=True)
class _WorkerGeneration:
generation_id: int
work_queue: queue.Queue[_PreparationJob]
stop_event: threading.Event = field(default_factory=threading.Event)
worker: threading.Thread | None = None
class SessionRecordingPreparationManager:
"""One bounded, process-owned conversion worker for durable recordings.
Jobs are keyed by the session plus an inexpensive source identity. They
intentionally outlive HTTP requests and browser tabs. Conversion stays
single-worker to bound Rerun's CPU, memory and temporary-disk pressure.
Worker generations make lifespan restart safe even if a third-party
exporter ignores cancellation longer than ``close(timeout=...)``. New
jobs can queue immediately, but a successor worker starts only after the
previous generation has actually exited, so two writers never overlap.
"""
def __init__(
self,
materializer: SessionRecordingMaterializer,
*,
queue_capacity: int = 128,
heartbeat_interval_seconds: float = 5.0,
ready_preparer: Callable[
[ReplayCommand, MaterializedRecording],
tuple[RecordedMediaManifest, ...],
]
| None = None,
) -> None:
if queue_capacity < 1:
raise ValueError("recording preparation queue capacity must be positive")
if heartbeat_interval_seconds <= 0:
raise ValueError("recording preparation heartbeat interval must be positive")
self.materializer = materializer
self._queue_capacity = queue_capacity
# Activity callbacks from the real exporter are rate-limited to this
# interval. There is deliberately no independent fake heartbeat: a
# hung exporter must become observable to the browser stall detector.
self._heartbeat_interval_seconds = heartbeat_interval_seconds
self._ready_preparer = ready_preparer
self._guard = threading.RLock()
self._current_by_session: dict[str, _PreparationJob] = {}
self._closed = True
self._generation_counter = 0
self._active_generation: _WorkerGeneration | None = None
self._pending_generation: _WorkerGeneration | None = None
self.start()
def start(self) -> None:
"""Start or restart the single worker for an application lifespan."""
with self._guard:
if not self._closed:
return
self._current_by_session = {
session_id: job
for session_id, job in self._current_by_session.items()
if job.state != "cancelled"
}
self._closed = False
active = self._active_generation
if active is not None and active.worker is not None and active.worker.is_alive():
self._pending_generation = self._new_generation_locked()
return
self._active_generation = None
generation = self._pending_generation or self._new_generation_locked()
self._pending_generation = None
self._start_generation_locked(generation)
def enqueue(
self,
command: ReplayCommand,
*,
retry_failed: bool = False,
retry_interrupted: bool = False,
) -> RecordingPreparationSnapshot:
source_command = _source_command(command)
identity = _source_identity(source_command)
with self._guard:
if self._closed:
raise RuntimeError("recording preparation manager is closed")
current = self._current_by_session.get(command.session_id)
if current is not None and current.source_identity == identity:
retry_terminal = retry_failed and current.state in {"failed", "cancelled"}
retry_restart = (
retry_interrupted
and current.state == "cancelled"
and current.interrupted_by_restart
)
if not retry_terminal and not retry_restart:
return self._snapshot_locked(current)
job = _PreparationJob(
preparation_id=uuid4().hex,
source_identity=identity,
command=source_command,
)
self._current_by_session[command.session_id] = job
try:
self._intake_queue_locked().put_nowait(job)
except queue.Full as exc:
if self._current_by_session.get(command.session_id) is job:
if current is None:
self._current_by_session.pop(command.session_id, None)
else:
self._current_by_session[command.session_id] = current
raise RecordingPreparationQueueFull("recording preparation queue is full") from exc
return self._snapshot_locked(job)
def resolve_cached(
self,
command: ReplayCommand,
) -> RecordingPreparationSnapshot | None:
"""Validate a published cache and register it as a ready job."""
# When launch preparation includes camera manifests, a disk-only RRD
# is not a complete ready result. The worker must validate both parts
# in one background transaction before publishing ``ready``.
if self._ready_preparer is not None:
return None
source_command = _source_command(command)
identity = _source_identity(source_command)
recording = self.materializer.get_cached(source_command)
if recording is None:
with self._guard:
current = self._current_by_session.get(command.session_id)
if current is not None and current.state == "ready":
self._current_by_session.pop(command.session_id, None)
return None
with self._guard:
current = self._current_by_session.get(command.session_id)
if (
current is not None
and current.source_identity == identity
and current.state in ACTIVE_PREPARATION_STATES | {"ready"}
):
# A worker may have started between the disk lookup and this
# transaction. Let that single job publish its own result.
if current.state == "ready":
current.recording = recording
return self._snapshot_locked(current)
ready = _PreparationJob(
preparation_id=uuid4().hex,
source_identity=identity,
command=source_command,
state="ready",
progress=1.0,
recording=recording,
recorded_media=(),
)
self._current_by_session[command.session_id] = ready
return self._snapshot_locked(ready)
def resolve_cached_pinned(
self,
command: ReplayCommand,
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
"""Validate and lease a published cache without starting conversion."""
if self._ready_preparer is not None:
return None
source_command = _source_command(command)
result = self.materializer.get_cached_pinned(source_command)
if result is None:
with self._guard:
current = self._current_by_session.get(command.session_id)
if current is not None and current.state == "ready":
self._current_by_session.pop(command.session_id, None)
return None
recording, release = result
identity = _source_identity(source_command)
with self._guard:
current = self._current_by_session.get(command.session_id)
if (
current is None
or current.source_identity != identity
or current.state not in ACTIVE_PREPARATION_STATES | {"ready"}
):
current = _PreparationJob(
preparation_id=uuid4().hex,
source_identity=identity,
command=source_command,
state="ready",
progress=1.0,
recording=recording,
recorded_media=(),
)
self._current_by_session[command.session_id] = current
snapshot = self._snapshot_locked(current)
return snapshot, release
def status(self, session_id: str) -> RecordingPreparationSnapshot | None:
with self._guard:
job = self._current_by_session.get(session_id)
if (
job is not None
and job.state == "ready"
and (
job.recording is None
or not self.materializer.is_recording_available(job.recording)
)
):
self._current_by_session.pop(session_id, None)
return None
return None if job is None else self._snapshot_locked(job)
def reserve_cached(
self,
command: ReplayCommand,
*,
lease_seconds: float = 120.0,
) -> RecordingPreparationSnapshot | None:
"""Pin a ready artifact across the launch-document to file-GET gap."""
if lease_seconds <= 0:
raise ValueError("recording launch lease must be positive")
pinned = self.resolve_cached_pinned(command)
if pinned is None:
return None
snapshot, release = pinned
timer = threading.Timer(lease_seconds, release)
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
timer.daemon = True
timer.start()
return snapshot
def pin_ready(
self,
session_id: str,
*,
preparation_id: str | None = None,
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
"""Cheaply lease the exact already-validated ready generation."""
with self._guard:
job = self._current_by_session.get(session_id)
if (
job is None
or job.state != "ready"
or job.recording is None
or (
preparation_id is not None
and job.preparation_id != preparation_id
)
):
return None
recording = job.recording
pinned = self.materializer.pin_recording(recording)
if pinned is None:
if self._current_by_session.get(session_id) is job:
self._current_by_session.pop(session_id, None)
return None
snapshot = self._snapshot_locked(job)
return snapshot, pinned
def reserve_ready(
self,
session_id: str,
*,
preparation_id: str | None = None,
lease_seconds: float = 120.0,
) -> RecordingPreparationSnapshot | None:
"""Hold a cheap launch lease without reopening or hashing the cache."""
if lease_seconds <= 0:
raise ValueError("recording launch lease must be positive")
pinned = self.pin_ready(session_id, preparation_id=preparation_id)
if pinned is None:
return None
snapshot, release = pinned
timer = threading.Timer(lease_seconds, release)
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
timer.daemon = True
timer.start()
return snapshot
def cancel(self, session_id: str, *, preparation_id: str | None = None) -> bool:
with self._guard:
job = self._current_by_session.get(session_id)
if (
job is None
or job.state not in ACTIVE_PREPARATION_STATES
or (preparation_id is not None and job.preparation_id != preparation_id)
):
return False
job.cancelled_by_operator = True
job.cancel_event.set()
if job.state == "queued":
self._transition_locked(job, "cancelled", job.progress)
return True
def close(self, *, timeout: float = 5.0) -> None:
with self._guard:
if self._closed:
return
self._closed = True
for job in self._current_by_session.values():
if job.state in ACTIVE_PREPARATION_STATES:
job.interrupted_by_restart = not job.cancelled_by_operator
job.cancel_event.set()
if job.state == "queued":
self._transition_locked(job, "cancelled", job.progress)
active = self._active_generation
if active is not None:
active.stop_event.set()
pending = self._pending_generation
if pending is not None:
pending.stop_event.set()
self._pending_generation = None
worker = None if active is None else active.worker
if worker is not None:
worker.join(timeout=max(0.0, timeout))
def _run_generation(self, generation: _WorkerGeneration) -> None:
work_queue = generation.work_queue
try:
while not generation.stop_event.is_set():
try:
job = work_queue.get(timeout=0.1)
except queue.Empty:
continue
try:
if job.state == "cancelled" or job.cancel_event.is_set():
with self._guard:
self._transition_locked(job, "cancelled", job.progress)
continue
with self._guard:
self._transition_locked(job, "validating", 0.05)
try:
recording = self.materializer.materialize(
job.command,
progress_callback=partial(self._progress, job),
cancel_event=job.cancel_event,
)
except RecordingMaterializationCancelled:
with self._guard:
self._transition_locked(job, "cancelled", job.progress)
except Exception:
# The public status intentionally does not expose paths,
# broker payloads or exporter internals.
with self._guard:
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
self._transition_locked(job, "failed", job.progress)
job.error = "Не удалось подготовить запись сессии."
job.updated_at_utc = _utc_now_iso()
else:
with self._guard:
# Cancellation may race the final materializer
# return. Never publish ``ready`` after accepting a
# cancel request for this exact job.
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
self._transition_locked(job, "finalizing", 0.95)
if job.cancel_event.is_set():
with self._guard:
self._transition_locked(job, "cancelled", job.progress)
continue
try:
recorded_media = (
()
if self._ready_preparer is None
else self._ready_preparer(job.command, recording)
)
validate_recorded_media_timeline(
recorded_media,
recording_start_seconds=(
recording.timeline_start_ns / 1_000_000_000
),
recording_end_seconds=(
recording.timeline_end_ns / 1_000_000_000
),
)
except Exception:
with self._guard:
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
self._transition_locked(job, "failed", job.progress)
job.error = "Не удалось подготовить запись сессии."
job.updated_at_utc = _utc_now_iso()
continue
with self._guard:
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
job.recording = recording
job.recorded_media = recorded_media
self._transition_locked(job, "ready", 1.0)
finally:
work_queue.task_done()
finally:
self._generation_exited(generation)
def _new_generation_locked(self) -> _WorkerGeneration:
self._generation_counter += 1
return _WorkerGeneration(
generation_id=self._generation_counter,
work_queue=queue.Queue(maxsize=self._queue_capacity),
)
def _start_generation_locked(self, generation: _WorkerGeneration) -> None:
self._active_generation = generation
worker = threading.Thread(
target=self._run_generation,
args=(generation,),
name=f"missioncore-recording-preparation-{generation.generation_id}",
daemon=True,
)
generation.worker = worker
worker.start()
def _generation_exited(self, generation: _WorkerGeneration) -> None:
with self._guard:
if self._active_generation is not generation:
return
self._active_generation = None
if self._closed:
return
successor = self._pending_generation or self._new_generation_locked()
self._pending_generation = None
self._start_generation_locked(successor)
def _intake_queue_locked(self) -> queue.Queue[_PreparationJob]:
pending = self._pending_generation
if pending is not None:
return pending.work_queue
active = self._active_generation
if active is None or active.stop_event.is_set():
pending = self._new_generation_locked()
self._pending_generation = pending
return pending.work_queue
return active.work_queue
def _progress(self, job: _PreparationJob, state: str, progress: float) -> None:
# Only the worker publishes ``ready`` after it owns the verified
# recording handle; accepting the final callback would expose a short
# ready-without-launch race to status polling.
if state not in {"validating", "exporting", "finalizing"}:
return
next_state = cast(PreparationState, state)
with self._guard:
if job.state in {"cancelled", "failed", "ready"}:
return
if _PREPARATION_PHASE[next_state] < _PREPARATION_PHASE[job.state]:
return
now = monotonic()
if (
job.state == next_state
and progress <= job.progress
and now - job.last_activity_monotonic < self._heartbeat_interval_seconds
):
return
self._transition_locked(job, next_state, progress)
def _transition_locked(
self,
job: _PreparationJob,
state: PreparationState,
progress: float,
) -> None:
if job.state not in ACTIVE_PREPARATION_STATES and state != job.state:
return
if _PREPARATION_PHASE[state] < _PREPARATION_PHASE[job.state]:
return
job.state = state
job.progress = max(job.progress, min(1.0, max(0.0, progress)))
job.updated_at_utc = _utc_now_iso()
job.last_activity_monotonic = monotonic()
def _snapshot_locked(self, job: _PreparationJob) -> RecordingPreparationSnapshot:
return RecordingPreparationSnapshot(
preparation_id=job.preparation_id,
session_id=job.command.session_id,
state=job.state,
progress=job.progress,
updated_at_utc=job.updated_at_utc,
cancellable=(
job.state == "queued"
or (
job.state in {"validating", "exporting", "finalizing"}
and self.materializer.supports_cooperative_cancellation
)
),
retryable=job.state in {"failed", "cancelled"},
error=job.error,
command=job.command,
recording=job.recording,
recorded_media=job.recorded_media,
)
def _source_identity(command: ReplayCommand) -> tuple[object, ...]:
"""Build a non-blocking identity; full validation belongs to the worker."""
identities: list[object] = [
command.session_id,
str(command.source_path),
command.replay_byte_length,
command.metadata_byte_length,
command.expected_source_sha256,
]
for path in (command.source_path, command.source_path.with_name("mqtt.metadata.jsonl")):
try:
value = os.lstat(path)
except OSError:
identities.extend((str(Path(path)), None, None, None, None))
else:
identities.extend(
(
str(Path(path)),
value.st_size,
value.st_mtime_ns,
value.st_ctime_ns,
value.st_ino,
)
)
return tuple(identities)
def _source_command(command: ReplayCommand) -> ReplayCommand:
"""Remove per-viewer launch policy from a shared preparation job."""
return replace(command, speed=1.0, loop=False)
def _utc_now_iso() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
File diff suppressed because it is too large Load Diff
+715
View File
@@ -0,0 +1,715 @@
from __future__ import annotations
import json
import os
import re
import sqlite3
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any, cast
from k1link.artifacts import utc_now_iso
from .legacy import discover_legacy_viewer_sessions
from .models import (
LayoutConflictError,
LegacySessionCandidate,
RecordedMediaArtifact,
ReplayCommand,
SessionArtifact,
SessionDetail,
SessionIntegrityError,
SessionModality,
SessionNotFoundError,
SessionNotReplayableError,
SessionPage,
SessionSource,
SessionStatus,
SessionSummary,
WorkspaceLayout,
)
IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
MAX_LAYOUT_BYTES = 256 * 1024
DATABASE_NAME = "mission-core.sqlite3"
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS observation_sessions (
session_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ready', 'interrupted', 'failed')),
started_at_utc TEXT,
completed_at_utc TEXT,
duration_seconds REAL,
modalities_json TEXT NOT NULL,
replayable INTEGER NOT NULL CHECK (replayable IN (0, 1)),
origin TEXT NOT NULL,
source_count INTEGER NOT NULL,
total_bytes INTEGER NOT NULL,
replay_raw_bytes INTEGER NOT NULL DEFAULT 0,
replay_metadata_bytes INTEGER NOT NULL DEFAULT 0,
allowed_root TEXT NOT NULL,
session_root TEXT NOT NULL,
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS observation_sessions_recent
ON observation_sessions(started_at_utc DESC, session_id DESC);
CREATE TABLE IF NOT EXISTS observation_session_artifacts (
session_id TEXT NOT NULL REFERENCES observation_sessions(session_id) ON DELETE CASCADE,
artifact_id TEXT NOT NULL,
kind TEXT NOT NULL,
media_type TEXT NOT NULL,
byte_length INTEGER NOT NULL,
sha256 TEXT,
integrity_status TEXT NOT NULL,
locator TEXT NOT NULL,
PRIMARY KEY (session_id, artifact_id)
);
CREATE TABLE IF NOT EXISTS observation_session_sources (
session_id TEXT NOT NULL REFERENCES observation_sessions(session_id) ON DELETE CASCADE,
source_id TEXT NOT NULL,
semantic_channel_id TEXT NOT NULL,
modality TEXT NOT NULL,
status TEXT NOT NULL,
seekable INTEGER NOT NULL CHECK (seekable IN (0, 1)),
artifact_id TEXT NOT NULL,
PRIMARY KEY (session_id, source_id),
FOREIGN KEY (session_id, artifact_id)
REFERENCES observation_session_artifacts(session_id, artifact_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS workspace_layouts (
workspace_id TEXT PRIMARY KEY,
layout_schema_version INTEGER NOT NULL,
revision INTEGER NOT NULL,
name TEXT NOT NULL,
layout_json TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
"""
def resolve_missioncore_data_dir(repository_root: Path) -> Path:
configured = os.environ.get("MISSIONCORE_DATA_DIR", "").strip()
if configured:
return Path(configured).expanduser().resolve()
return (repository_root.expanduser().resolve() / ".runtime" / "mission-core").resolve()
def resolve_missioncore_evidence_dir(repository_root: Path) -> Path:
"""Return the private source-of-record root for new observation sessions."""
configured = os.environ.get("MISSIONCORE_EVIDENCE_DIR", "").strip()
if configured:
return Path(configured).expanduser().resolve()
return (resolve_missioncore_data_dir(repository_root) / "evidence" / "sessions").resolve()
class SessionStore:
"""SQLite catalog plus confined filesystem references for host observation sessions."""
def __init__(self, repository_root: Path, *, data_dir: Path | None = None) -> None:
self.repository_root = repository_root.expanduser().resolve()
self.data_dir = (
data_dir.expanduser().resolve()
if data_dir is not None
else resolve_missioncore_data_dir(self.repository_root)
)
self.data_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
with _ignore_os_error():
self.data_dir.chmod(0o700)
self.database_path = self.data_dir / DATABASE_NAME
self._lock = threading.RLock()
self._initialize()
def import_legacy_viewer_live(self, root: Path) -> tuple[str, ...]:
allowed_root = root.expanduser().resolve()
candidates = discover_legacy_viewer_sessions(allowed_root)
imported: list[str] = []
for candidate in candidates:
self._upsert_legacy(candidate)
imported.append(candidate.session_id)
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
discovered = set(imported)
indexed = connection.execute(
"SELECT session_id FROM observation_sessions "
"WHERE origin = 'legacy-viewer-live' AND allowed_root = ?",
(str(allowed_root),),
).fetchall()
stale = [row["session_id"] for row in indexed if row["session_id"] not in discovered]
connection.executemany(
"DELETE FROM observation_sessions WHERE session_id = ?",
((session_id,) for session_id in stale),
)
connection.commit()
return tuple(imported)
def list_recent(self, *, limit: int = 20, cursor: str | None = None) -> SessionPage:
if not 1 <= limit <= 100:
raise ValueError("limit must be within 1..100")
parameters: list[object] = []
where = ""
with self._connect() as connection:
if cursor is not None:
_validate_identifier(cursor, "session cursor")
cursor_row = connection.execute(
"SELECT started_at_utc, session_id FROM observation_sessions "
"WHERE session_id = ?",
(cursor,),
).fetchone()
if cursor_row is None:
raise SessionNotFoundError("observation session cursor was not found")
where = (
"WHERE (COALESCE(started_at_utc, ''), session_id) < "
"(COALESCE(?, ''), ?)"
)
parameters.extend((cursor_row["started_at_utc"], cursor_row["session_id"]))
parameters.append(limit + 1)
rows = connection.execute(
f"SELECT * FROM observation_sessions {where} " # noqa: S608 - static clause
"ORDER BY COALESCE(started_at_utc, '') DESC, session_id DESC LIMIT ?",
parameters,
).fetchall()
has_more = len(rows) > limit
selected = rows[:limit]
items = tuple(_summary_from_row(row) for row in selected)
next_cursor = items[-1].session_id if has_more and items else None
return SessionPage(items=items, next_cursor=next_cursor)
def get_session(self, session_id: str) -> SessionDetail:
_validate_identifier(session_id, "session id")
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM observation_sessions WHERE session_id = ?",
(session_id,),
).fetchone()
if row is None:
raise SessionNotFoundError("observation session was not found")
source_rows = connection.execute(
"SELECT source_id, semantic_channel_id, modality, status, seekable, artifact_id "
"FROM observation_session_sources WHERE session_id = ? ORDER BY source_id",
(session_id,),
).fetchall()
artifact_rows = connection.execute(
"SELECT artifact_id, kind, media_type, byte_length, sha256, integrity_status "
"FROM observation_session_artifacts WHERE session_id = ? ORDER BY artifact_id",
(session_id,),
).fetchall()
sources = tuple(
SessionSource(
source_id=source["source_id"],
semantic_channel_id=source["semantic_channel_id"],
modality=cast(SessionModality, source["modality"]),
status=source["status"],
seekable=bool(source["seekable"]),
artifact_id=source["artifact_id"],
)
for source in source_rows
)
artifacts = tuple(
SessionArtifact(
artifact_id=artifact["artifact_id"],
kind=artifact["kind"],
media_type=artifact["media_type"],
byte_length=artifact["byte_length"],
sha256=artifact["sha256"],
integrity_status=artifact["integrity_status"],
)
for artifact in artifact_rows
)
return SessionDetail(summary=_summary_from_row(row), sources=sources, artifacts=artifacts)
def prepare_replay(
self,
session_id: str,
*,
speed: float = 1.0,
loop: bool = False,
) -> ReplayCommand:
detail = self.get_session(session_id)
if not detail.summary.replayable:
raise SessionNotReplayableError("observation session has no replayable spatial source")
if not 0 <= speed <= 100:
raise ValueError("speed must be within 0..100")
with self._connect() as connection:
row = connection.execute(
"SELECT s.allowed_root, s.session_root, s.replay_raw_bytes, "
"s.replay_metadata_bytes, a.locator, a.sha256 "
"FROM observation_sessions AS s "
"JOIN observation_session_artifacts AS a ON a.session_id = s.session_id "
"WHERE s.session_id = ? AND a.artifact_id = 'raw-mqtt'",
(session_id,),
).fetchone()
if row is None:
raise SessionNotReplayableError("observation session replay artifact is unavailable")
source_path = _resolve_confined_artifact(
Path(row["allowed_root"]),
Path(row["session_root"]),
Path(row["locator"]),
)
return ReplayCommand(
session_id=session_id,
source_path=source_path,
allowed_root=Path(row["allowed_root"]),
session_root=Path(row["session_root"]),
replay_byte_length=int(row["replay_raw_bytes"]),
metadata_byte_length=int(row["replay_metadata_bytes"]),
expected_source_sha256=row["sha256"],
speed=float(speed),
loop=loop,
)
def list_recorded_media(self, session_id: str) -> tuple[RecordedMediaArtifact, ...]:
"""Return confined archived-video handles without exposing device ids."""
_validate_identifier(session_id, "session id")
with self._connect() as connection:
session = connection.execute(
"SELECT allowed_root, session_root FROM observation_sessions "
"WHERE session_id = ?",
(session_id,),
).fetchone()
if session is None:
raise SessionNotFoundError("observation session was not found")
rows = connection.execute(
"SELECT DISTINCT a.artifact_id, a.locator, a.byte_length "
"FROM observation_session_artifacts AS a "
"JOIN observation_session_sources AS source "
"ON source.session_id = a.session_id AND source.artifact_id = a.artifact_id "
"WHERE a.session_id = ? AND a.kind = 'recorded-video' "
"AND source.modality = 'video' ORDER BY a.artifact_id",
(session_id,),
).fetchall()
return tuple(
_recorded_media_from_row(
session_id=session_id,
allowed_root=Path(session["allowed_root"]),
session_root=Path(session["session_root"]),
artifact_id=row["artifact_id"],
locator=Path(row["locator"]),
byte_length=row["byte_length"],
)
for row in rows
)
def get_recorded_media(
self,
session_id: str,
artifact_id: str,
) -> RecordedMediaArtifact:
_validate_identifier(artifact_id, "recorded media artifact id")
matches = tuple(
artifact
for artifact in self.list_recorded_media(session_id)
if artifact.artifact_id == artifact_id
)
if len(matches) != 1:
raise SessionNotFoundError("recorded media source was not found")
return matches[0]
def get_layout(self, workspace_id: str) -> WorkspaceLayout:
_validate_identifier(workspace_id, "workspace id")
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM workspace_layouts WHERE workspace_id = ?",
(workspace_id,),
).fetchone()
if row is None:
raise SessionNotFoundError("workspace layout was not found")
return _layout_from_row(row)
def save_layout(
self,
workspace_id: str,
*,
schema_version: int,
expected_revision: int,
name: str,
layout: dict[str, Any],
) -> WorkspaceLayout:
_validate_identifier(workspace_id, "workspace id")
if schema_version != 1:
raise ValueError("only workspace layout schema version 1 is supported")
if expected_revision < 0:
raise ValueError("expected revision must be non-negative")
normalized_name = name.strip()
if not 1 <= len(normalized_name) <= 160:
raise ValueError("layout name must contain 1..160 characters")
serialized = _serialize_layout(layout)
updated_at = utc_now_iso()
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
current = connection.execute(
"SELECT revision FROM workspace_layouts WHERE workspace_id = ?",
(workspace_id,),
).fetchone()
current_revision = 0 if current is None else int(current["revision"])
if current_revision != expected_revision:
connection.rollback()
raise LayoutConflictError("workspace layout revision changed")
revision = current_revision + 1
connection.execute(
"INSERT INTO workspace_layouts "
"(workspace_id, layout_schema_version, revision, name, layout_json, "
"updated_at_utc) VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(workspace_id) DO UPDATE SET "
"layout_schema_version = excluded.layout_schema_version, "
"revision = excluded.revision, name = excluded.name, "
"layout_json = excluded.layout_json, updated_at_utc = excluded.updated_at_utc",
(
workspace_id,
schema_version,
revision,
normalized_name,
serialized,
updated_at,
),
)
connection.commit()
return WorkspaceLayout(
workspace_id=workspace_id,
schema_version=schema_version,
revision=revision,
name=normalized_name,
layout=json.loads(serialized),
updated_at_utc=updated_at,
)
def _initialize(self) -> None:
with self._connect() as connection:
connection.executescript(SCHEMA_SQL)
columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(observation_sessions)")
}
if "replay_raw_bytes" not in columns:
connection.execute(
"ALTER TABLE observation_sessions "
"ADD COLUMN replay_raw_bytes INTEGER NOT NULL DEFAULT 0"
)
if "replay_metadata_bytes" not in columns:
connection.execute(
"ALTER TABLE observation_sessions "
"ADD COLUMN replay_metadata_bytes INTEGER NOT NULL DEFAULT 0"
)
connection.commit()
with _ignore_os_error():
self.database_path.chmod(0o600)
def _upsert_legacy(self, candidate: LegacySessionCandidate) -> None:
_validate_identifier(candidate.session_id, "legacy session id")
allowed_root = candidate.allowed_root.resolve()
session_root = candidate.session_root.resolve()
if not session_root.is_relative_to(allowed_root):
raise SessionIntegrityError("legacy session root escapes its allowed root")
sources = _legacy_sources(candidate)
artifacts = _legacy_artifacts(candidate, session_root)
now = utc_now_iso()
modalities_json = json.dumps(list(candidate.modalities), separators=(",", ":"))
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT origin, allowed_root, session_root, created_at_utc "
"FROM observation_sessions WHERE session_id = ?",
(candidate.session_id,),
).fetchone()
if existing is not None and (
existing["origin"] != "legacy-viewer-live"
or Path(existing["allowed_root"]).resolve() != allowed_root
or Path(existing["session_root"]).resolve() != session_root
):
connection.rollback()
raise SessionIntegrityError("session id is already bound to another origin")
created_at = existing["created_at_utc"] if existing is not None else now
connection.execute(
"INSERT INTO observation_sessions "
"(session_id, display_name, status, started_at_utc, completed_at_utc, "
"duration_seconds, modalities_json, replayable, origin, source_count, "
"total_bytes, replay_raw_bytes, replay_metadata_bytes, allowed_root, "
"session_root, created_at_utc, updated_at_utc) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(session_id) DO UPDATE SET "
"display_name = excluded.display_name, status = excluded.status, "
"started_at_utc = excluded.started_at_utc, "
"completed_at_utc = excluded.completed_at_utc, "
"duration_seconds = excluded.duration_seconds, "
"modalities_json = excluded.modalities_json, "
"replayable = excluded.replayable, source_count = excluded.source_count, "
"total_bytes = excluded.total_bytes, "
"replay_raw_bytes = excluded.replay_raw_bytes, "
"replay_metadata_bytes = excluded.replay_metadata_bytes, "
"updated_at_utc = excluded.updated_at_utc",
(
candidate.session_id,
candidate.display_name,
candidate.status,
candidate.started_at_utc,
candidate.completed_at_utc,
candidate.duration_seconds,
modalities_json,
int(candidate.replayable),
"legacy-viewer-live",
len(sources),
candidate.total_bytes,
candidate.replay_raw_byte_length,
candidate.replay_metadata_byte_length,
str(allowed_root),
str(session_root),
created_at,
now,
),
)
connection.execute(
"DELETE FROM observation_session_sources WHERE session_id = ?",
(candidate.session_id,),
)
connection.execute(
"DELETE FROM observation_session_artifacts WHERE session_id = ?",
(candidate.session_id,),
)
connection.executemany(
"INSERT INTO observation_session_artifacts "
"(session_id, artifact_id, kind, media_type, byte_length, sha256, "
"integrity_status, locator) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[
(candidate.session_id, *artifact)
for artifact in artifacts
],
)
connection.executemany(
"INSERT INTO observation_session_sources "
"(session_id, source_id, semantic_channel_id, modality, status, seekable, "
"artifact_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
[
(
candidate.session_id,
source.source_id,
source.semantic_channel_id,
source.modality,
source.status,
int(source.seekable),
source.artifact_id,
)
for source in sources
],
)
connection.commit()
@contextmanager
def _connect(self) -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(self.database_path, timeout=5.0)
connection.row_factory = sqlite3.Row
try:
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA synchronous = FULL")
connection.execute("PRAGMA busy_timeout = 5000")
connection.execute("PRAGMA journal_mode = WAL")
yield connection
finally:
connection.close()
def _legacy_sources(candidate: LegacySessionCandidate) -> tuple[SessionSource, ...]:
rows: list[SessionSource] = []
if "point-cloud" in candidate.modalities:
rows.append(
SessionSource(
source_id="sensor.lidar.primary",
semantic_channel_id="spatial.point-cloud.recorded",
modality="point-cloud",
status="recorded",
seekable=candidate.replayable,
artifact_id="raw-mqtt",
)
)
if "trajectory" in candidate.modalities:
rows.append(
SessionSource(
source_id="spatial.trajectory",
semantic_channel_id="spatial.pose.recorded",
modality="trajectory",
status="recorded",
seekable=candidate.replayable,
artifact_id="raw-mqtt",
)
)
rows.extend(
SessionSource(
source_id=media.source_id,
semantic_channel_id="camera.video.recorded",
modality="video",
status="recorded",
seekable=True,
artifact_id=media.artifact_id,
)
for media in candidate.media_sources
)
if len({source.source_id for source in rows}) != len(rows):
raise SessionIntegrityError("legacy session contains duplicate source identifiers")
return tuple(rows)
def _legacy_artifacts(
candidate: LegacySessionCandidate,
session_root: Path,
) -> tuple[tuple[str, str, str, int, str | None, str, str], ...]:
artifacts: list[tuple[str, str, str, int, str | None, str, str]] = [
(
"raw-mqtt",
"raw-transport",
"application/x-nodedc-k1mqtt",
candidate.raw_byte_length,
candidate.raw_sha256,
candidate.raw_integrity_status,
str(candidate.raw_path),
)
]
for media in candidate.media_sources:
try:
locator = media.locator.resolve(strict=True)
except OSError as exc:
raise SessionIntegrityError("legacy video artifact is missing") from exc
if not locator.is_dir() or not locator.is_relative_to(session_root):
raise SessionIntegrityError("legacy video artifact escapes its session root")
artifacts.append(
(
media.artifact_id,
"recorded-video",
"video/mp4",
media.byte_length,
None,
"validated-structure",
str(locator),
)
)
if len({artifact[0] for artifact in artifacts}) != len(artifacts):
raise SessionIntegrityError("legacy session contains duplicate artifact identifiers")
return tuple(artifacts)
def _summary_from_row(row: sqlite3.Row) -> SessionSummary:
raw_modalities = json.loads(row["modalities_json"])
modalities = tuple(cast(SessionModality, value) for value in raw_modalities)
return SessionSummary(
session_id=row["session_id"],
display_name=row["display_name"],
status=cast(SessionStatus, row["status"]),
started_at_utc=row["started_at_utc"],
completed_at_utc=row["completed_at_utc"],
duration_seconds=row["duration_seconds"],
modalities=modalities,
source_count=row["source_count"],
total_bytes=row["total_bytes"],
replayable=bool(row["replayable"]),
origin=row["origin"],
)
def _layout_from_row(row: sqlite3.Row) -> WorkspaceLayout:
payload = json.loads(row["layout_json"])
if not isinstance(payload, dict):
raise SessionIntegrityError("stored workspace layout is not a JSON object")
return WorkspaceLayout(
workspace_id=row["workspace_id"],
schema_version=row["layout_schema_version"],
revision=row["revision"],
name=row["name"],
layout=payload,
updated_at_utc=row["updated_at_utc"],
)
def _resolve_confined_artifact(
allowed_root: Path,
session_root: Path,
locator: Path,
) -> Path:
try:
allowed = allowed_root.resolve(strict=True)
session = session_root.resolve(strict=True)
artifact = locator.resolve(strict=True)
except OSError as exc:
raise SessionIntegrityError("session replay artifact is missing") from exc
if (
not allowed.is_dir()
or not session.is_dir()
or not artifact.is_file()
or not session.is_relative_to(allowed)
or not artifact.is_relative_to(session)
):
raise SessionIntegrityError("session replay artifact escapes its allowed root")
return artifact
def _recorded_media_from_row(
*,
session_id: str,
allowed_root: Path,
session_root: Path,
artifact_id: str,
locator: Path,
byte_length: int,
) -> RecordedMediaArtifact:
_validate_identifier(artifact_id, "recorded media artifact id")
try:
allowed = allowed_root.resolve(strict=True)
session = session_root.resolve(strict=True)
media_root = (session / "media").resolve(strict=True)
artifact = locator.resolve(strict=True)
except OSError as exc:
raise SessionIntegrityError("recorded media artifact is missing") from exc
if (
not allowed.is_dir()
or not session.is_dir()
or not media_root.is_dir()
or not artifact.is_dir()
or not session.is_relative_to(allowed)
or not media_root.is_relative_to(session)
or artifact.parent != media_root
):
raise SessionIntegrityError("recorded media artifact escapes its session root")
if not isinstance(byte_length, int) or isinstance(byte_length, bool) or byte_length < 1:
raise SessionIntegrityError("recorded media artifact has an invalid byte length")
public_suffix = artifact_id.removeprefix("recorded-video-")
public_source_id = f"recorded.camera.{public_suffix}"
_validate_identifier(public_source_id, "recorded media source id")
return RecordedMediaArtifact(
session_id=session_id,
public_source_id=public_source_id,
artifact_id=artifact_id,
source_path=artifact,
byte_length=byte_length,
)
def _serialize_layout(layout: dict[str, Any]) -> str:
try:
serialized = json.dumps(layout, ensure_ascii=False, separators=(",", ":"))
except (TypeError, ValueError) as exc:
raise ValueError("layout must be a JSON object") from exc
if len(serialized.encode("utf-8")) > MAX_LAYOUT_BYTES:
raise ValueError("layout exceeds the 256 KiB storage boundary")
return serialized
def _validate_identifier(value: str, label: str) -> None:
if not IDENTIFIER_PATTERN.fullmatch(value):
raise ValueError(f"{label} has an invalid shape")
@contextmanager
def _ignore_os_error() -> Iterator[None]:
try:
yield
except OSError:
return
+13 -3
View File
@@ -53,11 +53,17 @@ def _iter_native_capture(path: Path) -> Iterator[StreamMessage]:
epoch_ns = fallback_epoch_ns + frame.sequence - 1
monotonic_ns: int | None = None
if metadata_stream is not None:
epoch_ns, monotonic_ns = _read_native_timing(
timing = _read_native_timing(
metadata_stream,
expected_sequence=frame.sequence,
fallback_epoch_ns=epoch_ns,
)
if timing is None:
# A crash may leave one raw frame ahead of the last fully
# committed metadata line. Only the aligned prefix has a
# trustworthy source timeline and is safe to replay.
return
epoch_ns, monotonic_ns = timing
yield StreamMessage(
sequence=frame.sequence,
topic=frame.topic,
@@ -78,15 +84,19 @@ def _read_native_timing(
*,
expected_sequence: int,
fallback_epoch_ns: int,
) -> tuple[int, int | None]:
) -> tuple[int, int | None] | None:
line = stream.readline(MAX_METADATA_LINE_CHARS + 1)
if not line:
return fallback_epoch_ns, None
return None
if len(line) > MAX_METADATA_LINE_CHARS:
raise ReplayFormatError("native metadata line exceeds the reviewed bound")
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
if not line.endswith(("\n", "\r")):
# A non-newline final tail is the only tolerated corruption: the
# writer may have crashed between raw and metadata group commits.
return None
raise ReplayFormatError(
f"native metadata line {expected_sequence} is not valid JSON"
) from exc
+595
View File
@@ -0,0 +1,595 @@
from __future__ import annotations
import hashlib
import math
import os
import threading
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import TypedDict
from uuid import UUID, uuid4
import numpy as np
import rerun as rr
from rerun import blueprint as rrb
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
from k1link.protocol.normalizer import normalize_k1_message
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages
from k1link.viewer.rerun_bridge import (
MAX_TRAJECTORY_POSES,
TRAJECTORY_APPEND_INTERVAL_NS,
TRAJECTORY_FORCE_APPEND_NS,
TRAJECTORY_MIN_DISTANCE_METERS,
TRAJECTORY_PUBLISH_INTERVAL_NS,
RerunSceneSettings,
_parse_hex_color,
_point_colors,
)
APPLICATION_ID = "nodedc_mission_core_recorded"
SESSION_TIMELINE = "session_time"
CAPTURE_TIMELINE = "capture_time"
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
# blueprint update makes the update overwrite the existing scene instead of
# creating a fresh view/container (which would also reset the operator's eye
# position and layout).
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
class RrdExportSummary(TypedDict):
schema_version: int
input_path: str
output_path: str
recording_id: str
timeline: str
capture_timeline: str
source_messages: int
decoded_messages: int
point_frames: int
pose_frames: int
ignored_messages: int
points: int
trajectory_poses: int
trajectory_updates: int
session_origin_monotonic_ns: int
timeline_start_ns: int
timeline_end_ns: int
timeline_span_ns: int
first_decoded_time_ns: int
last_decoded_time_ns: int
source_sha256: str
rrd_sha256: str
rrd_bytes: int
class RrdExportError(RuntimeError):
"""A raw capture could not be converted into a complete durable RRD."""
class RrdExportCancelled(RrdExportError):
"""A background RRD export was cooperatively cancelled."""
@dataclass(slots=True)
class _ExportCounters:
source_messages: int = 0
point_frames: int = 0
pose_frames: int = 0
ignored_messages: int = 0
points: int = 0
first_decoded_time_ns: int | None = None
last_decoded_time_ns: int | None = None
@property
def decoded_messages(self) -> int:
return self.point_frames + self.pose_frames
def observe_decoded(self, session_time_ns: int) -> None:
if self.first_decoded_time_ns is None:
self.first_decoded_time_ns = session_time_ns
self.last_decoded_time_ns = session_time_ns
@dataclass(slots=True)
class _TrajectoryBuffer:
positions: list[tuple[float, float, float]]
last_append_time_ns: int | None = None
last_publish_time_ns: int | None = None
updates: int = 0
@classmethod
def empty(cls) -> _TrajectoryBuffer:
return cls(positions=[])
def process(
self,
recording: rr.RecordingStream,
position: tuple[float, float, float],
session_time_ns: int,
) -> None:
if not self._append(position, session_time_ns):
return
if (
self.last_publish_time_ns is not None
and session_time_ns - self.last_publish_time_ns < TRAJECTORY_PUBLISH_INTERVAL_NS
):
return
self.last_publish_time_ns = session_time_ns
self.updates += 1
recording.log(
"/world/trajectory",
rr.LineStrips3D(
[list(self.positions)],
colors=[247, 248, 244, 255],
radii=rr.Radius.ui_points(2.0),
),
)
def _append(self, position: tuple[float, float, float], session_time_ns: int) -> bool:
if not self.positions:
self.positions.append(position)
self.last_append_time_ns = session_time_ns
return True
assert self.last_append_time_ns is not None
elapsed_ns = session_time_ns - self.last_append_time_ns
if elapsed_ns < TRAJECTORY_APPEND_INTERVAL_NS:
return False
if (
math.dist(self.positions[-1], position) < TRAJECTORY_MIN_DISTANCE_METERS
and elapsed_ns < TRAJECTORY_FORCE_APPEND_NS
):
return False
self.positions.append(position)
self.last_append_time_ns = session_time_ns
if len(self.positions) > MAX_TRAJECTORY_POSES:
last = self.positions[-1]
self.positions = self.positions[::2]
if self.positions[-1] != last:
self.positions.append(last)
return True
def export_k1mqtt_to_rrd(
input_path: Path,
output_path: Path,
*,
cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None,
) -> RrdExportSummary:
"""Losslessly project every decodable K1 data-plane frame into one RRD.
The raw capture remains the source of record. The derived RRD uses a
recording-local duration timeline whose zero is the first raw message's
receive-monotonic timestamp. It never traverses the bounded live-preview
queue, so export throughput cannot drop point or pose frames.
The destination is replaced only after the temporary RRD has been closed,
flushed and fsynced. Any decode, timing, sink or rename failure therefore
leaves an existing destination artifact untouched.
"""
_raise_if_cancelled(cancel_event)
source = input_path.expanduser().resolve()
destination = output_path.expanduser().resolve()
_validate_paths(source, destination)
destination.parent.mkdir(parents=True, exist_ok=True)
recording_id = str(uuid4())
temporary = destination.with_name(f".{destination.name}.{recording_id}.tmp")
source_sha256 = _sha256_file(
source,
cancel_event=cancel_event,
activity_callback=activity_callback,
)
settings = RerunSceneSettings()
blueprint = _recorded_blueprint(settings)
recording: rr.RecordingStream | None = None
recording_closed = False
published = False
counters = _ExportCounters()
trajectory = _TrajectoryBuffer.empty()
session_origin_ns: int | None = None
previous_monotonic_ns: int | None = None
try:
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
recording.set_sinks(
rr.FileSink(temporary, write_footer=True),
default_blueprint=blueprint,
)
_log_static_scene(recording)
_log_session_origin(recording)
for message in iter_replay_messages(source):
_raise_if_cancelled(cancel_event)
_notify_activity(activity_callback)
counters.source_messages += 1
monotonic_ns = message.received_monotonic_ns
if monotonic_ns is None:
raise RrdExportError(
"native capture metadata must provide received_monotonic_ns "
f"for message {message.sequence}"
)
if previous_monotonic_ns is not None and monotonic_ns < previous_monotonic_ns:
raise RrdExportError(
f"native capture monotonic time decreases at message {message.sequence}"
)
if session_origin_ns is None:
session_origin_ns = monotonic_ns
session_time_ns = monotonic_ns - session_origin_ns
if session_time_ns > JS_MAX_SAFE_INTEGER:
raise RrdExportError(
"session duration exceeds the exact JavaScript nanosecond range"
)
previous_monotonic_ns = monotonic_ns
try:
decoded = normalize_k1_message(
message,
processing_started_monotonic_ns=monotonic_ns,
)
except NormalizationError as exc:
raise RrdExportError(
f"known K1 frame {message.sequence} failed normalization"
) from exc
if decoded is None:
counters.ignored_messages += 1
continue
_set_frame_time(
recording,
decoded.context.sequence,
session_time_ns,
decoded.context.captured_at_epoch_ns,
)
counters.observe_decoded(session_time_ns)
if isinstance(decoded, DecodedPointCloudView):
_log_points(recording, decoded, settings)
counters.point_frames += 1
counters.points += decoded.point_count
elif isinstance(decoded, DecodedPoseView):
position = (
float(decoded.position_xyz[0]),
float(decoded.position_xyz[1]),
float(decoded.position_xyz[2]),
)
_log_pose(recording, decoded, position)
trajectory.process(recording, position, session_time_ns)
counters.pose_frames += 1
else:
counters.ignored_messages += 1
if session_origin_ns is None:
raise RrdExportError("native capture contains no messages")
if counters.decoded_messages == 0:
raise RrdExportError("native capture contains no decodable point or pose frames")
assert counters.first_decoded_time_ns is not None
assert counters.last_decoded_time_ns is not None
_raise_if_cancelled(cancel_event)
recording.flush(timeout_sec=30.0)
recording.disconnect()
recording_closed = True
_raise_if_cancelled(cancel_event)
_fsync_file(temporary)
rrd_bytes = temporary.stat().st_size
if rrd_bytes <= 0:
raise RrdExportError("Rerun produced an empty recording")
rrd_sha256 = _sha256_file(
temporary,
cancel_event=cancel_event,
activity_callback=activity_callback,
)
summary = RrdExportSummary(
schema_version=1,
input_path=str(source),
output_path=str(destination),
recording_id=recording_id,
timeline=SESSION_TIMELINE,
capture_timeline=CAPTURE_TIMELINE,
source_messages=counters.source_messages,
decoded_messages=counters.decoded_messages,
point_frames=counters.point_frames,
pose_frames=counters.pose_frames,
ignored_messages=counters.ignored_messages,
points=counters.points,
trajectory_poses=len(trajectory.positions),
trajectory_updates=trajectory.updates,
session_origin_monotonic_ns=session_origin_ns,
timeline_start_ns=0,
# Playback completeness is defined by data actually written to
# the RRD. K1 status/heartbeat packets may continue long after the
# final point or pose frame; advertising that raw tail as the RRD
# end makes a strict browser buffering gate wait forever.
timeline_end_ns=counters.last_decoded_time_ns,
timeline_span_ns=counters.last_decoded_time_ns,
first_decoded_time_ns=counters.first_decoded_time_ns,
last_decoded_time_ns=counters.last_decoded_time_ns,
source_sha256=source_sha256,
rrd_sha256=rrd_sha256,
rrd_bytes=rrd_bytes,
)
_raise_if_cancelled(cancel_event)
os.replace(temporary, destination)
_fsync_directory(destination.parent)
published = True
return summary
except (ReplayFormatError, OSError) as exc:
raise RrdExportError(f"RRD export failed: {exc}") from exc
finally:
if recording is not None and not recording_closed:
with suppress(BaseException):
recording.disconnect()
if not published:
temporary.unlink(missing_ok=True)
def _validate_paths(source: Path, destination: Path) -> None:
if not source.is_file():
raise RrdExportError(f"raw capture does not exist: {source}")
if source.suffix.casefold() != ".k1mqtt":
raise RrdExportError("RRD export accepts only native .k1mqtt captures")
if destination.suffix.casefold() != ".rrd":
raise RrdExportError("RRD destination must use the .rrd suffix")
if source == destination:
raise RrdExportError("raw capture and RRD destination must be different files")
try:
replay_format = detect_replay_format(source)
except ReplayFormatError as exc:
raise RrdExportError(str(exc)) from exc
if replay_format != "k1mqtt":
raise RrdExportError("RRD export accepts only native K1MQTT captures")
def _recorded_blueprint(
settings: RerunSceneSettings,
*,
include_initial_playback_state: bool = True,
) -> rrb.Blueprint:
accumulation = max(0.0, settings.accumulation_seconds)
# An omitted range means Rerun's native latest-at query: the most recent
# LiDAR frame at the cursor. A zero-width range is *not* equivalent; it
# only matches rows stamped at the cursor's exact nanosecond and therefore
# makes an ordinary recorded scan appear empty.
time_ranges: list[rr.VisibleTimeRange] | None = None
if accumulation > 0:
time_ranges = [
rr.VisibleTimeRange(
SESSION_TIMELINE,
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
end=rr.TimeRangeBoundary.cursor_relative(),
)
]
point_visualizer = rr.Points3D.from_fields(
# A negative Radius value is Rerun's serialized representation
# for UI points. Blueprint overrides broadcast this singleton
# value across every recorded point without rewriting the store.
radii=rr.Radius.ui_points(settings.point_size),
# Recorded height/intensity/distance/RGB palettes are baked into
# each Points3D row. A uniform custom color is the one color mode
# that can be replaced safely by a singleton blueprint override.
colors=(
[_parse_hex_color(settings.custom_color)]
if settings.palette == "custom"
else None
),
).visualizer()
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
spatial_view = rrb.Spatial3DView(
origin="/world",
name="Пространственная сцена",
background=[7, 8, 10, 255],
line_grid=rrb.LineGrid3D(
visible=settings.show_grid,
color=[86, 91, 99, 110],
stroke_width=0.75,
),
overrides={
# EntityBehavior is evaluated from the blueprint store and can
# therefore hide/reveal already-recorded entities without
# rewriting the data RRD. Keep the point visualizer alongside it
# so size/color overrides remain active for the same entity.
"/world/points": [
rrb.EntityBehavior(visible=settings.show_points),
point_visualizer,
],
"/world/trajectory": rrb.EntityBehavior(
visible=settings.show_trajectory,
),
},
# A positive window accumulates historical frames. With no
# window, latest-at deliberately keeps one current LiDAR frame.
time_ranges=time_ranges,
)
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
root_container = rrb.Tabs(spatial_view)
root_container.id = RECORDED_ROOT_CONTAINER_ID
if include_initial_playback_state:
# This state is appropriate only while opening a newly exported RRD.
# Settings-only blueprint messages must not pause an already playing
# recording or mutate the host-owned panel state.
return rrb.Blueprint(
root_container,
rrb.TimePanel(
timeline=SESSION_TIMELINE,
play_state="paused",
state="hidden",
),
auto_layout=False,
auto_views=False,
collapse_panels=True,
)
return rrb.Blueprint(
root_container,
auto_layout=False,
auto_views=False,
collapse_panels=False,
)
def recorded_blueprint_rrd(
settings: RerunSceneSettings,
*,
application_id: str = APPLICATION_ID,
recording_id: str,
) -> bytes:
"""Serialize a small active blueprint update for an already-open recording.
The returned RRD contains blueprint-store messages only; it never copies the
recorded data store and is therefore safe to push through a WebViewer log
channel when operator display settings change.
"""
recording = rr.RecordingStream(
application_id,
recording_id=recording_id,
send_properties=False,
)
stream = rr.binary_stream(recording)
try:
recording.send_blueprint(
_recorded_blueprint(
settings,
include_initial_playback_state=False,
),
make_active=True,
make_default=False,
)
payload = stream.read(flush=True, flush_timeout_sec=5.0)
except Exception as exc:
raise RrdExportError("failed to serialize recorded blueprint") from exc
finally:
with suppress(Exception):
recording.disconnect()
if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576:
raise RrdExportError("serialized recorded blueprint is invalid")
return payload
def _log_static_scene(recording: rr.RecordingStream) -> None:
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
recording.log(
"/world/sensor_pose",
rr.TransformAxes3D(axis_length=0.45, show_frame=False),
static=True,
)
def _log_session_origin(recording: rr.RecordingStream) -> None:
"""Materialize the declared zero of ``session_time`` outside the 3D scene."""
recording.set_time(
SESSION_TIMELINE,
duration=np.timedelta64(0, "ns"),
)
recording.log(
"/__mission_core/session_origin",
rr.AnyValues(session_origin=True),
)
def _set_frame_time(
recording: rr.RecordingStream,
sequence: int,
session_time_ns: int,
capture_time_ns: int,
) -> None:
recording.set_time(
SESSION_TIMELINE,
duration=np.timedelta64(session_time_ns, "ns"),
)
recording.set_time(
CAPTURE_TIMELINE,
timestamp=np.datetime64(capture_time_ns, "ns"),
)
recording.set_time("message_sequence", sequence=sequence)
def _log_points(
recording: rr.RecordingStream,
frame: DecodedPointCloudView,
settings: RerunSceneSettings,
) -> None:
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
if frame.intensities is None:
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
else:
intensities = np.frombuffer(frame.intensities, dtype=np.uint8)
rgb = (
None
if frame.colors_rgb is None
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
)
recording.log(
"/world/points",
rr.Points3D(
positions,
colors=_point_colors(positions, intensities, rgb, settings),
radii=rr.Radius.ui_points(settings.point_size),
),
)
def _log_pose(
recording: rr.RecordingStream,
frame: DecodedPoseView,
position: tuple[float, float, float],
) -> None:
recording.log(
"/world/sensor_pose",
rr.Transform3D(
translation=position,
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
),
)
def _fsync_file(path: Path) -> None:
with path.open("rb") as stream:
os.fsync(stream.fileno())
def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _sha256_file(
path: Path,
*,
cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None,
) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
_raise_if_cancelled(cancel_event)
_notify_activity(activity_callback)
digest.update(chunk)
return digest.hexdigest()
def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
if cancel_event is not None and cancel_event.is_set():
raise RrdExportCancelled("RRD export was cancelled")
def _notify_activity(callback: Callable[[], None] | None) -> None:
if callback is not None:
callback()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff