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
+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