fix(observatory): refine catalog and replay UX

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 20:36:12 +03:00
parent 023151c186
commit be83283ee0
18 changed files with 2137 additions and 201 deletions
+150 -1
View File
@@ -118,6 +118,7 @@ CREATE TABLE IF NOT EXISTS observation_lab_instances (
published_at_utc TEXT NOT NULL,
include_recorded_media INTEGER CHECK (include_recorded_media IN (0, 1)),
replay_capability_json TEXT,
operator_display_name TEXT,
provenance_json TEXT NOT NULL
);
@@ -298,6 +299,13 @@ class SessionStore:
if row["session_id"] in lab_rows
else None
),
operator_display_name=(
_operator_display_name_from_row(
lab_rows[row["session_id"]]["operator_display_name"]
)
if row["session_id"] in lab_rows
else None
),
)
for row in selected
)
@@ -364,6 +372,13 @@ class SessionStore:
summary=_summary_from_row(
row,
lab=None if lab_row is None else _lab_binding_from_row(lab_row),
operator_display_name=(
None
if lab_row is None
else _operator_display_name_from_row(
lab_row["operator_display_name"]
)
),
),
sources=sources,
artifacts=artifacts,
@@ -627,6 +642,62 @@ class SessionStore:
raise SessionIntegrityError("LAB session publication was not durable")
return binding
def rename_capability_lab_projection(
self,
session_id: str,
display_name: str,
) -> str:
"""Set only an operator-facing alias on one typed LAB projection.
The immutable observation summary keeps the canonical publication name
so a strict publisher retry can still validate the exact sealed
projection. The alias affects only catalog presentation.
"""
_validate_identifier(session_id, "LAB session id")
normalized_name = _normalize_operator_display_name(display_name)
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
summary, _lab = _require_capability_owned_lab_projection(
connection,
session_id,
)
override = (
None
if normalized_name == summary["display_name"]
else normalized_name
)
updated = connection.execute(
"UPDATE observation_lab_instances SET operator_display_name = ? "
"WHERE session_id = ?",
(override, session_id),
).rowcount
if updated != 1:
connection.rollback()
raise SessionNotFoundError("observation session was not found")
connection.commit()
return normalized_name
def delete_capability_lab_projection(self, session_id: str) -> None:
"""Delete only one typed LAB catalog projection and its copied rows.
No evidence locator, source session, prepared recording, or viewer
cache is inspected or removed by this operation.
"""
_validate_identifier(session_id, "LAB session id")
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
_require_capability_owned_lab_projection(connection, session_id)
deleted = connection.execute(
"DELETE FROM observation_sessions WHERE session_id = ?",
(session_id,),
).rowcount
if deleted != 1:
connection.rollback()
raise SessionNotFoundError("observation session was not found")
connection.commit()
def delete_session(self, session_id: str) -> None:
"""Permanently delete one exact catalogued evidence directory and row."""
@@ -931,6 +1002,11 @@ class SessionStore:
"ADD COLUMN include_recorded_media INTEGER "
"CHECK (include_recorded_media IN (0, 1))"
)
if "operator_display_name" not in lab_columns:
connection.execute(
"ALTER TABLE observation_lab_instances "
"ADD COLUMN operator_display_name TEXT"
)
_migrate_canonical_replay_capabilities(connection)
connection.commit()
with _ignore_os_error():
@@ -1583,16 +1659,87 @@ def _canonical_rolling_capability(
return None
def _normalize_operator_display_name(value: str) -> str:
if not isinstance(value, str):
raise ValueError("LAB display name must be text")
normalized = value.strip()
if not 1 <= len(normalized) <= 160 or any(
ord(character) < 32 for character in normalized
):
raise ValueError("LAB display name must contain 1..160 printable characters")
return normalized
def _operator_display_name_from_row(value: object) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise SessionIntegrityError("stored LAB operator display name is invalid")
try:
return _normalize_operator_display_name(value)
except ValueError as exc:
raise SessionIntegrityError("stored LAB operator display name is invalid") from exc
def _require_capability_owned_lab_projection(
connection: sqlite3.Connection,
session_id: str,
) -> tuple[sqlite3.Row, sqlite3.Row]:
summary = connection.execute(
"SELECT * FROM observation_sessions WHERE session_id = ?",
(session_id,),
).fetchone()
if summary is None:
raise SessionNotFoundError("observation session was not found")
lab = connection.execute(
"SELECT * FROM observation_lab_instances WHERE session_id = ?",
(session_id,),
).fetchone()
if lab is None:
raise SessionIntegrityError(
"observation session is not a capability-owned LAB projection"
)
binding = _lab_binding_from_row(lab)
if (
summary["archive_id"] != LAB_ARCHIVE_ID
or summary["origin"] != LAB_ORIGIN
or binding.replay_capability is None
or binding.session_id != session_id
or binding.source_session_id == session_id
or lab["include_recorded_media"] not in {0, 1}
or connection.execute(
"SELECT 1 FROM observation_lab_instances WHERE session_id = ?",
(binding.source_session_id,),
).fetchone()
is not None
):
raise SessionIntegrityError(
"observation session is not a capability-owned LAB projection"
)
_operator_display_name_from_row(lab["operator_display_name"])
_validate_existing_lab_projection(
connection,
session_id=session_id,
source_session_id=binding.source_session_id,
display_name=summary["display_name"],
run_created_at_utc=binding.run_created_at_utc,
duration_seconds=summary["duration_seconds"],
include_recorded_media=bool(lab["include_recorded_media"]),
)
return summary, lab
def _summary_from_row(
row: sqlite3.Row,
*,
lab: LabSessionBinding | None = None,
operator_display_name: str | None = None,
) -> 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"],
display_name=operator_display_name or row["display_name"],
status=cast(SessionStatus, row["status"]),
started_at_utc=row["started_at_utc"],
completed_at_utc=row["completed_at_utc"],
@@ -1646,6 +1793,8 @@ def _serialize_replay_capability(value: LabReplayCapability | None) -> str | Non
def _replay_capability_from_row(value: object) -> LabReplayCapability | None:
if value is None:
return None
if not isinstance(value, str | bytes | bytearray):
raise SessionIntegrityError("stored LAB replay capability is invalid")
try:
document = json.loads(value)
except (TypeError, json.JSONDecodeError) as exc:
+2
View File
@@ -144,6 +144,7 @@ from k1link.web.map_api import (
build_map_router,
)
from k1link.web.map_view_api import build_map_view_router
from k1link.web.observatory_api import build_observatory_router
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
from k1link.web.plugin_runtime import (
STATE_READ_ACTION_ID,
@@ -691,6 +692,7 @@ app.include_router(
point_color_renderers=plugin_environment.point_color_renderers,
)
)
app.include_router(build_observatory_router(session_store))
app.include_router(
build_environment_router(root_provider=lambda: session_store.data_dir / "ui-environment")
)
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel, ConfigDict, Field
from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore
OBSERVATORY_PROJECTION_SCHEMA: Literal[
"missioncore.observatory-lab-projection/v1"
] = "missioncore.observatory-lab-projection/v1"
OBSERVATORY_RENAME_SCHEMA: Literal[
"missioncore.observatory-lab-projection-rename/v1"
] = "missioncore.observatory-lab-projection-rename/v1"
class _StrictApiModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class ObservatoryProjectionRenameRequest(_StrictApiModel):
schema_version: Literal[
"missioncore.observatory-lab-projection-rename/v1"
]
display_name: str = Field(min_length=1, max_length=160)
class ObservatoryProjectionDocument(_StrictApiModel):
schema_version: Literal["missioncore.observatory-lab-projection/v1"]
session_id: str
display_name: str
def build_observatory_router(store: SessionStore) -> APIRouter:
"""Build bounded catalog-only mutations for typed Observatory projections."""
router = APIRouter(tags=["observatory"])
@router.patch(
"/api/v1/observatory/lab-projections/{session_id}",
response_model=ObservatoryProjectionDocument,
)
def rename_observatory_lab_projection(
session_id: str,
request: ObservatoryProjectionRenameRequest,
) -> ObservatoryProjectionDocument:
try:
display_name = store.rename_capability_lab_projection(
session_id,
request.display_name,
)
except SessionNotFoundError as exc:
raise HTTPException(
status_code=404,
detail="Проекция Обсерватории не найдена.",
) from exc
except SessionIntegrityError as exc:
raise HTTPException(
status_code=409,
detail="Запись не является управляемой проекцией Обсерватории.",
) from exc
except ValueError as exc:
raise HTTPException(
status_code=422,
detail="Некорректные параметры проекции Обсерватории.",
) from exc
return ObservatoryProjectionDocument(
schema_version=OBSERVATORY_PROJECTION_SCHEMA,
session_id=session_id,
display_name=display_name,
)
@router.delete(
"/api/v1/observatory/lab-projections/{session_id}",
status_code=204,
)
def delete_observatory_lab_projection(session_id: str) -> Response:
try:
store.delete_capability_lab_projection(session_id)
except SessionNotFoundError as exc:
raise HTTPException(
status_code=404,
detail="Проекция Обсерватории не найдена.",
) from exc
except SessionIntegrityError as exc:
raise HTTPException(
status_code=409,
detail="Запись не является управляемой проекцией Обсерватории.",
) from exc
except ValueError as exc:
raise HTTPException(
status_code=422,
detail="Некорректный идентификатор проекции Обсерватории.",
) from exc
return Response(status_code=204)
return router