feat(sessions): add durable observation archive and replay API
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user