fix(observatory): refine catalog and replay UX
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user