feat(k1): integrate durable evidence with acquisition lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 17:51:05 +03:00
parent 656f0c524d
commit 007ff9fff2
6 changed files with 1638 additions and 242 deletions
+130 -1
View File
@@ -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():