feat(perception): publish immutable lab session instances
This commit is contained in:
@@ -5,6 +5,7 @@ from .active import (
|
||||
ActiveSessionLeaseError,
|
||||
recover_stale_active_session_marker,
|
||||
)
|
||||
from .lab_cache import publish_lab_replay_cache
|
||||
from .media import (
|
||||
RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||||
RecordedMediaFile,
|
||||
@@ -14,6 +15,7 @@ from .media import (
|
||||
validate_recorded_media_timeline,
|
||||
)
|
||||
from .models import (
|
||||
LabSessionBinding,
|
||||
LayoutConflictError,
|
||||
ObservationArtifactCandidate,
|
||||
ObservationSessionCandidate,
|
||||
@@ -50,6 +52,7 @@ from .store import (
|
||||
|
||||
__all__ = [
|
||||
"LayoutConflictError",
|
||||
"LabSessionBinding",
|
||||
"ActiveSessionLease",
|
||||
"ActiveSessionLeaseError",
|
||||
"MaterializedRecording",
|
||||
@@ -59,6 +62,7 @@ __all__ = [
|
||||
"ObservationSessionCandidate",
|
||||
"PluginRecordingExportCancelled",
|
||||
"PluginRecordingExportError",
|
||||
"publish_lab_replay_cache",
|
||||
"RecordingMaterializationCancelled",
|
||||
"RecordedMediaArtifact",
|
||||
"RECORDED_MEDIA_MANIFEST_SCHEMA",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Publish zero-copy replay caches for immutable LAB session aliases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from k1link.artifacts import write_json_atomic
|
||||
|
||||
from .models import SessionIntegrityError
|
||||
from .recording import (
|
||||
COMPATIBLE_CACHE_SCHEMAS,
|
||||
RECORDING_CACHE_FILENAME,
|
||||
RECORDING_CACHE_SIDECAR_FILENAME,
|
||||
)
|
||||
|
||||
|
||||
def publish_lab_replay_cache(
|
||||
data_dir: Path,
|
||||
*,
|
||||
source_session_id: str,
|
||||
lab_session_id: str,
|
||||
) -> None:
|
||||
"""Hard-link a validated source RRD and clone its path-free sidecars.
|
||||
|
||||
The RRD bytes are immutable and independent of the catalog id, so a hard
|
||||
link gives every LAB instance a normal cache endpoint without consuming a
|
||||
second copy of the multi-gigabyte recording.
|
||||
"""
|
||||
|
||||
root = data_dir.expanduser().resolve(strict=True)
|
||||
recordings = (root / "recordings").resolve(strict=True)
|
||||
source_root = recordings / source_session_id
|
||||
source_rrd = source_root / RECORDING_CACHE_FILENAME
|
||||
source_sidecar = source_root / RECORDING_CACHE_SIDECAR_FILENAME
|
||||
_require_regular(source_rrd, source_root)
|
||||
_require_regular(source_sidecar, source_root)
|
||||
document = _read_object(source_sidecar)
|
||||
if (
|
||||
document.get("schema_version") not in COMPATIBLE_CACHE_SCHEMAS
|
||||
or document.get("session_id") != source_session_id
|
||||
):
|
||||
raise SessionIntegrityError("source recording cache is incompatible")
|
||||
|
||||
target_root = recordings / lab_session_id
|
||||
target_root.mkdir(mode=0o700, parents=False, exist_ok=True)
|
||||
if target_root.is_symlink() or target_root.resolve() != target_root:
|
||||
raise SessionIntegrityError("LAB recording cache root is unsafe")
|
||||
target_rrd = target_root / RECORDING_CACHE_FILENAME
|
||||
if target_rrd.exists():
|
||||
source_stat = _require_regular(source_rrd, source_root)
|
||||
target_stat = _require_regular(target_rrd, target_root)
|
||||
if (source_stat.st_dev, source_stat.st_ino) != (target_stat.st_dev, target_stat.st_ino):
|
||||
raise SessionIntegrityError("LAB recording cache already contains different bytes")
|
||||
else:
|
||||
temporary = target_root / f".{RECORDING_CACHE_FILENAME}.{os.getpid()}.tmp"
|
||||
try:
|
||||
os.link(source_rrd, temporary, follow_symlinks=False)
|
||||
os.replace(temporary, target_rrd)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
target_stat = _require_regular(target_rrd, target_root)
|
||||
cloned = {
|
||||
**document,
|
||||
"session_id": lab_session_id,
|
||||
"recording_mtime_ns": target_stat.st_mtime_ns,
|
||||
}
|
||||
write_json_atomic(target_root / RECORDING_CACHE_SIDECAR_FILENAME, cloned)
|
||||
os.chmod(target_root / RECORDING_CACHE_SIDECAR_FILENAME, 0o600)
|
||||
_clone_recorded_media_sidecars(
|
||||
root / "recorded-media-preparations",
|
||||
source_session_id=source_session_id,
|
||||
lab_session_id=lab_session_id,
|
||||
)
|
||||
|
||||
|
||||
def _clone_recorded_media_sidecars(
|
||||
root: Path,
|
||||
*,
|
||||
source_session_id: str,
|
||||
lab_session_id: str,
|
||||
) -> None:
|
||||
if not root.is_dir():
|
||||
return
|
||||
for source in root.iterdir():
|
||||
if source.is_symlink() or not source.is_file():
|
||||
continue
|
||||
try:
|
||||
document = _read_object(source)
|
||||
except (OSError, SessionIntegrityError):
|
||||
continue
|
||||
if document.get("session_id") != source_session_id:
|
||||
continue
|
||||
artifact_id = document.get("artifact_id")
|
||||
if not isinstance(artifact_id, str):
|
||||
raise SessionIntegrityError("recorded media sidecar has no artifact identity")
|
||||
body = {key: value for key, value in document.items() if key != "checksum_sha256"}
|
||||
body["session_id"] = lab_session_id
|
||||
checksum = hashlib.sha256(_canonical_json(body)).hexdigest()
|
||||
target = root / _media_sidecar_name(lab_session_id, artifact_id)
|
||||
write_json_atomic(target, {**body, "checksum_sha256": checksum})
|
||||
os.chmod(target, 0o600)
|
||||
|
||||
|
||||
def _media_sidecar_name(session_id: str, artifact_id: str) -> str:
|
||||
key = f"{session_id}\0{artifact_id}".encode()
|
||||
return f"{hashlib.sha256(key).hexdigest()}.json"
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _read_object(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("cache sidecar is not a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _require_regular(path: Path, parent: Path) -> os.stat_result:
|
||||
metadata = path.lstat()
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or path.parent.resolve(strict=True) != parent.resolve(strict=True)
|
||||
):
|
||||
raise SessionIntegrityError("cache artifact is not a confined regular file")
|
||||
return metadata
|
||||
@@ -28,6 +28,35 @@ class LayoutConflictError(SessionStoreError):
|
||||
"""A workspace layout revision changed since the caller loaded it."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LabSessionBinding:
|
||||
"""Immutable provenance for one derived laboratory replay."""
|
||||
|
||||
session_id: str
|
||||
source_session_id: str
|
||||
lab_id: str
|
||||
result_kind: str
|
||||
result_id: str
|
||||
source_result_id: str | None
|
||||
config_sha256: str | None
|
||||
run_created_at_utc: str
|
||||
published_at_utc: str
|
||||
provenance: dict[str, Any]
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"lab_id": self.lab_id,
|
||||
"source_session_id": self.source_session_id,
|
||||
"result_kind": self.result_kind,
|
||||
"result_id": self.result_id,
|
||||
"source_result_id": self.source_result_id,
|
||||
"config_sha256": self.config_sha256,
|
||||
"run_created_at_utc": self.run_created_at_utc,
|
||||
"published_at_utc": self.published_at_utc,
|
||||
"provenance": self.provenance,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SessionSource:
|
||||
source_id: str
|
||||
@@ -81,9 +110,10 @@ class SessionSummary:
|
||||
total_bytes: int
|
||||
replayable: bool
|
||||
origin: str
|
||||
lab: LabSessionBinding | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
document: dict[str, Any] = {
|
||||
"schema_version": "missioncore.observation-session-summary/v1",
|
||||
"session_id": self.session_id,
|
||||
"display_name": self.display_name,
|
||||
@@ -97,6 +127,9 @@ class SessionSummary:
|
||||
"replayable": self.replayable,
|
||||
"origin": self.origin,
|
||||
}
|
||||
if self.lab is not None:
|
||||
document["lab"] = self.lab.as_dict()
|
||||
return document
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -16,6 +16,7 @@ from uuid import uuid4
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
from .models import (
|
||||
LabSessionBinding,
|
||||
LayoutConflictError,
|
||||
ObservationArtifactCandidate,
|
||||
ObservationSessionCandidate,
|
||||
@@ -39,6 +40,10 @@ from .plugin_contract import ObservationArchiveSource
|
||||
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"
|
||||
LAB_ARCHIVE_ID = "missioncore.lab-instances"
|
||||
LAB_ORIGIN = "missioncore.lab-instance/v1"
|
||||
LAB_ID_PATTERN = re.compile(r"^LAB [A-Z][A-Z0-9._-]{0,31}$")
|
||||
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS observation_sessions (
|
||||
@@ -94,6 +99,24 @@ CREATE TABLE IF NOT EXISTS observation_session_sources (
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS observation_lab_instances (
|
||||
session_id TEXT PRIMARY KEY
|
||||
REFERENCES observation_sessions(session_id) ON DELETE CASCADE,
|
||||
source_session_id TEXT NOT NULL
|
||||
REFERENCES observation_sessions(session_id) ON DELETE RESTRICT,
|
||||
lab_id TEXT NOT NULL,
|
||||
result_kind TEXT NOT NULL,
|
||||
result_id TEXT NOT NULL,
|
||||
source_result_id TEXT,
|
||||
config_sha256 TEXT,
|
||||
run_created_at_utc TEXT NOT NULL,
|
||||
published_at_utc TEXT NOT NULL,
|
||||
provenance_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS observation_lab_instances_source
|
||||
ON observation_lab_instances(source_session_id, published_at_utc DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_layouts (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
layout_schema_version INTEGER NOT NULL,
|
||||
@@ -162,7 +185,18 @@ class SessionStore:
|
||||
"WHERE plugin_id = ? AND archive_id = ? AND allowed_root = ?",
|
||||
(source.plugin_id, source.archive_id, str(allowed_root)),
|
||||
).fetchall()
|
||||
stale = [row["session_id"] for row in indexed if row["session_id"] not in discovered]
|
||||
referenced_sources = {
|
||||
row["source_session_id"]
|
||||
for row in connection.execute(
|
||||
"SELECT DISTINCT source_session_id FROM observation_lab_instances"
|
||||
).fetchall()
|
||||
}
|
||||
stale = [
|
||||
row["session_id"]
|
||||
for row in indexed
|
||||
if row["session_id"] not in discovered
|
||||
and row["session_id"] not in referenced_sources
|
||||
]
|
||||
connection.executemany(
|
||||
"DELETE FROM observation_sessions WHERE session_id = ?",
|
||||
((session_id,) for session_id in stale),
|
||||
@@ -193,9 +227,31 @@ class SessionStore:
|
||||
"ORDER BY COALESCE(started_at_utc, '') DESC, session_id DESC LIMIT ?",
|
||||
parameters,
|
||||
).fetchall()
|
||||
lab_rows = (
|
||||
{
|
||||
row["session_id"]: row
|
||||
for row in connection.execute(
|
||||
"SELECT * FROM observation_lab_instances WHERE session_id IN "
|
||||
f"({','.join('?' for _ in rows)})", # noqa: S608 - placeholder count only
|
||||
tuple(row["session_id"] for row in rows),
|
||||
).fetchall()
|
||||
}
|
||||
if rows
|
||||
else {}
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
selected = rows[:limit]
|
||||
items = tuple(_summary_from_row(row) for row in selected)
|
||||
items = tuple(
|
||||
_summary_from_row(
|
||||
row,
|
||||
lab=(
|
||||
_lab_binding_from_row(lab_rows[row["session_id"]])
|
||||
if row["session_id"] in lab_rows
|
||||
else None
|
||||
),
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -218,6 +274,10 @@ class SessionStore:
|
||||
"FROM observation_session_artifacts WHERE session_id = ? ORDER BY artifact_id",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
lab_row = connection.execute(
|
||||
"SELECT * FROM observation_lab_instances WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
sources = tuple(
|
||||
SessionSource(
|
||||
source_id=source["source_id"],
|
||||
@@ -240,7 +300,191 @@ class SessionStore:
|
||||
)
|
||||
for artifact in artifact_rows
|
||||
)
|
||||
return SessionDetail(summary=_summary_from_row(row), sources=sources, artifacts=artifacts)
|
||||
return SessionDetail(
|
||||
summary=_summary_from_row(
|
||||
row,
|
||||
lab=None if lab_row is None else _lab_binding_from_row(lab_row),
|
||||
),
|
||||
sources=sources,
|
||||
artifacts=artifacts,
|
||||
)
|
||||
|
||||
def get_lab_instance(self, session_id: str) -> LabSessionBinding | None:
|
||||
"""Return immutable LAB provenance without exposing filesystem locators."""
|
||||
|
||||
_validate_identifier(session_id, "session id")
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM observation_lab_instances WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return None if row is None else _lab_binding_from_row(row)
|
||||
|
||||
def publish_lab_instance(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
source_session_id: str,
|
||||
display_name: str,
|
||||
lab_id: str,
|
||||
result_kind: str,
|
||||
result_id: str,
|
||||
run_created_at_utc: str,
|
||||
source_result_id: str | None = None,
|
||||
config_sha256: str | None = None,
|
||||
provenance: dict[str, Any] | None = None,
|
||||
) -> LabSessionBinding:
|
||||
"""Append one immutable catalog projection over an existing source session.
|
||||
|
||||
Artifact/source rows are copied as references to the sealed evidence.
|
||||
No raw payload is copied and the LAB row uses a distinct archive id so
|
||||
device-plugin reconciliation cannot replace it.
|
||||
"""
|
||||
|
||||
_validate_identifier(session_id, "LAB session id")
|
||||
_validate_identifier(source_session_id, "source session id")
|
||||
if session_id == source_session_id:
|
||||
raise ValueError("LAB session id must differ from its source")
|
||||
if LAB_ID_PATTERN.fullmatch(lab_id) is None:
|
||||
raise ValueError("LAB id must use the form 'LAB E21'")
|
||||
_validate_identifier(result_kind, "LAB result kind")
|
||||
_validate_identifier(result_id, "LAB result id")
|
||||
if source_result_id is not None:
|
||||
_validate_identifier(source_result_id, "LAB source result id")
|
||||
if config_sha256 is not None and SHA256_PATTERN.fullmatch(config_sha256) is None:
|
||||
raise ValueError("LAB configuration SHA-256 is invalid")
|
||||
normalized_name = display_name.strip()
|
||||
if not 1 <= len(normalized_name) <= 160:
|
||||
raise ValueError("LAB display name must contain 1..160 characters")
|
||||
_require_utc_timestamp(run_created_at_utc, "LAB run creation time")
|
||||
serialized_provenance = _serialize_provenance(provenance or {})
|
||||
published_at = utc_now_iso()
|
||||
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
source = connection.execute(
|
||||
"SELECT * FROM observation_sessions WHERE session_id = ?",
|
||||
(source_session_id,),
|
||||
).fetchone()
|
||||
if source is None:
|
||||
connection.rollback()
|
||||
raise SessionNotFoundError("LAB source observation session was not found")
|
||||
if (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM observation_lab_instances WHERE session_id = ?",
|
||||
(source_session_id,),
|
||||
).fetchone()
|
||||
is not None
|
||||
):
|
||||
connection.rollback()
|
||||
raise SessionIntegrityError("LAB instances cannot be chained")
|
||||
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM observation_lab_instances WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
expected = {
|
||||
"source_session_id": source_session_id,
|
||||
"lab_id": lab_id,
|
||||
"result_kind": result_kind,
|
||||
"result_id": result_id,
|
||||
"source_result_id": source_result_id,
|
||||
"config_sha256": config_sha256,
|
||||
"run_created_at_utc": run_created_at_utc,
|
||||
"provenance_json": serialized_provenance,
|
||||
}
|
||||
if existing is not None:
|
||||
if any(existing[key] != value for key, value in expected.items()):
|
||||
connection.rollback()
|
||||
raise SessionIntegrityError(
|
||||
"LAB session id is already bound to different provenance"
|
||||
)
|
||||
connection.commit()
|
||||
return _lab_binding_from_row(existing)
|
||||
if (
|
||||
connection.execute(
|
||||
"SELECT 1 FROM observation_sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
is not None
|
||||
):
|
||||
connection.rollback()
|
||||
raise SessionIntegrityError("LAB session id already exists in the catalog")
|
||||
|
||||
connection.execute(
|
||||
"INSERT INTO observation_sessions "
|
||||
"(session_id, plugin_id, archive_id, display_name, status, "
|
||||
"started_at_utc, completed_at_utc, duration_seconds, modalities_json, "
|
||||
"replayable, origin, source_count, total_bytes, "
|
||||
"primary_replay_artifact_id, timeline_origin_epoch_ns, "
|
||||
"timeline_origin_monotonic_ns, allowed_root, session_root, "
|
||||
"created_at_utc, updated_at_utc) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
session_id,
|
||||
source["plugin_id"],
|
||||
LAB_ARCHIVE_ID,
|
||||
normalized_name,
|
||||
source["status"],
|
||||
run_created_at_utc,
|
||||
run_created_at_utc,
|
||||
source["duration_seconds"],
|
||||
source["modalities_json"],
|
||||
source["replayable"],
|
||||
LAB_ORIGIN,
|
||||
source["source_count"],
|
||||
source["total_bytes"],
|
||||
source["primary_replay_artifact_id"],
|
||||
source["timeline_origin_epoch_ns"],
|
||||
source["timeline_origin_monotonic_ns"],
|
||||
source["allowed_root"],
|
||||
source["session_root"],
|
||||
published_at,
|
||||
published_at,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO observation_session_artifacts "
|
||||
"(session_id, artifact_id, kind, media_type, byte_length, sha256, "
|
||||
"integrity_status, locator, replay_byte_length) "
|
||||
"SELECT ?, artifact_id, kind, media_type, byte_length, sha256, "
|
||||
"integrity_status, locator, replay_byte_length "
|
||||
"FROM observation_session_artifacts WHERE session_id = ?",
|
||||
(session_id, source_session_id),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO observation_session_sources "
|
||||
"(session_id, source_id, semantic_channel_id, modality, status, "
|
||||
"seekable, artifact_id) "
|
||||
"SELECT ?, source_id, semantic_channel_id, modality, status, "
|
||||
"seekable, artifact_id FROM observation_session_sources "
|
||||
"WHERE session_id = ?",
|
||||
(session_id, source_session_id),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO observation_lab_instances "
|
||||
"(session_id, source_session_id, lab_id, result_kind, result_id, "
|
||||
"source_result_id, config_sha256, run_created_at_utc, "
|
||||
"published_at_utc, provenance_json) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
session_id,
|
||||
source_session_id,
|
||||
lab_id,
|
||||
result_kind,
|
||||
result_id,
|
||||
source_result_id,
|
||||
config_sha256,
|
||||
run_created_at_utc,
|
||||
published_at,
|
||||
serialized_provenance,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
binding = self.get_lab_instance(session_id)
|
||||
if binding is None:
|
||||
raise SessionIntegrityError("LAB session publication was not durable")
|
||||
return binding
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
"""Permanently delete one exact catalogued evidence directory and row."""
|
||||
@@ -248,6 +492,29 @@ class SessionStore:
|
||||
_validate_identifier(session_id, "session id")
|
||||
with self._lock:
|
||||
with self._connect() as connection:
|
||||
lab = connection.execute(
|
||||
"SELECT 1 FROM observation_lab_instances WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if lab is not None:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
deleted = connection.execute(
|
||||
"DELETE FROM observation_sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).rowcount
|
||||
connection.commit()
|
||||
if deleted != 1:
|
||||
raise SessionNotFoundError("observation session was not found")
|
||||
return
|
||||
dependent = connection.execute(
|
||||
"SELECT session_id FROM observation_lab_instances "
|
||||
"WHERE source_session_id = ? LIMIT 1",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if dependent is not None:
|
||||
raise SessionIntegrityError(
|
||||
"source session has LAB instances; remove those instances first"
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT allowed_root, session_root FROM observation_sessions "
|
||||
"WHERE session_id = ?",
|
||||
@@ -737,7 +1004,11 @@ def _validate_candidate_replay(
|
||||
raise SessionIntegrityError("non-replayable observation declares replay artifacts")
|
||||
|
||||
|
||||
def _summary_from_row(row: sqlite3.Row) -> SessionSummary:
|
||||
def _summary_from_row(
|
||||
row: sqlite3.Row,
|
||||
*,
|
||||
lab: LabSessionBinding | None = None,
|
||||
) -> SessionSummary:
|
||||
raw_modalities = json.loads(row["modalities_json"])
|
||||
modalities = tuple(cast(SessionModality, value) for value in raw_modalities)
|
||||
return SessionSummary(
|
||||
@@ -752,9 +1023,58 @@ def _summary_from_row(row: sqlite3.Row) -> SessionSummary:
|
||||
total_bytes=row["total_bytes"],
|
||||
replayable=bool(row["replayable"]),
|
||||
origin=row["origin"],
|
||||
lab=lab,
|
||||
)
|
||||
|
||||
|
||||
def _lab_binding_from_row(row: sqlite3.Row) -> LabSessionBinding:
|
||||
try:
|
||||
provenance = json.loads(row["provenance_json"])
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("stored LAB provenance is invalid") from exc
|
||||
if not isinstance(provenance, dict):
|
||||
raise SessionIntegrityError("stored LAB provenance is not an object")
|
||||
return LabSessionBinding(
|
||||
session_id=row["session_id"],
|
||||
source_session_id=row["source_session_id"],
|
||||
lab_id=row["lab_id"],
|
||||
result_kind=row["result_kind"],
|
||||
result_id=row["result_id"],
|
||||
source_result_id=row["source_result_id"],
|
||||
config_sha256=row["config_sha256"],
|
||||
run_created_at_utc=row["run_created_at_utc"],
|
||||
published_at_utc=row["published_at_utc"],
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
def _serialize_provenance(value: dict[str, Any]) -> str:
|
||||
try:
|
||||
serialized = json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("LAB provenance must be JSON-compatible") from exc
|
||||
if len(serialized.encode("utf-8")) > 256 * 1024:
|
||||
raise ValueError("LAB provenance exceeds 256 KiB")
|
||||
return serialized
|
||||
|
||||
|
||||
def _require_utc_timestamp(value: str, field: str) -> None:
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{field} is invalid") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError(f"{field} must include a timezone")
|
||||
|
||||
|
||||
def _layout_from_row(row: sqlite3.Row) -> WorkspaceLayout:
|
||||
payload = json.loads(row["layout_json"])
|
||||
if not isinstance(payload, dict):
|
||||
|
||||
Reference in New Issue
Block a user