feat(k1): integrate durable evidence with acquisition lifecycle
This commit is contained in:
+130
-1
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -13,6 +13,19 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import ValidationError
|
||||
|
||||
from k1link import __version__
|
||||
from k1link.sessions import (
|
||||
MaterializedRecording,
|
||||
RecordedMediaInspector,
|
||||
RecordedMediaManifest,
|
||||
RecordingPreparationQueueFull,
|
||||
ReplayCommand,
|
||||
SessionRecordingMaterializer,
|
||||
SessionRecordingPreparationManager,
|
||||
SessionStore,
|
||||
recover_stale_active_session_marker,
|
||||
resolve_missioncore_evidence_dir,
|
||||
)
|
||||
from k1link.web.camera_archive import recover_incomplete_camera_archives
|
||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||
from k1link.web.plugin_catalog import DevicePluginCatalog, PluginCatalogError
|
||||
from k1link.web.plugin_runtime import (
|
||||
@@ -23,21 +36,125 @@ from k1link.web.plugin_runtime import (
|
||||
PluginExecutionError,
|
||||
PluginNotFoundError,
|
||||
)
|
||||
from k1link.web.session_api import build_session_router
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
|
||||
|
||||
|
||||
legacy_observation_sessions_root = REPOSITORY_ROOT / "sessions"
|
||||
observation_sessions_root = resolve_missioncore_evidence_dir(REPOSITORY_ROOT)
|
||||
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||
session_store = SessionStore(REPOSITORY_ROOT)
|
||||
session_recording_materializer = SessionRecordingMaterializer(session_store.data_dir)
|
||||
session_recorded_media_inspector = RecordedMediaInspector(
|
||||
session_store.data_dir / "recorded-media-preparations"
|
||||
)
|
||||
|
||||
|
||||
def _prepare_recorded_media_for_launch(
|
||||
command: ReplayCommand,
|
||||
_: MaterializedRecording,
|
||||
) -> tuple[RecordedMediaManifest, ...]:
|
||||
"""Prepare all camera descriptors inside the background job."""
|
||||
|
||||
return tuple(
|
||||
session_recorded_media_inspector.inspect(artifact, command)
|
||||
for artifact in session_store.list_recorded_media(command.session_id)
|
||||
)
|
||||
|
||||
|
||||
session_recording_preparation_manager = SessionRecordingPreparationManager(
|
||||
session_recording_materializer,
|
||||
ready_preparer=_prepare_recorded_media_for_launch,
|
||||
)
|
||||
|
||||
|
||||
def refresh_observation_catalog() -> tuple[str, ...]:
|
||||
"""Discover completed or recoverable local evidence without copying payloads."""
|
||||
|
||||
imported = [
|
||||
*session_store.import_legacy_viewer_live(legacy_observation_sessions_root),
|
||||
*session_store.import_legacy_viewer_live(observation_sessions_root),
|
||||
]
|
||||
return tuple(dict.fromkeys(imported))
|
||||
|
||||
|
||||
def enqueue_replayable_recordings() -> tuple[str, ...]:
|
||||
"""Reconcile finalized catalog sessions into the durable RRD work queue."""
|
||||
|
||||
enqueued: list[str] = []
|
||||
cursor: str | None = None
|
||||
while True:
|
||||
page = session_store.list_recent(limit=100, cursor=cursor)
|
||||
for summary in page.items:
|
||||
if not summary.replayable or summary.status not in {
|
||||
"ready",
|
||||
"interrupted",
|
||||
"failed",
|
||||
}:
|
||||
continue
|
||||
try:
|
||||
command = session_store.prepare_replay(summary.session_id)
|
||||
session_recording_preparation_manager.enqueue(
|
||||
command,
|
||||
retry_interrupted=True,
|
||||
)
|
||||
except RecordingPreparationQueueFull:
|
||||
return tuple(enqueued)
|
||||
except Exception:
|
||||
# One stale/corrupt catalog row must not starve every valid
|
||||
# finalized session that follows it in the reconciliation
|
||||
# page. The row remains visible as failed/interrupted evidence
|
||||
# and can be repaired independently.
|
||||
continue
|
||||
enqueued.append(summary.session_id)
|
||||
cursor = page.next_cursor
|
||||
if cursor is None:
|
||||
return tuple(enqueued)
|
||||
|
||||
|
||||
async def _recording_preparation_reconciler() -> None:
|
||||
"""Discover newly completed/recovered captures without blocking requests."""
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(refresh_observation_catalog)
|
||||
await asyncio.to_thread(enqueue_replayable_recordings)
|
||||
except Exception:
|
||||
# A transient filesystem/catalog failure must not permanently
|
||||
# disable preparation of sessions completed later in the run.
|
||||
pass
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
reconciler: asyncio.Task[None] | None = None
|
||||
try:
|
||||
session_recording_preparation_manager.start()
|
||||
# Recovery is intentionally a one-shot startup phase. The archive
|
||||
# helper owns a cross-process lease, while ordinary catalog requests
|
||||
# only perform discovery and therefore never touch a live writer.
|
||||
await asyncio.to_thread(recover_stale_active_session_marker, observation_sessions_root)
|
||||
for sessions_root in (
|
||||
legacy_observation_sessions_root,
|
||||
observation_sessions_root,
|
||||
):
|
||||
await asyncio.to_thread(recover_incomplete_camera_archives, sessions_root)
|
||||
# Full evidence discovery, hashing and RRD queue reconciliation can be
|
||||
# expensive on field captures. Start it immediately in the background
|
||||
# instead of holding the ASGI startup gate.
|
||||
reconciler = asyncio.create_task(_recording_preparation_reconciler())
|
||||
yield
|
||||
finally:
|
||||
if reconciler is not None:
|
||||
reconciler.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await reconciler
|
||||
await asyncio.to_thread(session_recording_preparation_manager.close)
|
||||
plugin_environment.close()
|
||||
|
||||
|
||||
@@ -127,6 +244,18 @@ async def device_plugin_events(websocket: WebSocket, plugin_id: str) -> None:
|
||||
for legacy_router in plugin_environment.legacy_routers:
|
||||
app.include_router(legacy_router)
|
||||
|
||||
app.include_router(
|
||||
build_session_router(
|
||||
session_store,
|
||||
# Production discovery belongs to the startup/background reconciler.
|
||||
# HTTP list/replay paths must never rescan evidence roots inline.
|
||||
catalog_refresher=None,
|
||||
recording_materializer=session_recording_materializer,
|
||||
recording_preparation_manager=session_recording_preparation_manager,
|
||||
media_inspector=session_recorded_media_inspector,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
|
||||
if frontend_dist.is_dir():
|
||||
|
||||
Reference in New Issue
Block a user