Compare commits

...

4 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS f9ffb7bd1c docs(observation): document durable sessions and viewer lifecycle 2026-07-17 17:55:39 +03:00
DCCONSTRUCTIONS e94c64eebd feat(control-station): add atomic recorded-session playback 2026-07-17 17:51:24 +03:00
DCCONSTRUCTIONS 007ff9fff2 feat(k1): integrate durable evidence with acquisition lifecycle 2026-07-17 17:51:05 +03:00
DCCONSTRUCTIONS 656f0c524d feat(sessions): add durable observation archive and replay API 2026-07-17 17:50:54 +03:00
75 changed files with 23941 additions and 649 deletions

3
.gitignore vendored
View File

@ -26,6 +26,9 @@ private/
# Real laboratory captures and decoded artifacts are sensitive and large.
captures/
sessions/
# The host runtime package shares the domain name but is source, not evidence.
!src/k1link/sessions/
!src/k1link/sessions/**/*.py
artifacts/raw/
artifacts/decoded/
*.pcap

View File

@ -10,9 +10,11 @@ The first proven hardware vertical is the XGRIDS/LixelKity K1 plugin. On firmwar
3.0.2 the host provisions the scanner onto an existing LAN without LixelGO,
connects to its MQTT broker, persists each raw frame before preview work, decodes
point cloud and pose, and renders the real cloud plus trajectory through an
embedded self-hosted Rerun Web Viewer. Capture files are `fsync`ed on clean
close; per-frame power-loss durability is not claimed. The former Foxglove
bridge remains only as a legacy regression module.
embedded self-hosted Rerun Web Viewer. Native MQTT persistence uses bounded
group commit (at most 0.5 seconds, 4 MiB or 32 messages), and camera archives
commit complete fMP4 segments before their index rows. These are explicit
crash-RPO bounds, not a zero-loss or disk-replication claim. The former
Foxglove bridge remains only as a legacy regression module.
The repository is intentionally migrating in stages. The current `src/k1link`
package is the compatibility implementation of the first plugin path; vendor
@ -124,19 +126,24 @@ revision or content hash. Publishing/vendoring those packages or enforcing an
immutable donor revision remains a packaging and CI prerequisite.
The Observation spatial workspace embeds the open-source Rerun Web Viewer
inside the Mission Core shell. It can open a compatible RRD file over HTTP(S) or a
Rerun gRPC/proxy source such as `rerun+http://127.0.0.1:9876/proxy`. It does not
use an external hosted viewer UI. Dynamic point-cloud and camera source
composition, host-owned window layout and the current live-only timeline contract
are fixed in [`ADR 0006`](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md).
inside the Mission Core shell. It can open a compatible RRD over same-origin
HTTP or a Rerun gRPC/proxy source such as
`rerun+http://127.0.0.1:9876/proxy`; no external hosted viewer UI is used.
Dynamic point-cloud/camera composition and the live source contract are fixed in
[`ADR 0006`](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md).
The durable session catalog, recorded `session_time` scrubber and versioned
workspace-layout profile are fixed in
[`ADR 0008`](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md).
The first K1 live session or replay in a `k1link serve` process creates one local
Rerun `RecordingStream`, starts its gRPC/proxy server on TCP 9876 and publishes
the resulting URL through control-plane state. Later sessions reset their
session-local scene and metrics and reuse that process-wide stream; this avoids
restarting the native listener while the embedded browser remains connected.
Unless an operator has entered a manual source, the React application assigns
that URL to the embedded viewer. The complete runtime path is K1 MQTT → raw-first
The first K1 live session or adapter file-replay (`.k1mqtt`/reviewed TSV) in a
`k1link serve` process creates one local Rerun `RecordingStream`, starts its
gRPC/proxy server on TCP 9876 and publishes the resulting URL through
control-plane state. Later live/file-replay sessions reset their session-local
scene and metrics and reuse that process-wide stream. Saved observation sessions
do not reuse this listener: they open a private digest-bound RRD generation over
same-origin HTTP through Rerun's native incremental receiver. Unless an operator
has entered a manual source, the React application assigns the applicable source
to the embedded viewer. The complete live runtime path is K1 MQTT → raw-first
evidence capture → bounded latest-wins preview queue → explicitly injected K1
protobuf/LZ4 normalizer → transport-neutral decoded local views → Rerun
`Points3D`, `Transform3D` and `LineStrips3D` → embedded Web Viewer. Rerun does
@ -146,15 +153,21 @@ portable Plugin SDK wire envelopes.
The default Rerun blueprint shows a 12-second sliding accumulation of real point
frames. Product controls are connected for point size, intensity/height/distance
or available RGB coloring, Turbo/Viridis/Plasma/grayscale/custom palettes,
point and trajectory visibility, and the scene grid. Projection, custom
timeline transport and saved layout remain later product work. No synthetic
point cloud, trajectory, camera frame or latency value is generated.
point and trajectory visibility, and the scene grid. The first disk action saves
and restores the versioned spatial layout without mutating sensor evidence.
Saved native point/pose sessions are materialized losslessly into private,
digest-bound RRD recordings and can be played, paused and scrubbed on a
zero-based `session_time` timeline. No synthetic point cloud, trajectory,
camera frame or latency value is generated.
A powered-device checkpoint passed 80 real MQTT messages through the current
Rerun runtime: 38 point-cloud frames, 42 pose frames, 2,775 points in the last
cloud and zero decode errors. The later RTSP camera preview is not yet wired into
the Rerun/runtime path. Rerun `capture_time` is the Mac receive timestamp, not a
proven K1 sensor timestamp or photon-to-screen measurement.
cloud and zero decode errors. The later RTSP camera preview is available through
the generic floating observation windows and new acquisitions archive its fMP4
segments independently of browser delivery. Historical sessions recorded before
that archive contract contain no video. Rerun `capture_time` and the camera
index use Mac receive/arrival timestamps, not proven K1 sensor timestamps or a
photon-to-screen measurement.
The old Foxglove implementation is retained only in
`src/k1link/viewer/foxglove_bridge.py` and its regression tests. The current
@ -162,6 +175,8 @@ live/replay runtime does not start it or use TCP 8765. The
[live viewer runbook](docs/06_K1_LIVE_VIEWER.md) records the active Rerun path and
its timing/security boundaries; the frontend contract is documented in
[`apps/control-station/README.md`](apps/control-station/README.md).
The host storage layout, recovery rules, replay API and operator path are in
[`docs/09_OBSERVATION_SESSIONS.md`](docs/09_OBSERVATION_SESSIONS.md).
The FastAPI application and credential endpoint bind to loopback, but the Rerun
gRPC server currently binds TCP 9876 on all network interfaces even though its
@ -214,9 +229,15 @@ present.
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
- [Live console and embedded Rerun runbook](docs/06_K1_LIVE_VIEWER.md)
- [Mission Core monorepo and plugin boundary](docs/07_MISSION_CORE_MONOREPO.md)
- [Owner-controlled LixelGO/iPhone observation](docs/08_LIXELGO_IPHONE_OBSERVATION.md)
- [Observation sessions, playback and workspace layout](docs/09_OBSERVATION_SESSIONS.md)
- [Monorepo architecture decision](docs/adr/0002-mission-core-monorepo.md)
- [Device plugin UI and runtime boundary](docs/adr/0003-device-plugin-ui-and-runtime-boundary.md)
- [Plugin SDK v0alpha2 and experimental device lifecycle](docs/adr/0004-plugin-sdk-v0alpha2-and-experimental-device-lifecycle.md)
- [Owner-controlled LixelGO observation decision](docs/adr/0005-owner-controlled-lixelgo-iphone-observation.md)
- [Vendor-neutral live observation sources](docs/adr/0006-vendor-neutral-observation-sources-and-live-only-timeline.md)
- [K1 camera preview copy-remux gateway](docs/adr/0007-k1-camera-preview-copy-remux-gateway.md)
- [Durable observation sessions and workspace layout](docs/adr/0008-durable-observation-sessions-and-workspace-layout.md)
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
- [Session manifest schema](schemas/session-manifest.schema.json)
- [Reference input provenance](docs/reference/README.md)
@ -240,6 +261,6 @@ reviewed step. Random writes, fuzzing, brute force, firmware operations,
destructive file access and credential guessing remain out of scope.
Real captures, projects, router metadata, serials, credentials, maps, images,
and logs are ignored by normal Git. Redacted manifests and SHA-256 inventories
are committed; encrypted artifact storage will be selected only when real data
exists.
and logs under `.runtime/`, canonical evidence roots and legacy `sessions/` are
ignored by normal Git. Redacted manifests and SHA-256 inventories are committed;
encrypted/replicated artifact storage remains a deployment decision.

View File

@ -7,9 +7,10 @@ device adapter, но структура интерфейса от него не
Внутри пространственной рабочей поверхности встроен открытый Rerun Web Viewer.
Это self-hosted frontend-компонент из npm-пакета `@rerun-io/web-viewer`, а не
переход во внешний облачный интерфейс. При запуске K1 live/replay backend сам
создаёт Rerun gRPC/proxy source; ручной адрес нужен только для другого Rerun
потока или совместимой RRD-записи.
переход во внешний облачный интерфейс. Для K1 live и adapter file-replay backend
сам создаёт Rerun gRPC/proxy source. Saved observation sessions открывают
проверенную digest-bound RRD generation через same-origin HTTP; ручной адрес
нужен только для другого совместимого Rerun source.
## Текущее состояние
@ -20,11 +21,13 @@ device adapter, но структура интерфейса от него не
| Локальный control plane | Реализован | React получает состояние и выполняет операции через FastAPI REST и WebSocket на loopback. |
| K1 BLE → Wi-Fi | Реализован | Реальный BLE-поиск всех видимых устройств и одна подтверждённая provisioning-запись выбранному устройству. |
| K1 live/replay MQTT | Реализован | Read-only приём, raw-first сохранение, декодирование облака точек и позы, реальные метрики. |
| Автоматический MQTT → Rerun | Реализован | Первый live/replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие сессии сбрасывают сцену и метрики и переиспользуют его. |
| Автоматический MQTT → Rerun | Реализован | Первый live/adapter file-replay поднимает process-wide `RecordingStream` и gRPC/proxy на TCP 9876; следующие такие сессии переиспользуют его. Saved observation replay использует отдельный immutable HTTP RRD path. |
| Встроенный Rerun Viewer | Реализован | Self-hosted npm-компонент автоматически открывает текущий gRPC source внутри Control Station; внешний viewer не используется. |
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория и сетка. Проекция, собственный timeline и сохранённые layout-профили ещё не подключены. |
| Контролы сцены → Rerun | Реализованы для текущей геометрии | Работают размер и видимость точек, атрибут цвета, палитра, окно накопления, траектория, сетка, host timeline и сохранение/восстановление spatial layout. Проекция и семантические слои ещё не подключены. |
| Сохранённые observation sessions | Реализованы для point/pose | Три последние сессии, background preparation, cache v6, generation-bound RRD, atomic admission, autoplay, play/pause/seek и controlled switching больших записей. |
| K1 camera preview и archive | Live реализован; recorded contract реализован | Обе RTSP/H.264 камеры физически приняты в live UI. Новые acquisition-owned fMP4 archives не зависят от browser windows; recorded player подключён, но реальная архивная K1 camera-session ещё не прошла physical acceptance. |
| Legacy Foxglove module | Только regression | Модуль и тесты сохранены для сравнения декодирования. Текущий live/replay runtime не запускает Foxglove WebSocket и не использует TCP 8765. |
| Камеры, карты и миссии | Интерфейс готов | Серверная логика и реальные каналы для этих рабочих поверхностей ещё не подключены. |
| Карты и миссии | Интерфейсный каркас | Реальные map/mission backends и vehicle control ещё не подключены. |
Приложение не генерирует демонстрационное облако, траекторию, кадры или
метрики. Если реальных данных нет, область сцены остаётся пустой, а числовые поля
@ -48,9 +51,16 @@ Mission Core Control Station ←→ REST /api/v1/device-plugins/*
+ plugin-scoped WebSocket events
FastAPI на 127.0.0.1:8000
CoreBluetooth + live/replay runtime
sealed/recovered observation session
└── SQLite catalog + bounded background preparation
└── atomic RRD cache v6 + recorded-media manifest v2
└── generation-bound same-origin HTTP
└── aggregate admission
└── Rerun native receiver + recorded fMP4 player
```
В момент готовности `RerunBridge` backend публикует адрес вида
В момент готовности live/file-replay `RerunBridge` backend публикует адрес вида
`rerun+http://127.0.0.1:9876/proxy`. Frontend автоматически назначает его сцене,
если оператор не указал ручной source. После остановки приёма URL и встроенный
viewer остаются активны, а следующая сессия сбрасывает session-local геометрию,
@ -65,7 +75,7 @@ viewer остаются активны, а следующая сессия сб
Shell собран из локальных NODE.DC UI packages и сохраняет одну структуру для
всех функциональных модулей:
1. `AppHeader` — марка NODEDC MISSION CORE, выбор архитектурного раздела и состояние
1. `AppHeader` — марка NODE DC, выбор архитектурного раздела и состояние
локального backend.
2. `AdminNavigationPanel` — контекст аппарата и список рабочих поверхностей
выбранного раздела.
@ -78,8 +88,10 @@ Shell собран из локальных NODE.DC UI packages и сохраня
blueprint-, selection- и time-панели скрыты, чтобы продуктовые действия жили в
Control Station. Размер точек, способ окрашивания и палитра, 12-секундное по
умолчанию накопление, видимость облака и траектории и сетка связаны с backend и
Rerun Blueprint. Кнопки собственного timeline, смена 2D/3D/карты и сохранение
layout пока остаются интерфейсным контрактом.
Rerun Blueprint. Host timeline управляет сохранённым `session_time`, а spatial
layout сохраняет display settings, tool windows и source-window geometry через
revisioned API. Смена 2D/3D/карты и семантические слои пока остаются
интерфейсным контрактом.
## Архитектурные разделы
@ -139,10 +151,15 @@ cd apps/control-station
npm run dev
```
Vite слушает `http://127.0.0.1:5173` и проксирует весь `/api` (включая WebSocket) на
`http://127.0.0.1:8000`. Другой локальный backend можно указать переменной
`VITE_API_TARGET`. Preview production-сборки запускается командой
`npm run preview` на `http://127.0.0.1:4173`.
Для полного live-viewer контура Vite должен открыться на
`http://127.0.0.1:5173`: только 5173, production preview 4173 и backend 8000
входят в текущий Rerun CORS allowlist. Если Vite сообщает fallback на 5174 или
выше, освободите 5173 и перезапустите `npm run dev`; REST/WebSocket proxy на
fallback-порту может работать, но прямой browser → Rerun 9876 будет отклонён.
Vite проксирует весь `/api` (включая WebSocket) на `http://127.0.0.1:8000`.
Другой локальный backend можно указать переменной `VITE_API_TARGET`. Preview
production-сборки запускается командой `npm run preview` на
`http://127.0.0.1:4173`.
## Операторский путь для текущего K1 adapter
@ -167,6 +184,9 @@ Vite слушает `http://127.0.0.1:5173` и проксирует весь `/a
9. Открыть **Наблюдение → Пространственная сцена**. Реальные облако и траектория,
частота, число точек, задержка и пропуски preview появятся после прихода
сообщений K1.
10. После нормального stop или recovery открыть **Сохранённые сессии**. Дождаться
состояния **Готово**, выбрать запись и использовать host timeline. Evidence
сохраняется автоматически; disk action сохраняет только workspace layout.
В проверочном live-сеансе через этот путь прошло 80 реальных MQTT-сообщений:
38 кадров `lio_pcl`, 42 кадра `lio_pose`, 2 775 точек в последнем облаке и
@ -203,9 +223,12 @@ Plasma, grayscale и custom, накопление `0120` секунд, а т
декодированном формате. Custom/class сейчас означает выбранный сплошной цвет,
а не готовую семантическую классификацию.
Custom timeline, переключатель 2D/3D/карты, семантические слои и сохранение RBL
пока не вызывают Blueprint/playback API. Черновик компоновки фиксируется только
в памяти текущей страницы и не записывается на диск.
Для saved session host timeline вызывает Rerun play/pause/seek только после
полного admission. Accumulation и display settings применяются через отдельный
маленький blueprint RRD и не заменяют основную запись. Versioned
`observation.spatial` layout сохраняется на host с optimistic revision; он не
содержит sensor evidence. Переключатель 2D/3D/карты и семантические слои пока не
вызывают готовый presentation backend.
## Контракт локального API
@ -216,6 +239,12 @@ Custom timeline, переключатель 2D/3D/карты, семантиче
| `GET` | `/api/v1/device-models` | Backend-каталог моделей из валидированных manifests; UI-каталог текущей сборки формируется отдельным static composition root. |
| `POST` | `/api/v1/device-plugins/{pluginId}/actions/{actionId}` | Namespaced действие через host allowlist; тело `{ "input": { ... } }`. |
| `WS` | `/api/v1/device-plugins/{pluginId}/events` | Plugin-scoped snapshots с `pluginId` и монотонным `sequence`. |
| `GET` | `/api/v1/observation-sessions` | Последние cataloged sessions и authoritative preparation state. |
| `POST` | `/api/v1/observation-sessions/{id}/replay` | Verified replay launch либо HTTP 202 preparation handle. |
| `GET` | `/api/v1/observation-sessions/{id}/recording-preparation` | Exact-job polling с `If-Match`. |
| `GET` | `/api/v1/observation-sessions/{id}/recording.rrd` | Immutable generation-bound RRD; arbitrary chunked `send_rrd` не используется. |
| `GET` | `/api/v1/observation-sessions/{id}/media/{artifact}/...` | Manifest/init/segments записанных камер по opaque identifiers. |
| `GET`, `PUT` | `/api/v1/workspace-layouts/observation.spatial` | Versioned host layout с optimistic revision. |
Frontend XGRIDS-плагина использует только v1alpha namespaced routes. Старые
`/api/state`, `/api/events`, `/api/ble/scan`, `/api/connect`, `/api/session/*` и
@ -257,7 +286,12 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
| `src/workspaces/DeviceWorkspace.tsx` | Generic выбор модели и `device.connection` slot. |
| `src/device-plugins/xgrids-k1/` | Реальный K1 BLE/Wi-Fi/live/replay UI, client и compatibility mapper. |
| `src/workspaces/Workspaces.tsx` | Оперативный обзор, spatial viewport и остальные продуктовые поверхности. |
| `src/components/RerunViewport.tsx` | Жизненный цикл встроенного Rerun Web Viewer и selection events. |
| `src/components/RerunViewport.tsx` | Live/recorded lifecycle WebViewer, native RRD open, atomic admission, playback и selection events. |
| `src/components/ObservationSessionSelect.tsx` | Три последние сессии, состояния `Готово` / `Обработка` / `Ошибка`. |
| `src/components/RecordedFmp4Player.tsx` | Проверенный generation-bound MSE playback архивных камер. |
| `src/core/observation/sessionArchive.ts` | Строгий wire contract catalog/preparation/replay/media API. |
| `src/core/observation/recordedSessionAdmission.ts` | Общий RRD/camera gate до публикации recorded workspace. |
| `src/core/observation/workspaceLayout.ts` | Versioned spatial layout и optimistic revision. |
| `src/sceneSettings.ts` | Типизированный UI-профиль пространственной сцены. |
| `src/presentation.ts` | Vendor-neutral подписи lifecycle и форматирование нормализованных метрик. |
| `src/styles.css`, `src/styles/*` | Компоновка shell и рабочих поверхностей. |
@ -277,14 +311,17 @@ facts и старые presentation-поля `phase`, `message`, `devices`,
случайные GATT writes и автоматические повторы запрещены.
- MQTT live/replay не публикует команды устройству. Запуск и остановка
физического сканирования остаются за кнопкой K1.
- Live-сессии сначала сохраняют сырые сообщения, затем формируют preview. При
перегрузке preview может быть отброшен, raw evidence сохраняется.
- Live-сессии сначала сохраняют сырые сообщения и camera segments, затем
формируют disposable preview. При перегрузке preview может быть отброшен,
native evidence сохраняется. MQTT durability имеет bounded group-commit RPO,
camera durability — текущий незавершённый fragment RPO; это не zero-loss claim.
- Timeline `capture_time` сохраняет Unix-время приёма сообщения Mac, а окно
накопления viewer использует session-local `stream_time`. `capture_time` — не
доказанный timestamp сенсора K1 и не photon-to-screen latency.
- Панорамный камерный поток в наблюдавшихся MQTT report topics отсутствует;
текущая Rerun-сцена содержит только облако точек и позу/траекторию, а
операционные метрики отображаются внешней оболочкой Mission Core.
- `sessions/` игнорируется Git и может содержать адреса, идентификаторы,
траекторию и карту помещения. В репозиторий попадают только redacted manifests
и безопасная документация.
- Панорамный камерный поток в MQTT report topics отсутствует; отдельные
left/right RTSP preview доступны через generic camera windows. LiDAR и camera
сейчас синхронизированы только по host arrival, не по доказанным sensor clocks.
- `.runtime/`, canonical evidence roots и legacy `sessions/` игнорируются Git и
могут содержать адреса, изображения, идентификаторы, траекторию и карту
помещения. В репозиторий попадают только код, тесты, redacted manifests и
безопасная документация.

View File

@ -7,7 +7,9 @@
"": {
"name": "@nodedc/mission-core-control-station",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@noble/hashes": "^2.2.0",
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
@ -834,6 +836,18 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodedc/tokens": {
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"link": true

View File

@ -4,6 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
@ -11,6 +12,7 @@
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@noble/hashes": "^2.2.0",
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",

View File

@ -0,0 +1,61 @@
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
if (manifest.version !== "0.34.1") {
throw new Error(
`Refusing to patch @rerun-io/web-viewer ${manifest.version}; audit the new vendor lifecycle first.`,
);
}
const patches = [
{
label: "compiled watchdog",
path: resolve(packageRoot, "index.js"),
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
},
{
label: "source watchdog",
path: resolve(packageRoot, "index.ts"),
before: ` function check_for_panic() {\n if (self.#handle?.has_panicked()) {`,
after: ` function check_for_panic() {\n if (self.#state !== "ready" || !self.#handle) {\n return;\n }\n if (self.#handle.has_panicked()) {`,
},
{
label: "compiled singleton global listener",
path: resolve(packageRoot, "index.js"),
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
},
{
label: "source singleton global listener",
path: resolve(packageRoot, "index.ts"),
before: `function setupGlobalEventListeners() {\n window.addEventListener("keyup", (e) => {`,
after: `let globalEventListenersInstalled = false;\nfunction setupGlobalEventListeners() {\n if (globalEventListenersInstalled) {\n return;\n }\n globalEventListenersInstalled = true;\n window.addEventListener("keyup", (e) => {`,
},
];
for (const patch of patches) {
const source = readFileSync(patch.path, "utf8");
if (source.includes(patch.after)) continue;
if (!source.includes(patch.before)) {
throw new Error(`Vendor ${patch.label} patch no longer matches ${patch.path}.`);
}
writeFileSync(patch.path, source.replace(patch.before, patch.after));
}
for (const path of [resolve(packageRoot, "index.js"), resolve(packageRoot, "index.ts")]) {
const source = readFileSync(path, "utf8");
const keyupListenerCount = source.split('window.addEventListener("keyup"').length - 1;
if (!source.includes("let globalEventListenersInstalled = false;") || keyupListenerCount !== 1) {
throw new Error(`Vendor singleton global listener verification failed for ${path}.`);
}
}
console.log(
"Patched @rerun-io/web-viewer 0.34.1 watchdog teardown and singleton global keyup listener.",
);

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AdminNavigationPanel,
AppHeader,
@ -26,10 +26,25 @@ import {
} from "@nodedc/ui-react";
import { LandingStage } from "./components/LandingStage";
import { ObservationSessionSelect } from "./components/ObservationSessionSelect";
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
import type { ViewerSettings } from "./core/runtime/contracts";
import {
createLatestAsyncCommitter,
type LatestAsyncCommitter,
} from "./core/runtime/latestAsyncCommitter";
import { useObservationLayout } from "./core/observation/useObservationLayout";
import { recordedObservationSources } from "./core/observation/recordedObservationSources";
import type { ObservationSessionReplayLaunch } from "./core/observation/sessionArchive";
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
import {
OBSERVATION_WORKSPACE_ID,
OBSERVATION_WORKSPACE_LAYOUT_VERSION,
type ObservationToolWindowId,
type ObservationWorkspaceLayoutProfile,
} from "./core/observation/workspaceLayout";
import {
rootById,
roots,
@ -48,7 +63,10 @@ import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { WorkspaceRenderer } from "./workspaces/Workspaces";
import "./styles/scene-windows.css";
type SceneToolWindowId = "sources" | "display" | "layers" | "layout";
type SceneToolWindowId = ObservationToolWindowId;
const sceneToolWindowIds: readonly SceneToolWindowId[] = ["sources", "display", "layers"];
const viewerSettingsQuietPeriodMs = 750;
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
@ -116,28 +134,117 @@ export default function App() {
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
const [sourceUrl, setSourceUrl] = useState("");
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [replayTransitioning, setReplayTransitioning] = useState(false);
const [sourceDraft, setSourceDraft] = useState("");
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
const [displayWindowOpen, setDisplayWindowOpen] = useState(false);
const [layerInspectorOpen, setLayerInspectorOpen] = useState(false);
const [sceneWindowOrder, setSceneWindowOrder] = useState<SceneToolWindowId[]>([]);
const [layoutWindowOpen, setLayoutWindowOpen] = useState(false);
const [layoutName, setLayoutName] = useState("Операторская сцена");
const [layoutDraftSaved, setLayoutDraftSaved] = useState(false);
const [layoutSaveNotice, setLayoutSaveNotice] = useState<string | null>(null);
const [sceneSettings, setSceneSettings] = useState<SceneSettings>(defaultSceneSettings);
const [displayDraft, setDisplayDraft] = useState<SceneSettings>(defaultSceneSettings);
const appliedProfileKeyRef = useRef<string | null>(null);
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
const replayActiveRef = useRef(false);
const sceneSettingsCommitterActiveRef = useRef(true);
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
const currentRoot = rootById(activeRoot);
const activeDefinition = workspaceById(workspace.activeView);
const rootWorkspaces = workspacesForRoot(activeRoot);
const activeSceneWindow = sceneWindowOrder[sceneWindowOrder.length - 1] ?? null;
const automaticSourceUrl = runtime.state?.spatialSource?.url.trim() ?? "";
const effectiveSourceUrl = sourceUrl || automaticSourceUrl;
const observationLayout = useObservationLayout(
runtime.state?.observationSources ?? [],
runtime.setObservationSourceActive,
const effectiveSourceUrl = replayTransitioning ? "" : sourceUrl || automaticSourceUrl;
const replayActive = Boolean(recordedReplay && sourceUrl === recordedReplay.sourceUrl);
const recordedSessionAdmission = useRecordedSessionAdmission(
replayActive ? recordedReplay : null,
);
const focusedObservationSource = runtime.state?.observationSources?.find(
const displayPaletteOptions = replayActive
? paletteOptions.map((option) => {
if (option.value === "turbo") {
return {
...option,
label: "Цвет записи",
description: "Вернуть цвета, сохранённые внутри RRD",
};
}
return option.value === "custom" ? option : { ...option, disabled: true };
})
: paletteOptions;
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
replayActiveRef.current = replayActive;
if (!sceneSettingsCommitterRef.current) {
sceneSettingsCommitterRef.current = createLatestAsyncCommitter<SceneSettings>({
commit: (settings) => replayActiveRef.current
? Promise.resolve(true)
: runtimeUpdateViewerSettingsRef.current(toViewerSettings(settings)),
onSettled: ({ value, applied, superseded }) => {
if (!sceneSettingsCommitterActiveRef.current) return;
if (applied) confirmedSceneSettingsRef.current = value;
if (superseded) return;
const next = applied ? value : confirmedSceneSettingsRef.current;
sceneSettingsRef.current = next;
setSceneSettings(next);
if (!applied && displayDraftRef.current === value) {
displayDraftRef.current = next;
setDisplayDraft(next);
}
},
});
}
const replaySources = useMemo(
() => recordedObservationSources(recordedReplay),
[recordedReplay],
);
const activeObservationSources = replayActive
? replaySources
: runtime.state?.observationSources ?? [];
const activeRuntimeState = useMemo(() => {
if (!replayActive || !recordedReplay) return runtime.state;
return {
...(runtime.state ?? { phase: "replaying" as const }),
phase: "replaying" as const,
sourceMode: "replay" as const,
spatialSource: {
id: "recorded.spatial.primary",
url: recordedReplay.sourceUrl,
label: "Сохранённая пространственная сцена",
kind: "rrd" as const,
},
observationSources: replaySources,
observationTimeline: {
mode: "recorded" as const,
seekable: true,
sessionRecording: true,
synchronization: "host-arrival-best-effort" as const,
range: {
startSeconds: recordedReplay.timelineStartSeconds,
endSeconds: recordedReplay.timelineEndSeconds,
},
},
};
}, [recordedReplay, replayActive, replaySources, runtime.state]);
const viewerSettingsTargetIdentity = [
runtime.state?.activeDevice?.pluginId,
runtime.state?.activeDevice?.modelId,
runtime.state?.activeDevice?.instanceId,
runtime.state?.deviceSession?.sessionId,
runtime.state?.deviceSession?.deviceId,
runtime.state?.acquisition?.acquisitionId,
].filter(Boolean).join(":") || "local-runtime";
const observationLayout = useObservationLayout(
activeObservationSources,
replayActive ? undefined : runtime.setObservationSourceActive,
);
const workspaceLayoutProfile = useWorkspaceLayoutProfile();
const focusedObservationSource = activeObservationSources.find(
(source) => source.id === observationLayout.focusedSourceId,
);
const observationFullscreenActive = Boolean(
@ -148,12 +255,80 @@ export default function App() {
useEffect(() => {
const remote = runtime.state?.viewerSettings;
if (!remote) return;
setSceneSettings((current) => mergeViewerSettings(current, remote));
if (!displayWindowOpen) {
setDisplayDraft((current) => mergeViewerSettings(current, remote));
if (
!remote ||
workspaceLayoutProfile.profile ||
sceneSettingsCommitterRef.current?.isBusy()
) return;
const merged = mergeViewerSettings(sceneSettingsRef.current, remote);
sceneSettingsRef.current = merged;
confirmedSceneSettingsRef.current = merged;
setSceneSettings(merged);
if (viewerSettingsCommitTimerRef.current === null) {
displayDraftRef.current = merged;
setDisplayDraft(merged);
}
}, [runtime.state?.viewerSettings, displayWindowOpen]);
}, [runtime.state?.viewerSettings, workspaceLayoutProfile.profile]);
useEffect(() => {
const profile = workspaceLayoutProfile.profile;
if (!profile) return;
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null;
}
sceneSettingsRef.current = profile.sceneSettings;
displayDraftRef.current = profile.sceneSettings;
confirmedSceneSettingsRef.current = profile.sceneSettings;
setSceneSettings(profile.sceneSettings);
setDisplayDraft(profile.sceneSettings);
setSourceWindowOpen(profile.toolWindows.sourcesOpen);
setDisplayWindowOpen(profile.toolWindows.displayOpen);
setLayerInspectorOpen(profile.toolWindows.layersOpen);
setSceneWindowOrder(profile.toolWindows.order.filter((windowId) => {
if (windowId === "sources") return profile.toolWindows.sourcesOpen;
if (windowId === "display") return profile.toolWindows.displayOpen;
return profile.toolWindows.layersOpen;
}));
observationLayout.restore(profile);
}, [observationLayout.restore, workspaceLayoutProfile.profile]);
useEffect(() => {
sceneSettingsCommitterActiveRef.current = true;
return () => {
sceneSettingsCommitterActiveRef.current = false;
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null;
}
};
}, []);
useEffect(() => {
const profile = workspaceLayoutProfile.profile;
const applicationKey = profile
? `${profile.revision}:${viewerSettingsTargetIdentity}`
: null;
if (
!profile ||
runtime.backendStatus !== "online" ||
!applicationKey ||
appliedProfileKeyRef.current === applicationKey
) {
return;
}
appliedProfileKeyRef.current = applicationKey;
void runtime.updateViewerSettings(toViewerSettings(profile.sceneSettings)).then((applied) => {
if (!applied && appliedProfileKeyRef.current === applicationKey) {
appliedProfileKeyRef.current = null;
}
});
}, [
runtime.backendStatus,
runtime.updateViewerSettings,
viewerSettingsTargetIdentity,
workspaceLayoutProfile.profile,
]);
const activateSceneWindow = useCallback((windowId: SceneToolWindowId) => {
setSceneWindowOrder((current) => [
@ -166,7 +341,6 @@ export default function App() {
if (windowId === "sources") setSourceWindowOpen(false);
if (windowId === "display") setDisplayWindowOpen(false);
if (windowId === "layers") setLayerInspectorOpen(false);
if (windowId === "layout") setLayoutWindowOpen(false);
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
}, []);
@ -204,8 +378,41 @@ export default function App() {
activateSceneWindow("sources");
};
const commitDisplaySettings = useCallback((next: SceneSettings) => {
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null;
}
displayDraftRef.current = next;
setDisplayDraft(next);
sceneSettingsCommitterRef.current?.enqueue(next);
}, []);
const stageDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
const next = { ...displayDraftRef.current, ...patch };
displayDraftRef.current = next;
setDisplayDraft(next);
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
}
viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
}, viewerSettingsQuietPeriodMs);
}, []);
const flushDisplaySettings = useCallback(() => {
if (viewerSettingsCommitTimerRef.current === null) return;
window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
}, []);
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
}, [commitDisplaySettings]);
const openDisplay = () => {
setDisplayDraft(sceneSettings);
setDisplayWindowOpen(true);
activateSceneWindow("display");
};
@ -215,28 +422,90 @@ export default function App() {
activateSceneWindow("layers");
};
const openLayout = () => {
setLayoutDraftSaved(false);
setLayoutWindowOpen(true);
activateSceneWindow("layout");
};
const beginRecordedReplaySwitch = useCallback(async () => {
// useObservationSessions calls this only after backend preparation has
// produced a validated launch descriptor. Keep the old scene mounted
// before this point; now perform one controlled receiver teardown before
// accepting the already-ready archive.
setReplayTransitioning(true);
setRecordedReplay(null);
setSourceUrl("");
setSourceDraft("");
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
});
}, []);
const applyDisplaySettings = async () => {
const applied = await runtime.updateViewerSettings(toViewerSettings(displayDraft));
if (applied) setSceneSettings(displayDraft);
};
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
setRecordedReplay(launch);
setSourceUrl(launch.sourceUrl);
setSourceDraft(launch.sourceUrl);
setReplayTransitioning(false);
}, []);
const applyScenePatch = async (patch: Partial<SceneSettings>) => {
const previous = sceneSettings;
const next = { ...sceneSettings, ...patch };
setSceneSettings(next);
setDisplayDraft((current) => (displayWindowOpen ? { ...current, ...patch } : next));
const applied = await runtime.updateViewerSettings(toViewerSettings(next));
if (!applied) {
setSceneSettings(previous);
if (!displayWindowOpen) setDisplayDraft(previous);
const settleRecordedReplaySwitch = useCallback((outcome: "accepted" | "error" | "cancelled") => {
if (outcome !== "accepted") setReplayTransitioning(false);
}, []);
useEffect(() => {
// ObservationSessionSelect owns the cancellable request and unmounts when
// the operator leaves the spatial workspace. Its unmount cannot safely
// call back into this owner, so clear the transient blanking state here.
// Returning to Observation then starts from a deterministic idle source
// instead of an orphaned `replayTransitioning=true` state.
if (activeDefinition?.kind !== "spatial") setReplayTransitioning(false);
}, [activeDefinition?.kind]);
const applyScenePatch = (patch: Partial<SceneSettings>) => commitDisplayPatch(patch);
const saveWorkspaceLayout = useCallback(async () => {
flushDisplaySettings();
await sceneSettingsCommitterRef.current?.waitForIdle();
const layout = observationLayout.snapshot();
if (!layout) {
setLayoutSaveNotice("Сцена ещё не измерила рабочую область.");
return;
}
const openWindowOrder = sceneWindowOrder.filter((windowId) => {
if (windowId === "sources") return sourceWindowOpen;
if (windowId === "display") return displayWindowOpen;
return layerInspectorOpen;
});
const completeWindowOrder = [
...sceneToolWindowIds.filter((windowId) => !openWindowOrder.includes(windowId)),
...openWindowOrder,
];
const draft: ObservationWorkspaceLayoutProfile = {
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
revision: workspaceLayoutProfile.profile?.revision ?? 0,
workspaceId: OBSERVATION_WORKSPACE_ID,
sceneSettings: sceneSettingsRef.current,
toolWindows: {
sourcesOpen: sourceWindowOpen,
displayOpen: displayWindowOpen,
layersOpen: layerInspectorOpen,
order: completeWindowOrder,
},
...layout,
};
const saved = await workspaceLayoutProfile.save(draft);
setLayoutSaveNotice(saved ? "Компоновка сохранена" : workspaceLayoutProfile.error);
}, [
displayWindowOpen,
flushDisplaySettings,
layerInspectorOpen,
observationLayout.snapshot,
sceneWindowOrder,
sourceWindowOpen,
workspaceLayoutProfile,
]);
useEffect(() => {
if (!layoutSaveNotice || layoutSaveNotice !== "Компоновка сохранена") return;
const timer = window.setTimeout(() => setLayoutSaveNotice(null), 2600);
return () => window.clearTimeout(timer);
}, [layoutSaveNotice]);
const contentActions = useMemo<ApplicationPanelUtilityAction[]>(() => {
const actions: ApplicationPanelUtilityAction[] = [];
@ -251,25 +520,27 @@ export default function App() {
if (activeDefinition?.kind === "spatial") {
actions.push(
{
label: workspaceLayoutProfile.state === "saving"
? "Сохраняем компоновку"
: "Сохранить компоновку",
icon: "save",
disabled: workspaceLayoutProfile.state === "saving",
onClick: () => void saveWorkspaceLayout(),
},
{ label: "Настроить визуальный движок", icon: "network", onClick: openSource },
{ label: "Настроить отображение", icon: "sliders", onClick: openDisplay },
{ label: "Открыть слои", icon: "list", onClick: openLayers },
);
}
actions.push({
label: "Сохранить компоновку",
icon: "save",
onClick: openLayout,
});
return actions;
}, [activeDefinition?.kind, runtime, sceneSettings, sourceUrl]);
}, [activeDefinition?.kind, runtime, saveWorkspaceLayout, workspaceLayoutProfile.state]);
const header = (
<AppHeader
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
brandLabel="NODEDC MISSION CORE"
left={<span className="station-label">MISSION CORE</span>}
center={
<>
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
@ -363,9 +634,29 @@ export default function App() {
{phaseLabel(runtime.state?.phase)}
</StatusBadge>
) : activeDefinition.kind === "spatial" ? (
<StatusBadge tone={effectiveSourceUrl ? "accent" : "warning"}>
{effectiveSourceUrl ? "Источник назначен" : "Без источника"}
</StatusBadge>
<div className="observation-header-tools">
<ObservationSessionSelect
limit={3}
disabled={runtime.pendingAction !== null}
onReplayBegin={beginRecordedReplaySwitch}
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
/>
{layoutSaveNotice || workspaceLayoutProfile.error ? (
<span
className="workspace-layout-feedback"
data-error={workspaceLayoutProfile.error ? "true" : undefined}
role={workspaceLayoutProfile.error ? "alert" : "status"}
>
<i
className="api-dot"
data-status={workspaceLayoutProfile.error ? "error" : "online"}
aria-hidden="true"
/>
{layoutSaveNotice || workspaceLayoutProfile.error}
</span>
) : null}
</div>
) : (
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
)
@ -380,10 +671,16 @@ export default function App() {
) : (
<WorkspaceRenderer
definition={activeDefinition}
state={runtime.state}
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
recordedReplay={replayActive ? recordedReplay : null}
recordedSessionAdmission={recordedSessionAdmission}
sceneSettings={sceneSettings}
accumulationSeconds={displayDraft.accumulationSeconds}
onAccumulationChange={(accumulationSeconds) =>
stageDisplayPatch({ accumulationSeconds })}
onAccumulationCommit={flushDisplaySettings}
observationLayout={observationLayout}
navigation={{
openView,
@ -419,6 +716,7 @@ export default function App() {
onClick={() => {
setSourceDraft("");
setSourceUrl("");
setRecordedReplay(null);
}}
>
Сбросить адрес
@ -429,6 +727,7 @@ export default function App() {
disabled={!sourceDraft.trim()}
onClick={() => {
setSourceUrl(sourceDraft.trim());
setRecordedReplay(null);
}}
>
Применить адрес
@ -530,21 +829,6 @@ export default function App() {
data-scene-active={activeSceneWindow === "display" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("display")}
onClose={() => closeSceneWindow("display")}
footer={
<WindowFooterActions>
<Button variant="ghost" onClick={() => setDisplayDraft(defaultSceneSettings)}>
Сбросить
</Button>
<Button
variant="primary"
shape="pill"
disabled={runtime.pendingAction === "viewer"}
onClick={() => void applyDisplaySettings()}
>
{runtime.pendingAction === "viewer" ? "Применяем…" : "Применить к сцене"}
</Button>
</WindowFooterActions>
}
>
<Inspector
defaultOpen={["points"]}
@ -556,6 +840,12 @@ export default function App() {
description: "Размер и способ окрашивания",
content: (
<div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl
label="Размер точки"
value={displayDraft.pointSize}
@ -563,34 +853,53 @@ export default function App() {
max={12}
step={0.5}
formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => setDisplayDraft((current) => ({ ...current, pointSize }))}
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
/>
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Атрибут цвета">
<Select
variant="split"
label="Атрибут цвета"
value={displayDraft.colorMode}
options={colorModeOptions}
onChange={(colorMode) => setDisplayDraft((current) => ({ ...current, colorMode }))}
disabled={replayActive}
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
/>
</ControlRow>
</div>
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
<ControlRow label="Палитра">
<Select
variant="split"
label="Палитра"
value={displayDraft.palette}
options={paletteOptions}
onChange={(palette) => setDisplayDraft((current) => ({ ...current, palette }))}
options={displayPaletteOptions}
onChange={(palette) => stageDisplayPatch({ palette })}
/>
</ControlRow>
</div>
{displayDraft.palette === "custom" ? (
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<ControlRow label="Цвет точек">
<ColorField
label="Цвет точек"
value={displayDraft.customColor}
onChange={(customColor) => setDisplayDraft((current) => ({ ...current, customColor }))}
onChange={(customColor) => stageDisplayPatch({ customColor })}
/>
</ControlRow>
</div>
) : null}
{replayActive ? (
<p className="scene-window-note">
В архиве градиентные цвета уже записаны в RRD. Без переэкспорта
можно вернуть цвет записи или назначить один свой цвет.
</p>
) : null}
</div>
),
@ -601,6 +910,12 @@ export default function App() {
description: "История облака и траектория",
content: (
<div className="inspector-control-stack">
<div
className="scene-settings-commit-field"
onPointerUp={flushDisplaySettings}
onKeyUp={flushDisplaySettings}
onBlur={flushDisplaySettings}
>
<RangeControl
label="Окно накопления"
value={displayDraft.accumulationSeconds}
@ -608,14 +923,15 @@ export default function App() {
max={120}
step={1}
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
onChange={(accumulationSeconds) => setDisplayDraft((current) => ({ ...current, accumulationSeconds }))}
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
/>
</div>
<div className="nodedc-field">
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
<Checker
checked={displayDraft.showTrajectory}
label="Показывать траекторию"
onChange={(showTrajectory) => setDisplayDraft((current) => ({ ...current, showTrajectory }))}
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
/>
</div>
</div>
@ -627,7 +943,7 @@ export default function App() {
description: "Сетка, подписи и камеры",
content: (
<div className="inspector-control-stack">
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => setDisplayDraft((current) => ({ ...current, showGrid }))} />
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
<div className="nodedc-field">
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
@ -726,59 +1042,6 @@ export default function App() {
/>
</Window>
<Window
open={layoutWindowOpen}
title="Компоновка рабочей области"
subtitle="КОМПОНОВКА / ЧЕРНОВИК"
size="sm"
placement="end"
draggable
closeOnBackdrop={false}
closeOnEscape={false}
lockBodyScroll={false}
trapFocus={false}
className="scene-tool-window scene-tool-window--layout"
data-scene-window="layout"
data-scene-active={activeSceneWindow === "layout" ? "true" : undefined}
onPointerDown={() => activateSceneWindow("layout")}
onClose={() => closeSceneWindow("layout")}
footer={
<WindowFooterActions>
<Button onClick={() => closeSceneWindow("layout")}>Закрыть</Button>
<Button
variant="primary"
shape="pill"
disabled={!layoutName.trim()}
onClick={() => setLayoutDraftSaved(true)}
>
Зафиксировать черновик
</Button>
</WindowFooterActions>
}
>
<div className="modal-stack">
<TextField
label="Название компоновки"
value={layoutName}
onChange={(event) => {
setLayoutName(event.target.value);
setLayoutDraftSaved(false);
}}
placeholder="Название профиля"
/>
<div className="modal-contract-note" data-tone={layoutDraftSaved ? "success" : "warning"}>
<Icon name={layoutDraftSaved ? "check" : "save"} />
<div>
<strong>{layoutDraftSaved ? "Черновик зафиксирован в текущем сеансе" : "Экспорт RBL ещё не подключён"}</strong>
<p>
{layoutDraftSaved
? "Это состояние интерфейса без записи на диск. Будущий адаптер сохранит профиль Rerun рядом с кодом."
: "Окно и контракт сохранения готовы; запись файла не имитируется."}
</p>
</div>
</div>
</div>
</Window>
</>
);
}

View File

@ -4,6 +4,11 @@ import { WorkspaceWindow } from "@nodedc/ui-react";
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
import { ObservationMedia, observationSourceStatusLabel } from "./ObservationSources";
import type { RecordedObservationPlayback } from "./RecordedFmp4Player";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
const WINDOW_WIDTH = 336;
const WINDOW_HEIGHT = 210;
@ -11,6 +16,18 @@ const WINDOW_GAP = 16;
const WINDOW_INSET = 18;
const TIMELINE_CLEARANCE = 64;
type ClosestTarget = { closest: (selectors: string) => unknown };
export function shouldCaptureWorkspacePointer(
button: number,
target: ClosestTarget | null,
): boolean {
if (button !== 0 || !target) return false;
if (target.closest(".nodedc-workspace-window__resize")) return true;
if (!target.closest(".nodedc-workspace-window__head")) return false;
return !target.closest("button, input, select, textarea, a");
}
export function initialObservationWindowRect(
index: number,
count = 1,
@ -59,6 +76,11 @@ export function FloatingObservationWindow({
onMaximizedChange,
onActivate,
onClose,
playback,
prepareRecorded,
recordedSessionGate,
recordedAdmissionKey,
onRecordedAdmissionChange,
}: {
source: ObservationSourceDescriptor;
index: number;
@ -71,6 +93,14 @@ export function FloatingObservationWindow({
onMaximizedChange: (maximized: boolean) => void;
onActivate: () => void;
onClose: () => void;
playback?: RecordedObservationPlayback | null;
prepareRecorded?: boolean;
recordedSessionGate?: RecordedAdmissionPhase;
recordedAdmissionKey?: string | null;
onRecordedAdmissionChange?: (
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}) {
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
@ -124,13 +154,29 @@ export function FloatingObservationWindow({
active={active}
zIndex={maximized ? 15 : active ? 9 : 7}
className="floating-observation-window"
onPointerDownCapture={(event) => {
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Pointer capture can fail if the browser ended the pointer between
// dispatch and capture. The donor's window listeners remain fallback.
}
}}
closeLabel={`Закрыть ${source.label}`}
maximizeLabel={`Развернуть ${source.label}`}
restoreLabel={`Восстановить ${source.label}`}
moveLabel={`Переместить ${source.label}`}
resizeLabel={`Изменить размер ${source.label}`}
>
<ObservationMedia source={source} />
<ObservationMedia
source={source}
playback={playback}
prepareRecorded={prepareRecorded}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedAdmissionKey}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
</WorkspaceWindow>
);
}

View File

@ -0,0 +1,239 @@
import { Dropdown, Icon } from "@nodedc/ui-react";
import {
type ObservationSessionReplayLaunch,
type ObservationSessionSummary,
} from "../core/observation/sessionArchive";
import { useObservationSessions } from "../core/observation/useObservationSessions";
import type {
ObservationPreparationPhase,
ObservationReplayOutcome,
} from "../core/observation/useObservationSessions";
import "../styles/observation-sessions.css";
type SessionVisualState = "ready" | "processing" | "error";
export function observationSessionVisualState(
session: ObservationSessionSummary,
{ pending = false, failed = false }: { pending?: boolean; failed?: boolean } = {},
): SessionVisualState {
if (failed) return "error";
if (pending) return "processing";
if (session.preparation !== null) {
if (["queued", "validating", "exporting", "finalizing"].includes(
session.preparation.state,
)) return "processing";
if (session.preparation.state === "ready" && session.replayable) return "ready";
return "error";
}
return session.status === "recording" ? "processing" : "error";
}
export function observationSessionVisualLabel(state: SessionVisualState): string {
if (state === "ready") return "Готово";
if (state === "processing") return "Обработка";
return "Ошибка";
}
const modalityLabel: Record<string, string> = {
"point-cloud": "облако точек",
pose: "траектория",
trajectory: "траектория",
video: "видео",
image: "изображения",
depth: "глубина",
telemetry: "телеметрия",
};
const preparationLabel: Record<ObservationPreparationPhase, string> = {
requesting: "Запрашиваем подготовку",
queued: "В очереди",
validating: "Проверяем запись",
exporting: "Готовим облако точек",
finalizing: "Завершаем подготовку",
failed: "Подготовка не выполнена",
cancelled: "Подготовка отменена",
};
function progressCopy(
phase: ObservationPreparationPhase,
progress: number | null,
): string {
const label = preparationLabel[phase];
return progress === null ? label : `${label} · ${Math.round(progress * 100)}%`;
}
function formatStartedAt(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function formatDuration(seconds: number): string {
const totalSeconds = Math.max(0, Math.round(seconds));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const remainingSeconds = totalSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function sessionDescription(session: ObservationSessionSummary): string {
const modalities = session.modalities.length
? session.modalities.map((value) => modalityLabel[value] ?? value).join(" · ")
: "каналы не зафиксированы";
return `${formatStartedAt(session.startedAtUtc)} · ${formatDuration(session.durationSeconds)} · ${modalities}`;
}
export function ObservationSessionSelect({
limit = 3,
disabled = false,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
disabled?: boolean;
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplayAccepted?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplaySettled?: (
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
}) {
const sessions = useObservationSessions({
limit,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
});
const triggerCopy = sessions.replayProgress
? progressCopy(sessions.replayProgress.phase, sessions.replayProgress.progress)
: sessions.state === "loading"
? "Загружаем сессии…"
: "Сохранённые сессии";
return (
<Dropdown
className="observation-session-select"
placement="bottom-end"
width={390}
offset={10}
disabled={disabled}
surfaceRole="dialog"
surfaceClassName="observation-session-menu"
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
<button
ref={(node) => {
setAnchorRef(node);
setTriggerRef(node);
}}
type="button"
className="observation-session-select__trigger"
data-active={open ? "true" : undefined}
aria-label="Открыть сохранённые сессии наблюдения"
aria-haspopup="dialog"
aria-expanded={open}
aria-controls={surfaceId}
disabled={disabled}
onClick={toggle}
>
<Icon name="database" size={15} />
<span>{triggerCopy}</span>
{sessions.state === "ready" ? <small>{sessions.items.length}</small> : null}
<Icon name="chevron-down" size={14} />
</button>
)}
>
{({ close }) => (
<div className="observation-session-menu__content">
<header className="observation-session-menu__head">
<div>
<span className="section-eyebrow">ИСТОРИЯ НАБЛЮДЕНИЯ</span>
<strong>Сохранённые сессии</strong>
</div>
<button
type="button"
aria-label="Обновить каталог сессий"
disabled={sessions.state === "loading"}
onClick={() => void sessions.refresh()}
>
<Icon name="refresh" size={14} />
</button>
</header>
{sessions.items.length > 0 ? (
<div className="observation-session-menu__list">
{sessions.items.map((session) => {
const pending = sessions.replayingSessionId === session.id;
const failed = sessions.failedSessionId === session.id;
const visualState = observationSessionVisualState(session, { pending, failed });
return (
<button
key={session.id}
type="button"
className="nodedc-dropdown-option observation-session-option"
disabled={pending || !session.replayable}
onClick={() => {
void sessions.replay(session.id).then((accepted) => {
if (accepted) close();
});
}}
>
<span className="nodedc-dropdown-option__icon">
<i data-session-visual-state={visualState} aria-hidden="true" />
</span>
<span className="nodedc-dropdown-option__body">
<span className="nodedc-dropdown-option__label">{session.label}</span>
<span className="nodedc-dropdown-option__description">
{sessionDescription(session)}
</span>
</span>
<span className="observation-session-option__state">
{observationSessionVisualLabel(visualState)}
</span>
</button>
);
})}
</div>
) : sessions.state === "loading" ? (
<div className="observation-session-menu__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Читаем каталог</strong>
</div>
) : (
<div className="observation-session-menu__empty">
<Icon name={sessions.error ? "alert" : "database"} size={18} />
<strong>{sessions.error ? "Каталог недоступен" : "Сессий пока нет"}</strong>
<span>{sessions.error ?? "Завершённые записи появятся здесь автоматически."}</span>
</div>
)}
{sessions.error && sessions.items.length > 0 ? (
<footer className="observation-session-menu__error" role="alert">
<Icon name="alert" size={13} />
<span>{sessions.error}</span>
{sessions.failedSessionId ? (
<button type="button" onClick={() => void sessions.retry()}>
Повторить
</button>
) : null}
</footer>
) : null}
</div>
)}
</Dropdown>
);
}

View File

@ -6,6 +6,14 @@ import type {
ObservationSourceModality,
} from "../core/runtime/contracts";
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
import {
RecordedFmp4Player,
type RecordedObservationPlayback,
} from "./RecordedFmp4Player";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
const sourceIcon: Record<ObservationSourceModality, IconName> = {
"point-cloud": "globe",
@ -29,7 +37,24 @@ export function observationSourceStatusLabel(source: ObservationSourceDescriptor
return availabilityCopy[source.availability];
}
export function ObservationMedia({ source }: { source: ObservationSourceDescriptor }) {
export function ObservationMedia({
source,
playback,
prepareRecorded = true,
recordedSessionGate = "ready",
recordedAdmissionKey = null,
onRecordedAdmissionChange,
}: {
source: ObservationSourceDescriptor;
playback?: RecordedObservationPlayback | null;
prepareRecorded?: boolean;
recordedSessionGate?: RecordedAdmissionPhase;
recordedAdmissionKey?: string | null;
onRecordedAdmissionChange?: (
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}) {
const sourceSelected = source.activation ? source.activation.selected : true;
const deliveryActive = Boolean(source.delivery && sourceSelected);
@ -41,6 +66,32 @@ export function ObservationMedia({ source }: { source: ObservationSourceDescript
return <MseFmp4WebSocketPlayer delivery={source.delivery} label={source.label} />;
}
if (
deliveryActive &&
source.delivery?.kind === "recorded-fmp4-manifest" &&
source.modality === "video"
) {
if (!prepareRecorded) {
return (
<div className="observation-media__empty" role="status">
<span className="busy-indicator" aria-hidden="true" />
<strong>Ожидает подготовки</strong>
<span>Канал будет проверен последовательно в рамках атомарной сессии.</span>
</div>
);
}
return (
<RecordedFmp4Player
source={source}
playback={playback}
prepare
sessionGate={recordedSessionGate}
admissionKey={recordedAdmissionKey}
onAdmissionChange={(state) => onRecordedAdmissionChange?.(source.id, state)}
/>
);
}
if (deliveryActive && source.delivery?.kind === "video-url" && source.modality === "video") {
return (
<video
@ -193,7 +244,8 @@ export function ObservationSourcePicker({
</div>
)}
<footer className="observation-source-menu__foot">
Каналы приходят из активного device-плагина; сцена не знает модель оборудования.
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
модель оборудования.
</footer>
</Dropdown>
);

View File

@ -8,6 +8,15 @@ export function ObservationTimeline({
mode = "live-only",
seekable = false,
synchronization = "host-arrival-best-effort",
rangeNs = null,
currentNs = null,
playing = false,
onSeek,
onPlayingChange,
onJumpToEnd,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
className = "",
}: {
active: boolean;
@ -15,38 +24,151 @@ export function ObservationTimeline({
mode?: ObservationTimelineMode;
seekable?: boolean;
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
rangeNs?: { min: number; max: number } | null;
currentNs?: number | null;
playing?: boolean;
onSeek?: (timeNs: number) => void;
onPlayingChange?: (playing: boolean) => void;
onJumpToEnd?: () => void;
accumulationSeconds?: number;
onAccumulationChange?: (value: number) => void;
onAccumulationCommit?: () => void;
className?: string;
}) {
const buffered = seekable && (mode === "buffered" || mode === "recorded");
const playbackRange = normalizeTimelineRange(rangeNs);
const buffered = Boolean(
seekable &&
(mode === "buffered" || mode === "recorded") &&
playbackRange,
);
const elapsedSeconds = playbackRange
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
: 0;
const durationSeconds = playbackRange
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
: 0;
const synchronizationLabel = {
"host-arrival-best-effort": "Синхронизация по приходу",
"shared-clock": "Общие часы",
"frame-accurate": "Покадровая синхронизация",
}[synchronization];
const accumulationValue = accumulationSeconds === undefined
? null
: normalizeAccumulationSeconds(accumulationSeconds);
return (
<div
className={`observation-timeline ${className}`.trim()}
data-active={active ? "true" : undefined}
data-mode={mode}
data-accumulation={accumulationValue !== null ? "true" : undefined}
>
{accumulationValue !== null ? (
<div className="observation-timeline__accumulation">
<span>Накопление</span>
<input
className="observation-timeline__track"
type="range"
min={0}
max={120}
step={1}
value={accumulationValue}
aria-label="Окно накопления облака точек"
aria-valuetext={formatAccumulationDuration(accumulationValue)}
onChange={(event) =>
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
onPointerUp={onAccumulationCommit}
onTouchEnd={onAccumulationCommit}
onKeyUp={onAccumulationCommit}
onBlur={onAccumulationCommit}
/>
<code>{formatAccumulationDuration(accumulationValue)}</code>
</div>
) : null}
<div className="observation-timeline__playback">
<Button
size="compact"
variant="ghost"
disabled={!buffered || !onSeek}
aria-label="Перейти к началу"
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
>
<Button size="compact" variant="ghost" disabled aria-label="Перейти к началу">
<Icon name="chevron-left" />
</Button>
<Button size="compact" variant="secondary" disabled>
{buffered ? "Воспроизвести" : "Только эфир"}
<Button
size="compact"
variant="secondary"
disabled={!buffered || !onPlayingChange}
onClick={() => onPlayingChange?.(!playing)}
>
{buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
</Button>
<div className="observation-timeline__track" data-disabled={!buffered ? "true" : undefined}>
<span style={{ width: active ? "100%" : "0%" }} />
</div>
<input
className="observation-timeline__track"
type="range"
min={0}
max={Math.max(durationSeconds, 0.001)}
step={0.01}
value={Math.min(elapsedSeconds, durationSeconds)}
disabled={!buffered || !onSeek}
aria-label="Позиция воспроизведения"
onChange={(event) => {
if (!playbackRange) return;
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
}}
/>
<div className="observation-timeline__meta">
<code>{active ? "LIVE" : "—:—:—.———"}</code>
<code>
{buffered
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
: active ? "LIVE" : "—:—:—.———"}
</code>
<small>
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
</small>
</div>
<span className="observation-timeline__follow" data-active={active ? "true" : undefined}>
ЭФИР
</span>
<button
type="button"
className="observation-timeline__follow"
data-active={!buffered && active ? "true" : undefined}
disabled={buffered ? !onJumpToEnd : true}
onClick={onJumpToEnd}
>
{buffered ? "К КОНЦУ" : "ЭФИР"}
</button>
</div>
</div>
);
}
export function normalizeAccumulationSeconds(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.min(120, Math.max(0, Math.round(value)));
}
export function formatAccumulationDuration(value: number): string {
const seconds = normalizeAccumulationSeconds(value);
return seconds === 0 ? "Кадр" : `${seconds} с`;
}
export function normalizeTimelineRange(
range: { min: number; max: number } | null | undefined,
): { min: number; max: number } | null {
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
if (range.max <= range.min) return null;
return range;
}
export function timelineOffsetSeconds(
range: { min: number; max: number },
currentNs: number,
): number {
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
return Math.max(0, (clamped - range.min) / 1_000_000_000);
}
export function formatTimelineDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
const wholeMinutes = Math.floor(seconds / 60);
const remaining = seconds - wholeMinutes * 60;
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
}

View File

@ -0,0 +1,735 @@
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex } from "@noble/hashes/utils.js";
import { useEffect, useMemo, useRef, useState } from "react";
import {
fetchObservationRecordedMediaManifest,
ObservationSessionContractError,
type ObservationRecordedMediaEpoch,
type ObservationRecordedMediaManifest,
type ObservationRecordedMediaSource,
type ObservationSessionFetch,
} from "../core/observation/sessionArchive";
import {
MAX_RECORDED_MEDIA_SOURCE_BYTES,
type RecordedAdmissionPhase,
type RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
export interface RecordedObservationPlayback {
currentSeconds: number;
playing: boolean;
}
export interface RecordedMediaLoadProgress {
loadedBytes: number;
totalBytes: number;
loadedParts: number;
totalParts: number;
}
interface VerifiedRecordedMediaEpoch {
descriptor: ObservationRecordedMediaEpoch;
readonly init: ArrayBuffer;
readonly segments: readonly ArrayBuffer[];
}
export interface VerifiedRecordedMediaArchive {
manifest: ObservationRecordedMediaManifest;
epochs: readonly VerifiedRecordedMediaEpoch[];
byteLength: number;
}
interface RecordedMediaBinaryDescriptor {
url: string;
byteLength: number;
sha256: string;
accept: string;
}
export type RecordedMediaPresentationState = "loading" | "ready" | "waiting" | "error";
export const RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS = 1;
let recordedMediaWorkerGeneration = 0;
export function recordedMediaPresentationState(
state: "loading" | "ready" | "error",
readyGeneration: string | null,
selectedGeneration: string | null,
waitingForEpoch: boolean,
sessionGate: RecordedAdmissionPhase = "ready",
): RecordedMediaPresentationState {
if (state === "error" || sessionGate === "error") return "error";
if (sessionGate !== "ready") return "loading";
if (waitingForEpoch) return "waiting";
return state === "ready" &&
selectedGeneration !== null &&
readyGeneration === selectedGeneration
? "ready"
: "loading";
}
export function selectRecordedMediaEpoch(
epochs: readonly ObservationRecordedMediaEpoch[],
currentSeconds: number,
): ObservationRecordedMediaEpoch | null {
if (!epochs.length || !Number.isFinite(currentSeconds)) return null;
let selected: ObservationRecordedMediaEpoch | null = null;
for (const epoch of epochs) {
if (epoch.timelineStartSeconds > currentSeconds) break;
selected = epoch;
}
return selected && currentSeconds <= selected.timelineEndSeconds ? selected : null;
}
export function recordedMediaSeekableCoverage(
durationSeconds: number,
seekableEndSeconds: number,
declaredDurationSeconds: number,
toleranceSeconds = RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
seekableStartSeconds = 0,
): boolean {
return (
Number.isFinite(durationSeconds) &&
Number.isFinite(seekableEndSeconds) &&
Number.isFinite(declaredDurationSeconds) &&
Number.isFinite(seekableStartSeconds) &&
declaredDurationSeconds > 0 &&
toleranceSeconds >= 0 &&
durationSeconds + toleranceSeconds >= declaredDurationSeconds &&
seekableStartSeconds <= toleranceSeconds &&
seekableStartSeconds >= -toleranceSeconds &&
seekableEndSeconds + toleranceSeconds >= declaredDurationSeconds
);
}
export function recordedMediaLocalTime(
epochStartSeconds: number,
currentSeconds: number,
durationSeconds = Number.POSITIVE_INFINITY,
): number {
if (!Number.isFinite(epochStartSeconds) || !Number.isFinite(currentSeconds)) return 0;
const local = Math.max(0, currentSeconds - epochStartSeconds);
return Number.isFinite(durationSeconds)
? Math.min(local, Math.max(0, durationSeconds))
: local;
}
function sourceContract(source: ObservationSourceDescriptor): ObservationRecordedMediaSource | null {
const delivery = source.delivery;
if (!delivery || delivery.kind !== "recorded-fmp4-manifest") return null;
return {
id: source.id,
label: source.label,
modality: "video",
manifestUrl: delivery.url,
manifestGenerationSha256: delivery.manifestGenerationSha256,
byteLength: delivery.byteLength,
mediaType: delivery.mediaType,
timelineStartSeconds: delivery.timelineStartSeconds,
timelineEndSeconds: delivery.timelineEndSeconds,
seekable: true,
synchronization: "host-arrival-best-effort",
};
}
function expectedPayloadEtag(digest: string): string {
return `"sha256:${digest}"`;
}
export async function fetchVerifiedRecordedMediaBytes(
descriptor: RecordedMediaBinaryDescriptor,
{
signal,
fetcher = globalThis.fetch,
}: { signal?: AbortSignal; fetcher?: ObservationSessionFetch } = {},
): Promise<ArrayBuffer> {
let response: Response;
try {
response = await fetcher(descriptor.url, {
method: "GET",
headers: {
Accept: descriptor.accept,
"If-Match": expectedPayloadEtag(descriptor.sha256),
},
signal,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservationSessionContractError(
"Не удалось загрузить канонический фрагмент записанного видео.",
);
}
if (!response.ok || response.status !== 200) {
throw new ObservationSessionContractError(
`Фрагмент записанного видео вернул HTTP ${response.status}.`,
);
}
if (response.headers.get("ETag") !== expectedPayloadEtag(descriptor.sha256)) {
throw new ObservationSessionContractError(
"Фрагмент записанного видео не соответствует immutable manifest.",
);
}
const contentLength = response.headers.get("Content-Length");
if (
contentLength === null ||
!/^[1-9][0-9]*$/.test(contentLength) ||
Number(contentLength) !== descriptor.byteLength
) {
throw new ObservationSessionContractError(
"Длина фрагмента записанного видео не соответствует immutable manifest.",
);
}
const payload = await response.arrayBuffer();
if (payload.byteLength !== descriptor.byteLength) {
throw new ObservationSessionContractError(
"Фрагмент записанного видео был усечён во время передачи.",
);
}
const digest = bytesToHex(sha256(new Uint8Array(payload)));
if (digest !== descriptor.sha256) {
throw new ObservationSessionContractError(
"SHA-256 фрагмента записанного видео не совпадает с immutable manifest.",
);
}
return payload;
}
function manifestIdentity(manifest: ObservationRecordedMediaManifest): string {
return JSON.stringify(manifest);
}
export async function fetchVerifiedRecordedMediaArchive(
source: ObservationRecordedMediaSource,
{
signal,
fetcher = globalThis.fetch,
onProgress,
}: {
signal?: AbortSignal;
fetcher?: ObservationSessionFetch;
onProgress?: (progress: RecordedMediaLoadProgress) => void;
} = {},
): Promise<VerifiedRecordedMediaArchive> {
const manifest = await fetchObservationRecordedMediaManifest(source, {
signal,
fetcher,
expectedGenerationSha256: source.manifestGenerationSha256,
});
const totalBytes = manifest.epochs.reduce(
(archiveTotal, epoch) => archiveTotal + epoch.initByteLength + epoch.segments.reduce(
(epochTotal, segment) => epochTotal + segment.byteLength,
0,
),
0,
);
const totalParts = manifest.epochs.reduce(
(total, epoch) => total + 1 + epoch.segments.length,
0,
);
if (
totalBytes < 1 ||
totalBytes > MAX_RECORDED_MEDIA_SOURCE_BYTES ||
totalBytes !== manifest.byteLength ||
totalBytes !== source.byteLength
) {
throw new ObservationSessionContractError(
"Размер записанного медиаканала выходит за безопасный лимит браузера.",
);
}
let loadedBytes = 0;
let loadedParts = 0;
const publishProgress = () => onProgress?.({
loadedBytes,
totalBytes,
loadedParts,
totalParts,
});
publishProgress();
const epochs: VerifiedRecordedMediaEpoch[] = [];
for (const epoch of manifest.epochs) {
const init = await fetchVerifiedRecordedMediaBytes({
url: epoch.initUrl,
byteLength: epoch.initByteLength,
sha256: epoch.initSha256,
accept: "video/mp4",
}, { signal, fetcher });
loadedBytes += init.byteLength;
loadedParts += 1;
publishProgress();
const segments: ArrayBuffer[] = [];
for (const segment of epoch.segments) {
const payload = await fetchVerifiedRecordedMediaBytes({
url: segment.url,
byteLength: segment.byteLength,
sha256: segment.sha256,
accept: "video/iso.segment",
}, { signal, fetcher });
segments.push(payload);
loadedBytes += payload.byteLength;
loadedParts += 1;
publishProgress();
}
epochs.push({ descriptor: epoch, init, segments });
}
// Bind the complete byte set to the same manifest generation at both ends
// of the transfer. A replacement during a long camera download fails closed.
const confirmed = await fetchObservationRecordedMediaManifest(source, {
signal,
fetcher,
expectedGenerationSha256: manifest.generationSha256,
});
if (manifestIdentity(confirmed) !== manifestIdentity(manifest)) {
throw new ObservationSessionContractError(
"Manifest записанного видео изменился во время полной загрузки.",
);
}
if (loadedBytes !== source.byteLength) {
throw new ObservationSessionContractError(
"Полная загрузка камеры не совпала с launch byte_length.",
);
}
return { manifest, epochs, byteLength: loadedBytes };
}
export function appendRecordedMediaBuffer(
sourceBuffer: SourceBuffer,
payload: ArrayBuffer,
signal: AbortSignal,
): Promise<void> {
return new Promise((resolve, reject) => {
const onUpdateEnd = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("SourceBuffer rejected archived fMP4 data"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
const cleanup = () => {
sourceBuffer.removeEventListener("updateend", onUpdateEnd);
sourceBuffer.removeEventListener("error", onError);
signal.removeEventListener("abort", onAbort);
};
if (signal.aborted) {
onAbort();
return;
}
sourceBuffer.addEventListener("updateend", onUpdateEnd, { once: true });
sourceBuffer.addEventListener("error", onError, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
try {
sourceBuffer.appendBuffer(payload);
} catch (error) {
cleanup();
reject(error);
}
});
}
function videoHasSeekableArchive(
video: HTMLVideoElement,
declaredDurationSeconds: number,
): boolean {
if (video.readyState < 1 || video.seekable.length < 1) return false;
return recordedMediaSeekableCoverage(
video.duration,
video.seekable.end(video.seekable.length - 1),
declaredDurationSeconds,
RECORDED_MEDIA_DURATION_TOLERANCE_SECONDS,
video.seekable.start(0),
);
}
function waitForSeekableArchive(
video: HTMLVideoElement,
declaredDurationSeconds: number,
signal: AbortSignal,
): Promise<void> {
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (videoHasSeekableArchive(video, declaredDurationSeconds)) return Promise.resolve();
return new Promise((resolve, reject) => {
const events = ["loadedmetadata", "durationchange", "progress", "canplay"] as const;
const timeout = globalThis.setTimeout(() => {
cleanup();
reject(new Error("Archived camera did not become seekable"));
}, 20_000);
const cleanup = () => {
globalThis.clearTimeout(timeout);
for (const event of events) video.removeEventListener(event, onProgress);
video.removeEventListener("error", onError);
signal.removeEventListener("abort", onAbort);
};
const onProgress = () => {
if (!videoHasSeekableArchive(video, declaredDurationSeconds)) return;
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("Archived camera decode failed"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
for (const event of events) video.addEventListener(event, onProgress);
video.addEventListener("error", onError, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
});
}
async function mountVerifiedRecordedEpoch(
video: HTMLVideoElement,
epoch: VerifiedRecordedMediaEpoch,
signal: AbortSignal,
): Promise<() => void> {
const descriptor = epoch.descriptor;
if (
descriptor.mediaType === "video/mp4" ||
!globalThis.MediaSource ||
!MediaSource.isTypeSupported(descriptor.mediaType)
) {
throw new Error("Archived camera codec is not supported");
}
const mediaSource = new MediaSource();
const objectUrl = URL.createObjectURL(mediaSource);
const cleanup = () => {
video.pause();
video.removeAttribute("src");
video.load();
URL.revokeObjectURL(objectUrl);
};
video.src = objectUrl;
try {
await new Promise<void>((resolve, reject) => {
const onOpen = () => {
cleanupListeners();
resolve();
};
const onAbort = () => {
cleanupListeners();
reject(new DOMException("Aborted", "AbortError"));
};
const cleanupListeners = () => {
mediaSource.removeEventListener("sourceopen", onOpen);
signal.removeEventListener("abort", onAbort);
};
if (signal.aborted) {
onAbort();
return;
}
mediaSource.addEventListener("sourceopen", onOpen, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
});
if (signal.aborted || mediaSource.readyState !== "open") {
throw new DOMException("Aborted", "AbortError");
}
const sourceBuffer = mediaSource.addSourceBuffer(descriptor.mediaType);
await appendRecordedMediaBuffer(sourceBuffer, epoch.init, signal);
for (const segment of epoch.segments) {
await appendRecordedMediaBuffer(sourceBuffer, segment, signal);
}
if (signal.aborted || mediaSource.readyState !== "open" || sourceBuffer.updating) {
throw new Error("Archived camera MediaSource closed before full append");
}
mediaSource.endOfStream();
await waitForSeekableArchive(
video,
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
signal,
);
return cleanup;
} catch (error) {
cleanup();
throw error;
}
}
export function RecordedFmp4Player({
source,
playback,
prepare = true,
sessionGate = "ready",
admissionKey = null,
onAdmissionChange,
}: {
source: ObservationSourceDescriptor;
playback?: RecordedObservationPlayback | null;
prepare?: boolean;
sessionGate?: RecordedAdmissionPhase;
admissionKey?: string | null;
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const onAdmissionChangeRef = useRef(onAdmissionChange);
onAdmissionChangeRef.current = onAdmissionChange;
const workerRef = useRef<{ admissionKey: string | null; generation: number } | null>(null);
if (!workerRef.current || workerRef.current.admissionKey !== admissionKey) {
recordedMediaWorkerGeneration += 1;
workerRef.current = { admissionKey, generation: recordedMediaWorkerGeneration };
}
const workerGeneration = workerRef.current.generation;
const reportAdmission = (next: RecordedCameraAdmissionState) => {
onAdmissionChangeRef.current?.({
...next,
admissionKey,
workerGeneration,
});
};
const recordedDelivery = source.delivery?.kind === "recorded-fmp4-manifest"
? source.delivery
: null;
const contract = useMemo(
() => sourceContract(source),
[
source.id,
source.label,
recordedDelivery?.id,
recordedDelivery?.url,
recordedDelivery?.mediaType,
recordedDelivery?.manifestGenerationSha256,
recordedDelivery?.byteLength,
recordedDelivery?.timelineStartSeconds,
recordedDelivery?.timelineEndSeconds,
],
);
const [archive, setArchive] = useState<VerifiedRecordedMediaArchive | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
const [progress, setProgress] = useState<RecordedMediaLoadProgress | null>(null);
const [bufferRevision, setBufferRevision] = useState(0);
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
const epoch = useMemo(
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
[archive?.manifest.epochs, currentSeconds],
);
const verifiedEpoch = useMemo(
() => epoch
? archive?.epochs.find(({ descriptor }) => descriptor.ordinal === epoch.ordinal) ?? null
: null,
[archive?.epochs, epoch],
);
const waitingForEpoch = Boolean(archive && !epoch);
const selectedGeneration = contract && epoch
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
: null;
const visualState = recordedMediaPresentationState(
state,
readyGeneration,
selectedGeneration,
waitingForEpoch,
sessionGate,
);
useEffect(() => {
if (!contract) {
setArchive(null);
setProgress(null);
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: null,
message: "Некорректный descriptor записанной камеры.",
});
return;
}
if (!prepare) return;
const abort = new AbortController();
setArchive(null);
setProgress(null);
setReadyGeneration(null);
setState("loading");
reportAdmission({
phase: "loading",
byteLength: contract.byteLength,
message: null,
});
void fetchVerifiedRecordedMediaArchive(contract, {
signal: abort.signal,
onProgress: (next) => {
if (!abort.signal.aborted) setProgress(next);
},
})
.then((loaded) => {
if (abort.signal.aborted) return;
setArchive(loaded);
})
.catch((error: unknown) => {
if (abort.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) {
return;
}
setArchive(null);
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: contract.byteLength,
message: "Архив записанной камеры не прошёл проверку.",
});
});
return () => abort.abort();
}, [admissionKey, contract, prepare]);
useEffect(() => {
if (!archive || !contract || !prepare) return;
const abort = new AbortController();
let disposed = false;
void (async () => {
for (const candidate of archive.epochs) {
const probe = document.createElement("video");
probe.muted = true;
probe.playsInline = true;
const cleanup = await mountVerifiedRecordedEpoch(probe, candidate, abort.signal);
cleanup();
if (disposed || abort.signal.aborted) return;
}
if (disposed || abort.signal.aborted) return;
reportAdmission({
phase: "ready",
byteLength: archive.byteLength,
message: null,
});
})().catch((error: unknown) => {
if (
disposed ||
abort.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError")
) return;
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive.byteLength,
message: "Не все codec epoch записанной камеры декодируются и доступны для seek.",
});
});
return () => {
disposed = true;
abort.abort();
};
}, [admissionKey, archive, contract, prepare]);
useEffect(() => {
const video = videoRef.current;
if (!video || !verifiedEpoch) return;
const epochDescriptor = verifiedEpoch.descriptor;
const generation = contract
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
: null;
setReadyGeneration(null);
setState("loading");
const abort = new AbortController();
let disposed = false;
let cleanup: (() => void) | null = null;
const loadEpoch = async () => {
try {
cleanup = await mountVerifiedRecordedEpoch(video, verifiedEpoch, abort.signal);
if (disposed || abort.signal.aborted) {
cleanup();
cleanup = null;
return;
}
setBufferRevision((revision) => revision + 1);
setReadyGeneration(generation);
setState("ready");
} catch (error) {
if (
disposed ||
abort.signal.aborted ||
(error instanceof DOMException && error.name === "AbortError")
) {
return;
}
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive?.byteLength ?? null,
message: "Записанная камера не стала seekable.",
});
}
};
void loadEpoch();
return () => {
disposed = true;
abort.abort();
cleanup?.();
};
}, [archive?.byteLength, contract, verifiedEpoch]);
useEffect(() => {
const video = videoRef.current;
if (!video || !epoch || visualState !== "ready") return;
const target = recordedMediaLocalTime(
epoch.timelineStartSeconds,
currentSeconds,
video.duration,
);
if (Number.isFinite(target) && Math.abs(video.currentTime - target) > 0.35) {
try {
video.currentTime = target;
} catch {
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive?.byteLength ?? null,
message: "Seek записанной камеры завершился ошибкой.",
});
return;
}
}
if (playback?.playing) {
void video.play().catch(() => undefined);
} else {
video.pause();
}
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
const progressPercent = progress && progress.totalBytes > 0
? Math.min(100, Math.floor((progress.loadedBytes / progress.totalBytes) * 100))
: 0;
return (
<div
className="recorded-media-player"
data-state={visualState}
aria-busy={visualState === "loading"}
>
<video
ref={videoRef}
className="observation-media__asset"
muted
playsInline
preload="auto"
aria-label={source.label}
/>
{visualState !== "ready" ? (
<div
className="recorded-media-player__notice"
role={visualState === "error" ? "alert" : "status"}
>
{visualState === "waiting"
? "Камера на этой позиции ещё не записывалась"
: visualState === "error"
? "Записанное видео недоступно"
: archive
? "Проверяем полную готовность записанного видео…"
: `Загружаем и проверяем записанное видео · ${progressPercent}%`}
</div>
) : null}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,84 @@
import type { ObservationSourceDescriptor } from "../runtime/contracts";
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
export function recordedObservationSources(
launch: ObservationSessionReplayLaunch | null,
): ObservationSourceDescriptor[] {
if (!launch) return [];
const spatial: ObservationSourceDescriptor = {
id: "recorded.spatial.primary",
sourceId: "recorded.spatial.primary",
semanticChannelId: "spatial.point-cloud.recorded",
label: "Сохранённая пространственная сцена",
description: "Облако точек и траектория из записи Rerun",
modality: "point-cloud",
role: "primary",
availability: "available",
transport: "recording",
endpointLabel: "RRD · session_time",
previewUrl: launch.sourceUrl,
delivery: null,
activation: null,
provider: {
pluginId: "missioncore.session-archive",
pluginVersion: "1",
modelId: "recorded-spatial",
compatibilityProfileId: null,
},
binding: {},
capabilities: {
overlay: false,
fullscreen: true,
resizable: false,
defaultVisible: true,
timelineMode: "recorded",
seekable: true,
sessionRecording: true,
clockId: launch.timeline,
spatialRegistration: "native",
},
};
const media = launch.mediaSources.map((source, index): ObservationSourceDescriptor => ({
id: source.id,
sourceId: source.id,
semanticChannelId: "camera.video.recorded",
label: source.label,
description: "Сохранённый видеоканал на общей временной шкале сессии",
modality: "video",
role: "auxiliary",
availability: "available",
transport: "recording",
endpointLabel: "Сохранённая сессия",
previewUrl: null,
delivery: {
id: `${launch.sessionId}:${source.id}`,
kind: "recorded-fmp4-manifest",
url: source.manifestUrl,
mediaType: source.mediaType,
manifestGenerationSha256: source.manifestGenerationSha256,
byteLength: source.byteLength,
timelineStartSeconds: source.timelineStartSeconds,
timelineEndSeconds: source.timelineEndSeconds,
},
activation: null,
provider: {
pluginId: "missioncore.session-archive",
pluginVersion: "1",
modelId: "recorded-media",
compatibilityProfileId: null,
},
binding: {},
capabilities: {
overlay: true,
fullscreen: true,
resizable: true,
defaultVisible: index < 2,
timelineMode: "recorded",
seekable: true,
sessionRecording: true,
clockId: "session_time",
spatialRegistration: "unresolved",
},
}));
return [spatial, ...media];
}

View File

@ -0,0 +1,123 @@
// A device-agnostic frontend safety policy. Sixteen channels covers multi-rig
// vehicles while the independent byte/concurrency limits keep admission
// bounded. OPFS-backed sealed generations are the planned scaling path beyond
// this in-memory laboratory policy.
export const MAX_RECORDED_CAMERA_SOURCES = 16;
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 128 * 1024 * 1024;
export const MAX_RECORDED_SESSION_CAMERA_BYTES = 512 * 1024 * 1024;
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
export type RecordedAdmissionPhase = "loading" | "ready" | "error";
export type RecordedCameraAdmissionPhase = "pending" | "loading" | "ready" | "error";
export interface RecordedCameraAdmissionState {
phase: RecordedCameraAdmissionPhase;
byteLength: number | null;
message: string | null;
admissionKey?: string | null;
workerGeneration?: number;
}
export type RecordedCameraAdmissionMap = Readonly<Record<string, RecordedCameraAdmissionState>>;
export interface RecordedCameraAdmissionDescriptor {
id: string;
byteLength: number;
}
export function recordedCameraDescriptorPreflight(
sources: readonly RecordedCameraAdmissionDescriptor[],
): RecordedAdmissionPhase {
if (sources.length > MAX_RECORDED_CAMERA_SOURCES) return "error";
if (new Set(sources.map(({ id }) => id)).size !== sources.length) return "error";
let totalBytes = 0;
for (const source of sources) {
if (
!source.id ||
!Number.isSafeInteger(source.byteLength) ||
source.byteLength < 1 ||
source.byteLength > MAX_RECORDED_MEDIA_SOURCE_BYTES
) return "error";
totalBytes += source.byteLength;
if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_RECORDED_SESSION_CAMERA_BYTES) {
return "error";
}
}
return "ready";
}
export function initialRecordedCameraAdmissions(
sourceIds: readonly string[],
declaredByteLengths: Readonly<Record<string, number>> = {},
): Record<string, RecordedCameraAdmissionState> {
return Object.fromEntries(sourceIds.map((sourceId) => [sourceId, {
phase: "pending" as const,
byteLength: declaredByteLengths[sourceId] ?? null,
message: null,
}]));
}
export function recordedSessionAdmissionPhase(
spatialPhase: RecordedAdmissionPhase,
sourceIds: readonly string[],
cameras: RecordedCameraAdmissionMap,
): RecordedAdmissionPhase {
if (sourceIds.length > MAX_RECORDED_CAMERA_SOURCES || spatialPhase === "error") {
return "error";
}
let totalBytes = 0;
for (const sourceId of sourceIds) {
const camera = cameras[sourceId];
if (!camera || camera.phase === "error") return "error";
if (camera.phase === "ready" && camera.byteLength === null) return "error";
if (camera.byteLength !== null) {
if (
!Number.isSafeInteger(camera.byteLength) ||
camera.byteLength < 1 ||
camera.byteLength > MAX_RECORDED_MEDIA_SOURCE_BYTES
) return "error";
totalBytes += camera.byteLength;
}
}
if (totalBytes > MAX_RECORDED_SESSION_CAMERA_BYTES) return "error";
if (spatialPhase !== "ready") return "loading";
return sourceIds.every((sourceId) => cameras[sourceId]?.phase === "ready")
? "ready"
: "loading";
}
export function nextRecordedCameraPreparationIds(
sourceIds: readonly string[],
cameras: RecordedCameraAdmissionMap,
preferredSourceIds: ReadonlySet<string> = new Set(),
concurrency = MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS,
): string[] {
if (!Number.isInteger(concurrency) || concurrency < 1) return [];
const pending = sourceIds.filter((sourceId) => {
const phase = cameras[sourceId]?.phase;
return phase === "pending" || phase === "loading" || phase === undefined;
});
pending.sort((left, right) => (
Number(cameras[right]?.phase === "loading") - Number(cameras[left]?.phase === "loading") ||
Number(preferredSourceIds.has(right)) - Number(preferredSourceIds.has(left)) ||
sourceIds.indexOf(left) - sourceIds.indexOf(right)
));
return pending.slice(0, concurrency);
}
export function mergeRecordedCameraAdmission(
current: RecordedCameraAdmissionState,
next: RecordedCameraAdmissionState,
): RecordedCameraAdmissionState {
const normalized = next.byteLength === null && current.byteLength !== null
? { ...next, byteLength: current.byteLength }
: next;
const currentWorker = current.workerGeneration ?? 0;
const nextWorker = normalized.workerGeneration ?? currentWorker;
if (nextWorker < currentWorker) return current;
if (current.phase === "error") return current;
if (nextWorker > currentWorker) return normalized;
if (normalized.phase === "error") return normalized;
if (current.phase === "ready") return current;
return normalized;
}

File diff suppressed because it is too large Load Diff

View File

@ -6,12 +6,25 @@ import {
openObservationSource,
shouldRestartObservationSource,
} from "./layoutPolicy";
import {
normalizeObservationWindowRect,
projectObservationLayoutSnapshot,
validateObservationLayoutSnapshot,
validateObservationViewportSize,
type ObservationLayoutSnapshot,
type ObservationViewportSize,
type ObservationWindowRect,
} from "./workspaceLayout";
export interface ObservationWindowRect {
x: number;
y: number;
width: number;
height: number;
export type { ObservationWindowRect } from "./workspaceLayout";
export type ObservationLayoutPresentationMode = "preserve" | "reset";
export function observationPresentationSourceAfterLayoutApply(
currentSourceId: string | null,
mode: ObservationLayoutPresentationMode,
): string | null {
return mode === "preserve" ? currentSourceId : null;
}
export interface ObservationLayoutController {
@ -20,6 +33,7 @@ export interface ObservationLayoutController {
activeFloatingSourceId: string | null;
maximizedFloatingSourceId: string | null;
windowRects: Readonly<Record<string, ObservationWindowRect>>;
viewportSize: ObservationViewportSize | null;
pendingSourceIds: ReadonlySet<string>;
toggleSource: (sourceId: string) => Promise<boolean>;
hideSource: (sourceId: string) => Promise<boolean>;
@ -27,6 +41,9 @@ export interface ObservationLayoutController {
activateFloatingSource: (sourceId: string) => void;
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
setViewportSize: (size: ObservationViewportSize) => void;
snapshot: () => ObservationLayoutSnapshot | null;
restore: (snapshot: ObservationLayoutSnapshot) => void;
}
function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
@ -50,6 +67,17 @@ function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): strin
.join("|");
}
function cloneSnapshot(snapshot: ObservationLayoutSnapshot): ObservationLayoutSnapshot {
return {
visibleSourceIds: [...snapshot.visibleSourceIds],
activeFloatingSourceId: snapshot.activeFloatingSourceId,
windowRects: Object.fromEntries(
Object.entries(snapshot.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
),
viewportSize: { ...snapshot.viewportSize },
};
}
export function useObservationLayout(
sources: readonly ObservationSourceDescriptor[],
setSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>,
@ -58,45 +86,151 @@ export function useObservationLayout(
const visibleIdsRef = useRef<string[]>([]);
const [pendingIds, setPendingIds] = useState<string[]>([]);
const [focusedSourceId, setFocusedSourceIdState] = useState<string | null>(null);
const [activeFloatingSourceId, setActiveFloatingSourceId] = useState<string | null>(null);
const [activeFloatingSourceId, setActiveFloatingSourceIdState] = useState<string | null>(null);
const activeFloatingSourceIdRef = useRef<string | null>(null);
const [maximizedFloatingSourceId, setMaximizedFloatingSourceId] = useState<string | null>(null);
const [windowRects, setWindowRects] = useState<Record<string, ObservationWindowRect>>({});
const [windowRects, setWindowRectsState] = useState<Record<string, ObservationWindowRect>>({});
const windowRectsRef = useRef<Record<string, ObservationWindowRect>>({});
const [viewportSize, setViewportSizeState] = useState<ObservationViewportSize | null>(null);
const viewportSizeRef = useRef<ObservationViewportSize | null>(null);
const desiredSnapshotRef = useRef<ObservationLayoutSnapshot | null>(null);
const restoredLayoutAuthorityRef = useRef(false);
const initializedCatalog = useRef<string | null>(null);
const sourceIdList = sources.map((source) => source.id).sort();
const sourceIdsIdentity = sourceIdList.join("\u0000");
const sourceIds = useMemo(() => new Set(sourceIdList), [sourceIdsIdentity]);
const sourceIdsRef = useRef<ReadonlySet<string>>(sourceIds);
sourceIdsRef.current = sourceIds;
const sourcesRef = useRef<readonly ObservationSourceDescriptor[]>(sources);
sourcesRef.current = sources;
const identity = catalogIdentity(sources);
const commitVisibleIds = useCallback((next: string[]) => {
visibleIdsRef.current = next;
setVisibleIds(next);
const commitVisibleIds = useCallback((next: readonly string[]) => {
const unique = [...new Set(next)];
visibleIdsRef.current = unique;
setVisibleIds(unique);
}, []);
const clearPresentation = useCallback((removedIds: readonly string[]) => {
const commitActiveFloatingSourceId = useCallback((next: string | null) => {
activeFloatingSourceIdRef.current = next;
setActiveFloatingSourceIdState(next);
}, []);
const commitWindowRects = useCallback((next: Record<string, ObservationWindowRect>) => {
windowRectsRef.current = next;
setWindowRectsState(next);
}, []);
const persistLiveLayout = useCallback(() => {
const currentViewport = viewportSizeRef.current;
if (!currentViewport) return;
const currentKnownIds = sourceIdsRef.current;
const previous = desiredSnapshotRef.current;
const unknownVisibleIds = previous?.visibleSourceIds.filter(
(sourceId) => !currentKnownIds.has(sourceId),
) ?? [];
const visibleSourceIds = [...new Set([...unknownVisibleIds, ...visibleIdsRef.current])];
const unknownRects = Object.entries(previous?.windowRects ?? {}).filter(
([sourceId]) => !currentKnownIds.has(sourceId),
);
const knownRects = Object.entries(windowRectsRef.current).map(([sourceId, rect]) => [
sourceId,
normalizeObservationWindowRect(rect, currentViewport),
] as const);
const previousActive = previous?.activeFloatingSourceId ?? null;
const activeFloatingSourceId = activeFloatingSourceIdRef.current ?? (
previousActive && !currentKnownIds.has(previousActive) ? previousActive : null
);
desiredSnapshotRef.current = validateObservationLayoutSnapshot({
visibleSourceIds,
activeFloatingSourceId: activeFloatingSourceId && visibleSourceIds.includes(activeFloatingSourceId)
? activeFloatingSourceId
: null,
windowRects: Object.fromEntries([...unknownRects, ...knownRects]),
viewportSize: currentViewport,
});
}, []);
const applyDesiredSnapshot = useCallback((
snapshot: ObservationLayoutSnapshot,
presentationMode: ObservationLayoutPresentationMode,
) => {
const targetViewport = viewportSizeRef.current ?? snapshot.viewportSize;
const projected = projectObservationLayoutSnapshot(
snapshot,
sourceIdsRef.current,
targetViewport,
);
let visibleSourceIds = [...projected.visibleSourceIds];
let activeFloatingSourceId = projected.activeFloatingSourceId;
if (visibleSourceIds.length === 0 && sourcesRef.current.length > 0) {
for (const source of sourcesRef.current.filter(canOpenByDefault)) {
visibleSourceIds = openObservationSource(
visibleSourceIds,
source.id,
sourcesRef.current,
).visibleIds;
}
activeFloatingSourceId = sourcesRef.current.find(
(source) => visibleSourceIds.includes(source.id) && source.capabilities.overlay,
)?.id ?? null;
}
commitVisibleIds(visibleSourceIds);
commitActiveFloatingSourceId(activeFloatingSourceId);
commitWindowRects({ ...projected.windowRects });
setFocusedSourceIdState((current) =>
observationPresentationSourceAfterLayoutApply(current, presentationMode));
setMaximizedFloatingSourceId((current) =>
observationPresentationSourceAfterLayoutApply(current, presentationMode));
}, [commitActiveFloatingSourceId, commitVisibleIds, commitWindowRects]);
const clearPresentation = useCallback((removedIds: readonly string[], persist = true) => {
if (!removedIds.length) return;
const removed = new Set(removedIds);
setFocusedSourceIdState((current) => current && removed.has(current) ? null : current);
setActiveFloatingSourceId((current) => current && removed.has(current) ? null : current);
if (
activeFloatingSourceIdRef.current &&
removed.has(activeFloatingSourceIdRef.current)
) {
commitActiveFloatingSourceId(null);
}
setMaximizedFloatingSourceId((current) => current && removed.has(current) ? null : current);
}, []);
if (persist) persistLiveLayout();
}, [commitActiveFloatingSourceId, persistLiveLayout]);
useEffect(() => {
const desired = desiredSnapshotRef.current;
if (desired) {
applyDesiredSnapshot(desired, "reset");
return;
}
const currentVisible = visibleIdsRef.current;
const nextVisible = currentVisible.filter((sourceId) => sourceIds.has(sourceId));
if (nextVisible.length !== currentVisible.length) commitVisibleIds(nextVisible);
setFocusedSourceIdState((current) => current && sourceIds.has(current) ? current : null);
setActiveFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
if (activeFloatingSourceIdRef.current && !sourceIds.has(activeFloatingSourceIdRef.current)) {
commitActiveFloatingSourceId(null);
}
setMaximizedFloatingSourceId((current) => current && sourceIds.has(current) ? current : null);
setWindowRects((current) => {
const entries = Object.entries(current);
const entries = Object.entries(windowRectsRef.current);
const nextEntries = entries.filter(([sourceId]) => sourceIds.has(sourceId));
return nextEntries.length === entries.length ? current : Object.fromEntries(nextEntries);
});
}, [commitVisibleIds, sourceIds]);
if (nextEntries.length !== entries.length) commitWindowRects(Object.fromEntries(nextEntries));
}, [
applyDesiredSnapshot,
commitActiveFloatingSourceId,
commitVisibleIds,
commitWindowRects,
sourceIds,
]);
useEffect(() => {
if (!identity) {
initializedCatalog.current = null;
if (!desiredSnapshotRef.current) initializedCatalog.current = null;
return;
}
const desired = desiredSnapshotRef.current;
if (desired) {
initializedCatalog.current = identity;
return;
}
if (initializedCatalog.current === identity) return;
@ -109,8 +243,16 @@ export function useObservationLayout(
const firstFloating = sources.find(
(source) => canOpenByDefault(source) && source.capabilities.overlay,
);
setActiveFloatingSourceId(firstFloating?.id ?? null);
}, [commitVisibleIds, identity, sources]);
commitActiveFloatingSourceId(firstFloating?.id ?? null);
persistLiveLayout();
}, [
applyDesiredSnapshot,
commitActiveFloatingSourceId,
commitVisibleIds,
identity,
persistLiveLayout,
sources,
]);
const selectedDeliveryIdentity = sources
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
@ -124,6 +266,7 @@ export function useObservationLayout(
.join("|");
useEffect(() => {
if (restoredLayoutAuthorityRef.current) return;
const selected = sources.filter(
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
);
@ -135,8 +278,9 @@ export function useObservationLayout(
change.removedIds.forEach((sourceId) => removed.add(sourceId));
}
commitVisibleIds(change.visibleIds);
clearPresentation([...removed]);
}, [clearPresentation, commitVisibleIds, selectedDeliveryIdentity]);
clearPresentation([...removed], false);
persistLiveLayout();
}, [clearPresentation, commitVisibleIds, persistLiveLayout, selectedDeliveryIdentity]);
const markPending = useCallback((source: ObservationSourceDescriptor, pending: boolean) => {
const groupId = source.activation?.groupId;
@ -160,11 +304,13 @@ export function useObservationLayout(
markPending(source, false);
}
}
restoredLayoutAuthorityRef.current = false;
const change = closeObservationSource(visibleIdsRef.current, sourceId);
commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds);
clearPresentation(change.removedIds, false);
persistLiveLayout();
return true;
}, [clearPresentation, commitVisibleIds, markPending, setSourceActive, sources]);
}, [clearPresentation, commitVisibleIds, markPending, persistLiveLayout, setSourceActive, sources]);
const toggleSource = useCallback(async (sourceId: string) => {
const source = sources.find((candidate) => candidate.id === sourceId);
@ -180,30 +326,95 @@ export function useObservationLayout(
markPending(source, false);
}
}
restoredLayoutAuthorityRef.current = false;
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
commitVisibleIds(change.visibleIds);
clearPresentation(change.removedIds);
setActiveFloatingSourceId(sourceId);
clearPresentation(change.removedIds, false);
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
return true;
}, [clearPresentation, commitVisibleIds, hideSource, markPending, setSourceActive, sources]);
}, [
clearPresentation,
commitActiveFloatingSourceId,
commitVisibleIds,
hideSource,
markPending,
persistLiveLayout,
setSourceActive,
sources,
]);
const setFocusedSourceId = useCallback((sourceId: string | null) => {
setFocusedSourceIdState(sourceId);
if (sourceId) setActiveFloatingSourceId(sourceId);
}, []);
if (sourceId) {
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
}
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const activateFloatingSource = useCallback((sourceId: string) => {
setActiveFloatingSourceId(sourceId);
}, []);
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const setFloatingMaximized = useCallback((sourceId: string, maximized: boolean) => {
setMaximizedFloatingSourceId(maximized ? sourceId : null);
if (maximized) setActiveFloatingSourceId(sourceId);
}, []);
if (maximized) {
commitActiveFloatingSourceId(sourceId);
persistLiveLayout();
}
}, [commitActiveFloatingSourceId, persistLiveLayout]);
const setWindowRect = useCallback((sourceId: string, rect: ObservationWindowRect) => {
setWindowRects((current) => ({ ...current, [sourceId]: rect }));
}, []);
if (
!Object.values(rect).every((value) => Number.isFinite(value)) ||
rect.width <= 0 ||
rect.height <= 0
) {
return;
}
const currentViewport = viewportSizeRef.current;
if (currentViewport) normalizeObservationWindowRect(rect, currentViewport);
commitWindowRects({ ...windowRectsRef.current, [sourceId]: { ...rect } });
persistLiveLayout();
}, [commitWindowRects, persistLiveLayout]);
const setViewportSize = useCallback((size: ObservationViewportSize) => {
const next = validateObservationViewportSize(size);
const current = viewportSizeRef.current;
if (current && current.width === next.width && current.height === next.height) return;
viewportSizeRef.current = next;
setViewportSizeState(next);
const desired = desiredSnapshotRef.current;
if (desired) {
desiredSnapshotRef.current = { ...desired, viewportSize: next };
// Entering fullscreen changes the shell geometry, which triggers this
// ResizeObserver path. Reproject persistent window rectangles without
// clearing the transient fullscreen/focus state that caused the resize.
applyDesiredSnapshot(desiredSnapshotRef.current, "preserve");
} else if (initializedCatalog.current) {
persistLiveLayout();
}
}, [applyDesiredSnapshot, persistLiveLayout]);
const snapshot = useCallback((): ObservationLayoutSnapshot | null => {
if (!viewportSizeRef.current) return null;
persistLiveLayout();
const desired = desiredSnapshotRef.current;
if (!desired) return null;
return cloneSnapshot(desired);
}, [persistLiveLayout]);
const restore = useCallback((saved: ObservationLayoutSnapshot) => {
const validated = validateObservationLayoutSnapshot(saved);
const currentViewport = viewportSizeRef.current;
desiredSnapshotRef.current = cloneSnapshot({
...validated,
viewportSize: currentViewport ?? validated.viewportSize,
});
restoredLayoutAuthorityRef.current = true;
applyDesiredSnapshot(desiredSnapshotRef.current, "reset");
}, [applyDesiredSnapshot]);
const visibleSourceIds = useMemo(() => new Set(visibleIds), [visibleIds]);
const pendingSourceIds = useMemo(() => new Set(pendingIds), [pendingIds]);
@ -214,6 +425,7 @@ export function useObservationLayout(
activeFloatingSourceId,
maximizedFloatingSourceId,
windowRects,
viewportSize,
pendingSourceIds,
toggleSource,
hideSource,
@ -221,5 +433,8 @@ export function useObservationLayout(
activateFloatingSource,
setFloatingMaximized,
setWindowRect,
setViewportSize,
snapshot,
restore,
};
}

View File

@ -0,0 +1,591 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
decodeObservationSessionPreparation,
fetchObservationSessionCatalog,
fetchObservationSessionPreparation,
replayObservationSession,
type ObservationSessionFetch,
type ObservationSessionPreparation,
type ObservationSessionReplayLaunch,
type ObservationSessionSummary,
} from "./sessionArchive";
export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
export type ObservationPreparationPhase =
| "requesting"
| ObservationSessionPreparation["state"];
export interface ObservationReplayProgress {
readonly sessionId: string;
readonly phase: ObservationPreparationPhase;
readonly progress: number | null;
readonly cancellable: boolean;
}
export interface ObservationSessionsController {
items: readonly ObservationSessionSummary[];
state: ObservationSessionsLoadState;
error: string | null;
replayingSessionId: string | null;
preparation: ObservationSessionPreparation | null;
replayProgress: ObservationReplayProgress | null;
failedSessionId: string | null;
refresh: () => Promise<boolean>;
replay: (sessionId: string) => Promise<boolean>;
retry: () => Promise<boolean>;
}
export interface ObservationReplayAttempt {
readonly signal: AbortSignal;
isCurrent: () => boolean;
finish: () => boolean;
}
export interface ObservationReplayCoordinator {
begin: () => ObservationReplayAttempt;
cancel: () => void;
}
export interface ObservationPreparationPollingOptions {
signal: AbortSignal;
fetcher?: ObservationSessionFetch;
onUpdate?: (preparation: ObservationSessionPreparation) => void;
requestTimeoutMs?: number;
heartbeatStallMs?: number;
maximumWaitMs?: number;
initialPollIntervalMs?: number;
maximumPollIntervalMs?: number;
now?: () => number;
sleep?: (milliseconds: number, signal: AbortSignal) => Promise<void>;
}
const PREPARATION_STORAGE_KEY = "missioncore.observation-session-preparation/v1";
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
const DEFAULT_HEARTBEAT_STALL_MS = 45_000;
const DEFAULT_MAXIMUM_WAIT_MS = 30 * 60_000;
const DEFAULT_INITIAL_POLL_INTERVAL_MS = 750;
const DEFAULT_MAXIMUM_POLL_INTERVAL_MS = 3_000;
export class ObservationPreparationStalledError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservationPreparationStalledError";
}
}
/** Latest selection wins, even if an obsolete server job finishes later. */
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
let sequence = 0;
let active: AbortController | null = null;
return {
begin() {
active?.abort();
const controller = new AbortController();
const attemptSequence = ++sequence;
active = controller;
return {
signal: controller.signal,
isCurrent: () => (
!controller.signal.aborted &&
active === controller &&
sequence === attemptSequence
),
finish: () => {
if (active !== controller || sequence !== attemptSequence) return false;
active = null;
return true;
},
};
},
cancel() {
sequence += 1;
active?.abort();
active = null;
},
};
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Операция с сохранёнными сессиями завершилась ошибкой.";
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
type PendingPreparationState = Exclude<
ObservationSessionPreparation["state"],
"failed" | "cancelled"
>;
function pendingPreparation(
preparation: ObservationSessionPreparation,
): preparation is ObservationSessionPreparation & { state: PendingPreparationState } {
return preparation.state !== "failed" && preparation.state !== "cancelled";
}
const PREPARATION_PHASE_ORDER: Record<
PendingPreparationState,
number
> = {
queued: 0,
validating: 1,
exporting: 2,
finalizing: 3,
};
function terminalPreparationError(preparation: ObservationSessionPreparation): Error {
if (preparation.error) return new Error(preparation.error);
return new Error(
preparation.state === "cancelled"
? "Подготовка записи отменена."
: "Сервер не смог подготовить сохранённую сессию.",
);
}
function defaultSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(new DOMException("cancelled", "AbortError"));
return;
}
const onAbort = () => {
globalThis.clearTimeout(timer);
reject(new DOMException("cancelled", "AbortError"));
};
const timer = globalThis.setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, milliseconds);
signal.addEventListener("abort", onAbort, { once: true });
});
}
async function withRequestTimeout<T>(
signal: AbortSignal,
timeoutMs: number,
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
if (signal.aborted) throw new DOMException("cancelled", "AbortError");
const controller = new AbortController();
let timedOut = false;
const forwardAbort = () => controller.abort();
signal.addEventListener("abort", forwardAbort, { once: true });
const timer = globalThis.setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
try {
return await operation(controller.signal);
} catch (error) {
if (timedOut && isAbortError(error)) {
throw new ObservationPreparationStalledError(
"Сервер слишком долго не отвечает. Подготовку можно повторить.",
);
}
throw error;
} finally {
globalThis.clearTimeout(timer);
signal.removeEventListener("abort", forwardAbort);
}
}
export async function waitForObservationReplayPreparation(
initial: ObservationSessionPreparation,
options: ObservationPreparationPollingOptions,
): Promise<ObservationSessionReplayLaunch> {
if (!pendingPreparation(initial)) throw terminalPreparationError(initial);
const requestTimeoutMs = Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
const heartbeatStallMs = Math.max(250, options.heartbeatStallMs ?? DEFAULT_HEARTBEAT_STALL_MS);
const maximumWaitMs = Math.max(heartbeatStallMs, options.maximumWaitMs ?? DEFAULT_MAXIMUM_WAIT_MS);
const initialInterval = Math.max(
50,
options.initialPollIntervalMs ?? DEFAULT_INITIAL_POLL_INTERVAL_MS,
);
const maximumInterval = Math.max(
initialInterval,
options.maximumPollIntervalMs ?? DEFAULT_MAXIMUM_POLL_INTERVAL_MS,
);
const now = options.now ?? Date.now;
const sleep = options.sleep ?? defaultSleep;
const startedAt = now();
let lastHeartbeatAt = startedAt;
let lastUpdatedAt = Date.parse(initial.updatedAtUtc);
let current: ObservationSessionPreparation = initial;
let interval = initialInterval;
options.onUpdate?.(current);
while (true) {
await sleep(interval, options.signal);
const response = await withRequestTimeout(
options.signal,
requestTimeoutMs,
(signal) => fetchObservationSessionPreparation(current, {
signal,
fetcher: options.fetcher,
}),
);
if (response.kind === "ready") return response.launch;
const next = response.preparation;
const nextUpdatedAt = Date.parse(next.updatedAtUtc);
if (nextUpdatedAt < lastUpdatedAt) {
throw new Error("Сервер вернул устаревшее состояние подготовки записи.");
}
if (nextUpdatedAt > lastUpdatedAt) {
lastUpdatedAt = nextUpdatedAt;
lastHeartbeatAt = now();
} else if (
next.state !== current.state ||
next.progress !== current.progress ||
next.cancellable !== current.cancellable
) {
throw new Error("Сервер изменил подготовку без обновления heartbeat timestamp.");
}
if (pendingPreparation(next) && pendingPreparation(current)) {
if (PREPARATION_PHASE_ORDER[next.state] < PREPARATION_PHASE_ORDER[current.state]) {
throw new Error("Сервер вернул подготовку на уже завершённую фазу.");
}
if (
next.progress !== null &&
current.progress !== null &&
next.progress + Number.EPSILON < current.progress
) {
throw new Error("Прогресс подготовки не может уменьшаться.");
}
}
current = next;
options.onUpdate?.(current);
if (!pendingPreparation(current)) throw terminalPreparationError(current);
if (current.state !== "queued" && now() - lastHeartbeatAt > heartbeatStallMs) {
throw new ObservationPreparationStalledError(
"Подготовка перестала обновляться. Текущая сцена сохранена; повторите запуск.",
);
}
if (now() - startedAt > maximumWaitMs) {
throw new ObservationPreparationStalledError(
"Подготовка превысила допустимое время. Текущая сцена сохранена; повторите запуск.",
);
}
interval = Math.min(maximumInterval, Math.round(interval * 1.35));
}
}
export async function resolveObservationSessionReplay(
sessionId: string,
options: ObservationPreparationPollingOptions,
): Promise<ObservationSessionReplayLaunch> {
const response = await withRequestTimeout(
options.signal,
Math.max(100, options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS),
(signal) => replayObservationSession(sessionId, { signal, fetcher: options.fetcher }),
);
if (response.kind === "ready") return response.launch;
if (!pendingPreparation(response.preparation)) {
options.onUpdate?.(response.preparation);
throw terminalPreparationError(response.preparation);
}
return waitForObservationReplayPreparation(response.preparation, options);
}
function preparationStoragePayload(preparation: ObservationSessionPreparation): unknown {
return {
schema_version: "missioncore.observation-session-preparation/v1",
preparation: {
preparation_id: preparation.preparationId,
session_id: preparation.sessionId,
state: preparation.state,
progress: preparation.progress,
updated_at_utc: preparation.updatedAtUtc,
status_url: preparation.statusUrl,
cancellable: preparation.cancellable,
...(preparation.state === "failed" || preparation.state === "cancelled"
? { retryable: preparation.retryable, error: preparation.error }
: {}),
},
};
}
export function storeObservationReplayPreparation(
preparation: ObservationSessionPreparation,
storage: Storage = window.localStorage,
): void {
if (!pendingPreparation(preparation)) {
storage.removeItem(PREPARATION_STORAGE_KEY);
return;
}
storage.setItem(PREPARATION_STORAGE_KEY, JSON.stringify(preparationStoragePayload(preparation)));
}
export function loadObservationReplayPreparation(
storage: Storage = window.localStorage,
): ObservationSessionPreparation | null {
const serialized = storage.getItem(PREPARATION_STORAGE_KEY);
if (!serialized) return null;
try {
const parsed = JSON.parse(serialized) as unknown;
if (
typeof parsed !== "object" ||
parsed === null ||
!("preparation" in parsed) ||
typeof parsed.preparation !== "object" ||
parsed.preparation === null ||
!("session_id" in parsed.preparation) ||
typeof parsed.preparation.session_id !== "string"
) {
throw new Error("invalid persisted preparation");
}
const preparation = decodeObservationSessionPreparation(
parsed,
parsed.preparation.session_id,
);
return pendingPreparation(preparation) ? preparation : null;
} catch {
storage.removeItem(PREPARATION_STORAGE_KEY);
return null;
}
}
export function clearObservationReplayPreparation(
storage: Storage = window.localStorage,
): void {
storage.removeItem(PREPARATION_STORAGE_KEY);
}
export function useObservationSessions({
limit = 3,
onReplayBegin,
onReplayAccepted,
onReplaySettled,
}: {
limit?: number;
/** Called only after the archive is ready, immediately before replacing the old viewer. */
onReplayBegin?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplayAccepted?: (
session: ObservationSessionSummary,
launch: ObservationSessionReplayLaunch,
) => void | Promise<void>;
onReplaySettled?: (
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
} = {}): ObservationSessionsController {
const [items, setItems] = useState<ObservationSessionSummary[]>([]);
const [state, setState] = useState<ObservationSessionsLoadState>("idle");
const [error, setError] = useState<string | null>(null);
const [replayingSessionId, setReplayingSessionId] = useState<string | null>(null);
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
const mounted = useRef(true);
const catalogSequence = useRef(0);
const reattachStarted = useRef(false);
const preparationPollSequence = useRef(0);
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
if (replayCoordinator.current === null) {
replayCoordinator.current = createObservationReplayCoordinator();
}
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
const refresh = useCallback(async () => {
const sequence = ++catalogSequence.current;
setState("loading");
setError(null);
try {
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
if (!mounted.current || sequence !== catalogSequence.current) return false;
setItems(catalog.items.slice(0, safeLimit));
setState("ready");
return true;
} catch (loadError) {
if (!mounted.current || sequence !== catalogSequence.current) return false;
setState("error");
setError(errorMessage(loadError));
return false;
}
}, [safeLimit]);
useEffect(() => {
mounted.current = true;
void refresh();
return () => {
mounted.current = false;
catalogSequence.current += 1;
replayCoordinator.current?.cancel();
};
}, [refresh]);
const catalogHasActivePreparation = items.some((item) => (
item.preparation !== null &&
["queued", "validating", "exporting", "finalizing"].includes(item.preparation.state)
));
useEffect(() => {
if (state !== "ready" || !catalogHasActivePreparation) return;
const sequence = ++preparationPollSequence.current;
const controller = new AbortController();
const timer = window.setTimeout(async () => {
try {
const catalog = await fetchObservationSessionCatalog({
limit: safeLimit,
signal: controller.signal,
});
if (
mounted.current &&
!controller.signal.aborted &&
sequence === preparationPollSequence.current
) {
setItems(catalog.items.slice(0, safeLimit));
}
} catch (pollError) {
if (
mounted.current &&
!controller.signal.aborted &&
sequence === preparationPollSequence.current
) {
setError(errorMessage(pollError));
}
}
}, 1_500);
return () => {
preparationPollSequence.current += 1;
window.clearTimeout(timer);
controller.abort();
};
}, [catalogHasActivePreparation, items, safeLimit, state]);
const executeReplay = useCallback(async (
session: ObservationSessionSummary,
resumedPreparation?: ObservationSessionPreparation,
) => {
const attempt = replayCoordinator.current!.begin();
setReplayingSessionId(session.id);
setFailedSessionId(null);
setError(null);
setReplayProgress({
sessionId: session.id,
phase: resumedPreparation?.state ?? "requesting",
progress: resumedPreparation?.progress ?? null,
cancellable: resumedPreparation?.cancellable ?? false,
});
let outcome: ObservationReplayOutcome = "cancelled";
try {
const onUpdate = (next: ObservationSessionPreparation) => {
if (!mounted.current || !attempt.isCurrent()) return;
setPreparation(next);
setReplayProgress({
sessionId: next.sessionId,
phase: next.state,
progress: next.progress,
cancellable: next.cancellable,
});
try {
storeObservationReplayPreparation(next);
} catch {
// Private browsing/storage quota must not break replay preparation.
}
};
const launch = resumedPreparation
? await waitForObservationReplayPreparation(resumedPreparation, {
signal: attempt.signal,
onUpdate,
})
: await resolveObservationSessionReplay(session.id, {
signal: attempt.signal,
onUpdate,
});
if (!mounted.current || !attempt.isCurrent()) return false;
// The current scene stays mounted throughout preparation. Only now that
// the launch descriptor exists do we release the previous viewer.
await onReplayBegin?.(session, launch);
if (!mounted.current || !attempt.isCurrent()) return false;
await onReplayAccepted?.(session, launch);
if (!mounted.current || !attempt.isCurrent()) return false;
outcome = "accepted";
try {
clearObservationReplayPreparation();
} catch {
// Storage is optional; an accepted replay must not turn into an error.
}
setPreparation(null);
setReplayProgress(null);
return true;
} catch (replayError) {
const aborted = isAbortError(replayError);
outcome = aborted ? "cancelled" : "error";
if (mounted.current && attempt.isCurrent() && !aborted) {
setError(errorMessage(replayError));
setFailedSessionId(session.id);
setReplayProgress(null);
try {
clearObservationReplayPreparation();
} catch {
// Storage is optional for the current browser lifetime.
}
}
return false;
} finally {
const current = attempt.finish();
if (mounted.current && current) {
setReplayingSessionId(null);
await onReplaySettled?.(session, outcome);
}
}
}, [onReplayAccepted, onReplayBegin, onReplaySettled]);
const replay = useCallback(async (sessionId: string) => {
const session = items.find((candidate) => candidate.id === sessionId);
if (!session || !session.replayable) return false;
reattachStarted.current = true;
return executeReplay(session);
}, [executeReplay, items]);
useEffect(() => {
if (state !== "ready" || reattachStarted.current) return;
reattachStarted.current = true;
let stored: ObservationSessionPreparation | null = null;
try {
stored = loadObservationReplayPreparation();
} catch {
stored = null;
}
if (!stored) return;
const session = items.find((candidate) => candidate.id === stored?.sessionId);
if (!session?.replayable) {
try {
clearObservationReplayPreparation();
} catch {
// Storage may be unavailable in hardened browser profiles.
}
return;
}
void executeReplay(session, stored);
}, [executeReplay, items, state]);
const retry = useCallback(async () => {
if (!failedSessionId) return false;
return replay(failedSessionId);
}, [failedSessionId, replay]);
return {
items,
state,
error,
replayingSessionId,
preparation,
replayProgress,
failedSessionId,
refresh,
replay,
retry,
};
}

View File

@ -0,0 +1,126 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ObservationSessionReplayLaunch } from "./sessionArchive";
import {
initialRecordedCameraAdmissions,
mergeRecordedCameraAdmission,
nextRecordedCameraPreparationIds,
recordedCameraDescriptorPreflight,
recordedSessionAdmissionPhase,
type RecordedAdmissionPhase,
type RecordedCameraAdmissionMap,
type RecordedCameraAdmissionState,
} from "./recordedSessionAdmission";
interface AdmissionSnapshot {
key: string | null;
spatialPhase: RecordedAdmissionPhase;
cameras: Record<string, RecordedCameraAdmissionState>;
}
export interface RecordedSessionAdmissionController {
key: string;
phase: RecordedAdmissionPhase;
cameraSourceIds: readonly string[];
cameras: RecordedCameraAdmissionMap;
activeCameraSourceIds: ReadonlySet<string>;
reportSpatial: (key: string, phase: RecordedAdmissionPhase) => void;
reportCamera: (
key: string,
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}
function replayAdmissionKey(replay: ObservationSessionReplayLaunch): string {
return [
replay.sessionId,
replay.sha256,
...replay.mediaSources.map((source) => (
[
source.id,
source.manifestGenerationSha256,
source.byteLength,
source.timelineStartSeconds,
source.timelineEndSeconds,
].join(":")
)),
].join("|");
}
export function useRecordedSessionAdmission(
replay: ObservationSessionReplayLaunch | null,
): RecordedSessionAdmissionController | null {
const key = replay ? replayAdmissionKey(replay) : null;
const cameraSourceIds = useMemo(
() => replay?.mediaSources.map(({ id }) => id) ?? [],
[replay],
);
const declaredByteLengths = useMemo(
() => Object.fromEntries(
(replay?.mediaSources ?? []).map(({ id, byteLength }) => [id, byteLength]),
),
[replay],
);
const [snapshot, setSnapshot] = useState<AdmissionSnapshot>(() => ({
key,
spatialPhase: "loading",
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
}));
useEffect(() => {
setSnapshot({
key,
spatialPhase: "loading",
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
});
}, [cameraSourceIds, declaredByteLengths, key]);
const reportSpatial = useCallback((reportedKey: string, phase: RecordedAdmissionPhase) => {
setSnapshot((current) => current.key !== reportedKey
? current
: { ...current, spatialPhase: phase });
}, []);
const reportCamera = useCallback((
reportedKey: string,
sourceId: string,
state: RecordedCameraAdmissionState,
) => {
setSnapshot((current) => {
if (current.key !== reportedKey || !(sourceId in current.cameras)) return current;
const merged = mergeRecordedCameraAdmission(current.cameras[sourceId], state);
if (merged === current.cameras[sourceId]) return current;
return { ...current, cameras: { ...current.cameras, [sourceId]: merged } };
});
}, []);
if (!replay || !key) return null;
const current = snapshot.key === key
? snapshot
: {
key,
spatialPhase: "loading" as const,
cameras: initialRecordedCameraAdmissions(cameraSourceIds, declaredByteLengths),
};
const descriptorPhase = recordedCameraDescriptorPreflight(replay.mediaSources);
const phase = descriptorPhase === "error"
? "error"
: recordedSessionAdmissionPhase(
current.spatialPhase,
cameraSourceIds,
current.cameras,
);
const activeCameraSourceIds = new Set(phase === "error"
? []
: nextRecordedCameraPreparationIds(cameraSourceIds, current.cameras));
return {
key,
phase,
cameraSourceIds,
cameras: current.cameras,
activeCameraSourceIds,
reportSpatial,
reportCamera,
};
}

View File

@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchObservationWorkspaceLayoutProfile,
saveObservationWorkspaceLayoutProfile,
WorkspaceLayoutApiError,
type ObservationWorkspaceLayoutProfile,
} from "./workspaceLayout";
export type WorkspaceLayoutProfileState =
| "idle"
| "loading"
| "ready"
| "saving"
| "error"
| "conflict";
export interface WorkspaceLayoutProfileController {
profile: ObservationWorkspaceLayoutProfile | null;
state: WorkspaceLayoutProfileState;
error: string | null;
refresh: () => Promise<ObservationWorkspaceLayoutProfile | null>;
save: (
profile: ObservationWorkspaceLayoutProfile,
) => Promise<ObservationWorkspaceLayoutProfile | null>;
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Операция с профилем рабочей поверхности завершилась ошибкой.";
}
export function useWorkspaceLayoutProfile(): WorkspaceLayoutProfileController {
const [profile, setProfile] = useState<ObservationWorkspaceLayoutProfile | null>(null);
const profileRef = useRef<ObservationWorkspaceLayoutProfile | null>(null);
const [state, setState] = useState<WorkspaceLayoutProfileState>("idle");
const [error, setError] = useState<string | null>(null);
const mounted = useRef(true);
const requestSequence = useRef(0);
const saveInFlight = useRef(false);
const commitProfile = useCallback((next: ObservationWorkspaceLayoutProfile | null) => {
profileRef.current = next;
setProfile(next);
}, []);
const refresh = useCallback(async () => {
const sequence = ++requestSequence.current;
setState("loading");
setError(null);
try {
const loaded = await fetchObservationWorkspaceLayoutProfile();
if (!mounted.current || sequence !== requestSequence.current) return null;
commitProfile(loaded);
setState("ready");
return loaded;
} catch (loadError) {
if (!mounted.current || sequence !== requestSequence.current) return null;
setState("error");
setError(errorMessage(loadError));
return null;
}
}, [commitProfile]);
useEffect(() => {
mounted.current = true;
void refresh();
return () => {
mounted.current = false;
requestSequence.current += 1;
};
}, [refresh]);
useEffect(() => {
if (state !== "error") return;
// The desktop shell can become ready before its loopback API. Keep the
// last-layout restore self-healing instead of requiring a page reload once
// the local service finishes starting.
const timer = window.setTimeout(() => void refresh(), 5_000);
return () => window.clearTimeout(timer);
}, [refresh, state]);
useEffect(() => {
const refreshAfterNetworkRecovery = () => void refresh();
window.addEventListener("online", refreshAfterNetworkRecovery);
return () => window.removeEventListener("online", refreshAfterNetworkRecovery);
}, [refresh]);
const save = useCallback(async (draft: ObservationWorkspaceLayoutProfile) => {
if (saveInFlight.current) return null;
const current = profileRef.current;
if (current && draft.revision !== current.revision) {
setState("conflict");
setError("Профиль изменился после открытия. Обновите данные перед сохранением.");
return null;
}
saveInFlight.current = true;
setState("saving");
setError(null);
try {
const saved = await saveObservationWorkspaceLayoutProfile(draft);
if (!mounted.current) return null;
commitProfile(saved);
setState("ready");
return saved;
} catch (saveError) {
if (!mounted.current) return null;
const conflict = saveError instanceof WorkspaceLayoutApiError && saveError.conflict;
setState(conflict ? "conflict" : "error");
setError(errorMessage(saveError));
return null;
} finally {
saveInFlight.current = false;
}
}, [commitProfile]);
return { profile, state, error, refresh, save };
}

View File

@ -0,0 +1,599 @@
import type { SceneSettings } from "../../sceneSettings";
export const OBSERVATION_WORKSPACE_ID = "observation.spatial" as const;
export const OBSERVATION_WORKSPACE_LAYOUT_VERSION = 1 as const;
export const OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT =
"/api/v1/workspace-layouts/observation.spatial" as const;
export type ObservationToolWindowId = "sources" | "display" | "layers";
export interface ObservationToolWindows {
sourcesOpen: boolean;
displayOpen: boolean;
layersOpen: boolean;
order: readonly ObservationToolWindowId[];
}
export interface ObservationWindowRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ObservationViewportSize {
width: number;
height: number;
}
export interface NormalizedObservationWindowRect {
x: number;
y: number;
width: number;
height: number;
}
export interface ObservationLayoutSnapshot {
visibleSourceIds: readonly string[];
activeFloatingSourceId: string | null;
windowRects: Readonly<Record<string, NormalizedObservationWindowRect>>;
viewportSize: ObservationViewportSize;
}
export interface ProjectedObservationLayout {
visibleSourceIds: readonly string[];
activeFloatingSourceId: string | null;
windowRects: Readonly<Record<string, ObservationWindowRect>>;
}
export interface ObservationWorkspaceLayoutProfile extends ObservationLayoutSnapshot {
version: typeof OBSERVATION_WORKSPACE_LAYOUT_VERSION;
revision: number;
workspaceId: typeof OBSERVATION_WORKSPACE_ID;
sceneSettings: SceneSettings;
toolWindows: ObservationToolWindows;
}
export type WorkspaceLayoutFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
type WireProfile = {
version: number;
revision: number;
workspace_id: string;
scene_settings: {
projection: string;
point_size: number;
color_mode: string;
palette: string;
custom_color: string;
accumulation_seconds: number;
show_points: boolean;
show_trajectory: boolean;
show_grid: boolean;
show_labels: boolean;
show_camera_frustums: boolean;
};
tool_windows: {
sources_open: boolean;
display_open: boolean;
layers_open: boolean;
order: ObservationToolWindowId[];
};
visible_source_ids: string[];
active_floating_source_id: string | null;
window_rects: Record<string, NormalizedObservationWindowRect>;
viewport_size: ObservationViewportSize;
};
const PROFILE_KEYS = new Set([
"version",
"revision",
"workspace_id",
"scene_settings",
"tool_windows",
"visible_source_ids",
"active_floating_source_id",
"window_rects",
"viewport_size",
]);
const SCENE_KEYS = new Set([
"projection",
"point_size",
"color_mode",
"palette",
"custom_color",
"accumulation_seconds",
"show_points",
"show_trajectory",
"show_grid",
"show_labels",
"show_camera_frustums",
]);
const TOOL_WINDOW_KEYS = new Set(["sources_open", "display_open", "layers_open", "order"]);
const VIEWPORT_KEYS = new Set(["width", "height"]);
const RECT_KEYS = new Set(["x", "y", "width", "height"]);
const PROJECTIONS = new Set<SceneSettings["projection"]>(["3d", "2d", "map"]);
const COLOR_MODES = new Set<SceneSettings["colorMode"]>([
"intensity",
"height",
"distance",
"rgb",
"class",
]);
const PALETTES = new Set<SceneSettings["palette"]>([
"turbo",
"viridis",
"plasma",
"grayscale",
"custom",
]);
const TOOL_WINDOW_IDS = new Set<ObservationToolWindowId>(["sources", "display", "layers"]);
const SAFE_STABLE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const MAX_SOURCES = 256;
const MAX_VIEWPORT_EDGE = 100_000;
const NORMALIZED_EPSILON = 1e-9;
export class WorkspaceLayoutContractError extends Error {
constructor(message: string) {
super(message);
this.name = "WorkspaceLayoutContractError";
}
}
export class WorkspaceLayoutApiError extends Error {
readonly status: number;
constructor(message: string, status = 0) {
super(message);
this.name = "WorkspaceLayoutApiError";
this.status = status;
}
get conflict(): boolean {
return this.status === 409 || this.status === 412;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, field: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть объектом.`);
}
return value;
}
function assertExactKeys(
value: Record<string, unknown>,
expected: ReadonlySet<string>,
field: string,
): void {
const actual = Object.keys(value);
const unknown = actual.filter((key) => !expected.has(key));
const missing = [...expected].filter((key) => !(key in value));
if (unknown.length || missing.length) {
const details = [
unknown.length ? `неизвестные: ${unknown.sort().join(", ")}` : "",
missing.length ? `отсутствуют: ${missing.sort().join(", ")}` : "",
].filter(Boolean).join("; ");
throw new WorkspaceLayoutContractError(`${field}: неверная схема (${details}).`);
}
}
function requireBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") {
throw new WorkspaceLayoutContractError(`Поле ${field} должно быть boolean.`);
}
return value;
}
function requireFiniteInRange(
value: unknown,
field: string,
minimum: number,
maximum: number,
{ integer = false, minimumExclusive = false }: { integer?: boolean; minimumExclusive?: boolean } = {},
): number {
const belowMinimum = minimumExclusive
? typeof value === "number" && value <= minimum
: typeof value === "number" && value < minimum;
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
belowMinimum ||
value > maximum ||
(integer && !Number.isInteger(value))
) {
throw new WorkspaceLayoutContractError(
`Поле ${field} должно быть конечным ${integer ? "целым " : ""}числом в допустимом диапазоне.`,
);
}
return value;
}
function requireStableId(value: unknown, field: string): string {
if (typeof value !== "string" || !SAFE_STABLE_ID.test(value)) {
throw new WorkspaceLayoutContractError(
`Поле ${field} должно содержать безопасный стабильный идентификатор источника.`,
);
}
return value;
}
function decodeViewport(value: unknown, field = "viewport_size"): ObservationViewportSize {
const record = requireRecord(value, field);
assertExactKeys(record, VIEWPORT_KEYS, field);
return {
width: requireFiniteInRange(record.width, `${field}.width`, 1, MAX_VIEWPORT_EDGE),
height: requireFiniteInRange(record.height, `${field}.height`, 1, MAX_VIEWPORT_EDGE),
};
}
export function validateObservationViewportSize(
value: ObservationViewportSize,
): ObservationViewportSize {
return decodeViewport(value, "viewport");
}
function decodeNormalizedRect(
value: unknown,
field: string,
): NormalizedObservationWindowRect {
const record = requireRecord(value, field);
assertExactKeys(record, RECT_KEYS, field);
const rect = {
x: requireFiniteInRange(record.x, `${field}.x`, 0, 1),
y: requireFiniteInRange(record.y, `${field}.y`, 0, 1),
width: requireFiniteInRange(record.width, `${field}.width`, 0, 1, { minimumExclusive: true }),
height: requireFiniteInRange(record.height, `${field}.height`, 0, 1, { minimumExclusive: true }),
};
if (
rect.x + rect.width > 1 + NORMALIZED_EPSILON ||
rect.y + rect.height > 1 + NORMALIZED_EPSILON
) {
throw new WorkspaceLayoutContractError(`${field} выходит за нормализованные границы viewport.`);
}
return rect;
}
function decodeStableIds(value: unknown): string[] {
if (!Array.isArray(value) || value.length > MAX_SOURCES) {
throw new WorkspaceLayoutContractError(
`Поле visible_source_ids должно быть массивом не более ${MAX_SOURCES} элементов.`,
);
}
const result = value.map((entry, index) => requireStableId(entry, `visible_source_ids[${index}]`));
if (new Set(result).size !== result.length) {
throw new WorkspaceLayoutContractError("Поле visible_source_ids содержит повторяющиеся id.");
}
return result;
}
function decodeWindowRects(value: unknown): Record<string, NormalizedObservationWindowRect> {
const record = requireRecord(value, "window_rects");
if (Object.keys(record).length > MAX_SOURCES) {
throw new WorkspaceLayoutContractError(
`Поле window_rects содержит более ${MAX_SOURCES} элементов.`,
);
}
return Object.fromEntries(Object.entries(record).map(([sourceId, rect]) => [
requireStableId(sourceId, "window_rects key"),
decodeNormalizedRect(rect, `window_rects.${sourceId}`),
]));
}
function requireEnum<T extends string>(
value: unknown,
allowed: ReadonlySet<T>,
field: string,
): T {
if (typeof value !== "string" || !allowed.has(value as T)) {
throw new WorkspaceLayoutContractError(`Поле ${field} содержит неизвестное значение.`);
}
return value as T;
}
function decodeSceneSettings(value: unknown): SceneSettings {
const record = requireRecord(value, "scene_settings");
assertExactKeys(record, SCENE_KEYS, "scene_settings");
if (typeof record.custom_color !== "string" || !HEX_COLOR.test(record.custom_color)) {
throw new WorkspaceLayoutContractError("Поле scene_settings.custom_color должно быть цветом #RRGGBB.");
}
return {
projection: requireEnum(record.projection, PROJECTIONS, "scene_settings.projection"),
pointSize: requireFiniteInRange(record.point_size, "scene_settings.point_size", 0.1, 32),
colorMode: requireEnum(record.color_mode, COLOR_MODES, "scene_settings.color_mode"),
palette: requireEnum(record.palette, PALETTES, "scene_settings.palette"),
customColor: record.custom_color,
accumulationSeconds: requireFiniteInRange(
record.accumulation_seconds,
"scene_settings.accumulation_seconds",
0,
3_600,
),
showPoints: requireBoolean(record.show_points, "scene_settings.show_points"),
showTrajectory: requireBoolean(record.show_trajectory, "scene_settings.show_trajectory"),
showGrid: requireBoolean(record.show_grid, "scene_settings.show_grid"),
showLabels: requireBoolean(record.show_labels, "scene_settings.show_labels"),
showCameraFrustums: requireBoolean(
record.show_camera_frustums,
"scene_settings.show_camera_frustums",
),
};
}
function decodeToolWindows(value: unknown): ObservationToolWindows {
const record = requireRecord(value, "tool_windows");
assertExactKeys(record, TOOL_WINDOW_KEYS, "tool_windows");
if (!Array.isArray(record.order) || record.order.length !== TOOL_WINDOW_IDS.size) {
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно содержать три окна.");
}
const order = record.order.map((entry, index) =>
requireEnum(entry, TOOL_WINDOW_IDS, `tool_windows.order[${index}]`));
if (new Set(order).size !== TOOL_WINDOW_IDS.size) {
throw new WorkspaceLayoutContractError("Поле tool_windows.order должно быть перестановкой окон.");
}
return {
sourcesOpen: requireBoolean(record.sources_open, "tool_windows.sources_open"),
displayOpen: requireBoolean(record.display_open, "tool_windows.display_open"),
layersOpen: requireBoolean(record.layers_open, "tool_windows.layers_open"),
order,
};
}
export function decodeObservationWorkspaceLayoutProfile(
value: unknown,
): ObservationWorkspaceLayoutProfile {
const record = requireRecord(value, "workspace layout");
assertExactKeys(record, PROFILE_KEYS, "workspace layout");
if (record.version !== OBSERVATION_WORKSPACE_LAYOUT_VERSION) {
throw new WorkspaceLayoutContractError(
`Неподдерживаемая версия workspace layout: ${String(record.version)}.`,
);
}
if (record.workspace_id !== OBSERVATION_WORKSPACE_ID) {
throw new WorkspaceLayoutContractError("Профиль относится к другой рабочей поверхности.");
}
const visibleSourceIds = decodeStableIds(record.visible_source_ids);
const activeFloatingSourceId = record.active_floating_source_id === null
? null
: requireStableId(record.active_floating_source_id, "active_floating_source_id");
if (activeFloatingSourceId && !visibleSourceIds.includes(activeFloatingSourceId)) {
throw new WorkspaceLayoutContractError(
"Активное плавающее окно должно относиться к видимому источнику.",
);
}
return {
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
revision: requireFiniteInRange(record.revision, "revision", 0, Number.MAX_SAFE_INTEGER, {
integer: true,
}),
workspaceId: OBSERVATION_WORKSPACE_ID,
sceneSettings: decodeSceneSettings(record.scene_settings),
toolWindows: decodeToolWindows(record.tool_windows),
visibleSourceIds,
activeFloatingSourceId,
windowRects: decodeWindowRects(record.window_rects),
viewportSize: decodeViewport(record.viewport_size),
};
}
export function encodeObservationWorkspaceLayoutProfile(
profile: ObservationWorkspaceLayoutProfile,
): WireProfile {
const wire: WireProfile = {
version: profile.version,
revision: profile.revision,
workspace_id: profile.workspaceId,
scene_settings: {
projection: profile.sceneSettings.projection,
point_size: profile.sceneSettings.pointSize,
color_mode: profile.sceneSettings.colorMode,
palette: profile.sceneSettings.palette,
custom_color: profile.sceneSettings.customColor,
accumulation_seconds: profile.sceneSettings.accumulationSeconds,
show_points: profile.sceneSettings.showPoints,
show_trajectory: profile.sceneSettings.showTrajectory,
show_grid: profile.sceneSettings.showGrid,
show_labels: profile.sceneSettings.showLabels,
show_camera_frustums: profile.sceneSettings.showCameraFrustums,
},
tool_windows: {
sources_open: profile.toolWindows.sourcesOpen,
display_open: profile.toolWindows.displayOpen,
layers_open: profile.toolWindows.layersOpen,
order: [...profile.toolWindows.order],
},
visible_source_ids: [...profile.visibleSourceIds],
active_floating_source_id: profile.activeFloatingSourceId,
window_rects: Object.fromEntries(
Object.entries(profile.windowRects).map(([sourceId, rect]) => [sourceId, { ...rect }]),
),
viewport_size: { ...profile.viewportSize },
};
// The decoder is the single runtime schema authority for inbound and outbound documents.
decodeObservationWorkspaceLayoutProfile(wire);
return wire;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value));
}
export function normalizeObservationWindowRect(
rect: ObservationWindowRect,
viewport: ObservationViewportSize,
): NormalizedObservationWindowRect {
const safeViewport = decodeViewport(viewport, "viewport");
for (const [field, value] of Object.entries(rect)) {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new WorkspaceLayoutContractError(`Поле rect.${field} должно быть конечным числом.`);
}
}
const x = clamp(rect.x, 0, Math.max(0, safeViewport.width - 1));
const y = clamp(rect.y, 0, Math.max(0, safeViewport.height - 1));
const width = clamp(rect.width, 1, Math.max(1, safeViewport.width - x));
const height = clamp(rect.height, 1, Math.max(1, safeViewport.height - y));
return {
x: x / safeViewport.width,
y: y / safeViewport.height,
width: width / safeViewport.width,
height: height / safeViewport.height,
};
}
export function denormalizeObservationWindowRect(
rect: NormalizedObservationWindowRect,
viewport: ObservationViewportSize,
): ObservationWindowRect {
const normalized = decodeNormalizedRect(rect, "rect");
const safeViewport = decodeViewport(viewport, "viewport");
return {
x: normalized.x * safeViewport.width,
y: normalized.y * safeViewport.height,
width: normalized.width * safeViewport.width,
height: normalized.height * safeViewport.height,
};
}
export function validateObservationLayoutSnapshot(
snapshot: ObservationLayoutSnapshot,
): ObservationLayoutSnapshot {
const wire = {
version: OBSERVATION_WORKSPACE_LAYOUT_VERSION,
revision: 0,
workspace_id: OBSERVATION_WORKSPACE_ID,
scene_settings: {
projection: "3d",
point_size: 1,
color_mode: "intensity",
palette: "turbo",
custom_color: "#ffffff",
accumulation_seconds: 0,
show_points: true,
show_trajectory: true,
show_grid: true,
show_labels: false,
show_camera_frustums: true,
},
tool_windows: {
sources_open: false,
display_open: false,
layers_open: false,
order: ["sources", "display", "layers"],
},
visible_source_ids: [...snapshot.visibleSourceIds],
active_floating_source_id: snapshot.activeFloatingSourceId,
window_rects: snapshot.windowRects,
viewport_size: snapshot.viewportSize,
};
const decoded = decodeObservationWorkspaceLayoutProfile(wire);
return {
visibleSourceIds: decoded.visibleSourceIds,
activeFloatingSourceId: decoded.activeFloatingSourceId,
windowRects: decoded.windowRects,
viewportSize: decoded.viewportSize,
};
}
export function projectObservationLayoutSnapshot(
snapshot: ObservationLayoutSnapshot,
knownSourceIds: ReadonlySet<string>,
viewport: ObservationViewportSize,
): ProjectedObservationLayout {
const validated = validateObservationLayoutSnapshot(snapshot);
const targetViewport = decodeViewport(viewport, "viewport");
const visibleSourceIds = validated.visibleSourceIds.filter((sourceId) => knownSourceIds.has(sourceId));
const visibleSet = new Set(visibleSourceIds);
return {
visibleSourceIds,
activeFloatingSourceId: validated.activeFloatingSourceId && visibleSet.has(validated.activeFloatingSourceId)
? validated.activeFloatingSourceId
: null,
windowRects: Object.fromEntries(
Object.entries(validated.windowRects)
.filter(([sourceId]) => knownSourceIds.has(sourceId))
.map(([sourceId, rect]) => [
sourceId,
denormalizeObservationWindowRect(rect, targetViewport),
]),
),
};
}
async function responseMessage(response: Response, fallback: string): Promise<string> {
try {
const body: unknown = await response.json();
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
return body.detail.trim();
}
} catch {
// A non-JSON error body is represented by the stable fallback.
}
return fallback;
}
export async function fetchObservationWorkspaceLayoutProfile({
fetcher = fetch,
}: { fetcher?: WorkspaceLayoutFetch } = {}): Promise<ObservationWorkspaceLayoutProfile | null> {
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (response.status === 404) return null;
if (!response.ok) {
throw new WorkspaceLayoutApiError(
await responseMessage(response, "Не удалось загрузить профиль рабочей поверхности."),
response.status,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new WorkspaceLayoutContractError("Сервер вернул профиль не в формате JSON.");
}
return decodeObservationWorkspaceLayoutProfile(body);
}
export async function saveObservationWorkspaceLayoutProfile(
profile: ObservationWorkspaceLayoutProfile,
{ fetcher = fetch }: { fetcher?: WorkspaceLayoutFetch } = {},
): Promise<ObservationWorkspaceLayoutProfile> {
const wire = encodeObservationWorkspaceLayoutProfile(profile);
const response = await fetcher(OBSERVATION_WORKSPACE_LAYOUT_ENDPOINT, {
method: "PUT",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"If-Match": `"${profile.revision}"`,
},
body: JSON.stringify(wire),
});
if (!response.ok) {
throw new WorkspaceLayoutApiError(
await responseMessage(response, response.status === 409 || response.status === 412
? "Профиль уже изменён в другом окне. Обновите данные и повторите сохранение."
: "Не удалось сохранить профиль рабочей поверхности."),
response.status,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new WorkspaceLayoutContractError("Сервер не вернул сохранённый профиль в формате JSON.");
}
const saved = decodeObservationWorkspaceLayoutProfile(body);
if (saved.revision <= profile.revision) {
throw new WorkspaceLayoutContractError("Сервер не увеличил revision сохранённого профиля.");
}
return saved;
}

View File

@ -123,6 +123,16 @@ export type ObservationSourceDelivery =
kind: "video-url" | "image-url";
url: string;
mediaType?: string | null;
}
| {
id: string;
kind: "recorded-fmp4-manifest";
url: string;
mediaType: "video/mp4";
manifestGenerationSha256: string;
byteLength: number;
timelineStartSeconds: number;
timelineEndSeconds: number;
};
export interface ObservationSourceActivation {

View File

@ -0,0 +1,89 @@
export interface LatestAsyncCommitter<T> {
enqueue: (value: T) => void;
hasPending: () => boolean;
isBusy: () => boolean;
waitForIdle: () => Promise<void>;
dispose: () => void;
}
export interface LatestAsyncCommitResult<T> {
value: T;
applied: boolean;
superseded: boolean;
}
/**
* Serializes an expensive settings commit while retaining only the newest
* value submitted during the active request. This keeps slider and color
* interactions from creating an unbounded backend queue.
*/
export function createLatestAsyncCommitter<T>({
commit,
onSettled,
}: {
commit: (value: T) => Promise<boolean>;
onSettled?: (result: LatestAsyncCommitResult<T>) => void;
}): LatestAsyncCommitter<T> {
let pending: T | undefined;
let running = false;
let disposed = false;
const idleWaiters = new Set<() => void>();
const settleIdleWaiters = () => {
if (running || pending !== undefined) return;
for (const resolve of idleWaiters) resolve();
idleWaiters.clear();
};
const drain = async () => {
if (running || disposed) return;
running = true;
try {
while (!disposed && pending !== undefined) {
const value = pending;
pending = undefined;
let applied = false;
try {
applied = await commit(value);
} catch {
applied = false;
}
if (disposed) return;
onSettled?.({
value,
applied,
superseded: pending !== undefined,
});
}
} finally {
running = false;
if (!disposed && pending !== undefined) {
void drain();
} else {
settleIdleWaiters();
}
}
};
return {
enqueue(value) {
if (disposed) return;
pending = value;
void drain();
},
hasPending: () => pending !== undefined,
isBusy: () => running || pending !== undefined,
waitForIdle() {
if (!running && pending === undefined) return Promise.resolve();
return new Promise<void>((resolve) => idleWaiters.add(resolve));
},
dispose() {
disposed = true;
pending = undefined;
settleIdleWaiters();
},
};
}

View File

@ -0,0 +1,233 @@
.observation-header-tools {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.65rem;
}
.workspace-layout-feedback {
display: inline-flex;
max-width: 15rem;
align-items: center;
gap: 0.45rem;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.2;
}
.workspace-layout-feedback[data-error="true"] {
color: rgb(var(--nodedc-danger-rgb));
}
.observation-session-select__trigger {
display: inline-flex;
min-width: 15rem;
height: 2.75rem;
align-items: center;
justify-content: flex-start;
gap: 0.55rem;
border: 0;
border-radius: 999px;
background: rgb(255 255 255 / 0.055);
color: var(--nodedc-text-secondary);
padding: 0 0.85rem;
font: inherit;
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-strong);
cursor: pointer;
}
.observation-session-select__trigger:hover,
.observation-session-select__trigger[data-active="true"] {
background: rgb(255 255 255 / 0.095);
color: var(--nodedc-text-primary);
}
.observation-session-select__trigger > span {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.observation-session-select__trigger > small {
display: grid;
min-width: 1.35rem;
height: 1.35rem;
place-items: center;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observation-session-menu {
overflow: hidden;
font-family: var(--nodedc-font-family);
}
.observation-session-menu__content,
.observation-session-menu__list {
display: grid;
}
.observation-session-menu__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 0.9rem 0.6rem;
}
.observation-session-menu__head span,
.observation-session-menu__head strong {
display: block;
}
.observation-session-menu__head strong {
margin-top: 0.3rem;
color: var(--nodedc-text-primary);
font-size: var(--nodedc-font-size-md);
}
.observation-session-menu__head button {
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border: 0;
border-radius: 50%;
background: rgb(255 255 255 / 0.055);
color: var(--nodedc-text-secondary);
cursor: pointer;
}
.observation-session-menu__head button:hover {
background: rgb(255 255 255 / 0.1);
color: var(--nodedc-text-primary);
}
.observation-session-menu__list {
max-height: min(26rem, 60vh);
gap: 0.2rem;
overflow: auto;
padding: 0.35rem;
}
.observation-session-option {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.observation-session-option i {
display: block;
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
}
.observation-session-option i[data-session-visual-state="ready"],
.observation-session-option i[data-session-visual-state="processing"] {
background: rgb(var(--nodedc-success-rgb));
}
.observation-session-option i[data-session-visual-state="processing"] {
animation: observation-session-processing 1.1s ease-in-out infinite;
}
.observation-session-option i[data-session-visual-state="error"] {
background: var(--nodedc-text-muted);
opacity: 0.48;
}
@keyframes observation-session-processing {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.observation-session-option i[data-session-visual-state="processing"] {
animation: none;
opacity: 0.72;
}
}
.observation-session-option__state {
max-width: 9.5rem;
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
font-weight: var(--nodedc-font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
}
.observation-session-menu__empty {
display: grid;
min-height: 9rem;
place-items: center;
align-content: center;
gap: 0.5rem;
padding: 1rem;
color: var(--nodedc-text-muted);
text-align: center;
}
.observation-session-menu__empty strong {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
}
.observation-session-menu__empty span {
max-width: 24rem;
font-size: var(--nodedc-font-size-xs);
line-height: 1.45;
}
.observation-session-menu__error {
display: flex;
align-items: flex-start;
gap: 0.45rem;
padding: 0.65rem 0.9rem 0.8rem;
color: rgb(var(--nodedc-danger-rgb));
font-size: var(--nodedc-font-size-xs);
line-height: 1.4;
}
.observation-session-menu__error > span {
min-width: 0;
flex: 1 1 auto;
}
.observation-session-menu__error button {
flex: 0 0 auto;
border: 0;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
color: var(--nodedc-text-primary);
padding: 0.4rem 0.65rem;
font: inherit;
font-weight: var(--nodedc-font-weight-strong);
cursor: pointer;
}
@media (max-width: 860px) {
.workspace-layout-feedback {
display: none;
}
.observation-session-select__trigger {
min-width: 2.75rem;
width: 2.75rem;
padding: 0;
justify-content: center;
}
.observation-session-select__trigger > span,
.observation-session-select__trigger > small,
.observation-session-select__trigger > svg:last-child {
display: none;
}
}

View File

@ -191,6 +191,32 @@ i[data-availability="error"] {
background: #050608;
}
.recorded-media-player {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #070809;
}
.recorded-media-player:not([data-state="ready"]) .observation-media__asset {
visibility: hidden;
}
.recorded-media-player__notice {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 18px;
color: rgba(247, 248, 244, 0.72);
background: #070809;
font: inherit;
font-size: 11px;
text-align: center;
pointer-events: none;
}
.mse-fmp4-player {
position: relative;
width: 100%;
@ -316,11 +342,8 @@ i[data-availability="error"] {
}
.observation-timeline {
display: grid;
display: block;
min-width: 0;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.6rem;
border: 0;
border-radius: 0.9rem;
background: rgb(9 10 13 / 0.78);
@ -328,22 +351,73 @@ i[data-availability="error"] {
backdrop-filter: blur(16px);
}
.observation-timeline__track {
height: 0.3rem;
overflow: hidden;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
.observation-timeline[data-accumulation="true"] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: center;
gap: 0.8rem;
}
.observation-timeline__track span {
display: block;
height: 100%;
border-radius: inherit;
.observation-timeline__playback {
display: grid;
min-width: 0;
grid-template-columns: auto auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.6rem;
}
.observation-timeline__accumulation {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.7rem;
padding: 0 0.45rem;
}
.observation-timeline__accumulation span,
.observation-timeline__accumulation code {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
white-space: nowrap;
}
.observation-timeline__accumulation code {
min-width: 2.65rem;
color: var(--nodedc-text-primary);
text-align: right;
}
.observation-timeline__track {
width: 100%;
height: 0.3rem;
appearance: none;
border: 0;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
cursor: pointer;
}
.observation-timeline__track::-webkit-slider-thumb {
width: 0.72rem;
height: 0.72rem;
appearance: none;
border: 0;
border-radius: 50%;
background: var(--nodedc-text-primary);
}
.observation-timeline__track[data-disabled="true"] {
.observation-timeline__track::-moz-range-thumb {
width: 0.72rem;
height: 0.72rem;
border: 0;
border-radius: 50%;
background: var(--nodedc-text-primary);
}
.observation-timeline__track:disabled {
opacity: 0.46;
cursor: default;
}
.observation-timeline__meta {
@ -364,11 +438,17 @@ i[data-availability="error"] {
align-items: center;
gap: 0.38rem;
color: var(--nodedc-text-muted);
border: 0;
background: transparent;
padding: 0.3rem 0.45rem;
font-size: 0.52rem;
font-weight: 820;
}
.observation-timeline__follow:not(:disabled) {
cursor: pointer;
}
.observation-timeline__follow::before {
width: 0.4rem;
height: 0.4rem;
@ -514,11 +594,15 @@ i[data-availability="error"] {
left: 0.6rem;
}
.observation-timeline {
.observation-timeline[data-accumulation="true"] {
grid-template-columns: minmax(0, 1fr);
}
.observation-timeline__playback {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.observation-timeline > .nodedc-button:first-child,
.observation-timeline__playback > .nodedc-button:first-child,
.observation-timeline__meta {
display: none;
}

View File

@ -104,7 +104,6 @@
}
@media (max-width: 760px) {
.station-label,
.control-station .nodedc-header__profile-button {
display: none;
}

View File

@ -1,13 +1,3 @@
.station-label {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.61rem;
font-weight: 820;
letter-spacing: 0.13em;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-dot {
width: 0.48rem;
height: 0.48rem;

View File

@ -45,17 +45,34 @@
min-height: 0;
}
.rerun-viewport__canvas[data-presented="false"] {
visibility: hidden;
}
.recorded-session-preloaders {
position: fixed;
left: -10000px;
top: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.rerun-viewport {
--rerun-tab-bar-height: 24px;
--rerun-native-chrome-height: 72px;
overflow: hidden;
}
/* Rerun WebViewer 0.34.1 draws its selected recording tab inside WASM.
The native panels are already disabled; crop only that fixed tab row. */
/* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its
panels are overridden: the native top row, recording tab and view tab.
They are drawn inside WASM and cannot be styled independently, so crop the
fixed native chrome while keeping the actual 3D viewport full-height. */
.rerun-viewport__canvas {
top: calc(-1 * var(--rerun-tab-bar-height));
top: calc(-1 * var(--rerun-native-chrome-height));
bottom: auto;
height: calc(100% + var(--rerun-tab-bar-height));
height: calc(100% + var(--rerun-native-chrome-height));
}
.rerun-viewport__canvas canvas {
@ -65,8 +82,9 @@
}
.rerun-viewport:is([data-status="loading"], [data-status="error"])
.rerun-viewport__canvas > div {
.rerun-viewport__canvas {
visibility: hidden;
pointer-events: none;
}
.rerun-viewport__notice {

View File

@ -14,6 +14,12 @@ import {
} from "../components/ObservationSources";
import { ObservationTimeline } from "../components/ObservationTimeline";
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
import type {
RecordedAdmissionPhase,
RecordedCameraAdmissionState,
} from "../core/observation/recordedSessionAdmission";
import type { ObservationLayoutController } from "../core/observation/useObservationLayout";
import type {
BackendStatus,
@ -22,6 +28,10 @@ import type {
} from "../core/runtime/contracts";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
rerunPresentationStatus,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunSelection,
type RerunViewportStatus,
} from "../components/RerunViewport";
@ -95,7 +105,12 @@ export interface WorkspaceRendererProps {
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
sourceUrl: string;
recordedReplay: ObservationSessionReplayLaunch | null;
recordedSessionAdmission: RecordedSessionAdmissionController | null;
sceneSettings: SceneSettings;
accumulationSeconds: number;
onAccumulationChange: (value: number) => void;
onAccumulationCommit: () => void;
observationLayout: ObservationLayoutController;
navigation: WorkspaceNavigation;
}
@ -248,13 +263,27 @@ function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
function SpatialWorkspace({
state,
sourceUrl,
recordedReplay,
recordedSessionAdmission,
sceneSettings,
accumulationSeconds,
onAccumulationChange,
onAccumulationCommit,
observationLayout,
navigation,
}: WorkspaceRendererProps) {
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
const [viewerMessage, setViewerMessage] = useState("");
const [selection, setSelection] = useState<RerunSelection | null>(null);
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
const recordedSource = state?.sourceMode === "replay" || /\.rrd(?:$|[?#])/i.test(sourceUrl);
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
? recordedSessionAdmission?.phase ?? "loading"
: "ready";
const recordedPlaybackReady = !recordedSource ||
(recordedSessionGate === "ready" &&
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
@ -277,28 +306,83 @@ function SpatialWorkspace({
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
const timeline = state?.observationTimeline;
const viewportRef = useRef<HTMLDivElement>(null);
const presentedViewerStatus = rerunPresentationStatus(
viewerStatus,
recordedSessionGate,
recordedSource,
);
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
setViewerStatus(status);
setViewerMessage(message ?? "");
}, []);
if (recordedSessionAdmission) {
recordedSessionAdmission.reportSpatial(
recordedSessionAdmission.key,
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
);
}
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
const onRecordedAdmissionChange = useCallback((
sourceId: string,
next: RecordedCameraAdmissionState,
) => {
if (!recordedSessionAdmission) return;
if (next.admissionKey !== recordedSessionAdmission.key) return;
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
if (!recordedSessionAdmission) return false;
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
}, [recordedSessionAdmission]);
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
const onPlaybackChange = useCallback(
(next: RerunPlaybackState | null) => setPlaybackState(next),
[],
);
const onPlaybackControllerChange = useCallback(
(next: RerunPlaybackController | null) => setPlaybackController(next),
[],
);
useEffect(() => {
if (pointCloudVisible && sourceUrl.trim()) return;
setViewerStatus("idle");
setViewerMessage("");
setSelection(null);
setPlaybackState(null);
setPlaybackController(null);
}, [pointCloudVisible, sourceUrl]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const publishViewportSize = () => {
const bounds = viewport.getBoundingClientRect();
if (bounds.width < 1 || bounds.height < 1) return;
observationLayout.setViewportSize({
width: bounds.width,
height: bounds.height,
});
};
publishViewportSize();
const observer = new ResizeObserver(publishViewportSize);
observer.observe(viewport);
return () => observer.disconnect();
}, [observationLayout.setViewportSize]);
const viewerStatusLabel = {
idle: "Источник не назначен",
loading: "Подключение",
ready: "Визуализатор готов",
error: "Ошибка источника",
}[viewerStatus];
}[presentedViewerStatus];
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
const viewerStatusTone = presentedViewerStatus === "ready"
? "success"
: presentedViewerStatus === "error"
? "danger"
: "neutral";
return (
<div
@ -331,9 +415,21 @@ function SpatialWorkspace({
{sourceUrl.trim() && pointCloudVisible ? (
<RerunViewport
sourceUrl={sourceUrl}
followLive={state?.sourceMode === "live"}
recordedArtifact={recordedSource ? recordedReplay : null}
followLive={!recordedSource && state?.sourceMode === "live"}
autoplayWhenReady={recordedSource}
presentationGate={recordedSessionGate}
expectedTimelineStartSeconds={recordedSource
? state?.observationTimeline?.range?.startSeconds
: undefined}
expectedTimelineEndSeconds={recordedSource
? state?.observationTimeline?.range?.endSeconds
: undefined}
sceneSettings={sceneSettings}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
onPlaybackChange={onPlaybackChange}
onPlaybackControllerChange={onPlaybackControllerChange}
/>
) : (
<EmptySpatialStage settings={sceneSettings} />
@ -415,13 +511,26 @@ function SpatialWorkspace({
</div>
) : null}
{!pointCloudFocused && !floatingSourceMaximized ? (
{!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
<ObservationTimeline
active={viewerStatus === "ready"}
active={presentedViewerStatus === "ready"}
sourceCount={Math.max(1, 1 + visibleMediaSources.length)}
mode={timeline?.mode}
seekable={timeline?.seekable}
mode={recordedSource && playbackState?.rangeNs
? "recorded"
: timeline?.mode}
seekable={recordedSource && playbackState?.rangeNs
? true
: timeline?.seekable}
synchronization={timeline?.synchronization}
rangeNs={playbackState?.rangeNs}
currentNs={playbackState?.currentNs}
playing={playbackState?.playing}
onSeek={playbackController?.seek}
onPlayingChange={playbackController?.setPlaying}
onJumpToEnd={playbackController?.jumpToEnd}
accumulationSeconds={accumulationSeconds}
onAccumulationChange={onAccumulationChange}
onAccumulationCommit={onAccumulationCommit}
className="scene-timeline"
/>
) : null}
@ -440,6 +549,14 @@ function SpatialWorkspace({
onMaximizedChange={(maximized) =>
observationLayout.setFloatingMaximized(source.id, maximized)}
onActivate={() => observationLayout.activateFloatingSource(source.id)}
playback={recordedSource && playbackState ? {
currentSeconds: playbackState.currentNs / 1_000_000_000,
playing: playbackState.playing,
} : null}
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
onClose={() => {
if (observationLayout.pendingSourceIds.has(source.id)) return;
observationLayout.setFloatingMaximized(source.id, false);
@ -447,6 +564,28 @@ function SpatialWorkspace({
}}
/>
)) : null}
{recordedSource ? (
<div className="recorded-session-preloaders" aria-hidden="true">
{mediaSources.filter((source) => (
source.delivery?.kind === "recorded-fmp4-manifest" &&
(pointCloudFocused || !observationLayout.visibleSourceIds.has(source.id)) &&
shouldPrepareRecordedSource(source.id)
)).map((source) => (
<ObservationMedia
key={source.id}
source={source}
playback={playbackState ? {
currentSeconds: playbackState.currentNs / 1_000_000_000,
playing: false,
} : null}
prepareRecorded
recordedSessionGate="loading"
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
))}
</div>
) : null}
</div>
<div className="spatial-contract-strip">
@ -470,6 +609,10 @@ function CameraSourceCard({
onToggle,
onFocus,
onClose,
prepareRecorded,
recordedSessionGate,
recordedAdmissionKey,
onRecordedAdmissionChange,
}: {
source: ObservationSourceDescriptor;
focused: boolean;
@ -479,6 +622,13 @@ function CameraSourceCard({
onToggle: () => void;
onFocus: () => void;
onClose: () => void;
prepareRecorded: boolean;
recordedSessionGate: RecordedAdmissionPhase;
recordedAdmissionKey: string | null;
onRecordedAdmissionChange: (
sourceId: string,
state: RecordedCameraAdmissionState,
) => void;
}) {
const deliveryActive = Boolean(
(source.delivery || source.previewUrl) &&
@ -542,7 +692,13 @@ function CameraSourceCard({
</div>
</header>
<div className="camera-slot__body">
<ObservationMedia source={source} />
<ObservationMedia
source={source}
prepareRecorded={prepareRecorded}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedAdmissionKey}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
</div>
<footer>
<span>{source.description}</span>
@ -552,13 +708,36 @@ function CameraSourceCard({
);
}
function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRendererProps) {
function CamerasWorkspace({
definition,
state,
observationLayout,
recordedReplay,
recordedSessionAdmission,
}: WorkspaceRendererProps) {
const sources = (state?.observationSources ?? []).filter(
(source) => source.modality === "video" || source.modality === "image" || source.modality === "depth",
);
const focusedSource = sources.find((source) => source.id === observationLayout.focusedSourceId) ?? null;
const timeline = state?.observationTimeline;
const displayedSources = focusedSource ? [focusedSource] : sources;
const recordedSessionGate: RecordedAdmissionPhase = recordedReplay
? recordedSessionAdmission?.phase ?? "loading"
: "ready";
const onRecordedAdmissionChange = useCallback((
sourceId: string,
next: RecordedCameraAdmissionState,
) => {
if (!recordedSessionAdmission) return;
if (next.admissionKey !== recordedSessionAdmission.key) return;
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
if (!recordedReplay) return true;
if (!recordedSessionAdmission) return false;
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
recordedSessionAdmission.cameras[sourceId]?.phase === "ready";
}, [recordedReplay, recordedSessionAdmission]);
return (
<div className="standard-workspace cameras-workspace" data-focused={focusedSource ? "true" : undefined}>
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
@ -587,6 +766,10 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
observationLayout.setFocusedSourceId(null);
void observationLayout.hideSource(source.id);
}}
prepareRecorded={shouldPrepareRecordedSource(source.id)}
recordedSessionGate={recordedSessionGate}
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
)) : (
<div className="camera-grid__empty">
@ -596,7 +779,25 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
</div>
)}
</div>
<ObservationTimeline
{recordedReplay ? (
<div className="recorded-session-preloaders" aria-hidden="true">
{sources.filter((source) => (
!displayedSources.some(({ id }) => id === source.id) &&
source.delivery?.kind === "recorded-fmp4-manifest" &&
shouldPrepareRecordedSource(source.id)
)).map((source) => (
<ObservationMedia
key={source.id}
source={source}
prepareRecorded
recordedSessionGate="loading"
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
onRecordedAdmissionChange={onRecordedAdmissionChange}
/>
))}
</div>
) : null}
{!recordedReplay || recordedSessionGate === "ready" ? <ObservationTimeline
active={sources.some((source) =>
source.availability === "streaming" &&
(!source.activation || source.activation.selected))}
@ -606,7 +807,7 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
seekable={timeline?.seekable}
synchronization={timeline?.synchronization}
className="camera-timeline"
/>
/> : null}
</div>
);
}

View File

@ -0,0 +1,832 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let decodeObservationSessionCatalog;
let decodeObservationSessionReplay;
let decodeObservationSessionPreparation;
let fetchObservationSessionCatalog;
let replayObservationSession;
let fetchObservationSessionPreparation;
let cancelObservationSessionPreparation;
let ObservationSessionApiError;
let ObservationSessionContractError;
let decodeObservationRecordedMediaManifest;
let createObservationReplayCoordinator;
let resolveObservationSessionReplay;
let waitForObservationReplayPreparation;
let storeObservationReplayPreparation;
let loadObservationReplayPreparation;
let observationSessionVisualState;
let observationSessionVisualLabel;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
decodeObservationSessionCatalog,
decodeObservationSessionReplay,
decodeObservationSessionPreparation,
fetchObservationSessionCatalog,
replayObservationSession,
fetchObservationSessionPreparation,
cancelObservationSessionPreparation,
ObservationSessionApiError,
ObservationSessionContractError,
decodeObservationRecordedMediaManifest,
} = await server.ssrLoadModule("/src/core/observation/sessionArchive.ts"));
({
createObservationReplayCoordinator,
resolveObservationSessionReplay,
waitForObservationReplayPreparation,
storeObservationReplayPreparation,
loadObservationReplayPreparation,
} = await server.ssrLoadModule(
"/src/core/observation/useObservationSessions.ts",
));
({ observationSessionVisualState, observationSessionVisualLabel } = await server.ssrLoadModule(
"/src/components/ObservationSessionSelect.tsx",
));
});
after(async () => {
await server?.close();
});
function session(overrides = {}) {
return {
id: "20260716T205632Z_viewer_live",
label: "K1 · наблюдение 16 июля",
started_at_utc: "2026-07-16T20:56:32.635Z",
completed_at_utc: "2026-07-16T21:20:43.379Z",
status: "ready",
modalities: ["point-cloud", "pose"],
duration_seconds: 1_450.744,
replayable: true,
...overrides,
};
}
function replay(overrides = {}) {
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
const sourceUrl = overrides.source_url ??
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording.rrd`;
const sha256 = overrides.sha256 ?? "a".repeat(64);
return {
schema_version: "missioncore.observation-session-replay/v2",
launch: {
kind: "rerun-recording",
session_id: sessionId,
source_url: sourceUrl,
viewer_source_url: overrides.viewer_source_url ?? `${sourceUrl}?generation=${sha256}`,
media_type: "application/vnd.rerun.rrd",
timeline: "session_time",
timeline_start_seconds: 0,
timeline_end_seconds: 1_450.744,
seekable: true,
byte_length: 123_456,
sha256,
playback: { speed: 1, loop: false },
media_sources: [],
...overrides,
},
};
}
function preparation(overrides = {}) {
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
return {
schema_version: "missioncore.observation-session-preparation/v1",
preparation: {
preparation_id: "prepare-20260717T131400Z",
session_id: sessionId,
state: "queued",
progress: null,
updated_at_utc: "2026-07-17T10:14:00Z",
status_url: `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/recording-preparation`,
cancellable: true,
...overrides,
},
};
}
const preparationEtag = '"prepare-20260717T131400Z"';
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
const catalog = decodeObservationSessionCatalog({ items: [session()] });
assert.deepEqual(catalog.items, [{
id: "20260716T205632Z_viewer_live",
label: "K1 · наблюдение 16 июля",
startedAtUtc: "2026-07-16T20:56:32.635Z",
completedAtUtc: "2026-07-16T21:20:43.379Z",
status: "ready",
modalities: ["point-cloud", "pose"],
durationSeconds: 1_450.744,
replayable: true,
preparation: null,
}]);
assert.equal("raw_capture" in catalog.items[0], false);
assert.equal("path" in catalog.items[0], false);
});
test("session catalog exposes authoritative background preparation state", () => {
const catalog = decodeObservationSessionCatalog({
items: [session({
preparation: {
preparation_id: "prepare-catalog-1",
state: "exporting",
progress: 0.42,
updated_at_utc: "2026-07-17T10:14:00Z",
cancellable: true,
retryable: false,
},
})],
});
assert.deepEqual(catalog.items[0].preparation, {
preparationId: "prepare-catalog-1",
state: "exporting",
progress: 0.42,
updatedAtUtc: "2026-07-17T10:14:00Z",
cancellable: true,
retryable: false,
error: null,
});
});
test("saved-session rows use only authoritative ready, processing and error states", () => {
const makeDecoded = (state, overrides = {}) => decodeObservationSessionCatalog({
items: [session({
status: "interrupted",
preparation: state === null ? null : {
preparation_id: `prepare-${state}`,
state,
progress: state === "ready" ? 1 : 0.5,
updated_at_utc: "2026-07-17T10:14:00Z",
cancellable: ["queued", "validating", "exporting", "finalizing"].includes(state),
retryable: state === "failed",
...(state === "failed" ? { error: "export failed" } : {}),
},
...overrides,
})],
}).items[0];
assert.equal(observationSessionVisualState(makeDecoded("ready")), "ready");
for (const state of ["queued", "validating", "exporting", "finalizing"]) {
assert.equal(observationSessionVisualState(makeDecoded(state)), "processing");
}
assert.equal(observationSessionVisualState(makeDecoded("failed")), "error");
assert.equal(observationSessionVisualState(makeDecoded("cancelled")), "error");
assert.equal(
observationSessionVisualState(makeDecoded("failed", { status: "recording" })),
"error",
);
assert.equal(observationSessionVisualState(makeDecoded(null)), "error");
assert.equal(
observationSessionVisualState(makeDecoded(null, { status: "recording" })),
"processing",
);
assert.equal(
observationSessionVisualState(makeDecoded("ready", { replayable: false })),
"error",
);
assert.equal(
observationSessionVisualState(makeDecoded("ready"), { failed: true }),
"error",
);
assert.equal(
observationSessionVisualState(makeDecoded("failed"), { pending: true, failed: true }),
"error",
);
assert.equal(observationSessionVisualLabel("ready"), "Готово");
assert.equal(observationSessionVisualLabel("processing"), "Обработка");
assert.equal(observationSessionVisualLabel("error"), "Ошибка");
});
test("saved-session lamps use green or dim gray only, never warning or danger colors", async () => {
const css = await readFile(
new URL("../src/styles/observation-sessions.css", import.meta.url),
"utf8",
);
const start = css.indexOf(".observation-session-option i {");
const end = css.indexOf(".observation-session-option__state", start);
assert.notEqual(start, -1);
assert.notEqual(end, -1);
const lampRules = css.slice(start, end);
assert.match(lampRules, /data-session-visual-state="ready"[\s\S]*--nodedc-success-rgb/);
assert.match(lampRules, /data-session-visual-state="processing"[\s\S]*animation:/);
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*--nodedc-text-muted/);
assert.match(lampRules, /data-session-visual-state="error"[\s\S]*opacity:\s*0\.48/);
assert.doesNotMatch(lampRules, /\b(?:warning|danger|yellow|red)\b/i);
});
test("session catalog sorts newest first and uses id as a deterministic tie-breaker", () => {
const catalog = decodeObservationSessionCatalog({
items: [
session({ id: "same-b", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
session({ id: "newest", started_at_utc: "2026-07-17T00:00:00Z", completed_at_utc: null }),
session({ id: "same-a", started_at_utc: "2026-07-16T19:00:00Z", completed_at_utc: null }),
],
});
assert.deepEqual(catalog.items.map(({ id }) => id), ["newest", "same-a", "same-b"]);
});
test("session catalog rejects raw storage fields, unsafe ids and duplicate ids", () => {
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({ raw_capture: "sessions/private/mqtt.raw.k1mqtt" })],
}),
ObservationSessionContractError,
);
assert.throws(
() => decodeObservationSessionCatalog({ items: [session({ id: "../private" })] }),
ObservationSessionContractError,
);
assert.throws(
() => decodeObservationSessionCatalog({ items: [session(), session()] }),
/повторяющийся id/,
);
});
test("session catalog rejects invalid temporal, status and duration values", () => {
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({ started_at_utc: "16 July 2026" })],
}),
/ISO-датой/,
);
assert.throws(
() => decodeObservationSessionCatalog({ items: [session({ status: "complete" })] }),
/неизвестное состояние/,
);
assert.throws(
() => decodeObservationSessionCatalog({ items: [session({ duration_seconds: -1 })] }),
/неотрицательным числом/,
);
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({ completed_at_utc: "2026-07-16T19:00:00Z" })],
}),
/раньше времени начала/,
);
});
test("catalog API preserves HTTP detail without accepting a malformed success body", async () => {
await assert.rejects(
fetchObservationSessionCatalog({
fetcher: async () => new Response(JSON.stringify({ detail: "storage unavailable" }), {
status: 503,
headers: { "Content-Type": "application/json" },
}),
}),
(error) => error instanceof ObservationSessionApiError &&
error.status === 503 && error.message === "storage unavailable",
);
await assert.rejects(
fetchObservationSessionCatalog({
fetcher: async () => new Response(JSON.stringify({ items: "not-an-array" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
}),
ObservationSessionContractError,
);
});
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
const calls = [];
const launch = await replayObservationSession("session-20260716T205632Z", {
fetcher: async (input, init) => {
calls.push({ input: String(input), init });
return new Response(JSON.stringify(replay()), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
});
assert.equal(calls.length, 1);
assert.equal(calls[0].input, "/api/v1/observation-sessions/session-20260716T205632Z/replay");
assert.equal(calls[0].init.method, "POST");
assert.equal(launch.kind, "ready");
assert.equal(launch.launch.timeline, "session_time");
assert.equal(
launch.launch.sourceUrl,
"/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
);
await assert.rejects(
replayObservationSession("../private", { fetcher: async () => new Response(null) }),
ObservationSessionContractError,
);
});
test("preparation contract is strict, session-scoped and same-origin", async () => {
const sessionId = "session-20260716T205632Z";
const decoded = decodeObservationSessionPreparation(preparation(), sessionId);
assert.equal(decoded.sessionId, sessionId);
assert.equal(decoded.state, "queued");
assert.equal(decoded.progress, null);
assert.throws(
() => decodeObservationSessionPreparation(
preparation({ status_url: "https://invalid.test/status" }),
sessionId,
),
/канонический same-origin/,
);
assert.throws(
() => decodeObservationSessionPreparation(preparation({ debug_path: "/tmp/raw" }), sessionId),
/неизвестные поля/,
);
assert.throws(
() => decodeObservationSessionPreparation(preparation({ progress: 1.1 }), sessionId),
/недопустимое число/,
);
await assert.rejects(
replayObservationSession(sessionId, {
fetcher: async () => new Response(JSON.stringify(preparation()), {
status: 202,
headers: { "Content-Type": "application/json" },
}),
}),
/preparation ETag/,
);
});
test("202 preparation polls through explicit phases and reveals launch only when ready", async () => {
const sessionId = "session-20260716T205632Z";
const calls = [];
const phases = [];
let poll = 0;
let clock = 0;
let previousViewerMounted = true;
const launch = await resolveObservationSessionReplay(sessionId, {
signal: new AbortController().signal,
requestTimeoutMs: 1_000,
heartbeatStallMs: 5_000,
maximumWaitMs: 10_000,
initialPollIntervalMs: 50,
maximumPollIntervalMs: 50,
now: () => clock,
sleep: async (milliseconds) => {
assert.equal(previousViewerMounted, true);
clock += milliseconds;
},
onUpdate: (value) => {
assert.equal(previousViewerMounted, true);
phases.push([value.state, value.progress]);
},
fetcher: async (input, init) => {
calls.push([String(input), init.method, new Headers(init.headers).get("If-Match")]);
if (init.method === "POST") {
return new Response(JSON.stringify(preparation()), {
status: 202,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
});
}
poll += 1;
if (poll === 1) {
return new Response(JSON.stringify(preparation({
state: "exporting",
progress: 0.5,
updated_at_utc: "2026-07-17T10:14:01Z",
})), {
status: 202,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
});
}
return new Response(JSON.stringify(replay({ session_id: sessionId })), {
status: 200,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
});
},
});
// This is the point where App is allowed to unmount the old viewer.
previousViewerMounted = false;
assert.equal(launch.sessionId, sessionId);
assert.deepEqual(phases, [["queued", null], ["exporting", 0.5]]);
assert.deepEqual(calls.map((entry) => entry[1]), ["POST", "GET", "GET"]);
assert.deepEqual(calls.map((entry) => entry[2]), [null, preparationEtag, preparationEtag]);
});
test("status-ready response must match the preparation ETag before launch is accepted", async () => {
const decoded = decodeObservationSessionPreparation(
preparation({ state: "finalizing", progress: 0.95 }),
"session-20260716T205632Z",
);
let receivedIfMatch;
await assert.rejects(
fetchObservationSessionPreparation(decoded, {
fetcher: async (_input, init) => {
receivedIfMatch = new Headers(init.headers).get("If-Match");
return new Response(JSON.stringify(replay()), {
status: 200,
headers: { "Content-Type": "application/json", ETag: '"replacement-job"' },
});
},
}),
/актуальный preparation ETag/,
);
assert.equal(receivedIfMatch, preparationEtag);
});
test("preparation cancellation is conditional on the same preparation ETag", async () => {
const decoded = decodeObservationSessionPreparation(
preparation({ state: "exporting", progress: 0.5 }),
"session-20260716T205632Z",
);
let request;
const result = await cancelObservationSessionPreparation(decoded, {
fetcher: async (input, init) => {
request = {
input: String(input),
method: init.method,
ifMatch: new Headers(init.headers).get("If-Match"),
};
return new Response(null, { status: 204 });
},
});
assert.equal(result, null);
assert.deepEqual(request, {
input: decoded.statusUrl,
method: "DELETE",
ifMatch: preparationEtag,
});
});
test("preparation polling fails explicitly when the server heartbeat stalls", async () => {
const decoded = decodeObservationSessionPreparation(
preparation({ state: "exporting", progress: 0.1 }),
"session-20260716T205632Z",
);
let clock = 0;
await assert.rejects(
waitForObservationReplayPreparation(decoded, {
signal: new AbortController().signal,
requestTimeoutMs: 1_000,
heartbeatStallMs: 250,
maximumWaitMs: 5_000,
initialPollIntervalMs: 150,
maximumPollIntervalMs: 150,
now: () => clock,
sleep: async (milliseconds) => { clock += milliseconds; },
fetcher: async () => new Response(JSON.stringify(preparation({
state: "exporting",
progress: 0.1,
})), {
status: 202,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
}),
}),
/перестала обновляться/,
);
});
test("queued preparation can wait behind the bounded backend worker without false heartbeat failure", async () => {
const decoded = decodeObservationSessionPreparation(
preparation({ state: "queued", progress: 0 }),
"session-20260716T205632Z",
);
let clock = 0;
let polls = 0;
const launch = await waitForObservationReplayPreparation(decoded, {
signal: new AbortController().signal,
requestTimeoutMs: 1_000,
heartbeatStallMs: 250,
maximumWaitMs: 5_000,
initialPollIntervalMs: 150,
maximumPollIntervalMs: 150,
now: () => clock,
sleep: async (milliseconds) => { clock += milliseconds; },
fetcher: async () => {
polls += 1;
if (polls <= 2) {
return new Response(JSON.stringify(preparation({ state: "queued", progress: 0 })), {
status: 202,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
});
}
return new Response(JSON.stringify(replay()), {
status: 200,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
});
},
});
assert.equal(clock, 450);
assert.equal(launch.sessionId, "session-20260716T205632Z");
});
test("terminal preparation failure is explicit and retryable", async () => {
const sessionId = "session-20260716T205632Z";
const response = await replayObservationSession(sessionId, {
fetcher: async () => new Response(JSON.stringify(preparation({
state: "failed",
progress: 0.4,
cancellable: false,
retryable: true,
error: "Повреждён индекс исходной записи.",
})), {
status: 409,
headers: { "Content-Type": "application/json", ETag: preparationEtag },
}),
});
assert.equal(response.kind, "preparing");
assert.equal(response.preparation.retryable, true);
assert.equal(response.preparation.error, "Повреждён индекс исходной записи.");
});
test("pending preparation survives reload only through its strict canonical contract", () => {
const values = new Map();
const storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => [...values.keys()][index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, String(value)),
};
const decoded = decodeObservationSessionPreparation(
preparation({ state: "exporting", progress: 0.7 }),
"session-20260716T205632Z",
);
storeObservationReplayPreparation(decoded, storage);
assert.deepEqual(loadObservationReplayPreparation(storage), decoded);
const key = storage.key(0);
storage.setItem(key, JSON.stringify({ ...preparation(), raw_path: "/private/raw" }));
assert.equal(loadObservationReplayPreparation(storage), null);
assert.equal(storage.length, 0);
});
test("replay API forwards cancellation and preserves AbortError semantics", async () => {
const controller = new AbortController();
let receivedSignal;
const pending = replayObservationSession("session-20260716T205632Z", {
signal: controller.signal,
fetcher: async (_input, init) => {
receivedSignal = init.signal;
return await new Promise((_resolve, reject) => {
init.signal.addEventListener("abort", () => {
reject(new DOMException("cancelled", "AbortError"));
}, { once: true });
});
},
});
controller.abort();
assert.equal(receivedSignal, controller.signal);
await assert.rejects(pending, (error) => error?.name === "AbortError");
});
test("replay coordinator makes rapid saved-session selection latest-request-wins", () => {
const coordinator = createObservationReplayCoordinator();
const first = coordinator.begin();
assert.equal(first.isCurrent(), true);
const second = coordinator.begin();
assert.equal(first.signal.aborted, true);
assert.equal(first.isCurrent(), false);
assert.equal(first.finish(), false);
assert.equal(second.isCurrent(), true);
assert.equal(second.finish(), true);
assert.equal(second.isCurrent(), false);
const third = coordinator.begin();
coordinator.cancel();
assert.equal(third.signal.aborted, true);
assert.equal(third.isCurrent(), false);
});
test("switching saved sessions aborts only local polling and never cancels shared backend work", async () => {
const hookSource = await readFile(
new URL("../src/core/observation/useObservationSessions.ts", import.meta.url),
"utf8",
);
const selectorSource = await readFile(
new URL("../src/components/ObservationSessionSelect.tsx", import.meta.url),
"utf8",
);
assert.doesNotMatch(hookSource, /cancelObservationSessionPreparation|method:\s*["']DELETE/);
assert.doesNotMatch(selectorSource, /cancelReplay|>\s*Отменить\s*</);
});
test("replay descriptor rejects path leaks, mismatched sessions and non-seekable data", () => {
assert.throws(
() => decodeObservationSessionReplay(replay({ source_url: "file:///private/session.rrd" })),
/same-origin/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({ source_url: "/api/v1/observation-sessions/other/recording.rrd" })),
/same-origin/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({ seekable: false })),
/seekable/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({
viewer_source_url: "/api/v1/observation-sessions/session-20260716T205632Z/recording.rrd",
})),
/канонически привязан/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({
viewer_source_url: `https://foreign.test/recording.rrd?generation=${"a".repeat(64)}`,
})),
/канонически привязан/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({ raw_path: "/private/raw.k1mqtt" })),
/неизвестные поля/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({ byte_length: Number.MAX_SAFE_INTEGER + 1 })),
/недопустимое число/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({ playback: { speed: 101, loop: false } })),
/недопустимое число/,
);
});
test("replay decodes opaque recorded cameras and their same-origin fMP4 manifest", () => {
const sessionId = "session-20260716T205632Z";
const source = {
id: "recorded.camera.5a5a5a5a5a5a5a5a",
label: "Записанная камера 1",
modality: "video",
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
manifest_generation_sha256: "c".repeat(64),
byte_length: 3_266,
media_type: "video/mp4",
timeline_start_seconds: 0.25,
timeline_end_seconds: 20,
seekable: true,
synchronization: "host-arrival-best-effort",
};
const decoded = decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [source],
}));
assert.equal(
decoded.viewerSourceUrl,
`/api/v1/observation-sessions/${sessionId}/recording.rrd?generation=${"a".repeat(64)}`,
);
assert.equal(decoded.mediaSources.length, 1);
assert.equal(decoded.mediaSources[0].id, source.id);
assert.equal(decoded.mediaSources[0].manifestUrl, source.manifest_url);
assert.equal(
decoded.mediaSources[0].manifestGenerationSha256,
source.manifest_generation_sha256,
);
const manifest = decodeObservationRecordedMediaManifest({
schema_version: "missioncore.observation-recorded-media/v2",
source_id: source.id,
generation_sha256: "c".repeat(64),
byte_length: 3_266,
timeline_start_seconds: 0.25,
timeline_end_seconds: 20,
synchronization: "host-arrival-best-effort",
epochs: [{
ordinal: 1,
timeline_start_seconds: 0.25,
timeline_end_seconds: 20,
media_type: 'video/mp4; codecs="avc1.640028"',
init_url: source.manifest_url.replace("/manifest", "/epochs/1/init.mp4"),
init_byte_length: 128,
init_sha256: "a".repeat(64),
segment_count: 12,
segment_url_prefix: source.manifest_url.replace("/manifest", "/epochs/1/segments/"),
segments: Array.from({ length: 12 }, (_, index) => ({
sequence: index + 1,
url: source.manifest_url.replace("/manifest", `/epochs/1/segments/${index + 1}.m4s`),
byte_length: 256 + index,
sha256: "b".repeat(64),
})),
}],
}, decoded.mediaSources[0]);
assert.equal(manifest.epochs[0].segmentCount, 12);
assert.equal(manifest.epochs[0].segments.length, 12);
assert.equal(manifest.epochs[0].timelineStartSeconds, 0.25);
assert.throws(
() => decodeObservationRecordedMediaManifest({
...{
schema_version: "missioncore.observation-recorded-media/v1",
source_id: source.id,
generation_sha256: "c".repeat(64),
byte_length: 3_266,
timeline_start_seconds: 0.25,
timeline_end_seconds: 20,
synchronization: "host-arrival-best-effort",
epochs: [],
},
}, decoded.mediaSources[0]),
/несовместим/,
);
assert.throws(
() => decodeObservationRecordedMediaManifest({
schema_version: "missioncore.observation-recorded-media/v2",
source_id: source.id,
generation_sha256: "c".repeat(64),
byte_length: 3_267,
timeline_start_seconds: 0.25,
timeline_end_seconds: 20,
synchronization: "host-arrival-best-effort",
epochs: [],
}, decoded.mediaSources[0]),
/несовместим|launch descriptor/,
);
});
test("recorded camera contracts reject foreign origins, path escapes and unknown fields", () => {
const sessionId = "session-20260716T205632Z";
const base = {
id: "recorded.camera.5a5a5a5a5a5a5a5a",
label: "Записанная камера 1",
modality: "video",
manifest_url: `/api/v1/observation-sessions/${sessionId}/media/recorded-video-5a5a5a5a5a5a5a5a/manifest`,
manifest_generation_sha256: "c".repeat(64),
byte_length: 384,
media_type: "video/mp4",
timeline_start_seconds: 0,
timeline_end_seconds: 20,
seekable: true,
synchronization: "host-arrival-best-effort",
};
assert.throws(
() => decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [{ ...base, manifest_url: "https://invalid.test/private" }],
})),
/same-origin/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [{ ...base, path: "/private/archive" }],
})),
/неизвестные поля/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [{ ...base, manifest_generation_sha256: undefined }],
})),
/immutable generation/,
);
assert.throws(
() => decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [{ ...base, manifest_generation_sha256: "not-a-digest" }],
})),
/immutable generation/,
);
const decoded = decodeObservationSessionReplay(replay({
session_id: sessionId,
timeline_end_seconds: 20,
media_sources: [base],
}));
assert.throws(
() => decodeObservationRecordedMediaManifest({
schema_version: "missioncore.observation-recorded-media/v2",
source_id: base.id,
generation_sha256: "c".repeat(64),
byte_length: 384,
timeline_start_seconds: 0,
timeline_end_seconds: 20,
synchronization: "host-arrival-best-effort",
epochs: [{
ordinal: 1,
timeline_start_seconds: 0,
timeline_end_seconds: 20,
media_type: 'video/mp4; codecs="avc1.640028"',
init_url: "file:///private/init.mp4",
init_byte_length: 128,
init_sha256: "a".repeat(64),
segment_count: 1,
segment_url_prefix: "file:///private/segments/",
segments: [{
sequence: 1,
url: "file:///private/segments/1.m4s",
byte_length: 256,
sha256: "b".repeat(64),
}],
}],
}, decoded.mediaSources[0]),
/небезопасный API URL/,
);
});

View File

@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createServer } from "vite";
let server;
@ -11,6 +13,20 @@ let shouldRestartObservationSource;
let consumeCameraLeaseRetry;
let resetCameraLeaseRetryBudget;
let initialObservationWindowRect;
let ObservationTimeline;
let shouldCaptureWorkspacePointer;
let normalizeTimelineRange;
let timelineOffsetSeconds;
let formatTimelineDuration;
let normalizeAccumulationSeconds;
let formatAccumulationDuration;
let resolveRerunSourceUrl;
let resolveRecordedBlueprintUrl;
let fetchRecordedBlueprintRrd;
let isRecordedPlaybackFullyBuffered;
let recordedObservationSources;
let selectRecordedMediaEpoch;
let recordedMediaLocalTime;
before(async () => {
server = await createServer({
@ -30,9 +46,32 @@ before(async () => {
({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
"/src/components/MseFmp4WebSocketPlayer.tsx",
));
({ initialObservationWindowRect } = await server.ssrLoadModule(
({ initialObservationWindowRect, shouldCaptureWorkspacePointer } = await server.ssrLoadModule(
"/src/components/FloatingObservationWindow.tsx",
));
({
ObservationTimeline,
normalizeTimelineRange,
timelineOffsetSeconds,
formatTimelineDuration,
normalizeAccumulationSeconds,
formatAccumulationDuration,
} =
await server.ssrLoadModule("/src/components/ObservationTimeline.tsx"));
({
resolveRerunSourceUrl,
resolveRecordedBlueprintUrl,
fetchRecordedBlueprintRrd,
isRecordedPlaybackFullyBuffered,
} = await server.ssrLoadModule(
"/src/components/RerunViewport.tsx",
));
({ recordedObservationSources } = await server.ssrLoadModule(
"/src/core/observation/recordedObservationSources.ts",
));
({ selectRecordedMediaEpoch, recordedMediaLocalTime } = await server.ssrLoadModule(
"/src/components/RecordedFmp4Player.tsx",
));
});
test("observation camera windows tile from the bottom-right above the live timeline", () => {
@ -75,6 +114,251 @@ test("observation camera tiling remains in bounds on a constrained viewport", ()
assert.ok(rects[1].y + rects[1].height <= rects[0].y);
});
test("floating observation interaction captures resize and movable header pointers", () => {
const target = (matches) => ({ closest: (selector) => matches.includes(selector) });
assert.equal(
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__resize"])),
true,
);
assert.equal(
shouldCaptureWorkspacePointer(0, target([".nodedc-workspace-window__head"])),
true,
);
assert.equal(
shouldCaptureWorkspacePointer(
0,
target([".nodedc-workspace-window__head", "button, input, select, textarea, a"]),
),
false,
);
assert.equal(
shouldCaptureWorkspacePointer(2, target([".nodedc-workspace-window__resize"])),
false,
);
});
test("recorded observation timeline clamps relative seek time without epoch precision in the UI", () => {
const range = normalizeTimelineRange({
min: 0,
max: 12_500_000_000,
});
assert.ok(range);
assert.equal(timelineOffsetSeconds(range, range.min + 2_250_000_000), 2.25);
assert.equal(timelineOffsetSeconds(range, range.min - 1), 0);
assert.equal(timelineOffsetSeconds(range, range.max + 1), 12.5);
assert.equal(formatTimelineDuration(62.125), "01:02.125");
});
test("recorded observation timeline rejects empty and non-finite ranges", () => {
assert.equal(normalizeTimelineRange(null), null);
assert.equal(normalizeTimelineRange({ min: 10, max: 10 }), null);
assert.equal(normalizeTimelineRange({ min: Number.NaN, max: 10 }), null);
});
test("accumulation control normalizes UI values and distinguishes a single frame", () => {
assert.equal(normalizeAccumulationSeconds(-3), 0);
assert.equal(normalizeAccumulationSeconds(12.6), 13);
assert.equal(normalizeAccumulationSeconds(999), 120);
assert.equal(normalizeAccumulationSeconds(Number.NaN), 0);
assert.equal(formatAccumulationDuration(0), "Кадр");
assert.equal(formatAccumulationDuration(12), "12 с");
});
test("spatial timeline renders synchronized accumulation and playback controls", () => {
const markup = renderToStaticMarkup(createElement(ObservationTimeline, {
active: true,
sourceCount: 2,
mode: "recorded",
seekable: true,
rangeNs: { min: 0, max: 20_000_000_000 },
currentNs: 5_000_000_000,
accumulationSeconds: 12,
onAccumulationChange: () => undefined,
onAccumulationCommit: () => undefined,
onSeek: () => undefined,
}));
assert.match(markup, /data-accumulation="true"/);
assert.match(markup, /Накопление/);
assert.match(markup, /aria-label="Окно накопления облака точек"/);
assert.match(markup, /aria-valuetext="12 с"/);
assert.match(markup, /aria-label="Позиция воспроизведения"/);
assert.equal((markup.match(/type="range"/g) ?? []).length, 2);
});
test("Rerun expands only root-relative session recordings onto the current origin", () => {
assert.equal(
resolveRerunSourceUrl(
" /api/v1/observation-sessions/session-1/recording.rrd ",
"http://127.0.0.1:5174",
),
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/recording.rrd",
);
assert.equal(
resolveRerunSourceUrl("rerun+http://127.0.0.1:9877/proxy", "http://127.0.0.1:5174"),
"rerun+http://127.0.0.1:9877/proxy",
);
assert.equal(
resolveRerunSourceUrl("//different-authority.invalid/session.rrd", "http://127.0.0.1:5174"),
"//different-authority.invalid/session.rrd",
);
});
test("recorded blueprint endpoint is derived only from canonical same-origin RRD sources", () => {
assert.equal(
resolveRecordedBlueprintUrl(
"/api/v1/observation-sessions/session-1/recording.rrd",
"http://127.0.0.1:5174",
),
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
);
assert.equal(
resolveRecordedBlueprintUrl(
"https://outside.invalid/api/v1/observation-sessions/session-1/recording.rrd",
"http://127.0.0.1:5174",
),
null,
);
assert.equal(
resolveRecordedBlueprintUrl(
"/api/v1/observation-sessions/../recording.rrd",
"http://127.0.0.1:5174",
),
null,
);
});
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
assert.equal(isRecordedPlaybackFullyBuffered(null, 20), false);
assert.equal(
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_500_000_000 }, 20),
false,
);
assert.equal(
isRecordedPlaybackFullyBuffered({ min: 0, max: 19_999_500_000 }, 20),
true,
);
assert.equal(
isRecordedPlaybackFullyBuffered({ min: 0, max: 1 }, undefined),
true,
);
assert.equal(
isRecordedPlaybackFullyBuffered({ min: 0, max: 20_000_000_000 }, Number.NaN),
false,
);
});
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
const calls = [];
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
const result = await fetchRecordedBlueprintRrd(
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
{
accumulationSeconds: 24,
showGrid: false,
showPoints: true,
showTrajectory: false,
pointSize: 4.5,
palette: "custom",
customColor: "#35d7c1",
},
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{
origin: "http://127.0.0.1:5174",
fetcher: async (input, init) => {
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
return new Response(payload, {
status: 200,
headers: { "Content-Type": "application/vnd.rerun.rrd" },
});
},
},
);
assert.deepEqual([...result], [...payload]);
assert.equal(calls[0].init.method, "POST");
assert.equal(calls[0].init.credentials, "same-origin");
assert.deepEqual(calls[0].body, {
application_id: "nodedc_mission_core_recorded",
recording_id: "recording-001",
accumulation_seconds: 24,
show_grid: false,
show_points: true,
show_trajectory: false,
point_size: 4.5,
palette: "custom",
custom_color: "#35d7c1",
});
await assert.rejects(
fetchRecordedBlueprintRrd(
"https://outside.invalid/api/v1/observation-sessions/session-1/blueprint.rrd",
{
accumulationSeconds: 24,
showGrid: false,
showPoints: true,
showTrajectory: false,
pointSize: 4.5,
palette: "custom",
customColor: "#35d7c1",
},
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
{ origin: "http://127.0.0.1:5174", fetcher: async () => new Response(payload) },
),
/Unsafe recorded blueprint request/,
);
});
test("recorded replay creates an isolated source catalog without live device bindings", () => {
const sources = recordedObservationSources({
kind: "rerun-recording",
sessionId: "session-1",
sourceUrl: "/api/v1/observation-sessions/session-1/recording.rrd",
viewerSourceUrl: `/api/v1/observation-sessions/session-1/recording.rrd?generation=${"a".repeat(64)}`,
mediaType: "application/vnd.rerun.rrd",
timeline: "session_time",
timelineStartSeconds: 0,
timelineEndSeconds: 20,
seekable: true,
byteLength: 123,
sha256: "a".repeat(64),
playback: { speed: 1, loop: false },
mediaSources: [{
id: "recorded.camera.abc123",
label: "Записанная камера 1",
modality: "video",
manifestUrl: "/api/v1/observation-sessions/session-1/media/recorded-video-abc123/manifest",
manifestGenerationSha256: "c".repeat(64),
byteLength: 1_024,
mediaType: "video/mp4",
timelineStartSeconds: 0.25,
timelineEndSeconds: 20,
seekable: true,
synchronization: "host-arrival-best-effort",
}],
});
assert.deepEqual(sources.map(({ modality }) => modality), ["point-cloud", "video"]);
assert.equal(sources[1].delivery.kind, "recorded-fmp4-manifest");
assert.equal(sources[1].delivery.manifestGenerationSha256, "c".repeat(64));
assert.deepEqual(sources[1].binding, {});
assert.equal(sources.some(({ provider }) => provider.pluginId.includes("xgrids")), false);
assert.equal(JSON.stringify(sources).includes("192.168"), false);
});
test("recorded camera epoch selection and shared-clock offset are deterministic", () => {
const epochs = [
{ ordinal: 1, timelineStartSeconds: 0.25, timelineEndSeconds: 9 },
{ ordinal: 2, timelineStartSeconds: 10, timelineEndSeconds: 20 },
];
assert.equal(selectRecordedMediaEpoch(epochs, 0), null);
assert.equal(selectRecordedMediaEpoch(epochs, 0.25).ordinal, 1);
assert.equal(selectRecordedMediaEpoch(epochs, 9), epochs[0]);
assert.equal(selectRecordedMediaEpoch(epochs, 9.9), null);
assert.equal(selectRecordedMediaEpoch(epochs, 10).ordinal, 2);
assert.equal(recordedMediaLocalTime(10, 12.5), 2.5);
assert.equal(recordedMediaLocalTime(10, 8), 0);
assert.equal(recordedMediaLocalTime(10, 30, 4), 4);
});
after(async () => {
await server?.close();
});

View File

@ -0,0 +1,303 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let fetchVerifiedRecordedMediaArchive;
let recordedMediaPresentationState;
let recordedMediaSeekableCoverage;
let appendRecordedMediaBuffer;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
fetchVerifiedRecordedMediaArchive,
recordedMediaPresentationState,
recordedMediaSeekableCoverage,
appendRecordedMediaBuffer,
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
});
after(async () => {
await server?.close();
});
function digest(payload) {
return createHash("sha256").update(payload).digest("hex");
}
function fixture() {
const manifestUrl = "/api/v1/observation-sessions/session-1/media/camera-1/manifest";
const init = Buffer.from("canonical-init");
const first = Buffer.from("canonical-first-fragment");
const second = Buffer.from("canonical-second-fragment");
const generation = "a".repeat(64);
const segmentPrefix = manifestUrl.replace("/manifest", "/epochs/1/segments/");
const manifest = {
schema_version: "missioncore.observation-recorded-media/v2",
source_id: "recorded.camera.camera-1",
generation_sha256: generation,
byte_length: init.byteLength + first.byteLength + second.byteLength,
timeline_start_seconds: 0,
timeline_end_seconds: 20,
synchronization: "host-arrival-best-effort",
epochs: [{
ordinal: 1,
timeline_start_seconds: 0,
timeline_end_seconds: 20,
media_type: 'video/mp4; codecs="avc1.640028"',
init_url: manifestUrl.replace("/manifest", "/epochs/1/init.mp4"),
init_byte_length: init.byteLength,
init_sha256: digest(init),
segment_count: 2,
segment_url_prefix: segmentPrefix,
segments: [first, second].map((payload, index) => ({
sequence: index + 1,
url: `${segmentPrefix}${index + 1}.m4s`,
byte_length: payload.byteLength,
sha256: digest(payload),
})),
}],
};
const source = {
id: manifest.source_id,
label: "Записанная камера",
modality: "video",
manifestUrl,
manifestGenerationSha256: generation,
byteLength: manifest.byte_length,
mediaType: "video/mp4",
timelineStartSeconds: 0,
timelineEndSeconds: 20,
seekable: true,
synchronization: "host-arrival-best-effort",
};
return { source, manifest, generation, init, first, second };
}
function jsonResponse(payload, generation) {
return new Response(JSON.stringify(payload), {
status: 200,
headers: {
"Content-Type": "application/json",
ETag: `"sha256:${generation}"`,
},
});
}
function mediaResponse(payload, sha = digest(payload), length = payload.byteLength) {
return new Response(payload, {
status: 200,
headers: {
"Content-Length": String(length),
ETag: `"sha256:${sha}"`,
},
});
}
function deferred() {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function flushAsync() {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
test("recorded camera archive stays pending until every canonical byte is verified", async () => {
const { source, manifest, generation, init, first, second } = fixture();
const finalSegment = deferred();
const requested = [];
let manifestRequests = 0;
const fetcher = async (input, request = {}) => {
const url = String(input);
requested.push(url);
const headers = new Headers(request.headers);
if (url === source.manifestUrl) {
manifestRequests += 1;
assert.equal(headers.get("If-Match"), `"sha256:${generation}"`);
return jsonResponse(manifest, generation);
}
const epoch = manifest.epochs[0];
if (url === epoch.init_url) {
assert.equal(headers.get("If-Match"), `"sha256:${epoch.init_sha256}"`);
return mediaResponse(init);
}
if (url === epoch.segments[0].url) return mediaResponse(first);
if (url === epoch.segments[1].url) return finalSegment.promise;
throw new Error(`Unexpected URL ${url}`);
};
let settled = false;
const archivePromise = fetchVerifiedRecordedMediaArchive(source, { fetcher })
.then((archive) => {
settled = true;
return archive;
});
await flushAsync();
assert.equal(settled, false, "init and a partial segment prefix must not publish the camera");
assert.deepEqual(requested.slice(0, 4), [
source.manifestUrl,
manifest.epochs[0].init_url,
manifest.epochs[0].segments[0].url,
manifest.epochs[0].segments[1].url,
]);
finalSegment.resolve(mediaResponse(second));
const archive = await archivePromise;
assert.equal(manifestRequests, 2, "the immutable generation is revalidated after transfer");
assert.equal(archive.byteLength, init.byteLength + first.byteLength + second.byteLength);
assert.equal(archive.epochs[0].segments.length, 2);
});
test("first camera manifest request is launch-generation bound and rejects replacement", async () => {
const { source, manifest, generation } = fixture();
const replacementGeneration = "d".repeat(64);
let requests = 0;
const fetcher = async (input, request = {}) => {
requests += 1;
assert.equal(String(input), source.manifestUrl);
assert.equal(
new Headers(request.headers).get("If-Match"),
`"sha256:${generation}"`,
);
return jsonResponse(
{ ...manifest, generation_sha256: replacementGeneration },
replacementGeneration,
);
};
await assert.rejects(
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
/несовместим|заменён/,
);
assert.equal(requests, 1);
});
test("recorded camera archive fails closed on truncation despite plausible headers", async () => {
const { source, manifest, generation, init, first, second } = fixture();
const fetcher = async (input) => {
const url = String(input);
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
const epoch = manifest.epochs[0];
if (url === epoch.init_url) return mediaResponse(init);
if (url === epoch.segments[0].url) return mediaResponse(first);
if (url === epoch.segments[1].url) {
return mediaResponse(second.subarray(0, second.byteLength - 1), digest(second), second.byteLength);
}
throw new Error(`Unexpected URL ${url}`);
};
await assert.rejects(
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
/усечён/,
);
});
test("recorded camera archive fails closed when bytes are replaced under an old ETag", async () => {
const { source, manifest, generation, init, first, second } = fixture();
const replacement = Buffer.from(second.map((value) => value ^ 0xff));
assert.equal(replacement.byteLength, second.byteLength);
const fetcher = async (input) => {
const url = String(input);
if (url === source.manifestUrl) return jsonResponse(manifest, generation);
const epoch = manifest.epochs[0];
if (url === epoch.init_url) return mediaResponse(init);
if (url === epoch.segments[0].url) return mediaResponse(first);
if (url === epoch.segments[1].url) {
return mediaResponse(replacement, digest(second), second.byteLength);
}
throw new Error(`Unexpected URL ${url}`);
};
await assert.rejects(
fetchVerifiedRecordedMediaArchive(source, { fetcher }),
/SHA-256/,
);
});
test("camera presentation gate opens only for the completely appended selected epoch", () => {
assert.equal(recordedMediaPresentationState("loading", null, "g1", false), "loading");
assert.equal(recordedMediaPresentationState("ready", null, "g1", false), "loading");
assert.equal(recordedMediaPresentationState("ready", "g2", "g1", false), "loading");
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false), "ready");
assert.equal(recordedMediaPresentationState("ready", "g1", null, true), "waiting");
assert.equal(recordedMediaPresentationState("error", "g1", "g1", false), "error");
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "loading"), "loading");
assert.equal(recordedMediaPresentationState("ready", "g1", null, true, "loading"), "loading");
assert.equal(recordedMediaPresentationState("ready", "g1", "g1", false, "error"), "error");
});
test("decoded duration and seekable range cover the complete declared epoch", () => {
assert.equal(recordedMediaSeekableCoverage(20, 20, 20), true);
assert.equal(recordedMediaSeekableCoverage(19, 19, 20), true);
assert.equal(recordedMediaSeekableCoverage(18.99, 20, 20), false);
assert.equal(recordedMediaSeekableCoverage(20, 18.99, 20), false);
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1), true);
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
});
test("aborting SourceBuffer append removes every temporary listener", async () => {
const listeners = new Map();
const sourceBuffer = {
addEventListener(type, listener) {
const bucket = listeners.get(type) ?? new Set();
bucket.add(listener);
listeners.set(type, bucket);
},
removeEventListener(type, listener) {
listeners.get(type)?.delete(listener);
},
appendBuffer() {},
};
const abort = new AbortController();
const pending = appendRecordedMediaBuffer(
sourceBuffer,
new ArrayBuffer(8),
abort.signal,
);
assert.equal(listeners.get("updateend")?.size, 1);
assert.equal(listeners.get("error")?.size, 1);
abort.abort();
await assert.rejects(pending, (error) => error?.name === "AbortError");
assert.equal(listeners.get("updateend")?.size, 0);
assert.equal(listeners.get("error")?.size, 0);
});
test("verified camera archives remain immutable for safe player remount", async () => {
const source = await readFile(
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
"utf8",
);
assert.doesNotMatch(source, /\.init\s*=\s*new ArrayBuffer/);
assert.doesNotMatch(source, /\.segments\.length\s*=\s*0/);
});
test("loading and error overlays fully conceal recorded camera pixels", async () => {
const css = await readFile(
new URL("../src/styles/observation.css", import.meta.url),
"utf8",
);
assert.match(
css,
/\.recorded-media-player:not\(\[data-state="ready"\]\) \.observation-media__asset\s*\{[^}]*visibility:\s*hidden/s,
);
assert.match(
css,
/\.recorded-media-player__notice\s*\{[^}]*inset:\s*0;[^}]*background:\s*#070809/s,
);
});

View File

@ -0,0 +1,175 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let admission;
let rerunPresentationStatus;
let recordedMediaPresentationState;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
admission = await server.ssrLoadModule(
"/src/core/observation/recordedSessionAdmission.ts",
);
({ rerunPresentationStatus } = await server.ssrLoadModule(
"/src/components/RerunViewport.tsx",
));
({ recordedMediaPresentationState } = await server.ssrLoadModule(
"/src/components/RecordedFmp4Player.tsx",
));
});
after(async () => {
await server?.close();
});
function camera(phase, byteLength = 1_024) {
return { phase, byteLength, message: null };
}
test("recorded session admits RRD and every declared camera as one atomic generation", () => {
const ids = ["camera.left", "camera.right"];
const oneCamera = {
"camera.left": camera("ready"),
"camera.right": camera("pending"),
};
const partialGate = admission.recordedSessionAdmissionPhase("ready", ids, oneCamera);
assert.equal(partialGate, "loading");
assert.equal(rerunPresentationStatus("ready", partialGate, true), "loading");
assert.equal(
recordedMediaPresentationState("ready", "generation", "generation", false, partialGate),
"loading",
);
const completeGate = admission.recordedSessionAdmissionPhase("ready", ids, {
...oneCamera,
"camera.right": camera("ready"),
});
assert.equal(completeGate, "ready");
assert.equal(rerunPresentationStatus("ready", completeGate, true), "ready");
assert.equal(
recordedMediaPresentationState("ready", "generation", "generation", false, completeGate),
"ready",
);
});
test("any RRD or camera failure closes the complete recorded session", () => {
const ids = ["camera.left", "camera.right"];
const cameras = {
"camera.left": camera("ready"),
"camera.right": camera("error"),
};
assert.equal(admission.recordedSessionAdmissionPhase("ready", ids, cameras), "error");
assert.equal(admission.recordedSessionAdmissionPhase("error", ids, {
...cameras,
"camera.right": camera("ready"),
}), "error");
assert.equal(rerunPresentationStatus("ready", "error", true), "error");
assert.equal(
recordedMediaPresentationState("ready", "generation", "generation", false, "error"),
"error",
);
});
test("camera admission enforces independent 16/128/512 MiB limits before fetching", () => {
const {
MAX_RECORDED_CAMERA_SOURCES,
MAX_RECORDED_MEDIA_SOURCE_BYTES,
MAX_RECORDED_SESSION_CAMERA_BYTES,
recordedCameraDescriptorPreflight,
} = admission;
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 128 * 1024 * 1024);
assert.equal(MAX_RECORDED_SESSION_CAMERA_BYTES, 512 * 1024 * 1024);
assert.equal(recordedCameraDescriptorPreflight(
Array.from({ length: 4 }, (_, index) => ({
id: `camera.${index}`,
byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES,
})),
), "ready");
assert.equal(recordedCameraDescriptorPreflight(
Array.from({ length: 17 }, (_, index) => ({ id: `camera.${index}`, byteLength: 1 })),
), "error");
assert.equal(recordedCameraDescriptorPreflight([
{ id: "camera.large", byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES + 1 },
]), "error");
assert.equal(recordedCameraDescriptorPreflight(
Array.from({ length: 5 }, (_, index) => ({
id: `camera.${index}`,
byteLength: 110 * 1024 * 1024,
})),
), "error");
assert.equal(recordedCameraDescriptorPreflight([
{ id: "camera.duplicate", byteLength: 1 },
{ id: "camera.duplicate", byteLength: 1 },
]), "error");
});
test("camera preparation remains single-flight and retains the loading permit", () => {
const ids = ["camera.first", "camera.preferred"];
const cameras = {
"camera.first": camera("loading"),
"camera.preferred": camera("pending"),
};
assert.deepEqual(
admission.nextRecordedCameraPreparationIds(
ids,
cameras,
new Set(["camera.preferred"]),
),
["camera.first"],
);
assert.deepEqual(
admission.nextRecordedCameraPreparationIds(ids, {
...cameras,
"camera.first": camera("ready"),
}),
["camera.preferred"],
);
});
test("new render workers close readiness and stale callbacks cannot reopen it", () => {
const ready = { ...camera("ready"), workerGeneration: 1 };
const remounted = admission.mergeRecordedCameraAdmission(ready, {
...camera("loading"),
workerGeneration: 2,
});
assert.equal(remounted.phase, "loading");
assert.equal(remounted.workerGeneration, 2);
assert.equal(admission.mergeRecordedCameraAdmission(remounted, {
...camera("ready"),
workerGeneration: 1,
}), remounted);
const admitted = admission.mergeRecordedCameraAdmission(remounted, {
...camera("ready"),
workerGeneration: 2,
});
assert.equal(admitted.phase, "ready");
const failed = admission.mergeRecordedCameraAdmission(admitted, {
...camera("error"),
workerGeneration: 2,
});
assert.equal(failed.phase, "error");
assert.equal(admission.mergeRecordedCameraAdmission(failed, {
...camera("loading"),
workerGeneration: 3,
}), failed);
});
test("recorded player is not mounted for a camera without a scheduler permit", async () => {
const source = await readFile(
new URL("../src/components/ObservationSources.tsx", import.meta.url),
"utf8",
);
const placeholder = source.indexOf("if (!prepareRecorded)");
const player = source.indexOf("<RecordedFmp4Player", placeholder);
assert.ok(placeholder >= 0 && player > placeholder);
assert.match(source.slice(placeholder, player), /return\s*\(/);
});

View File

@ -0,0 +1,154 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let createRecordedOpenWatchdog;
let recordedOpenWatchdogTimeoutMs;
let rerunViewerInitialSource;
let resolveRecordedViewerSourceUrl;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
createRecordedOpenWatchdog,
recordedOpenWatchdogTimeoutMs,
rerunViewerInitialSource,
resolveRecordedViewerSourceUrl,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
});
after(async () => {
await server?.close();
});
const sourceUrl = "/api/v1/observation-sessions/session-atomic/recording.rrd";
const sha256 = "a".repeat(64);
const viewerSourceUrl = `${sourceUrl}?generation=${sha256}`;
test("only the canonical digest-bound generation URL reaches the native Rerun receiver", () => {
const descriptor = {
sourceUrl,
viewerSourceUrl,
byteLength: 246_331_680,
sha256,
};
const resolved = resolveRecordedViewerSourceUrl(descriptor, "http://mission-core.test");
assert.equal(resolved, `http://mission-core.test${viewerSourceUrl}`);
assert.equal(rerunViewerInitialSource(resolved), resolved);
for (const unsafeViewerSourceUrl of [
sourceUrl,
`${sourceUrl}?generation=${"b".repeat(64)}`,
`${viewerSourceUrl}&extra=true`,
`https://foreign.test${viewerSourceUrl}`,
]) {
assert.throws(
() => resolveRecordedViewerSourceUrl({
...descriptor,
viewerSourceUrl: unsafeViewerSourceUrl,
}, "http://mission-core.test"),
/Unsafe recorded RRD viewer descriptor/,
);
}
});
test("recorded admission watchdog is size-aware and exits an incomplete load", () => {
const smallDelay = recordedOpenWatchdogTimeoutMs(4);
const currentDelay = recordedOpenWatchdogTimeoutMs(246_331_680);
const largeDelay = recordedOpenWatchdogTimeoutMs(805_687_122);
assert.ok(smallDelay >= 120_000);
assert.ok(currentDelay >= smallDelay);
assert.ok(largeDelay > currentDelay);
assert.ok(largeDelay <= 1_800_000);
assert.ok(largeDelay > 12_000);
let scheduledCallback;
let scheduledDelay;
let timeoutCount = 0;
const cancelled = [];
const watchdog = createRecordedOpenWatchdog({
byteLength: 246_331_680,
schedule(callback, timeoutMs) {
scheduledCallback = callback;
scheduledDelay = timeoutMs;
return 17;
},
cancel(handle) {
cancelled.push(handle);
},
onTimeout() {
timeoutCount += 1;
},
});
watchdog.arm();
assert.equal(watchdog.pending(), true);
assert.equal(scheduledDelay, currentDelay);
scheduledCallback();
assert.equal(timeoutCount, 1);
assert.equal(watchdog.pending(), false);
assert.deepEqual(cancelled, []);
});
test("complete recorded admission clears its watchdog", () => {
let timeoutCount = 0;
const cancelled = [];
const watchdog = createRecordedOpenWatchdog({
byteLength: 805_687_122,
schedule() {
return 23;
},
cancel(handle) {
cancelled.push(handle);
},
onTimeout() {
timeoutCount += 1;
},
});
watchdog.arm();
watchdog.clear();
assert.equal(watchdog.pending(), false);
assert.equal(timeoutCount, 0);
assert.deepEqual(cancelled, [23]);
});
test("recorded RRD bytes are never split across LogChannel.send_rrd calls", async () => {
const source = await readFile(
new URL("../src/components/RerunViewport.tsx", import.meta.url),
"utf8",
);
assert.doesNotMatch(source, /streamVerifiedRecordedRrd/);
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
assert.doesNotMatch(source, /recordedChannel/);
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
assert.match(
source,
/recordingOpened = true;[\s\S]*if \(!isRecordedSource\) clearLiveRecordingOpenTimer\(\);/,
);
assert.match(
source,
/viewerStartResolved = true;\s*if \(isRecordedSource && !recordedSceneAdmitted\) recordedOpenWatchdog\?\.arm\(\);/,
);
assert.match(
source,
/if \(readyToRender && !readyPublished\)[\s\S]*clearRecordedAdmissionWatchdog\(\);/,
);
});
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
const css = await readFile(new URL("../src/styles/spatial.css", import.meta.url), "utf8");
assert.match(
css,
/\.rerun-viewport:is\(\[data-status="loading"\], \[data-status="error"\]\)\s+\.rerun-viewport__canvas\s*\{[^}]*visibility:\s*hidden;[^}]*pointer-events:\s*none;/s,
);
assert.doesNotMatch(
css,
/data-status="loading"[^}]*\.rerun-viewport__canvas\s*>\s*div/s,
);
});

View File

@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let attemptRecordedAutoplay;
let createLatestAnimationFrameEmitter;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ attemptRecordedAutoplay, createLatestAnimationFrameEmitter } =
await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
});
after(async () => {
await server?.close();
});
test("recorded autoplay reports success only after seek and play both succeed", () => {
let seekAttempts = 0;
let playAttempts = 0;
const seekToStart = () => {
seekAttempts += 1;
};
const startPlaying = () => {
playAttempts += 1;
if (playAttempts === 1) throw new Error("receiver is not ready yet");
};
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), false);
assert.equal(attemptRecordedAutoplay(seekToStart, startPlaying), true);
assert.equal(seekAttempts, 2);
assert.equal(playAttempts, 2);
});
test("recorded autoplay does not try play when the seek itself fails", () => {
let playAttempts = 0;
assert.equal(attemptRecordedAutoplay(
() => {
throw new Error("timeline is not ready yet");
},
() => {
playAttempts += 1;
},
), false);
assert.equal(playAttempts, 0);
});
test("time updates coalesce to the latest value once per animation frame", () => {
let nextHandle = 1;
const frames = new Map();
const cancelled = [];
const emitted = [];
const emitter = createLatestAnimationFrameEmitter({
emit(value) {
emitted.push(value);
},
requestFrame(callback) {
const handle = nextHandle;
nextHandle += 1;
frames.set(handle, callback);
return handle;
},
cancelFrame(handle) {
cancelled.push(handle);
frames.delete(handle);
},
});
emitter.push(1);
emitter.push(2);
emitter.push(3);
assert.equal(frames.size, 1);
assert.deepEqual(emitted, []);
const firstFrame = frames.get(1);
frames.delete(1);
firstFrame(0);
assert.deepEqual(emitted, [3]);
emitter.push(4);
assert.equal(frames.size, 1);
emitter.cancel();
assert.deepEqual(cancelled, [2]);
assert.deepEqual(emitted, [3]);
emitter.push(5);
assert.equal(frames.size, 0);
assert.deepEqual(emitted, [3]);
});

View File

@ -0,0 +1,170 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let canPublishRecordedPlaybackController;
let createRecordedAutoplayGate;
let isRecordedPlaybackReady;
let isRecordedPlaybackPresentationReady;
let isUsableRecordedPlaybackRange;
let recordedPlaybackBufferState;
let recordedPlaybackRangeWhenReady;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
canPublishRecordedPlaybackController,
createRecordedAutoplayGate,
isRecordedPlaybackReady,
isRecordedPlaybackPresentationReady,
isUsableRecordedPlaybackRange,
recordedPlaybackBufferState,
recordedPlaybackRangeWhenReady,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
});
after(async () => {
await server?.close();
});
test("a first frame reports buffer telemetry but is not ready for presentation", () => {
assert.equal(isUsableRecordedPlaybackRange(null), false);
assert.equal(isUsableRecordedPlaybackRange({ min: Number.NaN, max: 0 }), false);
assert.equal(isUsableRecordedPlaybackRange({ min: 2, max: 1 }), false);
assert.equal(isUsableRecordedPlaybackRange({ min: 0, max: 0 }), true);
const firstFrame = recordedPlaybackBufferState({ min: 0, max: 0 }, 20);
assert.deepEqual(firstFrame, {
bufferedEndNs: 0,
expectedEndNs: 20_000_000_000,
bufferProgress: 0,
fullyBuffered: false,
});
assert.equal(isRecordedPlaybackReady(true, true, firstFrame), false);
assert.equal(recordedPlaybackRangeWhenReady({ min: 0, max: 0 }, firstFrame, true), null);
});
test("host timeline remains unmounted until the verified recording is fully ready", () => {
const partial = {
recordingId: "partial",
timeline: "session_time",
rangeNs: null,
currentNs: 0,
playing: false,
bufferedEndNs: 5,
expectedEndNs: 10,
bufferProgress: 0.5,
fullyBuffered: false,
};
assert.equal(isRecordedPlaybackPresentationReady("loading", partial), false);
assert.equal(isRecordedPlaybackPresentationReady("ready", partial), false);
assert.equal(isRecordedPlaybackPresentationReady("error", partial), false);
assert.equal(isRecordedPlaybackPresentationReady("ready", {
...partial,
rangeNs: { min: 0, max: 10 },
bufferedEndNs: 10,
bufferProgress: 1,
fullyBuffered: true,
}), true);
});
test("buffer progress grows independently and preserves the full-buffer tolerance", () => {
assert.deepEqual(recordedPlaybackBufferState({ min: 0, max: 5_000_000_000 }, 20), {
bufferedEndNs: 5_000_000_000,
expectedEndNs: 20_000_000_000,
bufferProgress: 0.25,
fullyBuffered: false,
});
const complete = recordedPlaybackBufferState({ min: 0, max: 19_999_500_000 }, 20);
assert.equal(complete.bufferProgress, 0.999975);
assert.equal(complete.fullyBuffered, true);
assert.equal(isRecordedPlaybackReady(false, true, complete), false);
assert.equal(isRecordedPlaybackReady(true, false, complete), false);
assert.equal(isRecordedPlaybackReady(true, true, complete), true);
assert.equal(
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, false),
null,
);
assert.deepEqual(
recordedPlaybackRangeWhenReady({ min: 0, max: 19_999_500_000 }, complete, true),
{ min: 0, max: 19_999_500_000 },
);
const wrongDeclaredStart = recordedPlaybackBufferState(
{ min: 5_000_000_000, max: 20_000_000_000 },
20,
0,
);
assert.equal(wrongDeclaredStart.bufferProgress, 1);
assert.equal(wrongDeclaredStart.fullyBuffered, false);
assert.equal(isRecordedPlaybackReady(true, true, wrongDeclaredStart), false);
});
test("recorded autoplay waits for the full range and then runs exactly once", () => {
const gate = createRecordedAutoplayGate();
const seeks = [];
let plays = 0;
const seek = (value) => seeks.push(value);
const play = () => {
plays += 1;
};
assert.equal(gate.attempt(false, true, true, { min: 0, max: 0 }, seek, play), false);
assert.equal(gate.attempt(true, false, true, { min: 0, max: 500_000_000 }, seek, play), false);
assert.equal(gate.attempt(true, true, false, { min: 0, max: 20_000_000_000 }, seek, play), false);
assert.deepEqual(seeks, []);
assert.equal(plays, 0);
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), true);
assert.equal(gate.attempt(true, true, true, { min: 0, max: 20_000_000_000 }, seek, play), false);
assert.deepEqual(seeks, [0]);
assert.equal(plays, 1);
assert.equal(gate.attempted(), true);
});
test("a failed vendor autoplay attempt is consumed instead of rewinding later", () => {
const gate = createRecordedAutoplayGate();
let seeks = 0;
let plays = 0;
assert.equal(gate.attempt(
true,
true,
true,
{ min: 0, max: 1 },
() => {
seeks += 1;
},
() => {
plays += 1;
throw new Error("receiver raced its first frame");
},
), false);
assert.equal(gate.attempt(
true,
true,
true,
{ min: 0, max: 2 },
() => {
seeks += 1;
},
() => {
plays += 1;
},
), false);
assert.equal(seeks, 1);
assert.equal(plays, 1);
});
test("recorded playback controller stays unpublished until the aggregate gate is ready", () => {
assert.equal(canPublishRecordedPlaybackController(false, "ready"), false);
assert.equal(canPublishRecordedPlaybackController(true, "loading"), false);
assert.equal(canPublishRecordedPlaybackController(true, "error"), false);
assert.equal(canPublishRecordedPlaybackController(true, "ready"), true);
});

View File

@ -0,0 +1,178 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let createLatestAsyncCommitter;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({ createLatestAsyncCommitter } = await server.ssrLoadModule(
"/src/core/runtime/latestAsyncCommitter.ts",
));
});
after(async () => {
await server?.close();
});
async function eventually(predicate, timeoutMs = 1_000) {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error("Timed out waiting for async commit queue");
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
test("viewer settings commits are serialized and intermediate slider values collapse", async () => {
const calls = [];
const resolvers = [];
const settled = [];
const committer = createLatestAsyncCommitter({
commit(value) {
calls.push(value);
return new Promise((resolve) => resolvers.push(resolve));
},
onSettled(result) {
settled.push(result);
},
});
committer.enqueue({ accumulationSeconds: 1 });
committer.enqueue({ accumulationSeconds: 2 });
committer.enqueue({ accumulationSeconds: 12 });
assert.deepEqual(calls, [{ accumulationSeconds: 1 }]);
assert.equal(committer.isBusy(), true);
resolvers.shift()(true);
await eventually(() => calls.length === 2);
assert.deepEqual(calls[1], { accumulationSeconds: 12 });
assert.equal(settled[0].superseded, true);
resolvers.shift()(true);
await eventually(() => !committer.isBusy());
assert.deepEqual(settled.map(({ value, applied, superseded }) => ({
value,
applied,
superseded,
})), [
{ value: { accumulationSeconds: 1 }, applied: true, superseded: true },
{ value: { accumulationSeconds: 12 }, applied: true, superseded: false },
]);
});
test("viewer settings commit failures settle as rejected without wedging the queue", async () => {
const settled = [];
const committer = createLatestAsyncCommitter({
async commit(value) {
if (value === "broken") throw new Error("offline");
return true;
},
onSettled(result) {
settled.push(result);
},
});
committer.enqueue("broken");
await eventually(() => settled.length === 1);
committer.enqueue("healthy");
await eventually(() => settled.length === 2);
assert.deepEqual(settled.map(({ value, applied }) => ({ value, applied })), [
{ value: "broken", applied: false },
{ value: "healthy", applied: true },
]);
assert.equal(committer.isBusy(), false);
});
test("waitForIdle resolves only after the running commit and latest queued value settle", async () => {
const calls = [];
const resolvers = [];
const committer = createLatestAsyncCommitter({
commit(value) {
calls.push(value);
return new Promise((resolve) => resolvers.push(resolve));
},
});
committer.enqueue("initial");
committer.enqueue("latest");
let idle = false;
const idlePromise = committer.waitForIdle().then(() => {
idle = true;
});
await Promise.resolve();
assert.equal(idle, false);
resolvers.shift()(true);
await eventually(() => calls.length === 2);
assert.deepEqual(calls, ["initial", "latest"]);
assert.equal(idle, false);
resolvers.shift()(true);
await idlePromise;
assert.equal(idle, true);
assert.equal(committer.isBusy(), false);
});
test("waitForIdle observes work enqueued by onSettled and resolves immediately when idle", async () => {
const calls = [];
let committer;
committer = createLatestAsyncCommitter({
async commit(value) {
calls.push(value);
return true;
},
onSettled({ value }) {
if (value === "first") committer.enqueue("follow-up");
},
});
await committer.waitForIdle();
committer.enqueue("first");
await committer.waitForIdle();
assert.deepEqual(calls, ["first", "follow-up"]);
assert.equal(committer.isBusy(), false);
});
test("layout serialization can flush a staged setting and read the confirmed latest value", async () => {
let staged = { pointSize: 2 };
let confirmed = { pointSize: 1 };
let resolveCommit;
const committer = createLatestAsyncCommitter({
commit() {
return new Promise((resolve) => {
resolveCommit = resolve;
});
},
onSettled({ value, applied }) {
if (applied) confirmed = value;
},
});
const saveLayout = async () => {
committer.enqueue(staged);
await committer.waitForIdle();
return { sceneSettings: confirmed };
};
const savePromise = saveLayout();
staged = { pointSize: 9 };
let saved = false;
void savePromise.then(() => {
saved = true;
});
await Promise.resolve();
assert.equal(saved, false);
resolveCommit(true);
const layout = await savePromise;
assert.deepEqual(layout, { sceneSettings: { pointSize: 2 } });
});

View File

@ -0,0 +1,229 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let decodeObservationWorkspaceLayoutProfile;
let encodeObservationWorkspaceLayoutProfile;
let fetchObservationWorkspaceLayoutProfile;
let normalizeObservationWindowRect;
let projectObservationLayoutSnapshot;
let saveObservationWorkspaceLayoutProfile;
let WorkspaceLayoutApiError;
let WorkspaceLayoutContractError;
let observationPresentationSourceAfterLayoutApply;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
decodeObservationWorkspaceLayoutProfile,
encodeObservationWorkspaceLayoutProfile,
fetchObservationWorkspaceLayoutProfile,
normalizeObservationWindowRect,
projectObservationLayoutSnapshot,
saveObservationWorkspaceLayoutProfile,
WorkspaceLayoutApiError,
WorkspaceLayoutContractError,
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
({ observationPresentationSourceAfterLayoutApply } = await server.ssrLoadModule(
"/src/core/observation/useObservationLayout.ts",
));
});
after(async () => {
await server?.close();
});
function wireProfile(overrides = {}) {
return {
version: 1,
revision: 7,
workspace_id: "observation.spatial",
scene_settings: {
projection: "3d",
point_size: 2.5,
color_mode: "intensity",
palette: "turbo",
custom_color: "#35d7c1",
accumulation_seconds: 12,
show_points: true,
show_trajectory: true,
show_grid: true,
show_labels: false,
show_camera_frustums: true,
},
tool_windows: {
sources_open: true,
display_open: false,
layers_open: true,
order: ["sources", "layers", "display"],
},
visible_source_ids: ["spatial.point-cloud.live", "camera.left"],
active_floating_source_id: "camera.left",
window_rects: {
"camera.left": { x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
"camera.right": { x: 0.55, y: 0.1, width: 0.4, height: 0.4 },
},
viewport_size: { width: 1_000, height: 500 },
...overrides,
};
}
test("workspace layout performs a lossless strict wire/camel/wire round trip", () => {
const wire = wireProfile();
const profile = decodeObservationWorkspaceLayoutProfile(wire);
assert.deepEqual(profile.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
assert.deepEqual(profile.toolWindows.order, ["sources", "layers", "display"]);
assert.equal(profile.sceneSettings.pointSize, 2.5);
assert.deepEqual(encodeObservationWorkspaceLayoutProfile(profile), wire);
});
test("workspace layout rejects unknown fields, transient transport data and invalid schema values", () => {
assert.throws(
() => decodeObservationWorkspaceLayoutProfile({
...wireProfile(),
source_url: "ws://192.168.68.52:9877",
}),
WorkspaceLayoutContractError,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ version: 2 })),
/Неподдерживаемая версия/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({ revision: 1.5 })),
/revision/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
visible_source_ids: ["../camera"],
active_floating_source_id: null,
})),
/стабильный идентификатор/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
window_rects: { "camera.left": { x: 0.8, y: 0, width: 0.4, height: 0.5 } },
})),
/выходит за нормализованные границы/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
tool_windows: {
sources_open: true,
display_open: true,
layers_open: true,
order: ["sources", "sources", "layers"],
},
})),
/перестановкой/,
);
assert.throws(
() => decodeObservationWorkspaceLayoutProfile(wireProfile({
scene_settings: { ...wireProfile().scene_settings, point_size: Number.POSITIVE_INFINITY },
})),
/point_size/,
);
});
test("restored desired layout survives an empty catalog and reveals only known stable sources later", () => {
const profile = decodeObservationWorkspaceLayoutProfile(wireProfile());
const snapshot = {
visibleSourceIds: profile.visibleSourceIds,
activeFloatingSourceId: profile.activeFloatingSourceId,
windowRects: profile.windowRects,
viewportSize: profile.viewportSize,
};
const empty = projectObservationLayoutSnapshot(snapshot, new Set(), { width: 500, height: 1_000 });
assert.deepEqual(empty, {
visibleSourceIds: [],
activeFloatingSourceId: null,
windowRects: {},
});
assert.deepEqual(snapshot.visibleSourceIds, ["spatial.point-cloud.live", "camera.left"]);
const cameraAppeared = projectObservationLayoutSnapshot(
snapshot,
new Set(["camera.left"]),
{ width: 500, height: 1_000 },
);
assert.deepEqual(cameraAppeared.visibleSourceIds, ["camera.left"]);
assert.equal(cameraAppeared.activeFloatingSourceId, "camera.left");
assert.deepEqual(cameraAppeared.windowRects["camera.left"], {
x: 50,
y: 100,
width: 200,
height: 400,
});
});
test("window rectangles normalize once and project proportionally into a different viewport", () => {
assert.deepEqual(
normalizeObservationWindowRect(
{ x: 100, y: 50, width: 400, height: 200 },
{ width: 1_000, height: 500 },
),
{ x: 0.1, y: 0.1, width: 0.4, height: 0.4 },
);
});
test("fullscreen presentation survives the viewport resize it causes", () => {
assert.equal(
observationPresentationSourceAfterLayoutApply("camera.left", "preserve"),
"camera.left",
);
assert.equal(
observationPresentationSourceAfterLayoutApply("camera.left", "reset"),
null,
);
});
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
const calls = [];
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
const saved = await saveObservationWorkspaceLayoutProfile(current, {
fetcher: async (input, init) => {
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
return new Response(JSON.stringify(wireProfile({ revision: 8 })), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
});
assert.equal(calls.length, 1);
assert.equal(calls[0].input, "/api/v1/workspace-layouts/observation.spatial");
assert.equal(calls[0].init.method, "PUT");
assert.equal(calls[0].init.headers["If-Match"], '"7"');
assert.equal(calls[0].body.revision, 7);
assert.equal(calls[0].body.workspace_id, "observation.spatial");
assert.equal(saved.revision, 8);
});
test("workspace layout API treats 404 as no profile and exposes revision conflicts", async () => {
assert.equal(
await fetchObservationWorkspaceLayoutProfile({
fetcher: async () => new Response(null, { status: 404 }),
}),
null,
);
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
await assert.rejects(
saveObservationWorkspaceLayoutProfile(current, {
fetcher: async () => new Response(JSON.stringify({ detail: "revision mismatch" }), {
status: 412,
headers: { "Content-Type": "application/json" },
}),
}),
(error) => error instanceof WorkspaceLayoutApiError &&
error.conflict && error.status === 412 && error.message === "revision mismatch",
);
});

View File

@ -13,8 +13,12 @@ All 1,140 captured `lio_pcl` frames decoded as raw-LZ4 protobuf blocks, yielding
4,165,862 points. All 1,215 `lio_pose` messages decoded, and the resulting
approximately 1.566 m displacement matched the controlled movement. A later
owner-operated LixelGO/iPhone capture also observed left/right RTSP/H.264 camera
preview and the remote start/stop wire mapping. The remaining camera work is a
bounded Mission Core receiver, not transport discovery.
preview and the remote start/stop wire mapping. Mission Core now has a bounded
read-only receiver, physical live left/right UI acceptance, acquisition-owned
fMP4 archival and a generation-bound recorded player. The remaining camera gate
is a newly recorded real K1 session played end-to-end with its point/pose
timeline. Full-resolution/raw panorama, device-clock synchronization,
intrinsics/extrinsics and remote delivery are still unproven.
The supplied Bible is useful as an OSINT dossier. It is not an executable plan
for the actual stand because many experiments assume a phone and LixelGO. The

View File

@ -3,7 +3,7 @@
This plan supersedes the app-dependent experiment order in the reference Bible.
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
## Live checkpoint — 2026-07-15
## Current checkpoint — 2026-07-17
| Stage | Result |
| --- | --- |
@ -15,8 +15,11 @@ Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
| Stage 4 artifacts/flows | GO — bounded capture, hashes and negative control |
| Stage 5 point cloud | GO — raw-LZ4 protobuf, 1,140 live frames decoded |
| Stage 5 pose | GO — 1,215 live frames decoded and motion-correlated |
| Stage 5 camera | GO (discovery) — left/right RTSP/H.264 preview observed; runtime adapter pending |
| Stage 6 live viewer | GO — React console, Foxglove cloud/path and Mac latency metrics |
| Stage 5 camera | GO (live) — left/right RTSP/H.264 preview observed, read-only runtime adapter and physical UI acceptance completed |
| Stage 6 live viewer | GO — React Control Station, embedded self-hosted Rerun cloud/trajectory and Mac pipeline metrics |
| Stage 7 observation archive | GO (point/pose) — durable catalog, recovery, background RRD preparation, saved-session timeline and atomic playback verified |
| Stage 7 recorded cameras | GO (contract), acceptance pending — acquisition-owned fMP4 archive and player are implemented/tested; no real archived K1 camera session exists yet |
| Stage 8 product storage | PAUSE — retention, replication, encryption, capacity monitoring and long-run browser/WASM stress remain deployment gates |
USB project copying remains optional ground truth rather than a blocker for the
now-verified network path. Owner-operated LixelGO traffic verifies the MQTT
@ -24,10 +27,14 @@ start/stop mapping and RTSP camera transport. MQTT control publishing remains
deliberately deferred because complete request/save/rollback semantics are not
yet modeled and the physical button remains a known-safe fallback.
The Stage 6 alpha uses a bounded raw-first bridge: loss in the visualization
queue cannot discard MQTT evidence. Acceptance is replay of the full captured
scan followed by a live ideal-LAN run with measured host pipeline latency.
Sensor-to-display latency remains a separate clock-correlation test.
The Stage 6 live path uses a bounded raw-first bridge: loss in the visualization
queue cannot discard MQTT evidence. Stage 7 adds an independent durable
observation-session lifecycle. Completed or recovered native captures are
prepared once by a bounded backend worker into digest-bound RRD/cache-v6 and
camera-manifest generations; a browser replay request never performs conversion.
The saved scene, controller and timeline remain hidden until the complete RRD
range and every declared camera pass admission. Sensor-to-display latency and
camera/LiDAR sensor-clock alignment remain separate correlation tests.
## Stage 0 — repository and host baseline
@ -213,4 +220,6 @@ useful stream is decoded or structurally identified.
- automated scan-button electronics;
- OpenWrt/monitor-mode infrastructure;
- firmware or internal-Linux analysis;
- camera branch if no external frame stream is evidenced.
- physical end-to-end replay acceptance for newly archived left/right cameras;
- long-running large-session WebViewer/WASM memory telemetry;
- production retention, replication, encryption and cross-platform packaging.

View File

@ -1,9 +1,11 @@
# K1 live console and embedded Rerun bridge
Status: the Rerun alpha milestone is implemented for the verified firmware-3
MQTT streams. Live capture and replay use real K1 point-cloud and pose messages;
the application does not generate a placeholder cloud, trajectory, camera frame
or latency value.
Status: the Rerun live and saved-session milestones are implemented for the
verified firmware-3 point/pose streams. Live and adapter file-replay use the
process-wide gRPC source; saved observation sessions use private digest-bound
RRD over same-origin HTTP. Left/right RTSP preview is delivered separately from
the spatial Rerun stream. The application does not generate placeholder cloud,
trajectory, camera frame or latency values.
## Active device-to-scene path
@ -36,10 +38,12 @@ React console <-- REST + WebSocket state --> FastAPI on 127.0.0.1:8000
```
The Paho MQTT callback does not decode or render the point cloud. It first
writes and flushes the raw frame and metadata, then enqueues a preview message.
If visualization cannot keep up, the oldest queued preview is discarded while
the raw capture continues. Rerun work stays on the dedicated publisher thread
and cannot block raw-first evidence capture.
appends the raw frame, flushes it to the operating-system page cache and stages
its aligned metadata, then enqueues a preview message. Raw bytes and metadata
become a durable pair at the bounded group-commit boundary (at most 0.5 s,
4 MiB or 32 messages), not at every callback. If visualization cannot keep up,
the oldest queued preview is discarded while capture continues. Rerun work
stays on the dedicated publisher thread and cannot block that raw-first path.
The current live/replay runtime instantiates only `RerunBridge`. The former
Foxglove implementation is not a parallel runtime and does not listen on TCP
@ -53,7 +57,7 @@ Prerequisites are the repository-local Python environment and Node.js 20.19+ or
```bash
uv sync --group dev
cd apps/control-station
npm install
npm ci
npm run typecheck
npm run build
cd ../..
@ -112,18 +116,20 @@ directly and use their sibling metadata receive timestamps when present.
9. Double-click K1 again to stop physical scanning, wait for steady green, then
stop the local session so captures and summaries are finalized.
Each live run creates an ignored `sessions/<UTC>_viewer_live/` directory with a
redacted manifest, operator notes, raw MQTT frames, per-message metadata and a
hash summary. The connector subscribes to the fixed report-topic allowlist and
does not publish an application request or modeling command.
Each new live run creates a direct child below `MISSIONCORE_EVIDENCE_DIR`, or
`.runtime/mission-core/evidence/sessions/` by default, with raw MQTT frames,
per-message metadata and a hash summary. Repository-level
`sessions/*_viewer_live` is legacy import-only evidence and is never selected by
the current writer. The connector subscribes to the fixed report-topic
allowlist and does not publish an application request or modeling command.
## Automatic Rerun source and lifecycle
On the first live or replay session, `RerunBridge`:
On the first live or adapter file-replay session, `RerunBridge`:
- creates an explicit `RecordingStream("nodedc_mission_core_spatial")`;
- installs the default spatial blueprint;
- starts the gRPC/proxy server on TCP 9876 with a 512 MiB late-client buffer;
- starts the gRPC/proxy server on TCP 9876 with a 32 MiB late-client buffer;
- reports `rerun+http://127.0.0.1:9876/proxy` only after the server is ready;
- accepts the local development and production browser origins used by this
repository;
@ -150,7 +156,7 @@ stream directly from the Rerun gRPC/proxy endpoint.
| `/world` | `ViewCoordinates` | right-handed Z-up display convention |
| `/world/points` | `Points3D` | decoded metric XYZ with computed or available RGB colors |
| `/world/sensor_pose` | `Transform3D` + `TransformAxes3D` | current decoded translation, xyzw quaternion and pose axes |
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 20,000 decoded poses |
| `/world/trajectory` | `LineStrips3D` | bounded path of up to 2,000 decoded poses |
Point count, frame rate, queue drops and measured pipeline time remain product
metrics in the outer Control Station; they are not logged as Rerun entities and
@ -189,9 +195,34 @@ shows only the current frame. RGB mode falls back to the verified scalar
coloring path when the active point format has no RGB fields. The current
`class`/custom behavior is a selected solid color, not semantic segmentation.
Projection switching, a custom playback timeline, semantic object/mask layers,
camera frustums and persisted RBL/layout profiles are not wired yet and must not
be inferred from the implemented controls above.
Projection switching, semantic object/mask layers and camera frustums are not
wired yet. The host-owned `observation.spatial` layout profile does persist the
implemented scene controls, tool-window state, dynamic source visibility and
normalized floating-window geometry. It is separate from Rerun's internal
blueprint and from observation evidence.
## Saved observation sessions
Every completed native live run is indexed by the local host session store. A
valid crash prefix without its final summary is indexed as `interrupted`. The
**Сохранённые сессии** control shows the three newest runs and launches an
opaque same-origin RRD rather than starting a second live bridge.
Sealing, recovery or legacy import makes the session eligible for the bounded
backend preparation worker. The worker reads every native message once and
atomically publishes a private cache-v6 RRD using the zero-based `session_time`
duration timeline. A SHA sidecar binds the generation to native evidence.
Replay-open never performs conversion: it returns HTTP 202 and a preparation
handle or reuses the verified artifact. The embedded viewer opens the exact
generation through Rerun's native incremental HTTP receiver. The bottom Control
Station timeline then controls play/pause and seek directly; it does not
approximate time with a React timer.
The native `.k1mqtt` remains the evidence master. RRD generation does not use
the bounded live-preview queue, so it retains every frame accepted by the
reviewed normalizer. See
[`09_OBSERVATION_SESSIONS.md`](09_OBSERVATION_SESSIONS.md) for storage,
recovery and HTTP Range details.
## Time and latency semantics
@ -228,7 +259,7 @@ and host firewall as appropriate, and never forward it directly to the public
Internet or cellular WAN. Remote operation requires an authenticated TLS reverse
proxy or another reviewed secure transport before deployment.
The 512 MiB Rerun server buffer limits retained late-client data but is not an
The 32 MiB Rerun server buffer limits retained late-client data but is not an
access-control mechanism. The listener and embedded viewer intentionally remain
ready between acquisition sessions. Stop `k1link serve` when the network
listener and its process memory must be closed unconditionally.
@ -236,8 +267,10 @@ listener and its process memory must be closed unconditionally.
## Current boundaries
- Raw panoramic camera frames were absent from the observed MQTT report topics.
This milestone contains point cloud and pose/trajectory; operational metrics
are rendered by the outer Control Station.
The Rerun spatial recording contains point cloud and pose/trajectory;
left/right compressed RTSP preview and acquisition-owned fMP4 archive use a
separate generic media path. Historical sessions predating that archive have
no recoverable video.
- Physical double-click remains the K1 scan start/stop control. Any MQTT command
publisher needs a separately reviewed state-changing profile.
- No terrain map, elevation model, obstacle segmentation, localization fusion,

View File

@ -17,6 +17,9 @@ already exist. The accepted device-lifecycle decision is recorded in
| `src/k1link/protocol/` | Firmware-scoped K1 codecs and explicit vendor normalizer | Plugin-owned protocol adapter |
| `src/k1link/data_plane/` | Transport-neutral decoded in-process consumer views | Local projections hydrated from portable SDK envelopes |
| `src/k1link/viewer/` | Rerun consumer, visualization runtime and legacy Foxglove regression module | Replaceable canonical scene sink and presentation adapters |
| `src/k1link/sessions/` | Host-owned SQLite catalog, evidence discovery/recovery, background preparation and derived-cache lifecycle | Device-neutral observation archive service |
| `src/k1link/web/session_api.py` | Opaque saved-session, immutable RRD/media and workspace-layout API | Versioned Control/Edge observation contract |
| `apps/control-station/src/core/observation/` | Session selection, replay admission, recorded-source and layout state | Device-neutral observation UI runtime |
| `docs/domain-model/` | Versioned experimental Mission Core vocabulary | Promotion source for meanings proven across devices/services |
## Implemented boundary
@ -33,6 +36,17 @@ raw K1 transport
-> embedded local viewer
```
The host-owned recorded path is separate from the disposable live preview:
```text
sealed or recovered native observation session
-> SQLite catalog and bounded single-worker preparation
-> atomic RRD cache v6 + immutable recorded-media manifest v2
-> generation-bound same-origin HTTP
-> aggregate RRD/camera admission
-> native Rerun HTTP receiver + recorded fMP4 player
```
Rerun does not import K1 protocol code, inspect MQTT topics or receive raw
payloads. `VisualizationRuntime` cannot choose a vendor decoder implicitly; its
composition owner injects one. The local `Decoded*View` classes are in-process
@ -52,6 +66,8 @@ The local vocabulary distinguishes:
```text
model != device != transport alias != device session
acquisition != operation != source/channel != evidence
observation session != preparation job != replay launch != viewer instance
workspace layout != sensor evidence
acknowledgement != completion
raw evidence != decoded data != viewer state
```
@ -103,10 +119,14 @@ by physical double-click; no modeling request is published.
Left/right camera preview transport, endpoint paths and H.264 framing are now
observed under the exact compatibility profile. The local read-only adapter
copy-remuxes one selected RTSP producer into bounded fMP4/WebSocket delivery for
the generic MSE UI. Portable FFmpeg packaging, fan-out and remote delivery remain
open. Device status and heartbeat remain raw observed channels without semantic
decoders. Device calibration command and sensor-to-vehicle extrinsics are
unavailable.
the generic MSE UI. New acquisitions archive selected-camera init/segments/index
independently of browser delivery. The generic recorded player, manifest-v2
validation and shared `session_time` controls are connected; historical sessions
created before this archive contract contain no recoverable video, so physical
recorded-camera acceptance remains open. Portable FFmpeg packaging, disk-backed
browser buffering, fan-out and remote delivery also remain open. Device status
and heartbeat remain raw observed channels without semantic decoders. Device
calibration command and sensor-to-vehicle extrinsics are unavailable.
The 2026-07-17 physical acceptance gate confirmed continuously updating point
clouds, a matching live trajectory, and both camera selections in the same
@ -128,14 +148,17 @@ for the measured gate and remaining limits.
5. Replace compatibility routes and singleton state with multi-device session
routing.
6. Split Edge execution from the Control Station behind authenticated transport.
7. Package the read-only RTSP/H.264 camera adapter for each target OS and evolve
same-host MSE delivery toward an authenticated Edge media plane; keep the
modeling-command publisher disabled until its separate safety gate closes.
7. Physically accept a newly archived left/right K1 session, then package the
read-only RTSP/H.264 adapter for each target OS, add disk-backed sealed media
caching and evolve same-host MSE delivery toward an authenticated Edge media
plane; keep the modeling-command publisher disabled until its separate safety
gate closes.
## Invariants
- Raw evidence is persisted before preview work and is never overwritten by a
decode result.
- Raw bytes are appended and OS-flushed before preview work; aligned raw and
metadata become durable together at the bounded group-commit boundary and
are never overwritten by a decode result.
- Mission Core starts when the XGRIDS manifest is absent. Formal installed
plugin quarantine remains a future supervisor feature.
- Core code does not branch on XGRIDS IDs, fields, topics or firmware.
@ -160,8 +183,13 @@ for the measured gate and remaining limits.
- complete SDK-envelope/EvidenceStore hot-path integration;
- remote Edge split, authenticated WAN relay and fleet orchestration;
- automatic K1 start/stop/calibration commands;
- automatic camera capability discovery, synchronized recording/rewind,
calibration, panoramic stitching and multi-consumer delivery.
- device-reported camera capability discovery beyond the reviewed K1 profile;
- physical acceptance of shared-timeline recorded-camera playback on a newly
archived K1 session;
- frame-accurate camera/LiDAR calibration, panoramic stitching, disk-backed
browser media cache and remote multi-consumer delivery;
- production retention, replication, encryption and long-run WebViewer/WASM
memory acceptance for large observation sessions.
## Workspace-repeatable locked architecture gate

View File

@ -0,0 +1,421 @@
# Observation sessions, playback and workspace layout
Status: implemented for native K1 point/pose evidence and host-side camera
archival. Recorded spatial playback is exposed in the Mission Core observation
workspace. The camera archive/player contract is implemented and covered by
tests, but all retained real K1 sessions predate canonical camera archival.
Physical archived-camera playback therefore remains an open acceptance gate;
historical sessions contain no recoverable video.
## Operator path
1. Start a normal acquisition from **Парк → Локальное устройство**.
2. Open **Наблюдение → Пространственная сцена**. Live point cloud, trajectory
and the selected camera remain live-only while acquisition is running.
3. Stop acquisition normally, or allow the local service to recover an
unexpected interruption on its next start. Session recording is automatic;
the disk action is not required.
4. Open **Сохранённые сессии** in the observation header. The menu shows the
three most recent indexed runs, their date, duration, state, modalities and
background preparation state.
5. Choose a replayable run. Opening a replay never performs conversion in the
request. A ready recording opens immediately; otherwise the client receives
HTTP 202, keeps the current scene mounted and polls the preparation status.
Only after the server returns a verified launch descriptor does Mission Core
pause and unload the previous viewer.
6. The client then receives and decodes the complete RRD. The new viewport and
timeline stay hidden until the declared recording range is fully buffered
(`fullyBuffered`). Playback starts once at that point. Partial frames are
never shown. If the run contains canonical camera archives, its recorded
camera sources replace all live device overlays.
7. Use **Воспроизвести / Пауза**, the scrubber, **К началу** and **К концу** on
the right half of the bottom timeline. The left half changes point-cloud
accumulation. The recorded timeline is zero-based `session_time`.
8. Use the first disk button to save the current workspace layout. The next
opening restores display settings, tool windows and dynamic source-window
positions. This action saves no sensor evidence.
Display controls have no separate Apply/Reset transaction. Boolean controls
commit immediately; sliders, colors and selects commit on release/blur or after
a short quiet period, and concurrent commits collapse to the newest value. A
layout save flushes and awaits this queue before serializing the confirmed
settings. Recorded display updates reuse stable Rerun scene identifiers and do
not mutate playback state, so changing accumulation, size, color or visibility
does not pause the active replay or replace its camera view.
Completed and recovered sessions are discovered on startup and by the catalog
reconciler, then deduplicated into one bounded, single-worker preparation queue.
The worker validates the native source, exports point/pose data, verifies source
stability and both SHA-256 digests, finalizes once, and atomically publishes the
digest-bound derived RRD plus its cache sidecar. Before the same job becomes
`ready`, it also parses and validates every archived camera index and retains
the immutable manifest generation in memory. The catalog exposes preparation
state and progress while this runs; a recording URL and camera descriptors are
returned only after the complete launch generation has passed validation.
Preparation is a backend lifecycle, not a viewer action: sealing or recovering
a native session makes it eligible for the reconciler, which builds the derived
RRD once in the background. Reopening, seeking or changing the workspace reads
that prepared artifact and never reruns conversion. A cold process may validate
an existing cache and rebuild its in-memory camera manifest in the worker, but
no replay, status, RRD or manifest request hashes native evidence or parses a
camera index synchronously.
The session menu uses three operator indicators:
| Indicator | Catalog/preparation state | Meaning |
| --- | --- | --- |
| Solid green + `Готово` | `ready` | Verified RRD is available. |
| Pulsing green + `Обработка` | `queued`, `validating`, `exporting`, `finalizing` | Background preparation is active. |
| Dim gray + `Ошибка` | failed, cancelled, non-replayable or invalid session | No launchable recording; inspect the message or retry. |
Saved-session status never uses yellow or red. Switching sessions aborts only
the obsolete browser poll; it does not cancel the process-owned conversion.
A queued job may legitimately wait behind another export and therefore uses the
overall preparation deadline rather than the active-export heartbeat timeout.
The first preparation of a long capture can take time because every decodable
point/pose message is projected into RRD. Later openings reuse a verified,
digest-bound cache. Derived RRD cache v6 is intentionally incompatible with v4
and v5 because only v6 guarantees that the payload itself contains a real
`session_time = 0` anchor; older generations are rebuilt once in the background.
The browser may use the strict `source_url` with `If-Match`, while the embedded
Rerun loader uses the canonical `viewer_source_url` whose lowercase SHA-256
`generation` query is bound to the same launch descriptor. A missing or stale
generation fails with `412`; a matching URL is served with exact length, strong
ETag and private immutable/no-transform caching. Every cache pin is released when the
HTTP response completes, disconnects or fails, so an interrupted switch cannot
make a derived recording permanently non-evictable. A bounded 120-second launch
reservation also protects the artifact between the verified launch response and
the WebViewer's subsequent RRD request, including a cold WASM startup; it
expires automatically if that request never arrives.
## Storage roots
By default, both catalog state and new source evidence are private to the
checkout:
```text
.runtime/mission-core/
├── mission-core.sqlite3
├── mission-core.sqlite3-shm
├── mission-core.sqlite3-wal
├── evidence/
│ └── sessions/
│ ├── .current_session # present only while a writer owns the root
│ └── <UTC>_viewer_live/
│ ├── captures/ # native MQTT source evidence
│ └── media/ # canonical camera archives
└── recordings/
├── .export.lock # cross-process conversion/eviction lock
└── <opaque-session-id>/
├── scene.rrd
└── scene.rrd.cache.json
```
`MISSIONCORE_DATA_DIR` relocates the catalog and derived cache. Unless it is
overridden separately, new evidence is written to
`$MISSIONCORE_DATA_DIR/evidence/sessions`. `MISSIONCORE_EVIDENCE_DIR` can select
another absolute MQTT evidence root. In the current K1 integration the camera
gateway additionally confines its recording directory to the repository
checkout, so a full point-plus-camera acquisition must keep the evidence root
inside that checkout. An external evidence volume currently supports MQTT
capture/cataloging but camera archival will fail closed until the gateway gets
a separately attested storage root:
```bash
export MISSIONCORE_DATA_DIR=/absolute/private/path/mission-core
# Full K1 point-plus-camera evidence must currently remain below the checkout.
export MISSIONCORE_EVIDENCE_DIR=/absolute/path/to/NODEDC_MISSION_CORE/.runtime/mission-core/evidence/sessions
export MISSIONCORE_RRD_CACHE_MAX_BYTES=8589934592
export MISSIONCORE_RRD_FREE_SPACE_RESERVE_BYTES=2147483648
uv run k1link serve
```
The directories and derived recording cache use owner-only permissions where
the host filesystem permits them. A new acquisition writer is assigned only a
direct child of the private evidence root; it no longer writes a new run to the
repository-level `sessions/` directory.
Repository-level `sessions/*_viewer_live` runs are a legacy, import-only source
for the catalog. They are discovered and confined in place: refresh does not
copy, move or migrate their large payloads, and no current writer is assigned
that root. Startup may recovery-seal an already existing, incomplete canonical
camera epoch there by preserving its valid segment prefix and writing an
`interrupted` summary; this is evidence recovery, not a new acquisition write.
Never add `.runtime/`, `sessions/`, raw captures, RRD files or camera media to
Git. They can contain mapped interiors, trajectories and identifiable images.
## Session source of record
For the current K1 profile:
```text
MQTT callback
├─ durable native .k1mqtt + aligned metadata (source of record)
└─ bounded latest-wins Rerun live preview (disposable)
selected RTSP producer
├─ durable init + fMP4 segments + JSONL index (camera evidence)
└─ bounded WebSocket/MSE preview (disposable)
```
The live preview is intentionally allowed to drop frames under load. Native
point/pose evidence and camera archive writes do not traverse that queue.
Native `.k1mqtt` bytes with aligned metadata and canonical camera fMP4 archives
are the evidence source of truth. The derived RRD contains every decodable point
and pose frame from that source, but remains a rebuildable view rather than an
evidence master. A cache entry is rebuilt only when native source
identity/digests change or an incompatible derived-data export revision is
introduced. A UI blueprint or workspace-layout revision never invalidates or
rewrites the data RRD. Cache v4/v5 payloads are not reusable as v6 because they
do not guarantee the real zero-time anchor.
Expensive cache misses run through the bounded preparation worker and one global
cross-process export gate to cap peak RAM, CPU and temporary-disk use. Crash
leftovers from candidates, exporter temporary files and staged replay prefixes
are scavenged under that lock before quota accounting. Ready cache hits and
active response leases do not wait behind that gate. The derived cache has an
8 GiB default quota, preserves a 2 GiB default filesystem reserve and evicts
least-recently-used RRDs only; it never deletes native evidence.
## Camera archive contract
New acquisitions archive each selected source and codec epoch below the same
private evidence session:
```text
<evidence-root>/sessions/<session-id>/media/<stable-source-id>/epoch-1/
├── init.mp4
├── segments/
│ ├── 1.m4s
│ └── ...
├── index.jsonl
└── summary.json
```
Each index row binds a segment sequence, byte length, SHA-256 and host arrival
epoch/monotonic timestamps. Camera durability uses a
`per-segment-fsync` policy: `init.mp4` and every complete media segment are
individually fsynced and their directory entries synchronized before the
matching JSONL index row is committed. The index and an atomic `interrupted`
checkpoint summary are fsynced before the append returns. The old interval and
byte constructor options are compatibility-only and cannot weaken this
segment-bound RPO. A power failure may still lose or leave uncommitted the
fragment currently being produced; it cannot make a committed index row point
past durable media. Camera windows may close or reconnect without terminating
archival while the owning acquisition remains active.
Synchronization is `host-arrival-best-effort`: LiDAR/MQTT and camera segments
share the Mac host clock boundary, but K1 sensor exposure time and LiDAR firing
time are not proven to use a shared device clock. Do not infer frame-accurate
calibration from the playback timeline.
Finalized media is prepared once by the same process-owned background job that
materializes the RRD. The gateway records host time only after a complete
`moof+mdat` fragment has arrived, so that timestamp is an availability/end
anchor, never a fragment-start timestamp. Preparation reads and SHA-verifies
every fragment, parses bounded ISO-BMFF timing tables (`mdhd`, `trex`, `tfhd`,
`trun`), anchors the epoch at
`max(0, first_arrival - first_fragment_duration)`, and sets its end to that start
plus the checked sum of every decoded fragment duration. The declared interval
therefore has exactly the duration MSE is expected to expose and is not stretched
by host scheduling jitter.
Epoch arrivals must be strictly monotonic; epoch intervals must be finite,
monotonic and non-overlapping. Replay v2 also requires every media interval to
fit inside the spatial RRD interval with a 50 ms numeric tolerance; it fails
preparation instead of clamping unreachable evidence. A future replay v3 must
separate `spatial_range` from a session-wide union range before out-of-RRD camera
coverage can be navigated. A missing, ambiguous, oversized or otherwise
unparseable timing table fails preparation; the archive remains evidence but is
never advertised as seekable media.
The prepared path-free v2 descriptor and full source stat identity (native raw
and timing metadata plus every camera summary, index, init and segment file) are
written under the private derived cache with a schema, generation and checksum.
Publication uses a private temporary file, file and directory `fsync`, and atomic
rename; startup scavenges crash-left temporary files. A restart reuses this
sidecar after confined O(n) stat validation, without rereading, hashing or parsing
media. A missing, corrupt or stale sidecar is rebuilt only by the background
worker. No replay, status, manifest or payload request performs conversion or
recalculates these intervals.
Every derived RRD contains a real `session_time = 0` row at the internal
`/__mission_core/session_origin` entity. The anchor is deliberately outside
`/world`, so it establishes the actual recording time range without creating a
3D layer or drawable scene object. Preparation verifies the derived recording
against the declared spatial range rather than relying on summary metadata
alone.
Sessions made before this archive contract have no recoverable video even if a
camera preview was visible at the time. In particular, the 2026-07-16 browser
camera acceptance run retained point/pose evidence only.
## Crash and interruption behavior
- SQLite uses WAL, foreign keys and full synchronous durability.
- Before creating a session directory, the acquisition takes an exclusive,
cross-process lease by atomically creating and locking
`<evidence-root>/.current_session`. The marker contains only the direct child
session name. While the lock is held, a second writer fails closed and
discovery excludes that in-progress session from replay.
- A process exit releases the operating-system lock. On the next service start,
stale-marker recovery removes the marker only after it can take the lock and
revalidate the marker inode without following symlinks. It never deletes the
interrupted session directory; normal catalog recovery then evaluates the
durable prefix. A locked/live or suspicious marker is left untouched.
- Native MQTT writes use a bounded group commit: at most 0.5 seconds, 4 MiB or
32 messages per group. Raw bytes are fsynced before their metadata rows are
written and fsynced. This is a bounded RPO, not a zero-loss guarantee: a hard
process or power failure may discard the final uncommitted group. Raw bytes
from an interrupted commit window may survive beyond the durable metadata;
replay uses only the last validated metadata-aligned raw boundary.
- A native capture with a missing final summary is accepted only when the raw
and metadata prefix is bounded, aligned and structurally valid. It is cataloged
as `interrupted`, never silently promoted to `ready`.
- A non-newline metadata crash tail can be ignored. Newline-terminated or
mid-file corruption fails closed.
- Camera recovery retains a contiguous valid segment prefix, quarantines
non-contiguous/orphan fragments instead of deleting them, rebuilds the index
when necessary and writes an `interrupted` summary. Unindexed bytes are not
presented as valid media.
- If a valid normal summary appears later, repeat discovery updates the same
session to `ready`.
- Materialization detects a native capture changing during export and refuses
to publish the derived RRD. It reopens the prepared raw source with no symlink
following inside the cataloged session roots, and interrupted recordings are
materialized from the last validated raw/metadata boundary only.
- Internal preparation cancellation is cooperative. A queued job cancels
immediately; an active job stops at a safe checkpoint, removes its candidate
and never publishes a partial RRD. The operator UI does not cancel automatic
preparation when switching sessions or closing a tab: preparation belongs to
the application worker, not to an HTTP request.
- `failed` and `cancelled` are terminal, retryable states. The API exposes a
sanitized error, and an explicit retry creates a fresh job for the current
source identity. A stalled poll or failed switch leaves the current scene
mounted; the latest operator selection wins over obsolete responses.
- A lifecycle shutdown marks only its own interrupted work for automatic
reconciliation after restart. An operator cancellation and a genuine failed
export remain terminal and are never retried forever by the reconciler.
These rules provide crash recovery, not replication. A single host disk failure
can still destroy local data. Vehicle deployment must add independent onboard
and control-station copies, capacity monitoring and a documented retention
policy.
## HTTP API
All paths are same-origin and expose opaque identifiers only:
```text
GET /api/v1/observation-sessions?limit=3
GET /api/v1/observation-sessions/{id}
POST /api/v1/observation-sessions/{id}/replay
GET /api/v1/observation-sessions/{id}/recording-preparation
DELETE /api/v1/observation-sessions/{id}/recording-preparation
GET /api/v1/observation-sessions/{id}/recording.rrd
POST /api/v1/observation-sessions/{id}/blueprint.rrd
GET /api/v1/observation-sessions/{id}/media/{artifact}/manifest
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/init.mp4
GET /api/v1/observation-sessions/{id}/media/{artifact}/epochs/{n}/segments/{m}.m4s
GET /api/v1/workspace-layouts/observation.spatial
PUT /api/v1/workspace-layouts/observation.spatial
```
The catalog embeds each replayable session's preparation state and progress.
`POST .../replay` returns either a verified replay v2 launch document or HTTP
202 with the preparation v1 document, `Location`, `Retry-After`, an exact quoted
preparation `ETag` and a same-origin status URL. Polling
`GET .../recording-preparation` sends that value as `If-Match` and returns HTTP
202 while the job is active, HTTP 409 for retryable `failed`/`cancelled` states,
and the launch document only when the same job and artifact are ready; every
response echoes the same `ETag`. `DELETE` also requires `If-Match`, so a stale
tab cannot cancel a replacement job. Conversion is never executed synchronously
by a replay-open request. Playback speed and loop are request-local launch
policy; they are not stored on or shared through a preparation job.
Production `recording.rrd` access is bound to the launch generation. The client
can use the strict query-free URL with exact strong
`If-Match: "sha256:<launch.sha256>"`; a request with neither an `If-Match` nor a
generation returns 428. The embedded Rerun receiver instead gets only the
canonical `viewer_source_url = source_url + "?generation=<launch.sha256>"`.
The server returns 412 for a malformed or replaced generation before opening
the body. A successful response echoes the strong ETag, exact `Content-Length`,
`application/vnd.rerun.rrd` and immutable private/no-transform cache policy.
Rerun's native HTTP receiver owns incremental decoding; `LogChannel.send_rrd`
is reserved for independently complete RRD payloads such as the small generated
blueprint and must never receive arbitrary HTTP byte slices. The canvas,
timeline, controller and autoplay remain closed until the decoded spatial range
exactly matches the launch descriptor and every declared camera is ready. API
documents never contain local paths. Layout updates require the quoted current
revision in `If-Match`; stale writers receive HTTP 412 rather than overwriting
another saved profile.
Recorded media routes expose only opaque catalog identifiers and ordinal codec
epochs. The required `missioncore.observation-recorded-media/v2` manifest carries
a strong `generation_sha256`, exact JS-safe aggregate `byte_length`, and finite
`timeline_start_seconds` / `timeline_end_seconds` for every epoch. Epoch ends
participate in the generation digest. Every epoch also declares its init byte
length/digest and a complete contiguous segment list with sequence, URL, byte
length and digest. The launch source repeats the same aggregate `byte_length`
and uses exactly `max(epoch.timeline_end_seconds)` as its end; the spatial RRD
end must never pad camera coverage. The browser cross-checks launch, manifest and
component lengths. The current browser laboratory policy accepts at most 16
camera sources, 128 MiB per source and 512 MiB across the session. This launch
preflight finishes before the first camera manifest/init/segment GET; admitted
cameras are then downloaded and decode-probed one at a time. Verified raw
buffers remain immutable so route changes and player remounts cannot append
emptied data. This bounded in-memory strategy is deliberate for the laboratory
milestone; an OPFS-backed sealed-generation cache is the next scaling step for
larger rigs and must preserve the same launch/manifest/hash admission contract.
The manifest response ETag is the exact generation, and the manifest GET itself
requires that generation in `If-Match`. Init and media GETs likewise require the
descriptor's exact SHA `If-Match`, then open through a confined directory file
descriptor with no symlink following, verify the declared SHA-256 and serve
exact-length bytes with immutable private `no-transform` caching and byte-range
support. Physical source ids, RTSP addresses and storage paths never cross the
API boundary.
## Current limit
The spatial RRD, trajectory and archived fMP4 cameras use the same operator
scrubber now. Camera epochs are aligned to zero-based `session_time` from the
shared host-arrival monotonic clock and rendered through MSE. The client selects
an epoch only inside its declared inclusive interval and verifies that the
decoded MSE seekable duration covers that interval. This remains best-effort
correlation: codec PTS and K1 sensor exposure time are not proven to share the
LiDAR clock. A codec epoch whose init segment does not expose a browser-supported
codec or whose timing cannot be proven remains retained evidence and fails
closed in the UI/background preparation.
Historical sessions with no canonical camera archive honestly show no recorded
video. Recorded blueprints can change accumulation, grid visibility, point and
trajectory visibility, point radius and a uniform custom point color without
rewriting the recording. The client deliberately has no progressive recorded
mode: the viewport, timeline and autoplay gate remain closed until the complete
declared RRD has been received and decoded. Height, intensity, distance and RGB
palettes remain baked into current RRD rows; fully dynamic recoloring requires
exporting the corresponding scalar components in a future recording schema.
## Verification checkpoint — 2026-07-17
The repository state described above passed the complete local pre-push gate:
- `uv sync --frozen --group dev` completed against the locked Python graph;
- `uv run pytest`: **284 passed**;
- `uv run ruff check .`: clean;
- project mypy and strict plugin-SDK mypy: clean across 51 and 12 source files;
- the XGRIDS K1 profile loader and emitted v0alpha2 JSON schema validated;
- clean `npm ci` ran the required Rerun 0.34.1 postinstall patch;
- frontend unit suite: **111 passed, 0 failed**;
- TypeScript project build and Vite production build completed;
- the generated `dist/index.html` and non-empty Rerun WASM artifact were verified.
Vite still reports its expected large-chunk warning for the embedded Rerun
viewer/WASM payload. That is a packaging optimization item, not a failed gate.
No retained physical K1 session contains the new canonical camera archive yet,
so real recorded-camera playback remains an explicit hardware acceptance test.

View File

@ -2,6 +2,11 @@
Status: accepted for the experimental Mission Core runtime, 2026-07-16.
Scope update, 2026-07-17: the live device descriptors in this ADR remain
live-only. ADR 0008 adds a separate host-owned recorded-source catalog,
seekable `session_time` playback and workspace layout; it does not retroactively
turn a live RTSP lease into recorded evidence.
## Context
The spatial scene and camera workspace must accept one device, a custom rig or a
@ -68,10 +73,12 @@ RTSP/FFmpeg generation before a new browser lease is issued.
The local laboratory adapter now terminates RTSP/RTP with FFmpeg, copy-remuxes
H.264 High L4 without transcoding and publishes complete bounded fMP4 segments
to a generic MSE player. Portable FFmpeg packaging, multi-consumer fan-out and
remote/WAN delivery are not claimed by this milestone. Synchronized rewind
becomes eligible only after persisted buffering and an evidenced mapping between
the RTP 90 kHz clock and the spatial stream clock. See ADR 0007.
to a generic MSE player. ADR 0008 subsequently added acquisition-owned durable
segments, a prepared recorded-media manifest and host-arrival-best-effort rewind
on the saved-session timeline. This is not proof of a shared device clock or
frame-accurate LiDAR/camera alignment. Portable FFmpeg packaging,
multi-consumer fan-out and remote/WAN delivery remain open. See ADR 0007 and
ADR 0008.
## Code anchors

View File

@ -2,6 +2,11 @@
Status: accepted for the local laboratory runtime, 2026-07-16.
Supersession note, 2026-07-17: the live copy-remux and lease decisions remain
active. ADR 0008 extends the same acquisition-owned producer with durable fMP4
epochs and a generic recorded player. That extension does not weaken the
single-live-producer or opaque transport boundary defined here.
## Context
The exact K1 firmware 3.0.2 profile exposes two RTSP/TCP H.264 preview paths.
@ -73,16 +78,22 @@ timeline once when the recording opens and does not poll or force the cursor.
- Same-origin local WebSocket delivery is not the future WAN transport. A later
Edge/Control Station split should use an authenticated WebRTC/WHEP or equivalent
congestion-aware media plane behind the same descriptor contract.
- Camera recording, synchronized rewind, calibration, frustums, panoramic
stitching and full-resolution raw imagery remain unavailable.
- Durable camera recording and host-arrival-best-effort saved-session rewind are
implemented under ADR 0008, but no retained real K1 session made after that
archive contract has completed physical recorded-camera acceptance yet.
- Device-clock synchronization, calibration, frustums, panoramic stitching,
full-resolution raw imagery and remote multi-consumer delivery remain
unavailable.
- A longer physical soak test is still required to characterize sustained
preview backpressure and rendering cost on target Edge hardware.
## Code anchors
- `src/k1link/web/xgrids_k1_camera.py`
- `src/k1link/web/camera_archive.py`
- `src/k1link/web/xgrids_k1_facade.py`
- `apps/control-station/src/components/MseFmp4WebSocketPlayer.tsx`
- `apps/control-station/src/components/RecordedFmp4Player.tsx`
- `apps/control-station/src/core/observation/useObservationLayout.ts`
- `apps/control-station/src/device-plugins/xgrids-k1/observationSources.ts`
- `tests/test_xgrids_camera_gateway.py`

View File

@ -0,0 +1,230 @@
# ADR 0008: durable observation sessions and workspace layout
- Status: accepted for the laboratory Control Station
- Date: 2026-07-17
- Scope: host observation storage, recorded spatial playback, camera evidence,
and operator workspace layout
## Context
Mission Core previously retained K1 live point/pose captures as ignored
laboratory directories, while the browser showed only the current process-wide
Rerun stream. Camera preview was copy-remuxed for a connected browser but was
not part of the durable session. A browser refresh, local service restart or an
unexpected process exit therefore made a completed run difficult to discover
and made camera history impossible to recover. The disk icon in the spatial
workspace also had no stable distinction between saving sensor evidence and
saving an operator's layout.
These are two separate product records:
1. an **observation session** is evidence from all sources active during one
acquisition and must be recorded automatically; and
2. a **workspace layout profile** is an operator preference and is saved
explicitly.
Conflating them would allow a UI action to decide whether evidence exists and
would make a layout save duplicate or mutate sensor data.
## Decision
### Host-owned catalog and private evidence root
The Control Station owns a private storage root selected by
`MISSIONCORE_DATA_DIR`; the repository-local fallback is
`.runtime/mission-core/`. The root is outside Git and contains a SQLite catalog
plus derived playback artifacts. New source evidence is written to the root
selected by `MISSIONCORE_EVIDENCE_DIR`, falling back to
`<MISSIONCORE_DATA_DIR>/evidence/sessions`. The current K1 camera gateway still
requires that root to remain inside the repository checkout; an external root
can retain MQTT evidence but camera archival fails closed. Treat support for an
independently attested external camera-evidence root as an open extraction item,
not as implemented behavior. SQLite uses foreign keys, WAL journaling and full
synchronous durability. Public APIs expose opaque session identifiers and
metadata, never filesystem paths.
Current acquisition writers create only direct children of the private
evidence root. Existing repository-level `sessions/*_viewer_live` runs are a
legacy import-only source: they are indexed in place, never copied or moved,
and are not assigned to a new writer. Their native `.k1mqtt` capture remains
the source of record. Import is repeatable and runs in a background reconciler,
so completed evidence becomes visible without a restart and HTTP catalog reads
never scan the evidence roots. Startup recovery may
seal an already incomplete canonical camera epoch under the legacy root; it
does not start or append a new acquisition there.
Before a new session directory is created, the writer atomically creates and
locks `<evidence-root>/.current_session`. The marker names the direct child and
prevents a second process from writing or replaying the active session. Process
exit releases the operating-system lock. On startup, an unlocked stale marker
is removed only after no-follow inode revalidation; the interrupted evidence
directory is preserved for normal recovery and cataloging.
If a process exits after raw and metadata creation but before a final summary,
the importer accepts only a bounded, structurally valid prefix with aligned
metadata and marks the run `interrupted`. It does not invent completion data.
When the valid final summary later appears, repeat import promotes the same
session to `ready`.
Native MQTT persistence uses a bounded group commit of at most 0.5 seconds,
4 MiB or 32 messages. Raw bytes are fsynced before their aligned metadata rows.
The final uncommitted group may be lost, and a raw tail may survive without
committed metadata; replay stops at the last validated aligned boundary.
### Recorded spatial playback
When a native capture is normally sealed or recovery-sealed, a bounded
single-worker reconciler automatically prepares its private derived `.rrd`.
Preparation is a backend lifecycle and is never executed by an HTTP replay
request. Reopening, seeking, switching tabs or changing display settings reads
the already prepared artifact and does not rerun conversion. Export is lossless
with respect to every decodable point and pose message in the native capture;
it does not pass through the bounded live-preview queue. The RRD uses the
recording-local `session_time` duration timeline, while the receive wall clock
remains secondary evidence.
Derived files are published atomically, hashed and accompanied by a cache
sidecar bound to the native file identity and digest. A process-owned queue and
cross-process file lock prevent overlapping exporters; crash candidates are
scavenged before quota accounting. A stale or inconsistent cache is rejected
and rebuilt. The browser receives the RRD only through the same-origin opaque
session API. It keeps the viewport and timeline hidden until the complete
declared recording has been downloaded and decoded, then publishes the scene
atomically and starts playback.
### Camera recording
Camera acquisition is owned by the device acquisition session, not by a
browser window. Preview remains bounded and disposable; archive writes occur
before preview fan-out. Closing or refreshing a camera window must not stop the
archive while acquisition is active.
Each codec epoch is stored below the observation session as an initialization
segment, independently addressable fMP4 media segments, an append-only JSONL
index and an atomic summary. Host arrival epoch and monotonic timestamps are
recorded for every segment. The `per-segment-fsync` contract makes `init.mp4`
and each complete media segment plus its directory entry durable before its
matching index row is committed; the index and interrupted checkpoint are
fsynced before append returns. Therefore camera RPO is bounded by the fragment
currently being produced, not by an interval or byte batch. Startup recovery
retains a contiguous valid prefix, quarantines orphan fragments and marks an
unfinalized epoch `interrupted`.
The background recording-preparation worker also prepares immutable camera
descriptors before publishing `ready`. Complete-fragment arrival timestamps are
end/availability anchors, so the worker SHA-verifies and parses every fragment,
anchors the first decoded sample at `first_arrival - first_duration`, and derives
coverage from the exact sum of ISO-BMFF sample durations. It computes finite
non-overlapping epoch coverage ends and exact aggregate bytes, then binds both to
recorded-media manifest v2's generation digest. Replay v2 additionally requires
camera coverage to stay inside the spatial RRD range with a 50 ms tolerance; a
future replay v3 will need distinct spatial and session-union ranges.
The path-free descriptor is atomically persisted in the private derived cache
with its checksum and a stat identity covering the native timing origin and
every camera summary, index, init and segment file. Restart performs confined
stat validation and reuses an unchanged descriptor without reading/hashing media;
a missing, corrupt or stale sidecar is rebuilt in the background. An unparseable
or ambiguous fragment fails readiness rather than exposing a fake seekable
camera. Replay requests consume the prepared descriptor and never repeat media
conversion or timing analysis. Launch and manifest aggregate byte counts must
agree before the browser admits camera payload downloads.
The derived RRD writes an actual zero-time anchor at the internal
`/__mission_core/session_origin` entity. Keeping it outside `/world` prevents a
synthetic visualization layer while ensuring that the decoded RRD timeline,
not merely its summary document, begins at session time zero. This changes the
derived payload contract: cache v6 rejects v4/v5 sidecars and performs one
background rebuild instead of silently reusing an archive without that row.
Replay v2 exposes two paths to the same pinned generation. `source_url` retains
the strict `If-Match: "sha256:…"` contract for Mission Core clients;
`viewer_source_url` appends the exact lowercase SHA-256 as a `generation` query
for Rerun's native URL loader, which cannot attach custom headers. The server
accepts either precondition, rejects a stale query with `412`, and keeps the
matching file pinned for the entire immutable/no-transform response lifecycle.
This is host-arrival synchronization, not proven sensor-clock synchronization.
It is sufficient to correlate current K1 point/pose and preview evidence at the
known clock boundary, but must not be described as frame-accurate optical/LiDAR
alignment.
Historical runs made before this ADR contain no camera archive. The fact that a
camera was visible in the old browser preview does not make video recoverable;
the catalog must not manufacture a camera modality for those sessions.
### Workspace layout
The first disk action in the spatial workspace saves only the versioned
`observation.spatial` layout document:
- scene display settings;
- open tool windows and their z-order;
- visible dynamic source identifiers;
- active floating source;
- source-window rectangles normalized to the observed viewport.
The document uses optimistic revision control (`ETag` and `If-Match`) and is
restored automatically on the next opening. Unknown source identifiers remain
desired state so a temporarily disconnected camera can recover its saved
window when that source returns. The save action never starts, completes or
modifies an observation session.
### API boundary
The stable host routes are:
- `GET /api/v1/observation-sessions` for the recent catalog;
- `GET /api/v1/observation-sessions/{id}` for modality/artifact metadata;
- `POST /api/v1/observation-sessions/{id}/replay` for a ready launch or a
preparation handle;
- `GET /api/v1/observation-sessions/{id}/recording-preparation` for exact-job
status polling;
- `GET /api/v1/observation-sessions/{id}/recording.rrd` for seekable spatial data;
- opaque manifest/init/segment routes below
`/api/v1/observation-sessions/{id}/media/{artifact}` for recorded cameras;
- `GET|PUT /api/v1/workspace-layouts/observation.spatial` for the layout profile.
The session API precedes the static frontend mount. Recording paths and storage
roots are server implementation details.
## Consequences
- A completed or interrupted spatial run is discovered and prepared
automatically, then can be replayed after browser and service restarts.
- A service crash can lose the final uncommitted MQTT group and the camera
fragment currently being produced; it cannot make an index point beyond
durable camera media or replay a raw tail beyond aligned MQTT metadata.
- Raw K1 evidence remains authoritative and derived RRD files can be discarded
and regenerated.
- Browser performance no longer controls whether camera evidence is retained.
- Layout restoration is portable across viewport sizes and dynamic device
source catalogs.
- Replay builds a separate recorded-source catalog, so an active device camera
can never be mixed into the evidence of a saved session. Archived fMP4 codec
epochs follow Rerun play/pause/seek through host-arrival best-effort offsets.
- A preparation ETag binds polling and cancellation to one exact job; a short
launch reservation prevents cache eviction between the launch response and
the viewer's first RRD request. Browser session switching never cancels the
shared backend preparation.
- Storage capacity, retention, quota, encryption-at-rest and export policy are
still deployment responsibilities; this ADR does not silently delete source
evidence.
- Recorded camera playback is confined to canonical archives with a supported
fMP4 codec declaration and provable sample duration. Missing historical media,
unsupported codec epochs and ambiguous timing fail closed; none is substituted
with a live preview.
## Rejected alternatives
- **Use browser cache or IndexedDB for sessions.** This loses the evidence when
the browser profile is cleared and cannot guarantee acquisition durability.
- **Save a session only when the operator presses the disk icon.** This makes
safety evidence optional and fails on crashes or forgotten clicks.
- **Store only RRD.** This discards the native evidence needed to review future
decoders and protocol assumptions.
- **Keep camera acquisition coupled to WebSocket viewers.** A UI disconnect
would stop recording and recreate the observed data-loss bug.
- **Copy every legacy run into the private catalog root.** This doubles large
artifacts without improving integrity; confined zero-copy indexing is
sufficient for the current laboratory store.

View File

@ -24,13 +24,16 @@ The plugin owns:
- firmware-scoped protobuf/LZ4 and legacy codecs;
- normalization of K1 point cloud and pose, plus raw-only preservation of the
still-undecoded status and heartbeat channels;
- exact-profile left/right RTSP producer selection, H.264 copy-remux preview and
the handoff of acquisition-owned init/fMP4/index evidence to the host archive;
- K1-specific operator instructions and compatibility tests.
The plugin does not own:
- Mission Core navigation, fleet, missions, users, roles, or audit;
- the generic spatial scene or Rerun Web Viewer;
- platform storage, remote transport, or other devices.
- the host observation catalog, RRD preparation/cache, recorded-camera player,
workspace layout, platform storage, remote transport, or other devices.
Until extraction is complete, `src/k1link` is the compatibility source of
truth and every move into this package must preserve replay and real-device

View File

@ -33,6 +33,9 @@ MAX_TOPIC_BYTES = 65_535
CONNECT_TIMEOUT_SECONDS = 10.0
KEEPALIVE_SECONDS = 30
LOOP_INTERVAL_SECONDS = 0.25
GROUP_COMMIT_INTERVAL_SECONDS = 0.5
GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024
GROUP_COMMIT_MAX_MESSAGES = 32
# Eight-byte file signature followed by repeated >IQ, topic UTF-8 bytes, payload bytes.
RAW_MAGIC = b"K1MQTT\x00\x01"
@ -166,6 +169,9 @@ class _CaptureWriter:
self.topic_counts: dict[str, int] = {}
self._raw: IO[bytes] | None = None
self._metadata: IO[str] | None = None
self._pending_metadata: list[str] = []
self._pending_raw_bytes = 0
self._last_commit_monotonic = time.monotonic()
def open(self) -> None:
self.out_dir.mkdir(parents=True, exist_ok=True)
@ -179,6 +185,7 @@ class _CaptureWriter:
self._raw = _open_binary_exclusive(self.raw_path)
self._raw.write(RAW_MAGIC)
self._metadata = _open_text_exclusive(self.metadata_path)
_fsync_directory(self.out_dir)
except BaseException:
with suppress(OSError):
self.close()
@ -212,7 +219,9 @@ class _CaptureWriter:
"max_message_bytes": self.max_message_bytes,
"reason": "message_too_large",
}
self._commit_pending()
self._write_metadata(metadata, record)
os.fsync(metadata.fileno())
raise MessageTooLargeError(
f"message on {topic!r} is {len(payload)} bytes; "
f"limit is {self.max_message_bytes} bytes"
@ -246,7 +255,17 @@ class _CaptureWriter:
"raw_payload_offset": offset + len(header) + len(topic_bytes),
"raw_frame_bytes": frame_bytes,
}
self._write_metadata(metadata, record)
self._pending_metadata.append(
json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
)
self._pending_raw_bytes += frame_bytes
if (
len(self._pending_metadata) >= GROUP_COMMIT_MAX_MESSAGES
or self._pending_raw_bytes >= GROUP_COMMIT_MAX_BYTES
or time.monotonic() - self._last_commit_monotonic
>= GROUP_COMMIT_INTERVAL_SECONDS
):
self._commit_pending()
return CapturedMqttMessage(
sequence=self.message_count,
topic=topic,
@ -261,7 +280,12 @@ class _CaptureWriter:
def close(self) -> None:
first_error: OSError | None = None
# Make raw frames durable before making their JSONL references durable.
try:
self._commit_pending()
except OSError as exc:
first_error = exc
# Make the magic (including the zero-message case) and any last stream
# buffers durable before the handles are released.
for stream in (self._raw, self._metadata):
if stream is None or stream.closed:
continue
@ -280,6 +304,13 @@ class _CaptureWriter:
if first_error is not None:
raise first_error
def maybe_commit(self, now_monotonic: float | None = None) -> None:
if not self._pending_metadata:
return
now = time.monotonic() if now_monotonic is None else now_monotonic
if now - self._last_commit_monotonic >= GROUP_COMMIT_INTERVAL_SECONDS:
self._commit_pending(now_monotonic=now)
@property
def raw_bytes(self) -> int:
if self.raw_path.exists():
@ -301,6 +332,26 @@ class _CaptureWriter:
stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
stream.flush()
def _commit_pending(self, *, now_monotonic: float | None = None) -> None:
if not self._pending_metadata:
return
raw = self._require_raw()
metadata = self._require_metadata()
# Group commit invariant: durable raw bytes always precede durable
# metadata references. A crash can therefore lose only the bounded
# in-memory group, never expose metadata pointing past durable raw.
raw.flush()
os.fsync(raw.fileno())
payload = "".join(self._pending_metadata)
self._pending_metadata.clear()
self._pending_raw_bytes = 0
metadata.write(payload)
metadata.flush()
os.fsync(metadata.fileno())
self._last_commit_monotonic = (
time.monotonic() if now_monotonic is None else now_monotonic
)
def validate_private_ipv4(value: str) -> str:
"""Require a literal RFC1918 address so capture cannot target arbitrary hosts."""
@ -536,6 +587,11 @@ def capture_mqtt(
break
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
try:
writer.maybe_commit()
except OSError as exc:
fail("capture_error", f"group commit failed: {type(exc).__name__}: {exc}")
break
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
fail(
"connection_lost",
@ -681,3 +737,12 @@ def _write_summary_exclusive(path: Path, summary: CaptureSummary) -> None:
stream.write(serialized)
stream.flush()
os.fsync(stream.fileno())
_fsync_directory(path.parent)
def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)

View File

@ -0,0 +1,65 @@
"""Host-owned observation session catalog and durable layout persistence."""
from .active import (
ActiveSessionLease,
ActiveSessionLeaseError,
recover_stale_active_session_marker,
)
from .media import (
RECORDED_MEDIA_MANIFEST_SCHEMA,
RecordedMediaFile,
RecordedMediaInspector,
RecordedMediaManifest,
validate_recorded_media_timeline,
)
from .models import (
LayoutConflictError,
RecordedMediaArtifact,
ReplayCommand,
SessionIntegrityError,
SessionNotFoundError,
SessionNotReplayableError,
)
from .preparation import (
RecordingPreparationQueueFull,
RecordingPreparationSnapshot,
SessionRecordingPreparationManager,
)
from .recording import (
MaterializedRecording,
RecordingMaterializationCancelled,
RecordingMaterializationError,
SessionRecordingMaterializer,
)
from .store import (
SessionStore,
resolve_missioncore_data_dir,
resolve_missioncore_evidence_dir,
)
__all__ = [
"LayoutConflictError",
"ActiveSessionLease",
"ActiveSessionLeaseError",
"MaterializedRecording",
"RecordingMaterializationCancelled",
"RecordedMediaArtifact",
"RECORDED_MEDIA_MANIFEST_SCHEMA",
"RecordedMediaFile",
"RecordedMediaInspector",
"RecordedMediaManifest",
"ReplayCommand",
"RecordingMaterializationError",
"RecordingPreparationQueueFull",
"RecordingPreparationSnapshot",
"SessionIntegrityError",
"SessionNotFoundError",
"SessionNotReplayableError",
"SessionRecordingMaterializer",
"SessionRecordingPreparationManager",
"SessionStore",
"recover_stale_active_session_marker",
"resolve_missioncore_data_dir",
"resolve_missioncore_evidence_dir",
"validate_recorded_media_timeline",
]

View File

@ -0,0 +1,172 @@
from __future__ import annotations
import importlib
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
ACTIVE_SESSION_MARKER = ".current_session"
class ActiveSessionLeaseError(RuntimeError):
"""The evidence root already has an active writer or cannot be leased."""
@dataclass(slots=True)
class ActiveSessionLease:
"""Cross-process lease that keeps an in-progress session out of replay.
The marker is intentionally written before the session directory is
created. Discovery therefore observes either no candidate yet or a
candidate protected by an already locked marker. A process crash releases
the OS lock; startup recovery can then remove the stale marker while
preserving the interrupted evidence directory.
"""
sessions_root: Path
session_root: Path
_descriptor: int
_marker_identity: tuple[int, int]
_released: bool = False
@classmethod
def acquire(cls, sessions_root: Path, session_root: Path) -> ActiveSessionLease:
root = sessions_root.expanduser().resolve()
root.mkdir(mode=0o700, parents=True, exist_ok=True)
target = session_root.expanduser().absolute()
if target.parent.resolve() != root or not target.name:
raise ActiveSessionLeaseError("active session must be a direct child of evidence root")
marker = root / ACTIVE_SESSION_MARKER
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(marker, flags, 0o600)
except FileExistsError as exc:
raise ActiveSessionLeaseError(
"another observation session owns the evidence root"
) from exc
try:
payload = f"{target.name}\n".encode()
os.write(descriptor, payload)
os.fsync(descriptor)
_lock_descriptor(descriptor, blocking=False)
_fsync_directory(root)
metadata = os.fstat(descriptor)
return cls(
sessions_root=root,
session_root=target,
_descriptor=descriptor,
_marker_identity=(metadata.st_dev, metadata.st_ino),
)
except BaseException:
try:
marker.unlink(missing_ok=True)
_fsync_directory(root)
finally:
os.close(descriptor)
raise
def release(self) -> None:
if self._released:
return
marker = self.sessions_root / ACTIVE_SESSION_MARKER
try:
try:
metadata = marker.lstat()
except FileNotFoundError:
metadata = None
if metadata is not None and (metadata.st_dev, metadata.st_ino) == self._marker_identity:
marker.unlink()
_fsync_directory(self.sessions_root)
finally:
_unlock_descriptor(self._descriptor)
os.close(self._descriptor)
self._released = True
def __enter__(self) -> ActiveSessionLease:
return self
def __exit__(self, *_: object) -> None:
self.release()
def recover_stale_active_session_marker(sessions_root: Path) -> bool:
"""Remove only an unlocked marker left by a terminated writer."""
root = sessions_root.expanduser().resolve()
marker = root / ACTIVE_SESSION_MARKER
try:
marker_stat = marker.lstat()
except FileNotFoundError:
return False
except OSError:
return False
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
return False
try:
descriptor = os.open(marker, os.O_RDWR | getattr(os, "O_NOFOLLOW", 0))
except OSError:
return False
locked = False
try:
try:
_lock_descriptor(descriptor, blocking=False)
locked = True
except OSError:
return False
opened = os.fstat(descriptor)
try:
current = marker.lstat()
except OSError:
return False
if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):
return False
marker.unlink()
_fsync_directory(root)
return True
finally:
if locked:
_unlock_descriptor(descriptor)
os.close(descriptor)
def _lock_descriptor(descriptor: int, *, blocking: bool) -> None:
if os.name == "nt":
msvcrt = cast(Any, importlib.import_module("msvcrt"))
os.lseek(descriptor, 0, os.SEEK_SET)
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
msvcrt.locking(descriptor, mode, 1)
return
import fcntl
operation = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
fcntl.flock(descriptor, operation)
def _unlock_descriptor(descriptor: int) -> None:
try:
if os.name == "nt":
msvcrt = cast(Any, importlib.import_module("msvcrt"))
os.lseek(descriptor, 0, os.SEEK_SET)
msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1)
return
import fcntl
fcntl.flock(descriptor, fcntl.LOCK_UN)
except OSError:
return
def _fsync_directory(path: Path) -> None:
try:
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
except OSError:
return
try:
os.fsync(descriptor)
finally:
os.close(descriptor)

View File

@ -0,0 +1,818 @@
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import stat
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import lru_cache
from pathlib import Path
from typing import IO, Any
from k1link.mqtt.capture import (
FRAME_HEADER,
GROUP_COMMIT_MAX_BYTES,
GROUP_COMMIT_MAX_MESSAGES,
MAX_CONFIGURABLE_MESSAGE_BYTES,
MAX_TOPIC_BYTES,
RAW_MAGIC,
)
from .models import (
LegacyMediaSourceCandidate,
LegacySessionCandidate,
SessionModality,
SessionStatus,
)
MAX_LEGACY_JSON_BYTES = 2 * 1024 * 1024
LEGACY_SESSION_PATTERN = re.compile(r"^[A-Za-z0-9._-]+_viewer_live(?:_[0-9]+)?$")
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
MEDIA_SOURCE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
MEDIA_EPOCH_PATTERN = re.compile(r"^epoch-(?!0+$)[0-9]+$")
MEDIA_SEGMENT_PATTERN = re.compile(r"^[0-9]+\.m4s$")
MAX_MEDIA_INDEX_BYTES = 32 * 1024 * 1024
MAX_RECOVERY_METADATA_BYTES = 64 * 1024 * 1024
MAX_RECOVERY_METADATA_LINE_BYTES = 64 * 1024
MAX_RECOVERY_MESSAGES = 500_000
@dataclass(frozen=True, slots=True)
class _RecoveredCapture:
message_count: int
topic_counts: dict[str, int]
started_at_utc: str
completed_at_utc: str
duration_seconds: float
raw_committed_bytes: int
metadata_committed_bytes: int
def discover_legacy_viewer_sessions(root: Path) -> tuple[LegacySessionCandidate, ...]:
"""Describe legacy live sessions without copying or decoding their payloads."""
allowed_root = root.expanduser().resolve()
if not allowed_root.is_dir():
return ()
active_session_root = _active_session_root(allowed_root)
candidates: list[LegacySessionCandidate] = []
for entry in sorted(allowed_root.iterdir()):
if not entry.is_dir() or not LEGACY_SESSION_PATTERN.fullmatch(entry.name):
continue
resolved = entry.resolve()
if not resolved.is_relative_to(allowed_root):
continue
# The saved-session catalog deliberately omits the writer-owned run.
# Besides avoiding a misleading interrupted/error row, this prevents
# the two-second reconciler from repeatedly scanning and hashing a
# capture that is still growing. Marker release makes it discoverable
# on the next reconciliation pass.
if active_session_root == resolved:
continue
candidates.append(
_describe_session(
allowed_root,
resolved,
active=False,
)
)
return tuple(candidates)
def _describe_session(
allowed_root: Path,
session_root: Path,
*,
active: bool,
) -> LegacySessionCandidate:
capture_root = session_root / "captures" / "mqtt_live"
raw_path = capture_root / "mqtt.raw.k1mqtt"
summary = _read_json_object(capture_root / "mqtt.summary.json")
manifest = _read_json_object(session_root / "manifest.redacted.json")
raw_bytes = raw_path.stat().st_size if _confined_file(raw_path, session_root) else 0
raw_magic_ok = False
if raw_bytes >= len(RAW_MAGIC):
with raw_path.open("rb") as stream:
raw_magic_ok = stream.read(len(RAW_MAGIC)) == RAW_MAGIC
has_completed_summary = _is_completed_summary(summary)
completed = (
_validate_completed_capture(capture_root, session_root, raw_path, summary)
if has_completed_summary and raw_magic_ok
else None
)
recovered = (
_recover_interrupted_capture(capture_root, session_root, raw_path)
if raw_magic_ok
and completed is None
and (not has_completed_summary or summary.get("error") is not None)
else None
)
message_count = (
_non_negative_int(summary.get("message_count"))
if completed is not None
else recovered.message_count if recovered is not None else 0
)
summary_topic_counts = summary.get("topic_counts")
topic_counts = (
_normalized_topic_counts(summary_topic_counts)
if completed is not None
else recovered.topic_counts if recovered is not None else {}
)
media_sources = _discover_media_sources(session_root)
modalities = list(_modalities(topic_counts))
if media_sources:
modalities.append("video")
replayable = bool(
not active
and
raw_magic_ok
and raw_bytes > len(RAW_MAGIC)
and message_count > 0
and any(modality in {"point-cloud", "trajectory"} for modality in modalities)
)
if completed is not None:
status = (
"interrupted"
if active
else _status(replayable, summary.get("error"), summary.get("stop_reason"))
)
started_at = (
_safe_timestamp(summary.get("created_at_utc"))
or _safe_timestamp(manifest.get("started_at_utc"))
or _timestamp_from_session_name(session_root.name)
)
completed_at = _safe_timestamp(summary.get("completed_at_utc")) or _safe_timestamp(
manifest.get("completed_at_utc")
)
duration = _duration(summary.get("capture_elapsed_seconds"))
declared_hash = _declared_raw_hash(summary)
integrity_status = "verified" if declared_hash is not None else "validated-structure"
else:
status = "interrupted" if recovered is not None or active else "failed"
started_at = (
recovered.started_at_utc
if recovered is not None
else _safe_timestamp(manifest.get("started_at_utc"))
or _timestamp_from_session_name(session_root.name)
)
completed_at = recovered.completed_at_utc if recovered is not None else None
duration = recovered.duration_seconds if recovered is not None else None
declared_hash = None
integrity_status = "validated-prefix" if recovered is not None else "unverified"
replay_raw_bytes = (
completed.raw_committed_bytes
if completed is not None
else recovered.raw_committed_bytes if recovered is not None else 0
)
replay_metadata_bytes = (
completed.metadata_committed_bytes
if completed is not None
else recovered.metadata_committed_bytes if recovered is not None else 0
)
return LegacySessionCandidate(
session_id=session_root.name,
display_name=session_root.name,
status=status,
started_at_utc=started_at,
completed_at_utc=completed_at,
duration_seconds=duration,
modalities=tuple(modalities),
replayable=replayable,
total_bytes=raw_bytes + sum(source.byte_length for source in media_sources),
allowed_root=allowed_root,
session_root=session_root,
raw_path=raw_path.resolve(strict=False),
raw_byte_length=raw_bytes,
replay_raw_byte_length=replay_raw_bytes,
replay_metadata_byte_length=replay_metadata_bytes,
raw_sha256=declared_hash,
raw_integrity_status=integrity_status,
media_sources=media_sources,
)
def _is_completed_summary(summary: dict[str, Any]) -> bool:
return (
"message_count" in summary
and isinstance(summary.get("message_count"), int)
and not isinstance(summary.get("message_count"), bool)
and isinstance(summary.get("topic_counts"), dict)
and isinstance(summary.get("stop_reason"), str)
)
def _validate_completed_capture(
capture_root: Path,
session_root: Path,
raw_path: Path,
summary: dict[str, Any],
) -> _RecoveredCapture | None:
metadata_path = capture_root / "mqtt.metadata.jsonl"
try:
raw_stat = raw_path.lstat()
metadata_stat = metadata_path.lstat()
except OSError:
return None
if not stat.S_ISREG(raw_stat.st_mode) or not stat.S_ISREG(metadata_stat.st_mode):
return None
fingerprint = json.dumps(
{
"message_count": summary.get("message_count"),
"topic_counts": summary.get("topic_counts"),
"raw_bytes": summary.get("raw_bytes"),
"artifact_hashes": summary.get("artifact_hashes"),
},
sort_keys=True,
separators=(",", ":"),
)
return _validate_completed_capture_cached(
str(capture_root),
str(session_root),
str(raw_path),
_stat_identity(raw_stat),
_stat_identity(metadata_stat),
fingerprint,
)
@lru_cache(maxsize=128)
def _validate_completed_capture_cached(
capture_root_text: str,
session_root_text: str,
raw_path_text: str,
_raw_identity: tuple[int, int, int, int, int],
_metadata_identity: tuple[int, int, int, int, int],
summary_fingerprint: str,
) -> _RecoveredCapture | None:
capture_root = Path(capture_root_text)
session_root = Path(session_root_text)
raw_path = Path(raw_path_text)
summary = json.loads(summary_fingerprint)
validated = _scan_capture_prefix(
capture_root,
session_root,
raw_path,
tolerate_incomplete_metadata_tail=False,
tolerate_raw_crash_tail=False,
)
if validated is None:
return None
if validated.message_count != _non_negative_int(summary.get("message_count")):
return None
if validated.topic_counts != _normalized_topic_counts(summary.get("topic_counts")):
return None
declared_raw_bytes = summary.get("raw_bytes")
if declared_raw_bytes is not None and (
not isinstance(declared_raw_bytes, int)
or isinstance(declared_raw_bytes, bool)
or declared_raw_bytes != validated.raw_committed_bytes
):
return None
raw_hash = _declared_raw_hash(summary)
if raw_hash is not None and _sha256_stable(raw_path) != raw_hash:
return None
metadata_hash = _declared_metadata_hash(summary)
metadata_path = capture_root / "mqtt.metadata.jsonl"
if metadata_hash is not None and _sha256_stable(metadata_path) != metadata_hash:
return None
return validated
def _recover_interrupted_capture(
capture_root: Path,
session_root: Path,
raw_path: Path,
) -> _RecoveredCapture | None:
return _scan_capture_prefix(
capture_root,
session_root,
raw_path,
tolerate_incomplete_metadata_tail=True,
tolerate_raw_crash_tail=True,
)
def _scan_capture_prefix(
capture_root: Path,
session_root: Path,
raw_path: Path,
*,
tolerate_incomplete_metadata_tail: bool,
tolerate_raw_crash_tail: bool,
) -> _RecoveredCapture | None:
metadata_path = capture_root / "mqtt.metadata.jsonl"
if not _confined_file(raw_path, session_root) or not _confined_file(
metadata_path, session_root
):
return None
try:
raw_size = raw_path.stat().st_size
with raw_path.open("rb") as raw_stream, metadata_path.open("rb") as metadata_stream:
if raw_stream.read(len(RAW_MAGIC)) != RAW_MAGIC:
return None
consumed_metadata_bytes = 0
message_count = 0
topic_counts: dict[str, int] = {}
first_epoch_ns: int | None = None
last_epoch_ns: int | None = None
first_monotonic_ns: int | None = None
last_monotonic_ns: int | None = None
first_timestamp: str | None = None
last_timestamp: str | None = None
metadata_committed_bytes = 0
while (
consumed_metadata_bytes < MAX_RECOVERY_METADATA_BYTES
and message_count < MAX_RECOVERY_MESSAGES
):
remaining = MAX_RECOVERY_METADATA_BYTES - consumed_metadata_bytes
read_limit = min(MAX_RECOVERY_METADATA_LINE_BYTES + 1, remaining + 1)
line = metadata_stream.readline(read_limit)
if not line:
break
consumed_metadata_bytes += len(line)
if (
len(line) > MAX_RECOVERY_METADATA_LINE_BYTES
or consumed_metadata_bytes > MAX_RECOVERY_METADATA_BYTES
):
return None
if not line.endswith(b"\n"):
if tolerate_incomplete_metadata_tail:
break
return None
record = _metadata_record(line)
if record is None:
return None
metadata_committed_bytes = consumed_metadata_bytes
if record.get("record_type") != "message":
continue
expected_sequence = message_count + 1
validated = _validate_recovery_frame(
raw_stream,
raw_size=raw_size,
record=record,
expected_sequence=expected_sequence,
)
if validated is None:
return None
topic, epoch_ns, monotonic_ns, timestamp = validated
if last_monotonic_ns is not None and monotonic_ns < last_monotonic_ns:
return None
message_count = expected_sequence
topic_counts[topic] = topic_counts.get(topic, 0) + 1
if first_epoch_ns is None:
first_epoch_ns = epoch_ns
first_monotonic_ns = monotonic_ns
first_timestamp = timestamp
last_epoch_ns = epoch_ns
last_monotonic_ns = monotonic_ns
last_timestamp = timestamp
if metadata_stream.read(1):
return None
raw_committed_bytes = raw_stream.tell()
if raw_committed_bytes != raw_size and not (
tolerate_raw_crash_tail
and _tolerable_raw_crash_tail(raw_stream, raw_size=raw_size)
):
return None
except OSError:
return None
if (
message_count == 0
or first_epoch_ns is None
or last_epoch_ns is None
or first_timestamp is None
or last_timestamp is None
or first_monotonic_ns is None
or last_monotonic_ns is None
):
return None
return _RecoveredCapture(
message_count=message_count,
topic_counts=topic_counts,
started_at_utc=first_timestamp,
completed_at_utc=last_timestamp,
duration_seconds=(last_monotonic_ns - first_monotonic_ns) / 1_000_000_000,
raw_committed_bytes=raw_committed_bytes,
metadata_committed_bytes=metadata_committed_bytes,
)
def _metadata_record(line: bytes) -> dict[str, Any] | None:
try:
value = json.loads(line.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None
def _tolerable_raw_crash_tail(raw_stream: IO[bytes], *, raw_size: int) -> bool:
"""Accept at most one raw frame which was not committed by metadata.
The capture writer flushes raw before metadata and does not begin the next
message until the current metadata record is written. Consequently a
process crash can leave only a prefix (or all) of one additional frame.
Anything beyond that boundary is corruption, not a recoverable tail.
"""
tail_offset = raw_stream.tell()
tail_bytes = raw_size - tail_offset
if tail_bytes <= 0:
return True
if tail_bytes > GROUP_COMMIT_MAX_BYTES + MAX_CONFIGURABLE_MESSAGE_BYTES:
return False
frames = 0
while raw_stream.tell() < raw_size:
remaining = raw_size - raw_stream.tell()
header = raw_stream.read(min(remaining, FRAME_HEADER.size))
if len(header) < FRAME_HEADER.size:
return frames < GROUP_COMMIT_MAX_MESSAGES
topic_length, payload_length = FRAME_HEADER.unpack(header)
if not 1 <= topic_length <= MAX_TOPIC_BYTES:
return False
if payload_length > MAX_CONFIGURABLE_MESSAGE_BYTES:
return False
frame_bytes = FRAME_HEADER.size + topic_length + payload_length
frame_remaining = raw_size - (raw_stream.tell() - FRAME_HEADER.size)
available_topic_bytes = min(
topic_length,
max(0, frame_remaining - FRAME_HEADER.size),
)
topic_prefix = raw_stream.read(available_topic_bytes)
if available_topic_bytes < topic_length:
return frames < GROUP_COMMIT_MAX_MESSAGES
try:
topic = topic_prefix.decode("utf-8")
except UnicodeDecodeError:
return False
if not topic:
return False
payload_available = frame_remaining - FRAME_HEADER.size - topic_length
if payload_available < payload_length:
return frames < GROUP_COMMIT_MAX_MESSAGES
raw_stream.seek(payload_length, 1)
frames += 1
if frames > GROUP_COMMIT_MAX_MESSAGES or frame_remaining < frame_bytes:
return False
return frames <= GROUP_COMMIT_MAX_MESSAGES
def _validate_recovery_frame(
raw_stream: IO[bytes],
*,
raw_size: int,
record: dict[str, Any],
expected_sequence: int,
) -> tuple[str, int, int, str] | None:
sequence = record.get("sequence")
topic = record.get("topic")
payload_bytes = record.get("payload_bytes")
frame_offset = record.get("raw_frame_offset")
payload_offset = record.get("raw_payload_offset")
frame_bytes = record.get("raw_frame_bytes")
epoch_ns = record.get("received_at_epoch_ns")
monotonic_ns = record.get("received_monotonic_ns")
if (
sequence != expected_sequence
or not isinstance(topic, str)
or not topic
or not isinstance(payload_bytes, int)
or isinstance(payload_bytes, bool)
or not 0 <= payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES
or not isinstance(frame_offset, int)
or isinstance(frame_offset, bool)
or frame_offset != raw_stream.tell()
or not isinstance(payload_offset, int)
or isinstance(payload_offset, bool)
or not isinstance(frame_bytes, int)
or isinstance(frame_bytes, bool)
or not isinstance(epoch_ns, int)
or isinstance(epoch_ns, bool)
or epoch_ns < 0
or not isinstance(monotonic_ns, int)
or isinstance(monotonic_ns, bool)
or monotonic_ns < 0
):
return None
header = raw_stream.read(FRAME_HEADER.size)
if len(header) != FRAME_HEADER.size:
return None
topic_length, raw_payload_bytes = FRAME_HEADER.unpack(header)
if (
not 1 <= topic_length <= MAX_TOPIC_BYTES
or raw_payload_bytes != payload_bytes
or payload_offset != frame_offset + FRAME_HEADER.size + topic_length
or frame_bytes != FRAME_HEADER.size + topic_length + payload_bytes
or frame_offset + frame_bytes > raw_size
):
return None
raw_topic = raw_stream.read(topic_length)
try:
decoded_topic = raw_topic.decode("utf-8")
except UnicodeDecodeError:
return None
if decoded_topic != topic:
return None
raw_stream.seek(payload_bytes, 1)
timestamp = _safe_timestamp(record.get("received_at_utc"))
if timestamp is None:
try:
timestamp = datetime.fromtimestamp(epoch_ns / 1_000_000_000, tz=UTC).isoformat()
except (OSError, OverflowError, ValueError):
return None
return topic, epoch_ns, monotonic_ns, timestamp
def _read_json_object(path: Path) -> dict[str, Any]:
try:
if not path.is_file() or path.stat().st_size > MAX_LEGACY_JSON_BYTES:
return {}
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _confined_file(path: Path, session_root: Path) -> bool:
try:
resolved = path.resolve(strict=True)
except OSError:
return False
return resolved.is_file() and resolved.is_relative_to(session_root)
def _non_negative_int(value: object) -> int:
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
def _normalized_topic_counts(value: object) -> dict[str, int]:
if not isinstance(value, dict):
return {}
result: dict[str, int] = {}
for topic, raw_count in value.items():
count = _non_negative_int(raw_count)
if isinstance(topic, str) and count > 0:
result[topic] = count
return result
def _duration(value: object) -> float | None:
if not isinstance(value, (int, float)) or isinstance(value, bool):
return None
duration = float(value)
return duration if math.isfinite(duration) and duration >= 0 else None
def _safe_timestamp(value: object) -> str | None:
if not isinstance(value, str) or len(value) > 64:
return None
candidate = value.strip()
try:
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return None
return candidate
def _timestamp_from_session_name(session_id: str) -> str | None:
prefix = session_id.split("_", 1)[0]
try:
parsed = datetime.strptime(prefix, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
except ValueError:
return None
return parsed.isoformat().replace("+00:00", "Z")
def _declared_raw_hash(summary: dict[str, Any]) -> str | None:
hashes = summary.get("artifact_hashes")
if not isinstance(hashes, dict):
return None
value = hashes.get("raw_sha256")
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
def _declared_metadata_hash(summary: dict[str, Any]) -> str | None:
hashes = summary.get("artifact_hashes")
if not isinstance(hashes, dict):
return None
value = hashes.get("metadata_jsonl_sha256")
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
def _sha256_stable(path: Path) -> str:
try:
current = path.lstat()
except OSError:
return ""
if not stat.S_ISREG(current.st_mode):
return ""
return _sha256_cached(str(path), _stat_identity(current))
@lru_cache(maxsize=128)
def _sha256_cached(
path_text: str,
expected_identity: tuple[int, int, int, int, int],
) -> str:
path = Path(path_text)
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError:
return ""
try:
before = os.fstat(descriptor)
if not stat.S_ISREG(before.st_mode) or _stat_identity(before) != expected_identity:
return ""
digest = hashlib.sha256()
while chunk := os.read(descriptor, 1024 * 1024):
digest.update(chunk)
after = os.fstat(descriptor)
try:
current = os.lstat(path)
except OSError:
return ""
if _stat_identity(before) != _stat_identity(after) or (
current.st_dev,
current.st_ino,
) != (before.st_dev, before.st_ino):
return ""
return digest.hexdigest()
finally:
os.close(descriptor)
def _stat_identity(value: os.stat_result) -> tuple[int, int, int, int, int]:
return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)
def _active_session_root(root: Path) -> Path | None:
marker = root / ".current_session"
try:
marker_stat = marker.lstat()
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
return None
value = marker.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return None
if not value:
return None
raw = Path(value).expanduser()
if raw.is_absolute():
candidate = raw
elif raw.parts and raw.parts[0] == root.name:
candidate = root.parent / raw
else:
candidate = root / raw
try:
resolved = candidate.resolve(strict=True)
except OSError:
return None
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
def _modalities(topic_counts: Mapping[str, int]) -> tuple[SessionModality, ...]:
topics = {
topic
for topic, count in topic_counts.items()
if isinstance(topic, str) and _non_negative_int(count) > 0
}
result: list[SessionModality] = []
if any(topic == "RealtimePointcloud" or topic.endswith("/lio_pcl") for topic in topics):
result.append("point-cloud")
if any(topic == "RealtimePath" or topic.endswith("/lio_pose") for topic in topics):
result.append("trajectory")
return tuple(result)
def _status(replayable: bool, error: object, stop_reason: object) -> SessionStatus:
if error is None and stop_reason in {
"duration_elapsed",
"external_stop",
"keyboard_interrupt",
}:
return "ready" if replayable else "failed"
return "interrupted" if replayable else "failed"
def _discover_media_sources(session_root: Path) -> tuple[LegacyMediaSourceCandidate, ...]:
media_root = session_root / "media"
try:
resolved_media_root = media_root.resolve(strict=True)
except OSError:
return ()
if not resolved_media_root.is_dir() or not resolved_media_root.is_relative_to(session_root):
return ()
candidates: list[LegacyMediaSourceCandidate] = []
for source_root in sorted(resolved_media_root.iterdir()):
if not source_root.is_dir() or not MEDIA_SOURCE_PATTERN.fullmatch(source_root.name):
continue
try:
resolved_source_root = source_root.resolve(strict=True)
except OSError:
continue
if not resolved_source_root.is_relative_to(resolved_media_root):
continue
epochs = [
epoch
for epoch in sorted(resolved_source_root.iterdir())
if epoch.is_dir()
and MEDIA_EPOCH_PATTERN.fullmatch(epoch.name)
and _validated_media_epoch(epoch, resolved_source_root.name)
]
if not epochs:
continue
byte_length = sum(_media_epoch_bytes(epoch) for epoch in epochs)
artifact_suffix = hashlib.sha256(resolved_source_root.name.encode()).hexdigest()[:16]
candidates.append(
LegacyMediaSourceCandidate(
source_id=resolved_source_root.name,
artifact_id=f"recorded-video-{artifact_suffix}",
locator=resolved_source_root,
byte_length=byte_length,
epoch_count=len(epochs),
)
)
return tuple(candidates)
def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool:
try:
resolved_epoch = epoch.resolve(strict=True)
init_path = (epoch / "init.mp4").resolve(strict=True)
segments_root = (epoch / "segments").resolve(strict=True)
index_path = (epoch / "index.jsonl").resolve(strict=True)
summary_path = (epoch / "summary.json").resolve(strict=True)
except OSError:
return False
if not all(
path.is_relative_to(resolved_epoch)
for path in (init_path, segments_root, index_path, summary_path)
):
return False
if (
not init_path.is_file()
or init_path.stat().st_size <= 0
or not segments_root.is_dir()
or not index_path.is_file()
or not 0 < index_path.stat().st_size <= MAX_MEDIA_INDEX_BYTES
or not summary_path.is_file()
):
return False
summary = _read_json_object(summary_path)
if summary.get("schema_version") not in {1, "missioncore.camera-recording/v1"}:
return False
if summary.get("source_id") != expected_source_id:
return False
segment_count = _non_negative_int(summary.get("segment_count"))
if segment_count < 1:
return False
segments = [
path.resolve()
for path in sorted(segments_root.iterdir())
if path.is_file() and MEDIA_SEGMENT_PATTERN.fullmatch(path.name)
]
if len(segments) != segment_count or any(
not path.is_relative_to(segments_root) or path.stat().st_size <= 0 for path in segments
):
return False
try:
index_records = [
json.loads(line)
for line in index_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return False
if len(index_records) != segment_count or not all(
isinstance(record, dict)
and _non_negative_int(record.get("sequence")) > 0
for record in index_records
):
return False
sequences = [int(record["sequence"]) for record in index_records]
return len(sequences) == len(set(sequences))
def _media_epoch_bytes(epoch: Path) -> int:
try:
resolved_epoch = epoch.resolve(strict=True)
except OSError:
return 0
total = 0
for path in resolved_epoch.rglob("*"):
try:
resolved = path.resolve(strict=True)
except OSError:
continue
if resolved.is_file() and resolved.is_relative_to(resolved_epoch):
total += resolved.stat().st_size
return total

1460
src/k1link/sessions/media.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,221 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
SessionStatus = Literal["ready", "interrupted", "failed"]
SessionModality = Literal["point-cloud", "trajectory", "video"]
class SessionStoreError(RuntimeError):
"""Base error for the host-owned observation session store."""
class SessionNotFoundError(SessionStoreError):
"""The requested opaque session identifier is not registered."""
class SessionNotReplayableError(SessionStoreError):
"""The session has no reviewed replay source."""
class SessionIntegrityError(SessionStoreError):
"""A stored artifact no longer satisfies its confinement/integrity boundary."""
class LayoutConflictError(SessionStoreError):
"""A workspace layout revision changed since the caller loaded it."""
@dataclass(frozen=True, slots=True)
class SessionSource:
source_id: str
semantic_channel_id: str
modality: SessionModality
status: str
seekable: bool
artifact_id: str
def as_dict(self) -> dict[str, Any]:
return {
"source_id": self.source_id,
"semantic_channel_id": self.semantic_channel_id,
"modality": self.modality,
"status": self.status,
"seekable": self.seekable,
"artifact_id": self.artifact_id,
}
@dataclass(frozen=True, slots=True)
class SessionArtifact:
artifact_id: str
kind: str
media_type: str
byte_length: int
sha256: str | None
integrity_status: str
def as_dict(self) -> dict[str, Any]:
return {
"artifact_id": self.artifact_id,
"kind": self.kind,
"media_type": self.media_type,
"byte_length": self.byte_length,
"sha256": self.sha256,
"integrity_status": self.integrity_status,
}
@dataclass(frozen=True, slots=True)
class SessionSummary:
session_id: str
display_name: str
status: SessionStatus
started_at_utc: str | None
completed_at_utc: str | None
duration_seconds: float | None
modalities: tuple[SessionModality, ...]
source_count: int
total_bytes: int
replayable: bool
origin: str
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.observation-session-summary/v1",
"session_id": self.session_id,
"display_name": self.display_name,
"status": self.status,
"started_at_utc": self.started_at_utc,
"completed_at_utc": self.completed_at_utc,
"duration_seconds": self.duration_seconds,
"modalities": list(self.modalities),
"source_count": self.source_count,
"total_bytes": self.total_bytes,
"replayable": self.replayable,
"origin": self.origin,
}
@dataclass(frozen=True, slots=True)
class SessionDetail:
summary: SessionSummary
sources: tuple[SessionSource, ...]
artifacts: tuple[SessionArtifact, ...]
def as_dict(self) -> dict[str, Any]:
duration = self.summary.duration_seconds
return {
"schema_version": "missioncore.observation-session/v1",
**{
key: value
for key, value in self.summary.as_dict().items()
if key != "schema_version"
},
"timeline": {
"mode": "recorded" if self.summary.replayable else "unavailable",
"seekable": self.summary.replayable,
"start_seconds": 0.0 if self.summary.replayable else None,
"end_seconds": duration if self.summary.replayable else None,
"synchronization": "host-arrival-best-effort",
},
"sources": [source.as_dict() for source in self.sources],
"artifacts": [artifact.as_dict() for artifact in self.artifacts],
}
@dataclass(frozen=True, slots=True)
class SessionPage:
items: tuple[SessionSummary, ...]
next_cursor: str | None
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.observation-session-list/v1",
"items": [item.as_dict() for item in self.items],
"next_cursor": self.next_cursor,
}
@dataclass(frozen=True, slots=True)
class WorkspaceLayout:
workspace_id: str
schema_version: int
revision: int
name: str
layout: dict[str, Any]
updated_at_utc: str
def as_dict(self) -> dict[str, Any]:
return {
"schema_version": "missioncore.workspace-layout/v1",
"workspace_id": self.workspace_id,
"layout_schema_version": self.schema_version,
"revision": self.revision,
"name": self.name,
"layout": self.layout,
"updated_at_utc": self.updated_at_utc,
}
@dataclass(frozen=True, slots=True)
class ReplayCommand:
"""Internal-only replay command. ``source_path`` never enters an API DTO."""
session_id: str
source_path: Path
allowed_root: Path
session_root: Path
replay_byte_length: int
metadata_byte_length: int
expected_source_sha256: str | None
speed: float
loop: bool
@dataclass(frozen=True, slots=True)
class RecordedMediaArtifact:
"""Internal handle for one confined archived camera source.
The physical source id and filesystem locator never enter the public API.
``public_source_id`` and ``artifact_id`` are opaque catalog identifiers.
"""
session_id: str
public_source_id: str
artifact_id: str
source_path: Path
byte_length: int
@dataclass(frozen=True, slots=True)
class LegacySessionCandidate:
session_id: str
display_name: str
status: SessionStatus
started_at_utc: str | None
completed_at_utc: str | None
duration_seconds: float | None
modalities: tuple[SessionModality, ...]
replayable: bool
total_bytes: int
allowed_root: Path
session_root: Path
raw_path: Path
raw_byte_length: int
replay_raw_byte_length: int
replay_metadata_byte_length: int
raw_sha256: str | None
raw_integrity_status: str
media_sources: tuple[LegacyMediaSourceCandidate, ...]
@dataclass(frozen=True, slots=True)
class LegacyMediaSourceCandidate:
source_id: str
artifact_id: str
locator: Path
byte_length: int
epoch_count: int

View File

@ -0,0 +1,617 @@
from __future__ import annotations
import os
import queue
import threading
from collections.abc import Callable
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from functools import partial
from pathlib import Path
from time import monotonic
from typing import Literal, cast
from uuid import uuid4
from .media import RecordedMediaManifest, validate_recorded_media_timeline
from .models import ReplayCommand
from .recording import (
MaterializedRecording,
RecordingMaterializationCancelled,
SessionRecordingMaterializer,
)
PreparationState = Literal[
"queued",
"validating",
"exporting",
"finalizing",
"ready",
"failed",
"cancelled",
]
ACTIVE_PREPARATION_STATES = frozenset({"queued", "validating", "exporting", "finalizing"})
_PREPARATION_PHASE: dict[PreparationState, int] = {
"queued": 0,
"validating": 1,
"exporting": 2,
"finalizing": 3,
"ready": 4,
"failed": 4,
"cancelled": 4,
}
class RecordingPreparationQueueFull(RuntimeError):
"""The bounded conversion queue cannot accept another recording."""
@dataclass(frozen=True, slots=True)
class RecordingPreparationSnapshot:
preparation_id: str
session_id: str
state: PreparationState
progress: float
updated_at_utc: str
cancellable: bool
retryable: bool
error: str | None
command: ReplayCommand
recording: MaterializedRecording | None
recorded_media: tuple[RecordedMediaManifest, ...] | None
@dataclass(slots=True)
class _PreparationJob:
preparation_id: str
source_identity: tuple[object, ...]
# This is an immutable source-preparation command. Per-browser playback
# policy (speed/loop) never belongs to a shared conversion job.
command: ReplayCommand
state: PreparationState = "queued"
progress: float = 0.0
updated_at_utc: str = field(default_factory=lambda: _utc_now_iso())
error: str | None = None
recording: MaterializedRecording | None = None
recorded_media: tuple[RecordedMediaManifest, ...] | None = None
cancel_event: threading.Event = field(default_factory=threading.Event)
last_activity_monotonic: float = field(default_factory=monotonic)
interrupted_by_restart: bool = False
cancelled_by_operator: bool = False
@dataclass(slots=True)
class _WorkerGeneration:
generation_id: int
work_queue: queue.Queue[_PreparationJob]
stop_event: threading.Event = field(default_factory=threading.Event)
worker: threading.Thread | None = None
class SessionRecordingPreparationManager:
"""One bounded, process-owned conversion worker for durable recordings.
Jobs are keyed by the session plus an inexpensive source identity. They
intentionally outlive HTTP requests and browser tabs. Conversion stays
single-worker to bound Rerun's CPU, memory and temporary-disk pressure.
Worker generations make lifespan restart safe even if a third-party
exporter ignores cancellation longer than ``close(timeout=...)``. New
jobs can queue immediately, but a successor worker starts only after the
previous generation has actually exited, so two writers never overlap.
"""
def __init__(
self,
materializer: SessionRecordingMaterializer,
*,
queue_capacity: int = 128,
heartbeat_interval_seconds: float = 5.0,
ready_preparer: Callable[
[ReplayCommand, MaterializedRecording],
tuple[RecordedMediaManifest, ...],
]
| None = None,
) -> None:
if queue_capacity < 1:
raise ValueError("recording preparation queue capacity must be positive")
if heartbeat_interval_seconds <= 0:
raise ValueError("recording preparation heartbeat interval must be positive")
self.materializer = materializer
self._queue_capacity = queue_capacity
# Activity callbacks from the real exporter are rate-limited to this
# interval. There is deliberately no independent fake heartbeat: a
# hung exporter must become observable to the browser stall detector.
self._heartbeat_interval_seconds = heartbeat_interval_seconds
self._ready_preparer = ready_preparer
self._guard = threading.RLock()
self._current_by_session: dict[str, _PreparationJob] = {}
self._closed = True
self._generation_counter = 0
self._active_generation: _WorkerGeneration | None = None
self._pending_generation: _WorkerGeneration | None = None
self.start()
def start(self) -> None:
"""Start or restart the single worker for an application lifespan."""
with self._guard:
if not self._closed:
return
self._current_by_session = {
session_id: job
for session_id, job in self._current_by_session.items()
if job.state != "cancelled"
}
self._closed = False
active = self._active_generation
if active is not None and active.worker is not None and active.worker.is_alive():
self._pending_generation = self._new_generation_locked()
return
self._active_generation = None
generation = self._pending_generation or self._new_generation_locked()
self._pending_generation = None
self._start_generation_locked(generation)
def enqueue(
self,
command: ReplayCommand,
*,
retry_failed: bool = False,
retry_interrupted: bool = False,
) -> RecordingPreparationSnapshot:
source_command = _source_command(command)
identity = _source_identity(source_command)
with self._guard:
if self._closed:
raise RuntimeError("recording preparation manager is closed")
current = self._current_by_session.get(command.session_id)
if current is not None and current.source_identity == identity:
retry_terminal = retry_failed and current.state in {"failed", "cancelled"}
retry_restart = (
retry_interrupted
and current.state == "cancelled"
and current.interrupted_by_restart
)
if not retry_terminal and not retry_restart:
return self._snapshot_locked(current)
job = _PreparationJob(
preparation_id=uuid4().hex,
source_identity=identity,
command=source_command,
)
self._current_by_session[command.session_id] = job
try:
self._intake_queue_locked().put_nowait(job)
except queue.Full as exc:
if self._current_by_session.get(command.session_id) is job:
if current is None:
self._current_by_session.pop(command.session_id, None)
else:
self._current_by_session[command.session_id] = current
raise RecordingPreparationQueueFull("recording preparation queue is full") from exc
return self._snapshot_locked(job)
def resolve_cached(
self,
command: ReplayCommand,
) -> RecordingPreparationSnapshot | None:
"""Validate a published cache and register it as a ready job."""
# When launch preparation includes camera manifests, a disk-only RRD
# is not a complete ready result. The worker must validate both parts
# in one background transaction before publishing ``ready``.
if self._ready_preparer is not None:
return None
source_command = _source_command(command)
identity = _source_identity(source_command)
recording = self.materializer.get_cached(source_command)
if recording is None:
with self._guard:
current = self._current_by_session.get(command.session_id)
if current is not None and current.state == "ready":
self._current_by_session.pop(command.session_id, None)
return None
with self._guard:
current = self._current_by_session.get(command.session_id)
if (
current is not None
and current.source_identity == identity
and current.state in ACTIVE_PREPARATION_STATES | {"ready"}
):
# A worker may have started between the disk lookup and this
# transaction. Let that single job publish its own result.
if current.state == "ready":
current.recording = recording
return self._snapshot_locked(current)
ready = _PreparationJob(
preparation_id=uuid4().hex,
source_identity=identity,
command=source_command,
state="ready",
progress=1.0,
recording=recording,
recorded_media=(),
)
self._current_by_session[command.session_id] = ready
return self._snapshot_locked(ready)
def resolve_cached_pinned(
self,
command: ReplayCommand,
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
"""Validate and lease a published cache without starting conversion."""
if self._ready_preparer is not None:
return None
source_command = _source_command(command)
result = self.materializer.get_cached_pinned(source_command)
if result is None:
with self._guard:
current = self._current_by_session.get(command.session_id)
if current is not None and current.state == "ready":
self._current_by_session.pop(command.session_id, None)
return None
recording, release = result
identity = _source_identity(source_command)
with self._guard:
current = self._current_by_session.get(command.session_id)
if (
current is None
or current.source_identity != identity
or current.state not in ACTIVE_PREPARATION_STATES | {"ready"}
):
current = _PreparationJob(
preparation_id=uuid4().hex,
source_identity=identity,
command=source_command,
state="ready",
progress=1.0,
recording=recording,
recorded_media=(),
)
self._current_by_session[command.session_id] = current
snapshot = self._snapshot_locked(current)
return snapshot, release
def status(self, session_id: str) -> RecordingPreparationSnapshot | None:
with self._guard:
job = self._current_by_session.get(session_id)
if (
job is not None
and job.state == "ready"
and (
job.recording is None
or not self.materializer.is_recording_available(job.recording)
)
):
self._current_by_session.pop(session_id, None)
return None
return None if job is None else self._snapshot_locked(job)
def reserve_cached(
self,
command: ReplayCommand,
*,
lease_seconds: float = 120.0,
) -> RecordingPreparationSnapshot | None:
"""Pin a ready artifact across the launch-document to file-GET gap."""
if lease_seconds <= 0:
raise ValueError("recording launch lease must be positive")
pinned = self.resolve_cached_pinned(command)
if pinned is None:
return None
snapshot, release = pinned
timer = threading.Timer(lease_seconds, release)
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
timer.daemon = True
timer.start()
return snapshot
def pin_ready(
self,
session_id: str,
*,
preparation_id: str | None = None,
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
"""Cheaply lease the exact already-validated ready generation."""
with self._guard:
job = self._current_by_session.get(session_id)
if (
job is None
or job.state != "ready"
or job.recording is None
or (
preparation_id is not None
and job.preparation_id != preparation_id
)
):
return None
recording = job.recording
pinned = self.materializer.pin_recording(recording)
if pinned is None:
if self._current_by_session.get(session_id) is job:
self._current_by_session.pop(session_id, None)
return None
snapshot = self._snapshot_locked(job)
return snapshot, pinned
def reserve_ready(
self,
session_id: str,
*,
preparation_id: str | None = None,
lease_seconds: float = 120.0,
) -> RecordingPreparationSnapshot | None:
"""Hold a cheap launch lease without reopening or hashing the cache."""
if lease_seconds <= 0:
raise ValueError("recording launch lease must be positive")
pinned = self.pin_ready(session_id, preparation_id=preparation_id)
if pinned is None:
return None
snapshot, release = pinned
timer = threading.Timer(lease_seconds, release)
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
timer.daemon = True
timer.start()
return snapshot
def cancel(self, session_id: str, *, preparation_id: str | None = None) -> bool:
with self._guard:
job = self._current_by_session.get(session_id)
if (
job is None
or job.state not in ACTIVE_PREPARATION_STATES
or (preparation_id is not None and job.preparation_id != preparation_id)
):
return False
job.cancelled_by_operator = True
job.cancel_event.set()
if job.state == "queued":
self._transition_locked(job, "cancelled", job.progress)
return True
def close(self, *, timeout: float = 5.0) -> None:
with self._guard:
if self._closed:
return
self._closed = True
for job in self._current_by_session.values():
if job.state in ACTIVE_PREPARATION_STATES:
job.interrupted_by_restart = not job.cancelled_by_operator
job.cancel_event.set()
if job.state == "queued":
self._transition_locked(job, "cancelled", job.progress)
active = self._active_generation
if active is not None:
active.stop_event.set()
pending = self._pending_generation
if pending is not None:
pending.stop_event.set()
self._pending_generation = None
worker = None if active is None else active.worker
if worker is not None:
worker.join(timeout=max(0.0, timeout))
def _run_generation(self, generation: _WorkerGeneration) -> None:
work_queue = generation.work_queue
try:
while not generation.stop_event.is_set():
try:
job = work_queue.get(timeout=0.1)
except queue.Empty:
continue
try:
if job.state == "cancelled" or job.cancel_event.is_set():
with self._guard:
self._transition_locked(job, "cancelled", job.progress)
continue
with self._guard:
self._transition_locked(job, "validating", 0.05)
try:
recording = self.materializer.materialize(
job.command,
progress_callback=partial(self._progress, job),
cancel_event=job.cancel_event,
)
except RecordingMaterializationCancelled:
with self._guard:
self._transition_locked(job, "cancelled", job.progress)
except Exception:
# The public status intentionally does not expose paths,
# broker payloads or exporter internals.
with self._guard:
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
self._transition_locked(job, "failed", job.progress)
job.error = "Не удалось подготовить запись сессии."
job.updated_at_utc = _utc_now_iso()
else:
with self._guard:
# Cancellation may race the final materializer
# return. Never publish ``ready`` after accepting a
# cancel request for this exact job.
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
self._transition_locked(job, "finalizing", 0.95)
if job.cancel_event.is_set():
with self._guard:
self._transition_locked(job, "cancelled", job.progress)
continue
try:
recorded_media = (
()
if self._ready_preparer is None
else self._ready_preparer(job.command, recording)
)
validate_recorded_media_timeline(
recorded_media,
recording_start_seconds=(
recording.timeline_start_ns / 1_000_000_000
),
recording_end_seconds=(
recording.timeline_end_ns / 1_000_000_000
),
)
except Exception:
with self._guard:
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
self._transition_locked(job, "failed", job.progress)
job.error = "Не удалось подготовить запись сессии."
job.updated_at_utc = _utc_now_iso()
continue
with self._guard:
if job.cancel_event.is_set():
self._transition_locked(job, "cancelled", job.progress)
else:
job.recording = recording
job.recorded_media = recorded_media
self._transition_locked(job, "ready", 1.0)
finally:
work_queue.task_done()
finally:
self._generation_exited(generation)
def _new_generation_locked(self) -> _WorkerGeneration:
self._generation_counter += 1
return _WorkerGeneration(
generation_id=self._generation_counter,
work_queue=queue.Queue(maxsize=self._queue_capacity),
)
def _start_generation_locked(self, generation: _WorkerGeneration) -> None:
self._active_generation = generation
worker = threading.Thread(
target=self._run_generation,
args=(generation,),
name=f"missioncore-recording-preparation-{generation.generation_id}",
daemon=True,
)
generation.worker = worker
worker.start()
def _generation_exited(self, generation: _WorkerGeneration) -> None:
with self._guard:
if self._active_generation is not generation:
return
self._active_generation = None
if self._closed:
return
successor = self._pending_generation or self._new_generation_locked()
self._pending_generation = None
self._start_generation_locked(successor)
def _intake_queue_locked(self) -> queue.Queue[_PreparationJob]:
pending = self._pending_generation
if pending is not None:
return pending.work_queue
active = self._active_generation
if active is None or active.stop_event.is_set():
pending = self._new_generation_locked()
self._pending_generation = pending
return pending.work_queue
return active.work_queue
def _progress(self, job: _PreparationJob, state: str, progress: float) -> None:
# Only the worker publishes ``ready`` after it owns the verified
# recording handle; accepting the final callback would expose a short
# ready-without-launch race to status polling.
if state not in {"validating", "exporting", "finalizing"}:
return
next_state = cast(PreparationState, state)
with self._guard:
if job.state in {"cancelled", "failed", "ready"}:
return
if _PREPARATION_PHASE[next_state] < _PREPARATION_PHASE[job.state]:
return
now = monotonic()
if (
job.state == next_state
and progress <= job.progress
and now - job.last_activity_monotonic < self._heartbeat_interval_seconds
):
return
self._transition_locked(job, next_state, progress)
def _transition_locked(
self,
job: _PreparationJob,
state: PreparationState,
progress: float,
) -> None:
if job.state not in ACTIVE_PREPARATION_STATES and state != job.state:
return
if _PREPARATION_PHASE[state] < _PREPARATION_PHASE[job.state]:
return
job.state = state
job.progress = max(job.progress, min(1.0, max(0.0, progress)))
job.updated_at_utc = _utc_now_iso()
job.last_activity_monotonic = monotonic()
def _snapshot_locked(self, job: _PreparationJob) -> RecordingPreparationSnapshot:
return RecordingPreparationSnapshot(
preparation_id=job.preparation_id,
session_id=job.command.session_id,
state=job.state,
progress=job.progress,
updated_at_utc=job.updated_at_utc,
cancellable=(
job.state == "queued"
or (
job.state in {"validating", "exporting", "finalizing"}
and self.materializer.supports_cooperative_cancellation
)
),
retryable=job.state in {"failed", "cancelled"},
error=job.error,
command=job.command,
recording=job.recording,
recorded_media=job.recorded_media,
)
def _source_identity(command: ReplayCommand) -> tuple[object, ...]:
"""Build a non-blocking identity; full validation belongs to the worker."""
identities: list[object] = [
command.session_id,
str(command.source_path),
command.replay_byte_length,
command.metadata_byte_length,
command.expected_source_sha256,
]
for path in (command.source_path, command.source_path.with_name("mqtt.metadata.jsonl")):
try:
value = os.lstat(path)
except OSError:
identities.extend((str(Path(path)), None, None, None, None))
else:
identities.extend(
(
str(Path(path)),
value.st_size,
value.st_mtime_ns,
value.st_ctime_ns,
value.st_ino,
)
)
return tuple(identities)
def _source_command(command: ReplayCommand) -> ReplayCommand:
"""Remove per-viewer launch policy from a shared preparation job."""
return replace(command, speed=1.0, loop=False)
def _utc_now_iso() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,715 @@
from __future__ import annotations
import json
import os
import re
import sqlite3
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any, cast
from k1link.artifacts import utc_now_iso
from .legacy import discover_legacy_viewer_sessions
from .models import (
LayoutConflictError,
LegacySessionCandidate,
RecordedMediaArtifact,
ReplayCommand,
SessionArtifact,
SessionDetail,
SessionIntegrityError,
SessionModality,
SessionNotFoundError,
SessionNotReplayableError,
SessionPage,
SessionSource,
SessionStatus,
SessionSummary,
WorkspaceLayout,
)
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"
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS observation_sessions (
session_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('ready', 'interrupted', 'failed')),
started_at_utc TEXT,
completed_at_utc TEXT,
duration_seconds REAL,
modalities_json TEXT NOT NULL,
replayable INTEGER NOT NULL CHECK (replayable IN (0, 1)),
origin TEXT NOT NULL,
source_count INTEGER NOT NULL,
total_bytes INTEGER NOT NULL,
replay_raw_bytes INTEGER NOT NULL DEFAULT 0,
replay_metadata_bytes INTEGER NOT NULL DEFAULT 0,
allowed_root TEXT NOT NULL,
session_root TEXT NOT NULL,
created_at_utc TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS observation_sessions_recent
ON observation_sessions(started_at_utc DESC, session_id DESC);
CREATE TABLE IF NOT EXISTS observation_session_artifacts (
session_id TEXT NOT NULL REFERENCES observation_sessions(session_id) ON DELETE CASCADE,
artifact_id TEXT NOT NULL,
kind TEXT NOT NULL,
media_type TEXT NOT NULL,
byte_length INTEGER NOT NULL,
sha256 TEXT,
integrity_status TEXT NOT NULL,
locator TEXT NOT NULL,
PRIMARY KEY (session_id, artifact_id)
);
CREATE TABLE IF NOT EXISTS observation_session_sources (
session_id TEXT NOT NULL REFERENCES observation_sessions(session_id) ON DELETE CASCADE,
source_id TEXT NOT NULL,
semantic_channel_id TEXT NOT NULL,
modality TEXT NOT NULL,
status TEXT NOT NULL,
seekable INTEGER NOT NULL CHECK (seekable IN (0, 1)),
artifact_id TEXT NOT NULL,
PRIMARY KEY (session_id, source_id),
FOREIGN KEY (session_id, artifact_id)
REFERENCES observation_session_artifacts(session_id, artifact_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS workspace_layouts (
workspace_id TEXT PRIMARY KEY,
layout_schema_version INTEGER NOT NULL,
revision INTEGER NOT NULL,
name TEXT NOT NULL,
layout_json TEXT NOT NULL,
updated_at_utc TEXT NOT NULL
);
"""
def resolve_missioncore_data_dir(repository_root: Path) -> Path:
configured = os.environ.get("MISSIONCORE_DATA_DIR", "").strip()
if configured:
return Path(configured).expanduser().resolve()
return (repository_root.expanduser().resolve() / ".runtime" / "mission-core").resolve()
def resolve_missioncore_evidence_dir(repository_root: Path) -> Path:
"""Return the private source-of-record root for new observation sessions."""
configured = os.environ.get("MISSIONCORE_EVIDENCE_DIR", "").strip()
if configured:
return Path(configured).expanduser().resolve()
return (resolve_missioncore_data_dir(repository_root) / "evidence" / "sessions").resolve()
class SessionStore:
"""SQLite catalog plus confined filesystem references for host observation sessions."""
def __init__(self, repository_root: Path, *, data_dir: Path | None = None) -> None:
self.repository_root = repository_root.expanduser().resolve()
self.data_dir = (
data_dir.expanduser().resolve()
if data_dir is not None
else resolve_missioncore_data_dir(self.repository_root)
)
self.data_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
with _ignore_os_error():
self.data_dir.chmod(0o700)
self.database_path = self.data_dir / DATABASE_NAME
self._lock = threading.RLock()
self._initialize()
def import_legacy_viewer_live(self, root: Path) -> tuple[str, ...]:
allowed_root = root.expanduser().resolve()
candidates = discover_legacy_viewer_sessions(allowed_root)
imported: list[str] = []
for candidate in candidates:
self._upsert_legacy(candidate)
imported.append(candidate.session_id)
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
discovered = set(imported)
indexed = connection.execute(
"SELECT session_id FROM observation_sessions "
"WHERE origin = 'legacy-viewer-live' AND allowed_root = ?",
(str(allowed_root),),
).fetchall()
stale = [row["session_id"] for row in indexed if row["session_id"] not in discovered]
connection.executemany(
"DELETE FROM observation_sessions WHERE session_id = ?",
((session_id,) for session_id in stale),
)
connection.commit()
return tuple(imported)
def list_recent(self, *, limit: int = 20, cursor: str | None = None) -> SessionPage:
if not 1 <= limit <= 100:
raise ValueError("limit must be within 1..100")
parameters: list[object] = []
where = ""
with self._connect() as connection:
if cursor is not None:
_validate_identifier(cursor, "session cursor")
cursor_row = connection.execute(
"SELECT started_at_utc, session_id FROM observation_sessions "
"WHERE session_id = ?",
(cursor,),
).fetchone()
if cursor_row is None:
raise SessionNotFoundError("observation session cursor was not found")
where = (
"WHERE (COALESCE(started_at_utc, ''), session_id) < "
"(COALESCE(?, ''), ?)"
)
parameters.extend((cursor_row["started_at_utc"], cursor_row["session_id"]))
parameters.append(limit + 1)
rows = connection.execute(
f"SELECT * FROM observation_sessions {where} " # noqa: S608 - static clause
"ORDER BY COALESCE(started_at_utc, '') DESC, session_id DESC LIMIT ?",
parameters,
).fetchall()
has_more = len(rows) > limit
selected = rows[:limit]
items = tuple(_summary_from_row(row) 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)
def get_session(self, session_id: str) -> SessionDetail:
_validate_identifier(session_id, "session id")
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM observation_sessions WHERE session_id = ?",
(session_id,),
).fetchone()
if row is None:
raise SessionNotFoundError("observation session was not found")
source_rows = connection.execute(
"SELECT source_id, semantic_channel_id, modality, status, seekable, artifact_id "
"FROM observation_session_sources WHERE session_id = ? ORDER BY source_id",
(session_id,),
).fetchall()
artifact_rows = connection.execute(
"SELECT artifact_id, kind, media_type, byte_length, sha256, integrity_status "
"FROM observation_session_artifacts WHERE session_id = ? ORDER BY artifact_id",
(session_id,),
).fetchall()
sources = tuple(
SessionSource(
source_id=source["source_id"],
semantic_channel_id=source["semantic_channel_id"],
modality=cast(SessionModality, source["modality"]),
status=source["status"],
seekable=bool(source["seekable"]),
artifact_id=source["artifact_id"],
)
for source in source_rows
)
artifacts = tuple(
SessionArtifact(
artifact_id=artifact["artifact_id"],
kind=artifact["kind"],
media_type=artifact["media_type"],
byte_length=artifact["byte_length"],
sha256=artifact["sha256"],
integrity_status=artifact["integrity_status"],
)
for artifact in artifact_rows
)
return SessionDetail(summary=_summary_from_row(row), sources=sources, artifacts=artifacts)
def prepare_replay(
self,
session_id: str,
*,
speed: float = 1.0,
loop: bool = False,
) -> ReplayCommand:
detail = self.get_session(session_id)
if not detail.summary.replayable:
raise SessionNotReplayableError("observation session has no replayable spatial source")
if not 0 <= speed <= 100:
raise ValueError("speed must be within 0..100")
with self._connect() as connection:
row = connection.execute(
"SELECT s.allowed_root, s.session_root, s.replay_raw_bytes, "
"s.replay_metadata_bytes, a.locator, a.sha256 "
"FROM observation_sessions AS s "
"JOIN observation_session_artifacts AS a ON a.session_id = s.session_id "
"WHERE s.session_id = ? AND a.artifact_id = 'raw-mqtt'",
(session_id,),
).fetchone()
if row is None:
raise SessionNotReplayableError("observation session replay artifact is unavailable")
source_path = _resolve_confined_artifact(
Path(row["allowed_root"]),
Path(row["session_root"]),
Path(row["locator"]),
)
return ReplayCommand(
session_id=session_id,
source_path=source_path,
allowed_root=Path(row["allowed_root"]),
session_root=Path(row["session_root"]),
replay_byte_length=int(row["replay_raw_bytes"]),
metadata_byte_length=int(row["replay_metadata_bytes"]),
expected_source_sha256=row["sha256"],
speed=float(speed),
loop=loop,
)
def list_recorded_media(self, session_id: str) -> tuple[RecordedMediaArtifact, ...]:
"""Return confined archived-video handles without exposing device ids."""
_validate_identifier(session_id, "session id")
with self._connect() as connection:
session = connection.execute(
"SELECT allowed_root, session_root FROM observation_sessions "
"WHERE session_id = ?",
(session_id,),
).fetchone()
if session is None:
raise SessionNotFoundError("observation session was not found")
rows = connection.execute(
"SELECT DISTINCT a.artifact_id, a.locator, a.byte_length "
"FROM observation_session_artifacts AS a "
"JOIN observation_session_sources AS source "
"ON source.session_id = a.session_id AND source.artifact_id = a.artifact_id "
"WHERE a.session_id = ? AND a.kind = 'recorded-video' "
"AND source.modality = 'video' ORDER BY a.artifact_id",
(session_id,),
).fetchall()
return tuple(
_recorded_media_from_row(
session_id=session_id,
allowed_root=Path(session["allowed_root"]),
session_root=Path(session["session_root"]),
artifact_id=row["artifact_id"],
locator=Path(row["locator"]),
byte_length=row["byte_length"],
)
for row in rows
)
def get_recorded_media(
self,
session_id: str,
artifact_id: str,
) -> RecordedMediaArtifact:
_validate_identifier(artifact_id, "recorded media artifact id")
matches = tuple(
artifact
for artifact in self.list_recorded_media(session_id)
if artifact.artifact_id == artifact_id
)
if len(matches) != 1:
raise SessionNotFoundError("recorded media source was not found")
return matches[0]
def get_layout(self, workspace_id: str) -> WorkspaceLayout:
_validate_identifier(workspace_id, "workspace id")
with self._connect() as connection:
row = connection.execute(
"SELECT * FROM workspace_layouts WHERE workspace_id = ?",
(workspace_id,),
).fetchone()
if row is None:
raise SessionNotFoundError("workspace layout was not found")
return _layout_from_row(row)
def save_layout(
self,
workspace_id: str,
*,
schema_version: int,
expected_revision: int,
name: str,
layout: dict[str, Any],
) -> WorkspaceLayout:
_validate_identifier(workspace_id, "workspace id")
if schema_version != 1:
raise ValueError("only workspace layout schema version 1 is supported")
if expected_revision < 0:
raise ValueError("expected revision must be non-negative")
normalized_name = name.strip()
if not 1 <= len(normalized_name) <= 160:
raise ValueError("layout name must contain 1..160 characters")
serialized = _serialize_layout(layout)
updated_at = utc_now_iso()
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
current = connection.execute(
"SELECT revision FROM workspace_layouts WHERE workspace_id = ?",
(workspace_id,),
).fetchone()
current_revision = 0 if current is None else int(current["revision"])
if current_revision != expected_revision:
connection.rollback()
raise LayoutConflictError("workspace layout revision changed")
revision = current_revision + 1
connection.execute(
"INSERT INTO workspace_layouts "
"(workspace_id, layout_schema_version, revision, name, layout_json, "
"updated_at_utc) VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(workspace_id) DO UPDATE SET "
"layout_schema_version = excluded.layout_schema_version, "
"revision = excluded.revision, name = excluded.name, "
"layout_json = excluded.layout_json, updated_at_utc = excluded.updated_at_utc",
(
workspace_id,
schema_version,
revision,
normalized_name,
serialized,
updated_at,
),
)
connection.commit()
return WorkspaceLayout(
workspace_id=workspace_id,
schema_version=schema_version,
revision=revision,
name=normalized_name,
layout=json.loads(serialized),
updated_at_utc=updated_at,
)
def _initialize(self) -> None:
with self._connect() as connection:
connection.executescript(SCHEMA_SQL)
columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(observation_sessions)")
}
if "replay_raw_bytes" not in columns:
connection.execute(
"ALTER TABLE observation_sessions "
"ADD COLUMN replay_raw_bytes INTEGER NOT NULL DEFAULT 0"
)
if "replay_metadata_bytes" not in columns:
connection.execute(
"ALTER TABLE observation_sessions "
"ADD COLUMN replay_metadata_bytes INTEGER NOT NULL DEFAULT 0"
)
connection.commit()
with _ignore_os_error():
self.database_path.chmod(0o600)
def _upsert_legacy(self, candidate: LegacySessionCandidate) -> None:
_validate_identifier(candidate.session_id, "legacy session id")
allowed_root = candidate.allowed_root.resolve()
session_root = candidate.session_root.resolve()
if not session_root.is_relative_to(allowed_root):
raise SessionIntegrityError("legacy session root escapes its allowed root")
sources = _legacy_sources(candidate)
artifacts = _legacy_artifacts(candidate, session_root)
now = utc_now_iso()
modalities_json = json.dumps(list(candidate.modalities), separators=(",", ":"))
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT origin, allowed_root, session_root, created_at_utc "
"FROM observation_sessions WHERE session_id = ?",
(candidate.session_id,),
).fetchone()
if existing is not None and (
existing["origin"] != "legacy-viewer-live"
or Path(existing["allowed_root"]).resolve() != allowed_root
or Path(existing["session_root"]).resolve() != session_root
):
connection.rollback()
raise SessionIntegrityError("session id is already bound to another origin")
created_at = existing["created_at_utc"] if existing is not None else now
connection.execute(
"INSERT INTO observation_sessions "
"(session_id, display_name, status, started_at_utc, completed_at_utc, "
"duration_seconds, modalities_json, replayable, origin, source_count, "
"total_bytes, replay_raw_bytes, replay_metadata_bytes, allowed_root, "
"session_root, created_at_utc, updated_at_utc) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(session_id) DO UPDATE SET "
"display_name = excluded.display_name, status = excluded.status, "
"started_at_utc = excluded.started_at_utc, "
"completed_at_utc = excluded.completed_at_utc, "
"duration_seconds = excluded.duration_seconds, "
"modalities_json = excluded.modalities_json, "
"replayable = excluded.replayable, source_count = excluded.source_count, "
"total_bytes = excluded.total_bytes, "
"replay_raw_bytes = excluded.replay_raw_bytes, "
"replay_metadata_bytes = excluded.replay_metadata_bytes, "
"updated_at_utc = excluded.updated_at_utc",
(
candidate.session_id,
candidate.display_name,
candidate.status,
candidate.started_at_utc,
candidate.completed_at_utc,
candidate.duration_seconds,
modalities_json,
int(candidate.replayable),
"legacy-viewer-live",
len(sources),
candidate.total_bytes,
candidate.replay_raw_byte_length,
candidate.replay_metadata_byte_length,
str(allowed_root),
str(session_root),
created_at,
now,
),
)
connection.execute(
"DELETE FROM observation_session_sources WHERE session_id = ?",
(candidate.session_id,),
)
connection.execute(
"DELETE FROM observation_session_artifacts WHERE session_id = ?",
(candidate.session_id,),
)
connection.executemany(
"INSERT INTO observation_session_artifacts "
"(session_id, artifact_id, kind, media_type, byte_length, sha256, "
"integrity_status, locator) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[
(candidate.session_id, *artifact)
for artifact in artifacts
],
)
connection.executemany(
"INSERT INTO observation_session_sources "
"(session_id, source_id, semantic_channel_id, modality, status, seekable, "
"artifact_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
[
(
candidate.session_id,
source.source_id,
source.semantic_channel_id,
source.modality,
source.status,
int(source.seekable),
source.artifact_id,
)
for source in sources
],
)
connection.commit()
@contextmanager
def _connect(self) -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(self.database_path, timeout=5.0)
connection.row_factory = sqlite3.Row
try:
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA synchronous = FULL")
connection.execute("PRAGMA busy_timeout = 5000")
connection.execute("PRAGMA journal_mode = WAL")
yield connection
finally:
connection.close()
def _legacy_sources(candidate: LegacySessionCandidate) -> tuple[SessionSource, ...]:
rows: list[SessionSource] = []
if "point-cloud" in candidate.modalities:
rows.append(
SessionSource(
source_id="sensor.lidar.primary",
semantic_channel_id="spatial.point-cloud.recorded",
modality="point-cloud",
status="recorded",
seekable=candidate.replayable,
artifact_id="raw-mqtt",
)
)
if "trajectory" in candidate.modalities:
rows.append(
SessionSource(
source_id="spatial.trajectory",
semantic_channel_id="spatial.pose.recorded",
modality="trajectory",
status="recorded",
seekable=candidate.replayable,
artifact_id="raw-mqtt",
)
)
rows.extend(
SessionSource(
source_id=media.source_id,
semantic_channel_id="camera.video.recorded",
modality="video",
status="recorded",
seekable=True,
artifact_id=media.artifact_id,
)
for media in candidate.media_sources
)
if len({source.source_id for source in rows}) != len(rows):
raise SessionIntegrityError("legacy session contains duplicate source identifiers")
return tuple(rows)
def _legacy_artifacts(
candidate: LegacySessionCandidate,
session_root: Path,
) -> tuple[tuple[str, str, str, int, str | None, str, str], ...]:
artifacts: list[tuple[str, str, str, int, str | None, str, str]] = [
(
"raw-mqtt",
"raw-transport",
"application/x-nodedc-k1mqtt",
candidate.raw_byte_length,
candidate.raw_sha256,
candidate.raw_integrity_status,
str(candidate.raw_path),
)
]
for media in candidate.media_sources:
try:
locator = media.locator.resolve(strict=True)
except OSError as exc:
raise SessionIntegrityError("legacy video artifact is missing") from exc
if not locator.is_dir() or not locator.is_relative_to(session_root):
raise SessionIntegrityError("legacy video artifact escapes its session root")
artifacts.append(
(
media.artifact_id,
"recorded-video",
"video/mp4",
media.byte_length,
None,
"validated-structure",
str(locator),
)
)
if len({artifact[0] for artifact in artifacts}) != len(artifacts):
raise SessionIntegrityError("legacy session contains duplicate artifact identifiers")
return tuple(artifacts)
def _summary_from_row(row: sqlite3.Row) -> 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"],
status=cast(SessionStatus, row["status"]),
started_at_utc=row["started_at_utc"],
completed_at_utc=row["completed_at_utc"],
duration_seconds=row["duration_seconds"],
modalities=modalities,
source_count=row["source_count"],
total_bytes=row["total_bytes"],
replayable=bool(row["replayable"]),
origin=row["origin"],
)
def _layout_from_row(row: sqlite3.Row) -> WorkspaceLayout:
payload = json.loads(row["layout_json"])
if not isinstance(payload, dict):
raise SessionIntegrityError("stored workspace layout is not a JSON object")
return WorkspaceLayout(
workspace_id=row["workspace_id"],
schema_version=row["layout_schema_version"],
revision=row["revision"],
name=row["name"],
layout=payload,
updated_at_utc=row["updated_at_utc"],
)
def _resolve_confined_artifact(
allowed_root: Path,
session_root: Path,
locator: Path,
) -> Path:
try:
allowed = allowed_root.resolve(strict=True)
session = session_root.resolve(strict=True)
artifact = locator.resolve(strict=True)
except OSError as exc:
raise SessionIntegrityError("session replay artifact is missing") from exc
if (
not allowed.is_dir()
or not session.is_dir()
or not artifact.is_file()
or not session.is_relative_to(allowed)
or not artifact.is_relative_to(session)
):
raise SessionIntegrityError("session replay artifact escapes its allowed root")
return artifact
def _recorded_media_from_row(
*,
session_id: str,
allowed_root: Path,
session_root: Path,
artifact_id: str,
locator: Path,
byte_length: int,
) -> RecordedMediaArtifact:
_validate_identifier(artifact_id, "recorded media artifact id")
try:
allowed = allowed_root.resolve(strict=True)
session = session_root.resolve(strict=True)
media_root = (session / "media").resolve(strict=True)
artifact = locator.resolve(strict=True)
except OSError as exc:
raise SessionIntegrityError("recorded media artifact is missing") from exc
if (
not allowed.is_dir()
or not session.is_dir()
or not media_root.is_dir()
or not artifact.is_dir()
or not session.is_relative_to(allowed)
or not media_root.is_relative_to(session)
or artifact.parent != media_root
):
raise SessionIntegrityError("recorded media artifact escapes its session root")
if not isinstance(byte_length, int) or isinstance(byte_length, bool) or byte_length < 1:
raise SessionIntegrityError("recorded media artifact has an invalid byte length")
public_suffix = artifact_id.removeprefix("recorded-video-")
public_source_id = f"recorded.camera.{public_suffix}"
_validate_identifier(public_source_id, "recorded media source id")
return RecordedMediaArtifact(
session_id=session_id,
public_source_id=public_source_id,
artifact_id=artifact_id,
source_path=artifact,
byte_length=byte_length,
)
def _serialize_layout(layout: dict[str, Any]) -> str:
try:
serialized = json.dumps(layout, ensure_ascii=False, separators=(",", ":"))
except (TypeError, ValueError) as exc:
raise ValueError("layout must be a JSON object") from exc
if len(serialized.encode("utf-8")) > MAX_LAYOUT_BYTES:
raise ValueError("layout exceeds the 256 KiB storage boundary")
return serialized
def _validate_identifier(value: str, label: str) -> None:
if not IDENTIFIER_PATTERN.fullmatch(value):
raise ValueError(f"{label} has an invalid shape")
@contextmanager
def _ignore_os_error() -> Iterator[None]:
try:
yield
except OSError:
return

View File

@ -53,11 +53,17 @@ def _iter_native_capture(path: Path) -> Iterator[StreamMessage]:
epoch_ns = fallback_epoch_ns + frame.sequence - 1
monotonic_ns: int | None = None
if metadata_stream is not None:
epoch_ns, monotonic_ns = _read_native_timing(
timing = _read_native_timing(
metadata_stream,
expected_sequence=frame.sequence,
fallback_epoch_ns=epoch_ns,
)
if timing is None:
# A crash may leave one raw frame ahead of the last fully
# committed metadata line. Only the aligned prefix has a
# trustworthy source timeline and is safe to replay.
return
epoch_ns, monotonic_ns = timing
yield StreamMessage(
sequence=frame.sequence,
topic=frame.topic,
@ -78,15 +84,19 @@ def _read_native_timing(
*,
expected_sequence: int,
fallback_epoch_ns: int,
) -> tuple[int, int | None]:
) -> tuple[int, int | None] | None:
line = stream.readline(MAX_METADATA_LINE_CHARS + 1)
if not line:
return fallback_epoch_ns, None
return None
if len(line) > MAX_METADATA_LINE_CHARS:
raise ReplayFormatError("native metadata line exceeds the reviewed bound")
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
if not line.endswith(("\n", "\r")):
# A non-newline final tail is the only tolerated corruption: the
# writer may have crashed between raw and metadata group commits.
return None
raise ReplayFormatError(
f"native metadata line {expected_sequence} is not valid JSON"
) from exc

View File

@ -0,0 +1,595 @@
from __future__ import annotations
import hashlib
import math
import os
import threading
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import TypedDict
from uuid import UUID, uuid4
import numpy as np
import rerun as rr
from rerun import blueprint as rrb
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
from k1link.protocol.normalizer import normalize_k1_message
from k1link.viewer.replay import ReplayFormatError, detect_replay_format, iter_replay_messages
from k1link.viewer.rerun_bridge import (
MAX_TRAJECTORY_POSES,
TRAJECTORY_APPEND_INTERVAL_NS,
TRAJECTORY_FORCE_APPEND_NS,
TRAJECTORY_MIN_DISTANCE_METERS,
TRAJECTORY_PUBLISH_INTERVAL_NS,
RerunSceneSettings,
_parse_hex_color,
_point_colors,
)
APPLICATION_ID = "nodedc_mission_core_recorded"
SESSION_TIMELINE = "session_time"
CAPTURE_TIMELINE = "capture_time"
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
# blueprint update makes the update overwrite the existing scene instead of
# creating a fresh view/container (which would also reset the operator's eye
# position and layout).
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
class RrdExportSummary(TypedDict):
schema_version: int
input_path: str
output_path: str
recording_id: str
timeline: str
capture_timeline: str
source_messages: int
decoded_messages: int
point_frames: int
pose_frames: int
ignored_messages: int
points: int
trajectory_poses: int
trajectory_updates: int
session_origin_monotonic_ns: int
timeline_start_ns: int
timeline_end_ns: int
timeline_span_ns: int
first_decoded_time_ns: int
last_decoded_time_ns: int
source_sha256: str
rrd_sha256: str
rrd_bytes: int
class RrdExportError(RuntimeError):
"""A raw capture could not be converted into a complete durable RRD."""
class RrdExportCancelled(RrdExportError):
"""A background RRD export was cooperatively cancelled."""
@dataclass(slots=True)
class _ExportCounters:
source_messages: int = 0
point_frames: int = 0
pose_frames: int = 0
ignored_messages: int = 0
points: int = 0
first_decoded_time_ns: int | None = None
last_decoded_time_ns: int | None = None
@property
def decoded_messages(self) -> int:
return self.point_frames + self.pose_frames
def observe_decoded(self, session_time_ns: int) -> None:
if self.first_decoded_time_ns is None:
self.first_decoded_time_ns = session_time_ns
self.last_decoded_time_ns = session_time_ns
@dataclass(slots=True)
class _TrajectoryBuffer:
positions: list[tuple[float, float, float]]
last_append_time_ns: int | None = None
last_publish_time_ns: int | None = None
updates: int = 0
@classmethod
def empty(cls) -> _TrajectoryBuffer:
return cls(positions=[])
def process(
self,
recording: rr.RecordingStream,
position: tuple[float, float, float],
session_time_ns: int,
) -> None:
if not self._append(position, session_time_ns):
return
if (
self.last_publish_time_ns is not None
and session_time_ns - self.last_publish_time_ns < TRAJECTORY_PUBLISH_INTERVAL_NS
):
return
self.last_publish_time_ns = session_time_ns
self.updates += 1
recording.log(
"/world/trajectory",
rr.LineStrips3D(
[list(self.positions)],
colors=[247, 248, 244, 255],
radii=rr.Radius.ui_points(2.0),
),
)
def _append(self, position: tuple[float, float, float], session_time_ns: int) -> bool:
if not self.positions:
self.positions.append(position)
self.last_append_time_ns = session_time_ns
return True
assert self.last_append_time_ns is not None
elapsed_ns = session_time_ns - self.last_append_time_ns
if elapsed_ns < TRAJECTORY_APPEND_INTERVAL_NS:
return False
if (
math.dist(self.positions[-1], position) < TRAJECTORY_MIN_DISTANCE_METERS
and elapsed_ns < TRAJECTORY_FORCE_APPEND_NS
):
return False
self.positions.append(position)
self.last_append_time_ns = session_time_ns
if len(self.positions) > MAX_TRAJECTORY_POSES:
last = self.positions[-1]
self.positions = self.positions[::2]
if self.positions[-1] != last:
self.positions.append(last)
return True
def export_k1mqtt_to_rrd(
input_path: Path,
output_path: Path,
*,
cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None,
) -> RrdExportSummary:
"""Losslessly project every decodable K1 data-plane frame into one RRD.
The raw capture remains the source of record. The derived RRD uses a
recording-local duration timeline whose zero is the first raw message's
receive-monotonic timestamp. It never traverses the bounded live-preview
queue, so export throughput cannot drop point or pose frames.
The destination is replaced only after the temporary RRD has been closed,
flushed and fsynced. Any decode, timing, sink or rename failure therefore
leaves an existing destination artifact untouched.
"""
_raise_if_cancelled(cancel_event)
source = input_path.expanduser().resolve()
destination = output_path.expanduser().resolve()
_validate_paths(source, destination)
destination.parent.mkdir(parents=True, exist_ok=True)
recording_id = str(uuid4())
temporary = destination.with_name(f".{destination.name}.{recording_id}.tmp")
source_sha256 = _sha256_file(
source,
cancel_event=cancel_event,
activity_callback=activity_callback,
)
settings = RerunSceneSettings()
blueprint = _recorded_blueprint(settings)
recording: rr.RecordingStream | None = None
recording_closed = False
published = False
counters = _ExportCounters()
trajectory = _TrajectoryBuffer.empty()
session_origin_ns: int | None = None
previous_monotonic_ns: int | None = None
try:
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
recording.set_sinks(
rr.FileSink(temporary, write_footer=True),
default_blueprint=blueprint,
)
_log_static_scene(recording)
_log_session_origin(recording)
for message in iter_replay_messages(source):
_raise_if_cancelled(cancel_event)
_notify_activity(activity_callback)
counters.source_messages += 1
monotonic_ns = message.received_monotonic_ns
if monotonic_ns is None:
raise RrdExportError(
"native capture metadata must provide received_monotonic_ns "
f"for message {message.sequence}"
)
if previous_monotonic_ns is not None and monotonic_ns < previous_monotonic_ns:
raise RrdExportError(
f"native capture monotonic time decreases at message {message.sequence}"
)
if session_origin_ns is None:
session_origin_ns = monotonic_ns
session_time_ns = monotonic_ns - session_origin_ns
if session_time_ns > JS_MAX_SAFE_INTEGER:
raise RrdExportError(
"session duration exceeds the exact JavaScript nanosecond range"
)
previous_monotonic_ns = monotonic_ns
try:
decoded = normalize_k1_message(
message,
processing_started_monotonic_ns=monotonic_ns,
)
except NormalizationError as exc:
raise RrdExportError(
f"known K1 frame {message.sequence} failed normalization"
) from exc
if decoded is None:
counters.ignored_messages += 1
continue
_set_frame_time(
recording,
decoded.context.sequence,
session_time_ns,
decoded.context.captured_at_epoch_ns,
)
counters.observe_decoded(session_time_ns)
if isinstance(decoded, DecodedPointCloudView):
_log_points(recording, decoded, settings)
counters.point_frames += 1
counters.points += decoded.point_count
elif isinstance(decoded, DecodedPoseView):
position = (
float(decoded.position_xyz[0]),
float(decoded.position_xyz[1]),
float(decoded.position_xyz[2]),
)
_log_pose(recording, decoded, position)
trajectory.process(recording, position, session_time_ns)
counters.pose_frames += 1
else:
counters.ignored_messages += 1
if session_origin_ns is None:
raise RrdExportError("native capture contains no messages")
if counters.decoded_messages == 0:
raise RrdExportError("native capture contains no decodable point or pose frames")
assert counters.first_decoded_time_ns is not None
assert counters.last_decoded_time_ns is not None
_raise_if_cancelled(cancel_event)
recording.flush(timeout_sec=30.0)
recording.disconnect()
recording_closed = True
_raise_if_cancelled(cancel_event)
_fsync_file(temporary)
rrd_bytes = temporary.stat().st_size
if rrd_bytes <= 0:
raise RrdExportError("Rerun produced an empty recording")
rrd_sha256 = _sha256_file(
temporary,
cancel_event=cancel_event,
activity_callback=activity_callback,
)
summary = RrdExportSummary(
schema_version=1,
input_path=str(source),
output_path=str(destination),
recording_id=recording_id,
timeline=SESSION_TIMELINE,
capture_timeline=CAPTURE_TIMELINE,
source_messages=counters.source_messages,
decoded_messages=counters.decoded_messages,
point_frames=counters.point_frames,
pose_frames=counters.pose_frames,
ignored_messages=counters.ignored_messages,
points=counters.points,
trajectory_poses=len(trajectory.positions),
trajectory_updates=trajectory.updates,
session_origin_monotonic_ns=session_origin_ns,
timeline_start_ns=0,
# Playback completeness is defined by data actually written to
# the RRD. K1 status/heartbeat packets may continue long after the
# final point or pose frame; advertising that raw tail as the RRD
# end makes a strict browser buffering gate wait forever.
timeline_end_ns=counters.last_decoded_time_ns,
timeline_span_ns=counters.last_decoded_time_ns,
first_decoded_time_ns=counters.first_decoded_time_ns,
last_decoded_time_ns=counters.last_decoded_time_ns,
source_sha256=source_sha256,
rrd_sha256=rrd_sha256,
rrd_bytes=rrd_bytes,
)
_raise_if_cancelled(cancel_event)
os.replace(temporary, destination)
_fsync_directory(destination.parent)
published = True
return summary
except (ReplayFormatError, OSError) as exc:
raise RrdExportError(f"RRD export failed: {exc}") from exc
finally:
if recording is not None and not recording_closed:
with suppress(BaseException):
recording.disconnect()
if not published:
temporary.unlink(missing_ok=True)
def _validate_paths(source: Path, destination: Path) -> None:
if not source.is_file():
raise RrdExportError(f"raw capture does not exist: {source}")
if source.suffix.casefold() != ".k1mqtt":
raise RrdExportError("RRD export accepts only native .k1mqtt captures")
if destination.suffix.casefold() != ".rrd":
raise RrdExportError("RRD destination must use the .rrd suffix")
if source == destination:
raise RrdExportError("raw capture and RRD destination must be different files")
try:
replay_format = detect_replay_format(source)
except ReplayFormatError as exc:
raise RrdExportError(str(exc)) from exc
if replay_format != "k1mqtt":
raise RrdExportError("RRD export accepts only native K1MQTT captures")
def _recorded_blueprint(
settings: RerunSceneSettings,
*,
include_initial_playback_state: bool = True,
) -> rrb.Blueprint:
accumulation = max(0.0, settings.accumulation_seconds)
# An omitted range means Rerun's native latest-at query: the most recent
# LiDAR frame at the cursor. A zero-width range is *not* equivalent; it
# only matches rows stamped at the cursor's exact nanosecond and therefore
# makes an ordinary recorded scan appear empty.
time_ranges: list[rr.VisibleTimeRange] | None = None
if accumulation > 0:
time_ranges = [
rr.VisibleTimeRange(
SESSION_TIMELINE,
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
end=rr.TimeRangeBoundary.cursor_relative(),
)
]
point_visualizer = rr.Points3D.from_fields(
# A negative Radius value is Rerun's serialized representation
# for UI points. Blueprint overrides broadcast this singleton
# value across every recorded point without rewriting the store.
radii=rr.Radius.ui_points(settings.point_size),
# Recorded height/intensity/distance/RGB palettes are baked into
# each Points3D row. A uniform custom color is the one color mode
# that can be replaced safely by a singleton blueprint override.
colors=(
[_parse_hex_color(settings.custom_color)]
if settings.palette == "custom"
else None
),
).visualizer()
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
spatial_view = rrb.Spatial3DView(
origin="/world",
name="Пространственная сцена",
background=[7, 8, 10, 255],
line_grid=rrb.LineGrid3D(
visible=settings.show_grid,
color=[86, 91, 99, 110],
stroke_width=0.75,
),
overrides={
# EntityBehavior is evaluated from the blueprint store and can
# therefore hide/reveal already-recorded entities without
# rewriting the data RRD. Keep the point visualizer alongside it
# so size/color overrides remain active for the same entity.
"/world/points": [
rrb.EntityBehavior(visible=settings.show_points),
point_visualizer,
],
"/world/trajectory": rrb.EntityBehavior(
visible=settings.show_trajectory,
),
},
# A positive window accumulates historical frames. With no
# window, latest-at deliberately keeps one current LiDAR frame.
time_ranges=time_ranges,
)
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
root_container = rrb.Tabs(spatial_view)
root_container.id = RECORDED_ROOT_CONTAINER_ID
if include_initial_playback_state:
# This state is appropriate only while opening a newly exported RRD.
# Settings-only blueprint messages must not pause an already playing
# recording or mutate the host-owned panel state.
return rrb.Blueprint(
root_container,
rrb.TimePanel(
timeline=SESSION_TIMELINE,
play_state="paused",
state="hidden",
),
auto_layout=False,
auto_views=False,
collapse_panels=True,
)
return rrb.Blueprint(
root_container,
auto_layout=False,
auto_views=False,
collapse_panels=False,
)
def recorded_blueprint_rrd(
settings: RerunSceneSettings,
*,
application_id: str = APPLICATION_ID,
recording_id: str,
) -> bytes:
"""Serialize a small active blueprint update for an already-open recording.
The returned RRD contains blueprint-store messages only; it never copies the
recorded data store and is therefore safe to push through a WebViewer log
channel when operator display settings change.
"""
recording = rr.RecordingStream(
application_id,
recording_id=recording_id,
send_properties=False,
)
stream = rr.binary_stream(recording)
try:
recording.send_blueprint(
_recorded_blueprint(
settings,
include_initial_playback_state=False,
),
make_active=True,
make_default=False,
)
payload = stream.read(flush=True, flush_timeout_sec=5.0)
except Exception as exc:
raise RrdExportError("failed to serialize recorded blueprint") from exc
finally:
with suppress(Exception):
recording.disconnect()
if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576:
raise RrdExportError("serialized recorded blueprint is invalid")
return payload
def _log_static_scene(recording: rr.RecordingStream) -> None:
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
recording.log(
"/world/sensor_pose",
rr.TransformAxes3D(axis_length=0.45, show_frame=False),
static=True,
)
def _log_session_origin(recording: rr.RecordingStream) -> None:
"""Materialize the declared zero of ``session_time`` outside the 3D scene."""
recording.set_time(
SESSION_TIMELINE,
duration=np.timedelta64(0, "ns"),
)
recording.log(
"/__mission_core/session_origin",
rr.AnyValues(session_origin=True),
)
def _set_frame_time(
recording: rr.RecordingStream,
sequence: int,
session_time_ns: int,
capture_time_ns: int,
) -> None:
recording.set_time(
SESSION_TIMELINE,
duration=np.timedelta64(session_time_ns, "ns"),
)
recording.set_time(
CAPTURE_TIMELINE,
timestamp=np.datetime64(capture_time_ns, "ns"),
)
recording.set_time("message_sequence", sequence=sequence)
def _log_points(
recording: rr.RecordingStream,
frame: DecodedPointCloudView,
settings: RerunSceneSettings,
) -> None:
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
if frame.intensities is None:
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
else:
intensities = np.frombuffer(frame.intensities, dtype=np.uint8)
rgb = (
None
if frame.colors_rgb is None
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
)
recording.log(
"/world/points",
rr.Points3D(
positions,
colors=_point_colors(positions, intensities, rgb, settings),
radii=rr.Radius.ui_points(settings.point_size),
),
)
def _log_pose(
recording: rr.RecordingStream,
frame: DecodedPoseView,
position: tuple[float, float, float],
) -> None:
recording.log(
"/world/sensor_pose",
rr.Transform3D(
translation=position,
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
),
)
def _fsync_file(path: Path) -> None:
with path.open("rb") as stream:
os.fsync(stream.fileno())
def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _sha256_file(
path: Path,
*,
cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None,
) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
_raise_if_cancelled(cancel_event)
_notify_activity(activity_callback)
digest.update(chunk)
return digest.hexdigest()
def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
if cancel_event is not None and cancel_event.is_set():
raise RrdExportCancelled("RRD export was cancelled")
def _notify_activity(callback: Callable[[], None] | None) -> None:
if callback is not None:
callback()

View File

@ -514,9 +514,9 @@ class VisualizationRuntime:
callback()
def new_live_session_dir(repository_root: Path) -> Path:
def new_live_session_dir(sessions_root: Path) -> Path:
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
base = repository_root / "sessions" / f"{stamp}_viewer_live"
base = sessions_root / f"{stamp}_viewer_live"
candidate = base
suffix = 1
while candidate.exists():

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():

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,12 @@ from typing import IO, Any, Final, Literal
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from k1link.mqtt import validate_private_ipv4
from k1link.web.camera_archive import (
CameraArchiveError,
CameraArchiveKind,
CameraArchiveStatus,
CameraArchiveWriter,
)
CameraSourceId = Literal["sensor.camera.left", "sensor.camera.right"]
@ -31,6 +37,7 @@ CAMERA_EXCLUSIVE_GROUP: Final = "camera.preview.decoder"
MAX_FMP4_BOX_BYTES: Final = 8 * 1024 * 1024
MAX_FMP4_SEGMENT_BYTES: Final = 1024 * 1024
MAX_QUEUED_SEGMENTS: Final = 4
CAMERA_DRAIN_TIMEOUT_SECONDS: Final = 5.0
@dataclass
@ -38,34 +45,52 @@ class CameraProcessLease:
generation: int
source_id: CameraSourceId
process: subprocess.Popen[bytes]
stderr_tail: deque[str] = field(default_factory=lambda: deque(maxlen=12))
stderr_tail: deque[str]
segments: queue.Queue[tuple[str, bytes] | None] = field(
default_factory=lambda: queue.Queue(maxsize=MAX_QUEUED_SEGMENTS)
)
failure_code: str | None = None
class XgridsK1CameraGateway:
"""One fail-closed K1 RTSP owner with a browser-safe fMP4 delivery plane.
@dataclass
class _CameraProducer:
generation: int
source_id: CameraSourceId
process: subprocess.Popen[bytes]
archive: CameraArchiveWriter | None
stderr_tail: deque[str] = field(default_factory=lambda: deque(maxlen=12))
delivery: CameraProcessLease | None = None
init_segment: bytes | None = None
failure_code: str | None = None
stop_requested: bool = False
drain_requested: bool = False
reader_started: bool = False
reader_done: threading.Event = field(default_factory=threading.Event)
Selection and delivery are deliberately separate. A plugin action selects
one allowlisted producer and publishes an opaque generation. Only a matching
WebSocket may then start FFmpeg. This keeps the device address and RTSP path
out of the generic UI and prevents two browser windows from opening two K1
sessions.
class XgridsK1CameraGateway:
"""One fail-closed K1 producer with independent archive and preview planes.
During an acquisition the gateway, rather than a browser WebSocket, owns
FFmpeg. Every complete fMP4 segment is durably appended before it can enter
the bounded preview queue. Attaching, dropping, or disconnecting a browser
therefore cannot stop or back-pressure the source-of-record camera stream.
Outside an acquisition the legacy lazy-preview lifecycle remains available.
"""
def __init__(self, repository_root: Path, plugin_id: str) -> None:
self._repository_root = repository_root.resolve()
self._plugin_id = plugin_id
self._lock = threading.RLock()
self._lifecycle_lock = threading.Lock()
self._revision = 0
self._generation = 0
self._phase = "idle"
self._source_id: CameraSourceId | None = None
self._target_host: str | None = None
self._process: subprocess.Popen[bytes] | None = None
self._process_generation: int | None = None
self._producer: _CameraProducer | None = None
self._recording_root: Path | None = None
self._archive_summaries: list[dict[str, Any]] = []
self._error: dict[str, str] | None = None
self._closed = False
self._ffmpeg_path, self._ffmpeg_source = _resolve_ffmpeg(self._repository_root)
@ -97,6 +122,21 @@ class XgridsK1CameraGateway:
"exclusive_group": CAMERA_EXCLUSIVE_GROUP,
"max_active": 1,
},
"recording": {
"active": self._recording_root is not None,
"session": (
self._recording_root.name if self._recording_root is not None else None
),
"active_epoch": (
self._producer.generation
if self._producer is not None and self._producer.archive is not None
else None
),
"completed_epochs": len(self._archive_summaries),
"last_summary": (
dict(self._archive_summaries[-1]) if self._archive_summaries else None
),
},
"delivery": delivery,
"runtime_dependency": {
"kind": "ffmpeg",
@ -110,21 +150,18 @@ class XgridsK1CameraGateway:
if source_id not in CAMERA_SOURCE_PATHS:
raise ValueError("неизвестный camera source")
target = validate_private_ipv4(target_host)
old_process: subprocess.Popen[bytes] | None = None
with self._lifecycle_lock:
with self._lock:
if self._closed:
raise RuntimeError("camera gateway уже закрыт")
self._require_open_locked()
if self._ffmpeg_path is None:
self._generation += 1
self._revision += 1
self._source_id = source_id
self._target_host = target
self._phase = "error"
self._error = {
"code": "ffmpeg-unavailable",
"message": "Локальный camera adapter FFmpeg не найден.",
}
self._set_error_locked(
"ffmpeg-unavailable",
"Локальный camera adapter FFmpeg не найден.",
)
raise RuntimeError("локальный camera adapter FFmpeg не найден")
if (
self._source_id == source_id
@ -133,169 +170,517 @@ class XgridsK1CameraGateway:
):
return self.snapshot()
old_process = self._detach_process_locked()
old_producer, old_delivery = self._detach_producer_locked()
self._generation += 1
self._revision += 1
self._source_id = source_id
self._target_host = target
self._phase = "selected"
self._error = None
recording_active = self._recording_root is not None
_terminate_process(old_process)
self._shutdown_producer(
old_producer,
old_delivery,
status="complete",
failure_code="source-switch",
)
if recording_active:
self._spawn_selected_producer()
return self.snapshot()
def stop(self, generation: int) -> dict[str, Any]:
old_process: subprocess.Popen[bytes] | None
with self._lifecycle_lock:
with self._lock:
if self._source_id is None:
return self.snapshot()
if generation != self._generation:
raise ValueError("camera preview generation устарело")
old_process = self._detach_process_locked()
producer, delivery = self._detach_producer_locked()
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._error = None
_terminate_process(old_process)
self._shutdown_producer(
producer,
delivery,
status="complete",
failure_code="source-stopped",
)
return self.snapshot()
def stop_current(self) -> dict[str, Any]:
old_process: subprocess.Popen[bytes] | None
with self._lifecycle_lock:
with self._lock:
old_process = self._detach_process_locked()
producer, delivery = self._detach_producer_locked()
changed = self._source_id is not None or self._phase != "idle"
if changed:
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._recording_root = None
self._error = None
_terminate_process(old_process)
self._shutdown_producer(
producer,
delivery,
status="interrupted",
failure_code="gateway-stop",
)
return self.snapshot()
def start_recording(self, session_dir: Path) -> dict[str, Any]:
"""Make an existing observation session the camera recording root."""
root = session_dir.expanduser().resolve()
if not root.is_dir():
raise ValueError("observation session directory does not exist")
if not root.is_relative_to(self._repository_root):
raise ValueError("camera recording root must stay inside the repository")
with self._lifecycle_lock:
with self._lock:
self._require_open_locked()
if self._recording_root is not None:
if self._recording_root == root:
return self.snapshot()
raise RuntimeError("для camera gateway уже активна другая acquisition-сессия")
producer, delivery = self._detach_producer_locked()
self._recording_root = root
self._archive_summaries = []
selected = self._source_id is not None
if selected:
self._phase = "selected"
self._revision += 1
# A pre-acquisition browser-owned process cannot become evidence
# retrospectively. Restart it at a clean codec epoch instead.
self._shutdown_producer(
producer,
delivery,
status="interrupted",
failure_code="recording-start-restart",
)
if selected:
self._spawn_selected_producer()
return self.snapshot()
def stop_recording(
self,
*,
status: CameraArchiveStatus = "complete",
failure_code: str | None = None,
) -> dict[str, Any]:
"""Stop the acquisition-owned producer and seal its active epoch."""
with self._lifecycle_lock:
with self._lock:
producer, delivery = self._detach_producer_locked()
recording_was_active = self._recording_root is not None
self._recording_root = None
if self._source_id is not None and self._phase != "error":
self._phase = "selected"
if recording_was_active or producer is not None:
self._revision += 1
self._shutdown_producer(
producer,
delivery,
status=status,
failure_code=failure_code,
)
return self.snapshot()
def open_delivery(self, generation: int) -> CameraProcessLease:
with self._lifecycle_lock:
with self._lock:
if self._closed:
raise RuntimeError("camera gateway закрыт")
self._require_open_locked()
if generation != self._generation or self._source_id is None:
raise ValueError("camera preview generation не активно")
if self._process is not None:
producer = self._producer
if producer is None:
producer = self._spawn_selected_producer()
with self._lock:
if self._producer is not producer or producer.generation != generation:
raise ValueError("camera preview generation не активно")
if producer.delivery is not None:
raise RuntimeError("для camera preview уже открыт browser consumer")
if self._ffmpeg_path is None or self._target_host is None:
raise RuntimeError("camera preview runtime не готов")
argv = _build_ffmpeg_argv(
self._ffmpeg_path,
self._target_host,
self._source_id,
lease = CameraProcessLease(
generation=producer.generation,
source_id=producer.source_id,
process=producer.process,
stderr_tail=producer.stderr_tail,
)
producer.delivery = lease
if producer.init_segment is not None:
lease.segments.put_nowait(("init", producer.init_segment))
self._revision += 1
return lease
def mark_streaming(self, lease: CameraProcessLease) -> None:
with self._lock:
producer = self._producer
if producer is None or producer.delivery is not lease:
return
self._mark_streaming_locked(producer)
def release_delivery(self, lease: CameraProcessLease, *, client_closed: bool) -> None:
producer_to_stop: _CameraProducer | None = None
with self._lifecycle_lock:
with self._lock:
producer = self._producer
if producer is None or producer.delivery is not lease:
return
producer.delivery = None
self._revision += 1
# During acquisition the browser is a disposable observer. In
# legacy preview-only mode retain the old lazy-owner behavior.
if self._recording_root is None:
producer.stop_requested = True
self._producer = None
producer_to_stop = producer
self._phase = "selected"
self._error = None
if producer_to_stop is not None:
self._shutdown_producer(
producer_to_stop,
None,
status="interrupted",
failure_code=(lease.failure_code or "browser-disconnected"),
)
def close(self) -> None:
with self._lifecycle_lock:
with self._lock:
if self._closed:
return
self._closed = True
producer, delivery = self._detach_producer_locked()
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._recording_root = None
self._error = None
self._shutdown_producer(
producer,
delivery,
status="interrupted",
failure_code="gateway-closed",
)
def _spawn_selected_producer(self) -> _CameraProducer:
with self._lock:
self._require_open_locked()
source_id = self._source_id
target_host = self._target_host
ffmpeg_path = self._ffmpeg_path
generation = self._generation
recording_root = self._recording_root
if source_id is None or target_host is None or ffmpeg_path is None:
raise RuntimeError("camera preview runtime не готов")
if self._producer is not None:
return self._producer
archive: CameraArchiveWriter | None = None
if recording_root is not None:
try:
archive = CameraArchiveWriter(recording_root, source_id, generation)
except (OSError, ValueError, CameraArchiveError) as exc:
with self._lock:
self._set_error_locked(
"camera-storage-failed",
"Не удалось открыть долговременное хранилище camera stream.",
)
raise RuntimeError(
"Не удалось открыть долговременное хранилище camera stream."
) from exc
try:
process = subprocess.Popen(
argv,
_build_ffmpeg_argv(ffmpeg_path, target_host, source_id),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=False,
start_new_session=(os.name == "posix"),
)
except OSError as exc:
self._revision += 1
self._phase = "error"
self._error = {
"code": "ffmpeg-start-failed",
"message": "Не удалось запустить локальный camera adapter.",
}
raise RuntimeError("не удалось запустить camera adapter") from exc
except (OSError, ValueError) as exc:
if archive is not None:
with suppress(CameraArchiveError):
self._record_archive_summary(
archive.close(status="failed", failure_code="ffmpeg-start-failed")
)
with self._lock:
self._set_error_locked(
"ffmpeg-start-failed",
"Не удалось запустить локальный camera adapter.",
)
raise RuntimeError("Не удалось запустить локальный camera adapter.") from exc
if process.stdout is None or process.stderr is None:
_terminate_process(process)
raise RuntimeError("camera adapter не открыл media pipes")
lease = CameraProcessLease(
generation=generation,
source_id=self._source_id,
process=process,
if archive is not None:
self._record_archive_summary(
archive.close(status="failed", failure_code="ffmpeg-pipes-unavailable")
)
self._process = process
self._process_generation = generation
with self._lock:
self._set_error_locked(
"ffmpeg-pipes-unavailable",
"Camera adapter не открыл media pipes.",
)
raise RuntimeError("camera adapter не открыл media pipes")
producer = _CameraProducer(
generation=generation,
source_id=source_id,
process=process,
archive=archive,
)
with self._lock:
if (
self._closed
or generation != self._generation
or source_id != self._source_id
or target_host != self._target_host
):
producer.stop_requested = True
stale = True
else:
self._producer = producer
self._revision += 1
self._phase = "connecting"
self._error = None
stale = False
if stale:
self._shutdown_producer(
producer,
None,
status="interrupted",
failure_code="stale-generation",
)
raise RuntimeError("camera generation изменилась во время запуска adapter")
threading.Thread(
target=_drain_stderr,
args=(lease,),
args=(producer,),
name=f"k1-camera-stderr-{generation}",
daemon=True,
).start()
producer.reader_started = True
threading.Thread(
target=_read_fmp4_stdout,
args=(lease,),
args=(self, producer),
name=f"k1-camera-fmp4-{generation}",
daemon=True,
).start()
return lease
return producer
def _publish_segment(
self,
producer: _CameraProducer,
kind: CameraArchiveKind,
payload: bytes,
) -> bool:
if len(payload) > MAX_FMP4_SEGMENT_BYTES:
self._mark_producer_failure(
producer,
"segment-too-large",
"Camera adapter отклонил слишком большой video segment.",
)
return False
def mark_streaming(self, lease: CameraProcessLease) -> None:
with self._lock:
if (
lease.generation != self._generation
or self._process is not lease.process
or self._phase == "streaming"
):
producer_owned = self._producer is producer or (
producer.drain_requested and producer.archive is not None
)
if not producer_owned or producer.stop_requested:
return False
archive = producer.archive
if archive is not None:
try:
# Source of record first; preview is always expendable.
archive.append(kind, payload)
except (CameraArchiveError, OSError, ValueError):
self._mark_producer_failure(
producer,
"camera-storage-failed",
"Долговременная запись camera stream завершилась ошибкой.",
)
return False
with self._lock:
producer_owned = self._producer is producer or (
producer.drain_requested and producer.archive is not None
)
if not producer_owned or producer.stop_requested:
return False
if kind == "init":
producer.init_segment = payload
else:
self._mark_streaming_locked(producer)
delivery = producer.delivery
if delivery is None:
return True
try:
delivery.segments.put_nowait((kind, payload))
except queue.Full:
self._drop_slow_delivery(producer, delivery)
return True
def _drop_slow_delivery(
self,
producer: _CameraProducer,
delivery: CameraProcessLease,
) -> None:
with self._lock:
if self._producer is producer and producer.delivery is delivery:
producer.delivery = None
delivery.failure_code = "consumer-too-slow"
self._revision += 1
_close_segment_queue(delivery)
def _mark_producer_failure(
self,
producer: _CameraProducer,
code: str,
message: str,
) -> None:
with self._lock:
producer_owned = self._producer is producer or producer.drain_requested
if not producer_owned or producer.stop_requested:
return
producer.failure_code = code
self._set_error_locked(code, message)
with suppress(OSError):
producer.process.terminate()
def _producer_ended(self, producer: _CameraProducer) -> None:
with self._lock:
delivery = producer.delivery
producer.delivery = None
owns_shutdown = self._producer is producer and not producer.stop_requested
if owns_shutdown:
self._producer = None
self._revision += 1
if producer.failure_code is None:
producer.failure_code = "camera-source-ended"
self._set_error_locked(
"camera-source-ended",
_safe_ffmpeg_message(producer.stderr_tail),
)
if delivery is not None:
delivery.failure_code = producer.failure_code or "camera-source-ended"
_close_segment_queue(delivery)
if not owns_shutdown:
return
_terminate_process(producer.process)
status: CameraArchiveStatus = (
"interrupted"
if producer.failure_code
in {"camera-source-ended", "incomplete-fmp4-fragment"}
else "failed"
)
try:
self._finalize_archive(producer, status, producer.failure_code)
except CameraArchiveError:
with self._lock:
self._set_error_locked(
"camera-storage-finalize-failed",
"Не удалось завершить долговременную запись camera stream.",
)
def _shutdown_producer(
self,
producer: _CameraProducer | None,
delivery: CameraProcessLease | None,
*,
status: CameraArchiveStatus,
failure_code: str | None,
) -> None:
if delivery is not None:
delivery.failure_code = producer.failure_code if producer is not None else failure_code
_close_segment_queue(delivery)
if producer is None:
return
_terminate_process(producer.process, close_streams=False)
drained = (
not producer.reader_started
or producer.reader_done.wait(timeout=CAMERA_DRAIN_TIMEOUT_SECONDS)
)
_close_process_streams(producer.process)
if not drained:
producer.failure_code = "camera-drain-timeout"
with self._lock:
self._set_error_locked(
"camera-drain-timeout",
"Camera adapter не завершил долговременную запись вовремя.",
)
effective_failure_code = producer.failure_code or failure_code
effective_status: CameraArchiveStatus = (
"interrupted"
if producer.failure_code == "incomplete-fmp4-fragment"
else "failed"
if producer.failure_code is not None
else status
)
if delivery is not None:
delivery.failure_code = effective_failure_code
try:
self._finalize_archive(
producer,
effective_status,
effective_failure_code,
)
except CameraArchiveError:
with self._lock:
self._set_error_locked(
"camera-storage-finalize-failed",
"Не удалось завершить долговременную запись camera stream.",
)
raise
def _finalize_archive(
self,
producer: _CameraProducer,
status: CameraArchiveStatus,
failure_code: str | None,
) -> None:
archive = producer.archive
producer.archive = None
if archive is None:
return
self._record_archive_summary(archive.close(status=status, failure_code=failure_code))
def _record_archive_summary(self, summary: dict[str, Any]) -> None:
with self._lock:
self._archive_summaries.append(dict(summary))
def _detach_producer_locked(
self,
) -> tuple[_CameraProducer | None, CameraProcessLease | None]:
producer = self._producer
self._producer = None
if producer is None:
return None, None
producer.drain_requested = producer.archive is not None
producer.stop_requested = not producer.drain_requested
delivery = producer.delivery
producer.delivery = None
return producer, delivery
def _mark_streaming_locked(self, producer: _CameraProducer) -> None:
if self._producer is producer and self._phase != "streaming":
self._revision += 1
self._phase = "streaming"
def release_delivery(self, lease: CameraProcessLease, *, client_closed: bool) -> None:
_terminate_process(lease.process)
with self._lock:
if self._process is lease.process:
self._process = None
self._process_generation = None
if lease.generation != self._generation or self._source_id is None:
return
def _set_error_locked(self, code: str, message: str) -> None:
self._revision += 1
if client_closed:
self._phase = "selected"
self._error = None
else:
self._phase = "error"
if lease.failure_code == "consumer-too-slow":
message = (
"Browser не успевает принимать camera stream; "
"канал остановлен без накопления задержки."
)
elif lease.failure_code == "invalid-fmp4":
message = "Camera adapter вернул некорректный fMP4 stream."
elif lease.failure_code == "segment-too-large":
message = "Camera adapter отклонил слишком большой video segment."
else:
message = _safe_ffmpeg_message(lease.stderr_tail)
self._error = {
"code": lease.failure_code or "camera-source-ended",
"message": message,
}
self._error = {"code": code, "message": message}
def close(self) -> None:
old_process: subprocess.Popen[bytes] | None
with self._lock:
def _require_open_locked(self) -> None:
if self._closed:
return
self._closed = True
old_process = self._detach_process_locked()
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._error = None
_terminate_process(old_process)
def _detach_process_locked(self) -> subprocess.Popen[bytes] | None:
process = self._process
self._process = None
self._process_generation = None
return process
raise RuntimeError("camera gateway уже закрыт")
def _resolve_ffmpeg(repository_root: Path) -> tuple[Path | None, str]:
@ -394,36 +779,10 @@ def _read_mp4_box(stream: IO[bytes]) -> tuple[bytes, bytes]:
return box_type, header + _read_exact(stream, size - len(header))
def _enqueue_segment(lease: CameraProcessLease, kind: str, payload: bytes) -> bool:
if len(payload) > MAX_FMP4_SEGMENT_BYTES:
lease.failure_code = "segment-too-large"
_finish_segment_queue(lease)
with suppress(OSError):
lease.process.terminate()
return False
try:
lease.segments.put_nowait((kind, payload))
return True
except queue.Full:
lease.failure_code = "consumer-too-slow"
while True:
try:
lease.segments.get_nowait()
except queue.Empty:
break
lease.segments.put_nowait(None)
with suppress(OSError):
lease.process.terminate()
return False
def _finish_segment_queue(lease: CameraProcessLease) -> None:
def _close_segment_queue(lease: CameraProcessLease) -> None:
try:
lease.segments.put_nowait(None)
except queue.Full:
# A full queue is itself a bounded-latency failure. Never leave FFmpeg
# back-pressured while the browser accumulates tens of seconds.
lease.failure_code = lease.failure_code or "consumer-too-slow"
while True:
try:
lease.segments.get_nowait()
@ -432,11 +791,21 @@ def _finish_segment_queue(lease: CameraProcessLease) -> None:
lease.segments.put_nowait(None)
def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
stream = lease.process.stdout
def _read_fmp4_stdout(
gateway: XgridsK1CameraGateway,
producer: _CameraProducer,
) -> None:
stream = producer.process.stdout
if stream is None:
lease.failure_code = "invalid-fmp4"
_finish_segment_queue(lease)
try:
gateway._mark_producer_failure(
producer,
"invalid-fmp4",
"Camera adapter вернул некорректный fMP4 stream.",
)
gateway._producer_ended(producer)
finally:
producer.reader_done.set()
return
init_parts: list[bytes] = []
@ -448,7 +817,11 @@ def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
if not init_sent:
init_parts.append(box)
if box_type == b"moov":
if not _enqueue_segment(lease, "init", b"".join(init_parts)):
if not gateway._publish_segment(
producer,
"init",
b"".join(init_parts),
):
return
init_sent = True
continue
@ -459,7 +832,11 @@ def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
if fragment_parts:
fragment_parts.append(box)
if box_type == b"mdat":
if not _enqueue_segment(lease, "media", b"".join(fragment_parts)):
if not gateway._publish_segment(
producer,
"media",
b"".join(fragment_parts),
):
return
fragment_parts = []
continue
@ -469,15 +846,32 @@ def _read_fmp4_stdout(lease: CameraProcessLease) -> None:
fragment_parts.append(box)
except EOFError:
if not init_sent:
lease.failure_code = "invalid-fmp4"
gateway._mark_producer_failure(
producer,
"invalid-fmp4",
"Camera adapter вернул некорректный fMP4 stream.",
)
elif fragment_parts:
gateway._mark_producer_failure(
producer,
"incomplete-fmp4-fragment",
"Camera stream завершился на неполном video fragment.",
)
except (OSError, ValueError):
lease.failure_code = "invalid-fmp4"
gateway._mark_producer_failure(
producer,
"invalid-fmp4",
"Camera adapter вернул некорректный fMP4 stream.",
)
finally:
_finish_segment_queue(lease)
try:
gateway._producer_ended(producer)
finally:
producer.reader_done.set()
def _drain_stderr(lease: CameraProcessLease) -> None:
stream = lease.process.stderr
def _drain_stderr(producer: _CameraProducer) -> None:
stream = producer.process.stderr
if stream is None:
return
try:
@ -487,7 +881,7 @@ def _drain_stderr(lease: CameraProcessLease) -> None:
return
text = line.decode("utf-8", errors="replace").strip()
if text:
lease.stderr_tail.append(text[-240:])
producer.stderr_tail.append(text[-240:])
except (OSError, ValueError):
return
@ -504,13 +898,21 @@ def _safe_ffmpeg_message(stderr_tail: deque[str]) -> str:
return "Camera stream завершился до первого пригодного видеофрагмента."
def _terminate_process(process: subprocess.Popen[bytes] | None) -> None:
def _terminate_process(
process: subprocess.Popen[bytes] | None,
*,
close_streams: bool = True,
) -> None:
if process is None:
return
try:
if process.poll() is None:
if os.name == "posix":
with suppress(ProcessLookupError):
# The process can have exited or lost its dedicated process
# group between poll() and killpg(). Either outcome means
# there is no group left for us to signal; still reap the
# child and finish the archive instead of aborting teardown.
with suppress(OSError):
os.killpg(process.pid, signal.SIGINT)
else:
process.terminate()
@ -524,6 +926,11 @@ def _terminate_process(process: subprocess.Popen[bytes] | None) -> None:
process.kill()
process.wait(timeout=1.0)
finally:
if close_streams:
_close_process_streams(process)
def _close_process_streams(process: subprocess.Popen[bytes]) -> None:
for stream in (process.stdout, process.stderr):
if stream is not None:
with suppress(OSError):
@ -536,9 +943,7 @@ def build_xgrids_k1_camera_router(
) -> APIRouter:
router = APIRouter(include_in_schema=False)
@router.websocket(
f"/api/v1/device-plugins/{plugin_id}/camera-preview/{{generation}}"
)
@router.websocket(f"/api/v1/device-plugins/{plugin_id}/camera-preview/{{generation}}")
async def camera_preview(websocket: WebSocket, generation: int) -> None:
await websocket.accept()
try:

View File

@ -7,7 +7,9 @@ import importlib.util
import json
import secrets
import threading
import time
from collections.abc import Mapping
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal, Protocol, cast
@ -20,6 +22,7 @@ from k1link.ble.scanner import scan
from k1link.ble.wifi_provisioning import AP_FALLBACK_IPV4, provision_wifi_once
from k1link.mqtt import validate_private_ipv4
from k1link.protocol.normalizer import normalize_k1_message
from k1link.sessions import ActiveSessionLease, resolve_missioncore_evidence_dir
from k1link.viewer.rerun_bridge import RerunSceneSettings
from k1link.viewer.runtime import VisualizationRuntime, new_live_session_dir
from k1link.web.device_lifecycle import (
@ -181,6 +184,7 @@ class XgridsK1CompatibilityService:
def __init__(self, repository_root: Path) -> None:
self.repository_root = repository_root.resolve()
self.evidence_root = resolve_missioncore_evidence_dir(self.repository_root)
self._lock = threading.Lock()
self._provisioning_gate = threading.Lock()
self._provisioning_active = False
@ -206,6 +210,7 @@ class XgridsK1CompatibilityService:
self._acquisition_out_dir: Path | None = None
self._acquisition_start_operation_id: str | None = None
self._acquisition_stop_operation_id: str | None = None
self._acquisition_session_lease: ActiveSessionLease | None = None
# The host-owned visual runtime receives the vendor normalizer
# explicitly. There is no implicit K1 decoder in the visual layer.
self.runtime = VisualizationRuntime(normalizer=normalize_k1_message)
@ -216,7 +221,8 @@ class XgridsK1CompatibilityService:
def state(self) -> dict[str, Any]:
runtime = self.runtime.snapshot()
self._reconcile_acquisition(runtime)
camera_preview = self.camera_preview.snapshot()
self._reconcile_acquisition(runtime, camera_preview)
camera_preview = self.camera_preview.snapshot()
metrics = runtime["metrics"]
with self._lock:
@ -401,9 +407,6 @@ class XgridsK1CompatibilityService:
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
# A reprovision can replace both the device session and its address.
# Revoke the previous browser lease before any new device-side write.
self.camera_preview.stop_current()
# Unwrap once at the provisioning service boundary. The plain value is
# kept only in this stack frame, included in a keyed request digest, and
# passed to the reviewed BLE write boundary; it is never journaled.
@ -463,12 +466,17 @@ class XgridsK1CompatibilityService:
)
self._provisioning_active = True
# A reprovision can replace both the device session and its address.
# Revoke preview/producer state only after the active-acquisition
# guard; a rejected network write must never stop evidence capture.
self.camera_preview.stop_current()
self._set_operation(
"provisioning",
"Передаём устройству настройки Wi-Fi одним подтверждённым запросом.",
)
session_dir = _new_operation_session_dir(
self.repository_root,
self.evidence_root,
"viewer_wifi_provisioning",
)
session_dir.mkdir(parents=True, exist_ok=False)
@ -654,7 +662,7 @@ class XgridsK1CompatibilityService:
},
)
self._acquisition = acquisition
self._acquisition_out_dir = new_live_session_dir(self.repository_root)
self._acquisition_out_dir = new_live_session_dir(self.evidence_root)
self._acquisition_start_operation_id = None
self._acquisition_stop_operation_id = None
self._compatibility_attestation = _attestation_snapshot(
@ -725,12 +733,27 @@ class XgridsK1CompatibilityService:
message_code="acquisition.start.waiting_receiver_ready",
)
self._acquisition_start_operation_id = operation.operation_id
lease = ActiveSessionLease.acquire(out_dir.parent, out_dir)
with self._lock:
if self._acquisition_session_lease is not None:
lease.release()
raise RuntimeError("evidence-сессия уже удерживается активным acquisition")
self._acquisition_session_lease = lease
self.runtime.start_live(
acquisition.target_host,
out_dir,
duration_seconds=acquisition.duration_seconds,
)
self._arm_camera_recording(out_dir)
except Exception as exc:
with suppress(Exception):
self.camera_preview.stop_recording(
status="failed",
failure_code="acquisition-start-failed",
)
with suppress(Exception):
self.runtime.stop()
self._release_acquisition_session_lease()
with self._lock:
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
acquisition.transition("failed", message_code="acquisition.start.failed")
@ -845,8 +868,10 @@ class XgridsK1CompatibilityService:
try:
with self._lock:
acquisition.transition("stopping", message_code="acquisition.stopping")
self.camera_preview.stop_current()
self.runtime.stop()
self._stop_acquisition_sources(
camera_status="complete",
camera_failure_code=None,
)
self._cancel_pending_acquisition_operations(
exclude_operation_id=operation.operation_id,
reason_code="superseded-by-stop",
@ -915,8 +940,10 @@ class XgridsK1CompatibilityService:
return self.state()
try:
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
self.camera_preview.stop_current()
self.runtime.stop()
self._stop_acquisition_sources(
camera_status="interrupted",
camera_failure_code="acquisition-aborted",
)
self._cancel_pending_acquisition_operations(
exclude_operation_id=operation.operation_id,
reason_code="superseded-by-abort",
@ -997,10 +1024,10 @@ class XgridsK1CompatibilityService:
def stop(self) -> dict[str, Any]:
"""Deprecated capture-only shim; it never claims that K1 stopped scanning."""
self.camera_preview.stop_current()
with self._lock:
acquisition = self._acquisition
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
self.camera_preview.stop_current()
self.runtime.stop()
return self.state()
return self.stop_acquisition(
@ -1012,6 +1039,16 @@ class XgridsK1CompatibilityService:
def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]:
target = self._camera_target_for_session(request.device_session_id)
with self._lock:
acquisition = self._acquisition
out_dir = self._acquisition_out_dir
acquisition_active = (
acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES
)
if acquisition_active:
if out_dir is None:
raise RuntimeError("для acquisition не выделена evidence-сессия")
self._arm_camera_recording(out_dir, require_session=True)
self.camera_preview.select(request.source_id, target)
return self.state()
@ -1035,6 +1072,8 @@ class XgridsK1CompatibilityService:
f"{type(camera_error).__name__}: {camera_error}"
)
raise
finally:
self._release_acquisition_session_lease()
if camera_error is not None:
raise camera_error
@ -1053,6 +1092,71 @@ class XgridsK1CompatibilityService:
)
return self.state()
def _arm_camera_recording(
self,
out_dir: Path,
*,
require_session: bool = False,
) -> None:
camera = self.camera_preview.snapshot()
if camera["recording"]["active"]:
self.camera_preview.start_recording(out_dir)
return
if not out_dir.is_dir():
if not require_session and camera["active_source_id"] is None:
# The real runtime creates the session in its source thread. If
# no camera has been selected yet, defer arming until selection
# instead of racing that mkdir. No media can be lost yet.
return
deadline = time.monotonic() + 5.0
while not out_dir.is_dir():
runtime = self.runtime.snapshot()
if runtime.get("phase") == "error":
raise RuntimeError(
"evidence-сессия не создана: live runtime завершился ошибкой"
)
if time.monotonic() >= deadline:
raise RuntimeError(
"evidence-сессия не появилась вовремя для записи camera stream"
)
time.sleep(0.01)
self.camera_preview.start_recording(out_dir)
def _stop_acquisition_sources(
self,
*,
camera_status: Literal["complete", "interrupted", "failed"],
camera_failure_code: str | None,
) -> None:
camera_error: Exception | None = None
try:
self.camera_preview.stop_recording(
status=camera_status,
failure_code=camera_failure_code,
)
except Exception as exc:
camera_error = exc
try:
self.runtime.stop()
except Exception as exc:
if camera_error is not None:
exc.add_note(
"camera archive cleanup also failed: "
f"{type(camera_error).__name__}: {camera_error}"
)
raise
finally:
self._release_acquisition_session_lease()
if camera_error is not None:
raise camera_error
def _release_acquisition_session_lease(self) -> None:
with self._lock:
lease = self._acquisition_session_lease
self._acquisition_session_lease = None
if lease is not None:
lease.release()
def _set_operation(self, phase: str, message: str) -> None:
with self._lock:
self._operation_phase = phase
@ -1127,13 +1231,20 @@ class XgridsK1CompatibilityService:
raise ValueError("указана неизвестная acquisition-сессия")
return acquisition
def _reconcile_acquisition(self, runtime: Mapping[str, Any]) -> None:
def _reconcile_acquisition(
self,
runtime: Mapping[str, Any],
camera: Mapping[str, Any],
) -> None:
completed_operation_id: str | None = None
failed_operation_id: str | None = None
failed_stop_operation_id: str | None = None
unconfirmed_stop_operation_id: str | None = None
receiver_ready_operation_id: str | None = None
acquisition_id: str | None = None
camera_terminal_status: Literal["complete", "failed"] | None = None
camera_failure_code: str | None = None
stop_runtime_for_camera_failure = False
with self._lock:
acquisition = self._acquisition
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
@ -1143,7 +1254,42 @@ class XgridsK1CompatibilityService:
phase = runtime.get("phase")
source_mode = runtime.get("source_mode")
source_ready = runtime.get("source_ready") is True
if acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0:
camera_recording = camera.get("recording")
camera_recording_active = (
isinstance(camera_recording, dict) and camera_recording.get("active") is True
)
camera_error = camera.get("error")
if camera.get("phase") == "error" and camera_recording_active:
waiting_for_start = acquisition.state in {
"starting",
"awaiting_external_start",
}
waiting_for_stop = acquisition.state in {
"awaiting_external_stop",
"stopping",
"finalizing",
}
camera_failure_code = (
str(camera_error.get("code"))
if isinstance(camera_error, dict) and camera_error.get("code")
else "camera-producer-failed"
)
acquisition.transition(
"failed",
message_code="acquisition.camera_failed",
result={
"receiver_stopped": False,
"device_state": "unknown",
"camera_failure_code": camera_failure_code,
},
)
if waiting_for_start:
failed_operation_id = self._acquisition_start_operation_id
if waiting_for_stop:
failed_stop_operation_id = self._acquisition_stop_operation_id
camera_terminal_status = "failed"
stop_runtime_for_camera_failure = True
elif acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0:
acquisition.transition("acquiring", message_code="acquisition.acquiring")
completed_operation_id = self._acquisition_start_operation_id
acquisition_id = acquisition.acquisition_id
@ -1168,6 +1314,8 @@ class XgridsK1CompatibilityService:
"finalizing",
}
acquisition.transition("failed", message_code="acquisition.runtime_failed")
camera_terminal_status = "failed"
camera_failure_code = "runtime-failed"
if waiting_for_start:
failed_operation_id = self._acquisition_start_operation_id
if waiting_for_stop:
@ -1181,6 +1329,8 @@ class XgridsK1CompatibilityService:
message_code="acquisition.receiver_completed_without_point_data",
result={"receiver_stopped": True, "device_state": "unknown"},
)
camera_terminal_status = "failed"
camera_failure_code = "receiver-completed-without-point-data"
failed_operation_id = self._acquisition_start_operation_id
elif acquisition.state == "acquiring" and source_mode == "idle":
acquisition.transition(
@ -1188,14 +1338,28 @@ class XgridsK1CompatibilityService:
message_code="acquisition.receiver_completed",
result={"receiver_stopped": True, "device_state": "unknown"},
)
camera_terminal_status = "complete"
elif acquisition.state == "awaiting_external_stop" and source_mode == "idle":
acquisition.transition(
"failed",
message_code="acquisition.receiver_completed_before_stop_confirmation",
result={"receiver_stopped": True, "device_state": "unknown"},
)
camera_terminal_status = "failed"
camera_failure_code = "receiver-completed-before-stop-confirmation"
unconfirmed_stop_operation_id = self._acquisition_stop_operation_id
if camera_terminal_status is not None:
try:
self.camera_preview.stop_recording(
status=camera_terminal_status,
failure_code=camera_failure_code,
)
finally:
self._release_acquisition_session_lease()
if stop_runtime_for_camera_failure:
self.runtime.stop()
if receiver_ready_operation_id is not None:
self._operations.transition_if_pending(
receiver_ready_operation_id,
@ -1581,9 +1745,9 @@ def _device_calibration_snapshot(active_profile_id: str | None) -> dict[str, Any
}
def _new_operation_session_dir(repository_root: Path, suffix: str) -> Path:
def _new_operation_session_dir(sessions_root: Path, suffix: str) -> Path:
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
base = repository_root / "sessions" / f"{stamp}_{suffix}"
base = sessions_root / f"{stamp}_{suffix}"
candidate = base
serial = 1
while candidate.exists():

View File

@ -0,0 +1,40 @@
from __future__ import annotations
from pathlib import Path
from k1link.sessions import ActiveSessionLease, recover_stale_active_session_marker
from k1link.sessions.legacy import discover_legacy_viewer_sessions
def test_active_session_lease_hides_live_evidence_until_release(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
session = sessions / "20260717T011050Z_viewer_live"
lease = ActiveSessionLease.acquire(sessions, session)
try:
session.mkdir()
capture = session / "captures" / "mqtt_live"
capture.mkdir(parents=True)
(capture / "mqtt.raw.k1mqtt").write_bytes(b"K1MQTT\x00unfinished")
candidates = discover_legacy_viewer_sessions(sessions)
assert candidates == ()
assert recover_stale_active_session_marker(sessions) is False
finally:
lease.release()
assert not (sessions / ".current_session").exists()
candidates = discover_legacy_viewer_sessions(sessions)
assert len(candidates) == 1
assert candidates[0].session_id == session.name
assert candidates[0].replayable is False
def test_startup_recovery_removes_only_an_unlocked_stale_marker(tmp_path: Path) -> None:
sessions = tmp_path / "sessions"
sessions.mkdir()
marker = sessions / ".current_session"
marker.write_text("20260717T011050Z_viewer_live\n", encoding="utf-8")
assert recover_stale_active_session_marker(sessions) is True
assert not marker.exists()
assert recover_stale_active_session_marker(sessions) is False

View File

@ -0,0 +1,338 @@
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
import time
from pathlib import Path
import pytest
from k1link.sessions.legacy import discover_legacy_viewer_sessions
from k1link.web.camera_archive import (
CAMERA_ARCHIVE_SCHEMA,
CAMERA_COMMIT_POLICY,
CameraArchiveError,
CameraArchiveWriter,
recover_incomplete_camera_archives,
)
def test_camera_archive_writes_canonical_segments_and_seals_summary(tmp_path: Path) -> None:
session = tmp_path / "session"
session.mkdir()
writer = CameraArchiveWriter(
session,
"sensor.camera.left",
7,
commit_interval_seconds=60,
commit_bytes=1024,
)
init = b"init-segment"
first = b"first-fragment"
second = b"second-fragment"
writer.append("init", init, host_epoch_ns=100, host_monotonic_ns=200)
writer.append("media", first, host_epoch_ns=110, host_monotonic_ns=210)
writer.append("media", second, host_epoch_ns=120, host_monotonic_ns=220)
# The tuning values cannot weaken the segment RPO: every append has already
# published its fragment, index record, and interrupted crash checkpoint.
checkpoint = json.loads(writer.summary_path.read_text(encoding="utf-8"))
assert checkpoint["status"] == "interrupted"
assert checkpoint["segment_count"] == 2
assert checkpoint["commit_policy"] == CAMERA_COMMIT_POLICY
summary = writer.close()
entries = [json.loads(line) for line in writer.index_path.read_text().splitlines()]
assert writer.archive_dir.name == "epoch-7"
assert writer.init_path.read_bytes() == init
assert [path.name for path in sorted(writer.segments_dir.iterdir())] == [
"1.m4s",
"2.m4s",
]
assert [entry["kind"] for entry in entries] == ["media", "media"]
assert [entry["sequence"] for entry in entries] == [1, 2]
assert [entry["path"] for entry in entries] == [
"segments/1.m4s",
"segments/2.m4s",
]
for entry, expected in zip(entries, (first, second), strict=True):
payload = (writer.archive_dir / entry["path"]).read_bytes()
assert payload == expected
assert entry["length"] == len(expected)
assert entry["sha256"] == hashlib.sha256(expected).hexdigest()
assert summary["schema_version"] == CAMERA_ARCHIVE_SCHEMA
assert summary["status"] == "complete"
assert summary["segment_count"] == 2
assert summary["entry_count"] == 2
assert summary["media_segment_count"] == 2
assert summary["valid_bytes"] == len(init) + len(first) + len(second)
assert summary["stream_sha256"] == hashlib.sha256(init + first + second).hexdigest()
assert summary["synchronization"] == "host-arrival-best-effort"
assert summary["artifacts"] == {
"init": "init.mp4",
"segments": "segments",
"index": "index.jsonl",
}
assert "192.168" not in json.dumps(summary)
assert writer.close() == summary
def test_camera_archive_rejects_media_without_init_and_existing_epoch(tmp_path: Path) -> None:
session = tmp_path / "session"
session.mkdir()
writer = CameraArchiveWriter(session, "sensor.camera.right", 1)
with pytest.raises(CameraArchiveError, match="before"):
writer.append("media", b"fragment")
writer.append("init", b"init")
writer.close(status="interrupted", failure_code="source-ended")
with pytest.raises(FileExistsError):
CameraArchiveWriter(session, "sensor.camera.right", 1)
with pytest.raises(ValueError, match="safe storage"):
CameraArchiveWriter(session, "../camera", 2)
@pytest.mark.parametrize("symlink_component", ["media", "source"])
def test_camera_writer_never_follows_precreated_storage_symlinks(
tmp_path: Path,
symlink_component: str,
) -> None:
session = tmp_path / "session"
session.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
if symlink_component == "media":
(session / "media").symlink_to(outside, target_is_directory=True)
else:
media = session / "media"
media.mkdir()
(media / "sensor.camera.left").symlink_to(outside, target_is_directory=True)
with pytest.raises(CameraArchiveError, match="no-follow"):
CameraArchiveWriter(session, "sensor.camera.left", 1)
assert list(outside.iterdir()) == []
def test_camera_writer_keeps_directory_fds_across_path_swap(tmp_path: Path) -> None:
session = tmp_path / "session"
session.mkdir()
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
writer.append("init", b"init")
original_epoch = writer.archive_dir
moved_epoch = original_epoch.with_name("epoch-1-moved")
original_epoch.rename(moved_epoch)
outside_epoch = tmp_path / "outside-epoch"
outside_epoch.mkdir()
original_epoch.symlink_to(outside_epoch, target_is_directory=True)
original_segments = moved_epoch / "segments"
moved_segments = moved_epoch / "segments-held-by-fd"
original_segments.rename(moved_segments)
outside_segments = tmp_path / "outside-segments"
outside_segments.mkdir()
original_segments.symlink_to(outside_segments, target_is_directory=True)
writer.append("media", b"frame-after-path-swap")
summary = writer.close()
assert (moved_segments / "1.m4s").read_bytes() == b"frame-after-path-swap"
assert json.loads((moved_epoch / "summary.json").read_text(encoding="utf-8")) == summary
assert list(outside_epoch.iterdir()) == []
assert list(outside_segments.iterdir()) == []
def test_recovery_seals_unindexed_durable_tail_and_preserves_orphans(tmp_path: Path) -> None:
sessions_root = tmp_path / "sessions"
session = sessions_root / "20260717T011050Z_viewer_live"
session.mkdir(parents=True)
writer = CameraArchiveWriter(session, "sensor.camera.left", 3)
writer.append("init", b"init")
writer.append("media", b"frame-1", host_epoch_ns=100, host_monotonic_ns=200)
# Catalog refresh must not hash or rewrite a writer that is still active in
# this server process.
assert recover_incomplete_camera_archives(sessions_root) == ()
writer.close(status="interrupted", failure_code="synthetic-process-crash")
epoch = writer.archive_dir
writer.summary_path.unlink()
# Simulate a process dying after atomically publishing the next fragment but
# before its JSONL entry, plus a non-contiguous fragment that cannot be put on
# the trusted timeline. Recovery salvages #2 and quarantines (never deletes) #4.
(writer.segments_dir / "2.m4s").write_bytes(b"frame-2")
(writer.segments_dir / "4.m4s").write_bytes(b"orphan-frame")
recovered = recover_incomplete_camera_archives(sessions_root)
assert len(recovered) == 1
summary = recovered[0]
assert summary["status"] == "interrupted"
assert summary["failure_code"] == "server-process-interrupted"
assert summary["segment_count"] == 2
entries = [
json.loads(line)
for line in writer.index_path.read_text(encoding="utf-8").splitlines()
]
assert [entry["sequence"] for entry in entries] == [1, 2]
assert entries[0]["host_epoch_ns"] == 100
assert entries[1]["recovered"] is True
assert (writer.segments_dir / "2.m4s").read_bytes() == b"frame-2"
assert not (writer.segments_dir / "4.m4s").exists()
assert any(
path.read_bytes() == b"orphan-frame"
for path in (epoch / "recovery-orphans").iterdir()
if path.is_file()
)
# Recovery output is exactly the layout consumed by legacy media discovery,
# and a second scan is an idempotent no-op.
assert recover_incomplete_camera_archives(sessions_root) == ()
candidates = discover_legacy_viewer_sessions(sessions_root)
assert len(candidates) == 1
assert [source.source_id for source in candidates[0].media_sources] == [
"sensor.camera.left"
]
def test_recovery_replaces_index_and_summary_symlinks_without_following_targets(
tmp_path: Path,
) -> None:
sessions_root = tmp_path / "sessions"
session = sessions_root / "20260717T011051Z_viewer_live"
session.mkdir(parents=True)
writer = CameraArchiveWriter(session, "sensor.camera.right", 1)
writer.append("init", b"init")
writer.append("media", b"frame")
writer.close(status="interrupted")
outside_index = tmp_path / "outside-index"
outside_summary = tmp_path / "outside-summary"
outside_index.write_bytes(b"do-not-read-or-overwrite-index")
outside_summary.write_bytes(b"do-not-read-or-overwrite-summary")
writer.index_path.unlink()
writer.summary_path.unlink()
writer.index_path.symlink_to(outside_index)
writer.summary_path.symlink_to(outside_summary)
recovered = recover_incomplete_camera_archives(sessions_root)
assert len(recovered) == 1
assert recovered[0]["segment_count"] == 1
assert outside_index.read_bytes() == b"do-not-read-or-overwrite-index"
assert outside_summary.read_bytes() == b"do-not-read-or-overwrite-summary"
assert writer.index_path.is_symlink() is False
assert writer.summary_path.is_symlink() is False
assert json.loads(writer.index_path.read_text(encoding="utf-8"))["recovered"] is True
def test_recovery_fails_closed_on_symlinked_quarantine_directory(tmp_path: Path) -> None:
sessions_root = tmp_path / "sessions"
session = sessions_root / "20260717T011052Z_viewer_live"
session.mkdir(parents=True)
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
writer.append("init", b"init")
writer.append("media", b"frame-1")
writer.close(status="interrupted")
writer.summary_path.unlink()
orphan = writer.segments_dir / "3.m4s"
orphan.write_bytes(b"orphan")
outside = tmp_path / "outside-quarantine"
outside.mkdir()
(writer.archive_dir / "recovery-orphans").symlink_to(outside, target_is_directory=True)
with pytest.raises(CameraArchiveError, match="real directory"):
recover_incomplete_camera_archives(sessions_root)
assert list(outside.iterdir()) == []
assert orphan.read_bytes() == b"orphan"
def test_recovery_quarantines_segment_symlink_without_reading_target(tmp_path: Path) -> None:
sessions_root = tmp_path / "sessions"
session = sessions_root / "20260717T011053Z_viewer_live"
session.mkdir(parents=True)
writer = CameraArchiveWriter(session, "sensor.camera.left", 1)
writer.append("init", b"init")
writer.append("media", b"frame-1")
writer.close(status="interrupted")
writer.summary_path.unlink()
outside = tmp_path / "outside-segment"
outside.write_bytes(b"external-evidence-must-not-be-read-or-moved")
(writer.segments_dir / "2.m4s").symlink_to(outside)
recovered = recover_incomplete_camera_archives(sessions_root)
assert len(recovered) == 1
assert recovered[0]["segment_count"] == 1
assert outside.read_bytes() == b"external-evidence-must-not-be-read-or-moved"
quarantined = list((writer.archive_dir / "recovery-orphans").glob("2.m4s*"))
assert len(quarantined) == 1
assert quarantined[0].is_symlink()
assert quarantined[0].resolve() == outside.resolve()
def test_recovery_lock_symlink_is_rejected_without_touching_target(tmp_path: Path) -> None:
sessions_root = tmp_path / "sessions"
sessions_root.mkdir()
outside = tmp_path / "outside-lock"
outside.write_bytes(b"external-lock-target")
(sessions_root / ".camera-recovery.lock").symlink_to(outside)
with pytest.raises(CameraArchiveError, match="lock failed no-follow"):
recover_incomplete_camera_archives(sessions_root)
assert outside.read_bytes() == b"external-lock-target"
@pytest.mark.skipif(os.name != "posix", reason="uses POSIX flock to assert worker serialization")
def test_recovery_startup_lease_serializes_another_process(tmp_path: Path) -> None:
import fcntl
sessions_root = tmp_path / "sessions"
sessions_root.mkdir()
lock_path = sessions_root / ".camera-recovery.lock"
lock_path.touch(mode=0o600)
descriptor = os.open(lock_path, os.O_RDWR)
process: subprocess.Popen[str] | None = None
locked = False
try:
fcntl.flock(descriptor, fcntl.LOCK_EX)
locked = True
process = subprocess.Popen(
[
sys.executable,
"-c",
(
"from pathlib import Path; "
"from k1link.web.camera_archive import "
"recover_incomplete_camera_archives; "
"recover_incomplete_camera_archives(Path(__import__('sys').argv[1])); "
"print('recovered')"
),
str(sessions_root),
],
cwd=Path(__file__).parents[1],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
time.sleep(0.15)
assert process.poll() is None
fcntl.flock(descriptor, fcntl.LOCK_UN)
locked = False
stdout, stderr = process.communicate(timeout=5)
assert process.returncode == 0, stderr
assert stdout.strip() == "recovered"
finally:
if locked:
fcntl.flock(descriptor, fcntl.LOCK_UN)
os.close(descriptor)
if process is not None and process.poll() is None:
process.kill()
process.wait(timeout=5)

View File

@ -10,6 +10,7 @@ import pytest
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.reasoncodes import ReasonCode
import k1link.mqtt.capture as capture_module
from k1link.mqtt.capture import (
FRAME_HEADER,
RAW_MAGIC,
@ -256,3 +257,85 @@ def test_capture_reader_rejects_invalid_or_unbounded_frames(
with pytest.raises(CaptureFormatError, match=message):
list(iter_capture_frames(path, max_payload_bytes=4))
def _mqtt_message(topic: str, payload: bytes) -> mqtt.MQTTMessage:
message = mqtt.MQTTMessage(topic=topic.encode("utf-8"))
message.payload = payload
message.qos = 0
message.retain = False
message.dup = False
return message
def test_group_commit_fsyncs_raw_before_publishing_metadata(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
writer.open()
assert writer._raw is not None # noqa: SLF001
assert writer._metadata is not None # noqa: SLF001
raw_fd = writer._raw.fileno() # noqa: SLF001
metadata_fd = writer._metadata.fileno() # noqa: SLF001
fsync_calls: list[int] = []
real_fsync = capture_module.os.fsync
def observe_fsync(descriptor: int) -> None:
fsync_calls.append(descriptor)
real_fsync(descriptor)
monkeypatch.setattr(capture_module.os, "fsync", observe_fsync)
monkeypatch.setattr(capture_module, "GROUP_COMMIT_MAX_MESSAGES", 2)
writer.record(_mqtt_message("RealtimePath", b"one"))
assert writer.metadata_path.read_bytes() == b""
writer.record(_mqtt_message("RealtimePath", b"two"))
assert fsync_calls[:2] == [raw_fd, metadata_fd]
assert len(writer.metadata_path.read_text(encoding="utf-8").splitlines()) == 2
writer.close()
def test_group_commit_timer_bounds_quiet_stream_rpo(
tmp_path: Path,
) -> None:
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
writer.open()
writer.record(_mqtt_message("RealtimePath", b"one"))
assert writer.metadata_path.read_bytes() == b""
writer.maybe_commit(
writer._last_commit_monotonic # noqa: SLF001
+ capture_module.GROUP_COMMIT_INTERVAL_SECONDS
)
assert len(writer.metadata_path.read_text(encoding="utf-8").splitlines()) == 1
writer.close()
def test_raw_fsync_failure_never_publishes_metadata_ahead_of_raw(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
writer = capture_module._CaptureWriter(tmp_path / "capture", 1024) # noqa: SLF001
writer.open()
writer.record(_mqtt_message("RealtimePath", b"one"))
assert writer._raw is not None # noqa: SLF001
raw_fd = writer._raw.fileno() # noqa: SLF001
real_fsync = capture_module.os.fsync
def fail_raw_fsync(descriptor: int) -> None:
if descriptor == raw_fd:
raise OSError("synthetic raw fsync failure")
real_fsync(descriptor)
monkeypatch.setattr(capture_module.os, "fsync", fail_raw_fsync)
with pytest.raises(OSError, match="synthetic raw fsync failure"):
writer._commit_pending() # noqa: SLF001
assert writer.metadata_path.read_bytes() == b""
# Restore durability primitive so the fixture can close normally.
monkeypatch.setattr(capture_module.os, "fsync", real_fsync)
writer.close()

324
tests/test_rrd_export.py Normal file
View File

@ -0,0 +1,324 @@
from __future__ import annotations
import hashlib
import json
import struct
import subprocess
import sys
import threading
from pathlib import Path
import pytest
import k1link.viewer.rrd_export as export_module
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.viewer.rerun_bridge import RerunSceneSettings
from k1link.viewer.rrd_export import (
RECORDED_POINTS_VISUALIZER_ID,
RECORDED_ROOT_CONTAINER_ID,
RECORDED_SPATIAL_VIEW_ID,
SESSION_TIMELINE,
RrdExportCancelled,
RrdExportError,
_recorded_blueprint,
export_k1mqtt_to_rrd,
recorded_blueprint_rrd,
)
def _point_payload(x: float) -> bytes:
return struct.pack("<III", 16, 0, 0) + struct.pack(
"<fffBBBB",
x,
-2.0,
3.0,
10,
20,
30,
40,
)
def _pose_payload(x: float) -> bytes:
return struct.pack("<ffffffff", x, 2.0, 3.0, 99.0, 1.0, 0.0, 0.0, 0.0)
def _write_capture(
directory: Path,
frames: list[tuple[str, bytes, int]],
) -> Path:
capture = directory / "mqtt.raw.k1mqtt"
raw = bytearray(RAW_MAGIC)
metadata: list[dict[str, object]] = []
epoch_origin_ns = 1_784_124_315_000_000_000
monotonic_origin_ns = 9_000_000_000
for sequence, (topic, payload, session_time_ns) in enumerate(frames, start=1):
topic_raw = topic.encode("utf-8")
raw.extend(FRAME_HEADER.pack(len(topic_raw), len(payload)))
raw.extend(topic_raw)
raw.extend(payload)
metadata.append(
{
"record_type": "message",
"sequence": sequence,
"received_at_epoch_ns": epoch_origin_ns + session_time_ns,
"received_monotonic_ns": monotonic_origin_ns + session_time_ns,
}
)
capture.write_bytes(raw)
(directory / "mqtt.metadata.jsonl").write_text(
"".join(json.dumps(item) + "\n" for item in metadata),
encoding="utf-8",
)
return capture
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _print_rrd_entity(path: Path, entity: str) -> str:
completed = subprocess.run(
[
sys.executable,
"-m",
"rerun_cli",
"rrd",
"print",
"-vvv",
"--entity",
entity,
str(path),
],
check=True,
capture_output=True,
text=True,
)
return completed.stdout
def test_recorded_blueprint_accumulates_point_frames_on_session_timeline() -> None:
blueprint = _recorded_blueprint(
RerunSceneSettings(
point_size=7.5,
palette="custom",
custom_color="#112233",
accumulation_seconds=18.5,
show_points=False,
show_trajectory=True,
show_grid=False,
)
)
spatial_view = blueprint.root_container.contents[0]
visible_ranges = spatial_view.properties["VisibleTimeRanges"]
line_grid = spatial_view.properties["LineGrid3D"]
assert visible_ranges.ranges.as_arrow_array().to_pylist() == [
{
"timeline": SESSION_TIMELINE,
"range": {
"start": -18_500_000_000,
"end": 0,
},
}
]
assert line_grid.visible.as_arrow_array().to_pylist() == [False]
point_behavior, point_visualizer = spatial_view.visualizer_overrides["/world/points"]
trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"]
assert point_behavior.visible.as_arrow_array().to_pylist() == [False]
assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True]
point_overrides = {
str(batch.component_descriptor()): batch.as_arrow_array().to_pylist()
for batch in point_visualizer.overrides
}
assert point_overrides == {
"Points3D:colors": [0x112233FF],
# Negative values are Rerun's encoding for screen-space UI points.
"Points3D:radii": [-7.5],
}
def test_zero_accumulation_uses_latest_frame_instead_of_empty_time_range() -> None:
blueprint = _recorded_blueprint(
RerunSceneSettings(accumulation_seconds=0.0, show_grid=True)
)
spatial_view = blueprint.root_container.contents[0]
assert "VisibleTimeRanges" not in spatial_view.properties
point_behavior, point_visualizer = spatial_view.visualizer_overrides["/world/points"]
trajectory_behavior = spatial_view.visualizer_overrides["/world/trajectory"]
assert point_behavior.visible.as_arrow_array().to_pylist() == [True]
assert trajectory_behavior.visible.as_arrow_array().to_pylist() == [True]
point_components = {
str(batch.component_descriptor()) for batch in point_visualizer.overrides
}
assert point_components == {"Points3D:radii"}
def test_dynamic_blueprint_reuses_scene_ids_without_playback_mutation() -> None:
first = _recorded_blueprint(
RerunSceneSettings(show_points=False, show_trajectory=True),
include_initial_playback_state=False,
)
second = _recorded_blueprint(
RerunSceneSettings(show_points=True, show_trajectory=False),
include_initial_playback_state=False,
)
assert not hasattr(first, "time_panel")
assert not hasattr(second, "time_panel")
assert first.root_container.id == second.root_container.id == RECORDED_ROOT_CONTAINER_ID
first_view = first.root_container.contents[0]
second_view = second.root_container.contents[0]
assert first_view.id == second_view.id == RECORDED_SPATIAL_VIEW_ID
first_point_behavior, first_point_visualizer = first_view.visualizer_overrides[
"/world/points"
]
second_point_behavior, second_point_visualizer = second_view.visualizer_overrides[
"/world/points"
]
first_trajectory = first_view.visualizer_overrides["/world/trajectory"]
second_trajectory = second_view.visualizer_overrides["/world/trajectory"]
assert first_point_visualizer.id == RECORDED_POINTS_VISUALIZER_ID
assert second_point_visualizer.id == RECORDED_POINTS_VISUALIZER_ID
assert first_point_behavior.visible.as_arrow_array().to_pylist() == [False]
assert second_point_behavior.visible.as_arrow_array().to_pylist() == [True]
assert first_trajectory.visible.as_arrow_array().to_pylist() == [True]
assert second_trajectory.visible.as_arrow_array().to_pylist() == [False]
payload = recorded_blueprint_rrd(
RerunSceneSettings(show_points=False, show_trajectory=False),
recording_id="stable-recording",
)
assert b"PlayState" not in payload
assert b"play_state" not in payload
assert str(RECORDED_ROOT_CONTAINER_ID).encode() in payload
assert str(RECORDED_SPATIAL_VIEW_ID).encode() in payload
assert str(RECORDED_POINTS_VISUALIZER_ID).encode() in payload
def test_export_preserves_every_decodable_frame_and_source_timeline(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[
("lixel/application/report/heartbeat", b"opaque", 0),
("RealtimePointcloud", _point_payload(1.0), 100_000_000),
("RealtimePath", _pose_payload(1.0), 600_000_000),
("RealtimePointcloud", _point_payload(2.0), 900_000_000),
("RealtimePath", _pose_payload(1.2), 1_200_000_000),
("lixel/application/report/heartbeat", b"opaque-tail", 9_500_000_000),
],
)
output = tmp_path / "session.rrd"
summary = export_k1mqtt_to_rrd(capture, output)
assert summary["source_messages"] == 6
assert summary["decoded_messages"] == 4
assert summary["point_frames"] == 2
assert summary["pose_frames"] == 2
assert summary["ignored_messages"] == 2
assert summary["points"] == 2
assert summary["trajectory_poses"] == 2
assert summary["trajectory_updates"] == 2
assert summary["session_origin_monotonic_ns"] == 9_000_000_000
assert summary["timeline"] == "session_time"
assert summary["timeline_start_ns"] == 0
assert summary["timeline_end_ns"] == 1_200_000_000
assert summary["timeline_span_ns"] == 1_200_000_000
assert summary["first_decoded_time_ns"] == 100_000_000
assert summary["last_decoded_time_ns"] == 1_200_000_000
assert summary["source_sha256"] == _sha256(capture)
assert summary["rrd_bytes"] == output.stat().st_size
assert summary["rrd_sha256"] == _sha256(output)
assert output.stat().st_size > 0
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
origin_table = _print_rrd_entity(output, "/__mission_core/session_origin")
assert "/__mission_core/session_origin" in origin_table
assert "session_time" in origin_table
assert "P0D" in origin_table
assert "[true]" in origin_table
def test_atomic_rename_failure_preserves_existing_recording(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
capture = _write_capture(
tmp_path,
[("RealtimePointcloud", _point_payload(1.0), 0)],
)
output = tmp_path / "session.rrd"
trusted = b"existing-good-recording"
output.write_bytes(trusted)
def fail_replace(_source: Path, _destination: Path) -> None:
raise OSError("synthetic rename failure")
monkeypatch.setattr(export_module.os, "replace", fail_replace)
with pytest.raises(RrdExportError, match="synthetic rename failure"):
export_k1mqtt_to_rrd(capture, output)
assert output.read_bytes() == trusted
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
def test_missing_monotonic_metadata_never_replaces_existing_recording(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[("RealtimePointcloud", _point_payload(1.0), 0)],
)
(tmp_path / "mqtt.metadata.jsonl").unlink()
output = tmp_path / "session.rrd"
output.write_bytes(b"existing-good-recording")
with pytest.raises(RrdExportError, match="received_monotonic_ns"):
export_k1mqtt_to_rrd(capture, output)
assert output.read_bytes() == b"existing-good-recording"
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []
def test_export_preserves_valid_prefix_before_crash_metadata_tail(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[
("RealtimePointcloud", _point_payload(1.0), 100_000_000),
("RealtimePointcloud", _point_payload(2.0), 200_000_000),
],
)
metadata_path = tmp_path / "mqtt.metadata.jsonl"
first_line = metadata_path.read_text(encoding="utf-8").splitlines(keepends=True)[0]
metadata_path.write_text(
first_line + '{"record_type":"message"',
encoding="utf-8",
)
output = tmp_path / "session.rrd"
summary = export_k1mqtt_to_rrd(capture, output)
assert summary["source_messages"] == 1
assert summary["decoded_messages"] == 1
assert summary["point_frames"] == 1
assert summary["timeline_end_ns"] == 0
assert output.stat().st_size > 0
def test_export_honors_cooperative_cancellation_without_publishing(tmp_path: Path) -> None:
capture = _write_capture(
tmp_path,
[("RealtimePointcloud", _point_payload(1.0), 0)],
)
output = tmp_path / "session.rrd"
output.write_bytes(b"trusted-existing-recording")
cancelled = threading.Event()
cancelled.set()
with pytest.raises(RrdExportCancelled):
export_k1mqtt_to_rrd(capture, output, cancel_event=cancelled)
assert output.read_bytes() == b"trusted-existing-recording"
assert list(tmp_path.glob(".session.rrd.*.tmp")) == []

1659
tests/test_session_api.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,357 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any
import pytest
from fastapi import APIRouter, HTTPException
from fastapi.routing import APIRoute
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.sessions import (
SessionRecordingMaterializer,
SessionRecordingPreparationManager,
SessionStore,
)
from k1link.web import app as app_module
from k1link.web.session_api import build_session_router
def _make_completed_session(root: Path, session_id: str) -> Path:
capture = root / session_id / "captures" / "mqtt_live"
capture.mkdir(parents=True)
topics = (
"lixel/application/report/lio_pcl",
"lixel/application/report/lio_pose",
)
raw = bytearray(RAW_MAGIC)
metadata: list[dict[str, object]] = []
for sequence, topic in enumerate(topics, start=1):
encoded_topic = topic.encode("utf-8")
payload = b"fixture"
frame_offset = len(raw)
raw.extend(FRAME_HEADER.pack(len(encoded_topic), len(payload)))
raw.extend(encoded_topic)
raw.extend(payload)
metadata.append(
{
"record_type": "message",
"sequence": sequence,
"received_at_epoch_ns": 1_000_000_000 + sequence,
"received_monotonic_ns": 2_000_000_000 + sequence,
"topic": topic,
"payload_bytes": len(payload),
"raw_frame_offset": frame_offset,
"raw_payload_offset": frame_offset + FRAME_HEADER.size + len(encoded_topic),
"raw_frame_bytes": FRAME_HEADER.size + len(encoded_topic) + len(payload),
}
)
(capture / "mqtt.raw.k1mqtt").write_bytes(raw)
metadata_path = capture / "mqtt.metadata.jsonl"
metadata_path.write_text(
"".join(json.dumps(record) + "\n" for record in metadata),
encoding="utf-8",
)
(capture / "mqtt.summary.json").write_text(
json.dumps(
{
"created_at_utc": "2026-07-16T20:56:32.699Z",
"completed_at_utc": "2026-07-16T20:57:02.699Z",
"capture_elapsed_seconds": 30.0,
"stop_reason": "external_stop",
"error": None,
"message_count": 2,
"raw_bytes": len(raw),
"topic_counts": {topic: 1 for topic in topics},
"artifact_hashes": {
"raw_sha256": hashlib.sha256(raw).hexdigest(),
"metadata_jsonl_sha256": hashlib.sha256(metadata_path.read_bytes()).hexdigest(),
},
}
),
encoding="utf-8",
)
return capture.parents[1]
def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
return next(
route.endpoint
for route in router.routes
if isinstance(route, APIRoute) and route.path == path and method in route.methods
)
def test_list_refresh_discovers_new_completed_session_without_server_restart(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
store = SessionStore(repository, data_dir=tmp_path / "data")
refresh_calls = 0
def refresh() -> tuple[str, ...]:
nonlocal refresh_calls
refresh_calls += 1
return store.import_legacy_viewer_live(sessions)
router = build_session_router(store, catalog_refresher=refresh)
list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")
assert list_route(limit=20, cursor=None) == {"items": []}
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
listing = list_route(limit=20, cursor=None)
assert refresh_calls == 2
assert [item["id"] for item in listing["items"]] == [session.name]
def test_detail_and_replay_refresh_catalog_before_lookup(tmp_path: Path) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
refresh_calls = 0
def refresh() -> tuple[str, ...]:
nonlocal refresh_calls
refresh_calls += 1
return store.import_legacy_viewer_live(sessions)
router = build_session_router(store, catalog_refresher=refresh)
detail_route = _endpoint(
router,
"/api/v1/observation-sessions/{session_id}",
"GET",
)
replay_route = _endpoint(
router,
"/api/v1/observation-sessions/{session_id}/replay",
"POST",
)
detail = detail_route(session_id=session.name)
replay = asyncio.run(replay_route(session_id=session.name, request=None))
assert detail["session_id"] == session.name
assert replay["launch"]["session_id"] == session.name
assert refresh_calls == 2
def test_catalog_refresh_failure_is_a_stable_service_error(tmp_path: Path) -> None:
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
def fail_refresh() -> None:
raise OSError("private local path must not escape")
router = build_session_router(store, catalog_refresher=fail_refresh)
list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")
with pytest.raises(HTTPException) as error:
list_route(limit=20, cursor=None)
assert error.value.status_code == 503
assert "private local path" not in str(error.value.detail)
def test_startup_warmup_enqueues_every_finalized_replayable_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
def exporter(source: Path, destination: Path) -> dict[str, object]:
payload = b"startup-prepared-recording"
destination.write_bytes(payload)
return {
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
"rrd_bytes": len(payload),
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1,
}
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
)
monkeypatch.setattr(app_module, "session_store", store)
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
try:
enqueued = app_module.enqueue_replayable_recordings()
deadline = time.monotonic() + 2
snapshot = manager.status(session.name)
while snapshot is not None and snapshot.state != "ready" and time.monotonic() < deadline:
time.sleep(0.005)
snapshot = manager.status(session.name)
assert enqueued == (session.name,)
assert snapshot is not None
assert snapshot.state == "ready"
assert snapshot.recording is not None
finally:
manager.close()
def test_reconciliation_skips_one_stale_session_and_prepares_later_valid_session(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
good = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
stale = _make_completed_session(sessions, "20260716T205633Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
(stale / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt").unlink()
def exporter(source: Path, destination: Path) -> dict[str, object]:
payload = b"prepared-after-stale-row"
destination.write_bytes(payload)
return {
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
"rrd_bytes": len(payload),
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1,
}
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
)
monkeypatch.setattr(app_module, "session_store", store)
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
try:
enqueued = app_module.enqueue_replayable_recordings()
deadline = time.monotonic() + 2
snapshot = manager.status(good.name)
while snapshot is not None and snapshot.state != "ready" and time.monotonic() < deadline:
time.sleep(0.005)
snapshot = manager.status(good.name)
assert enqueued == (good.name,)
assert manager.status(stale.name) is None
assert snapshot is not None and snapshot.state == "ready"
finally:
manager.close()
def test_reconciler_requeues_only_a_restart_interrupted_job(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
started = threading.Event()
release = threading.Event()
calls = 0
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
if calls == 1:
started.set()
assert release.wait(timeout=2)
payload = f"restart-{calls}".encode()
destination.write_bytes(payload)
return {
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
"rrd_bytes": len(payload),
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1,
}
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
)
monkeypatch.setattr(app_module, "session_store", store)
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
try:
app_module.enqueue_replayable_recordings()
assert started.wait(timeout=1)
manager.close(timeout=0.001)
manager.start()
release.set()
deadline = time.monotonic() + 2
snapshot = manager.status(session.name)
while (
snapshot is None or snapshot.state != "cancelled"
) and time.monotonic() < deadline:
time.sleep(0.005)
snapshot = manager.status(session.name)
assert snapshot is not None and snapshot.state == "cancelled"
interrupted_id = snapshot.preparation_id
app_module.enqueue_replayable_recordings()
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
snapshot = manager.status(session.name)
if snapshot is not None and snapshot.state == "ready":
break
time.sleep(0.005)
assert snapshot is not None and snapshot.state == "ready"
assert snapshot.preparation_id != interrupted_id
assert calls == 2
finally:
release.set()
manager.close()
def test_reconciler_does_not_loop_retry_a_genuine_failed_job(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
calls = 0
def exporter(_source: Path, _destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
raise OSError("genuine export failure")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(store.data_dir, exporter=exporter)
)
monkeypatch.setattr(app_module, "session_store", store)
monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
try:
app_module.enqueue_replayable_recordings()
deadline = time.monotonic() + 2
snapshot = manager.status(session.name)
while (
snapshot is None or snapshot.state != "failed"
) and time.monotonic() < deadline:
time.sleep(0.005)
snapshot = manager.status(session.name)
assert snapshot is not None and snapshot.state == "failed"
failed_id = snapshot.preparation_id
app_module.enqueue_replayable_recordings()
time.sleep(0.05)
unchanged = manager.status(session.name)
assert unchanged is not None
assert unchanged.preparation_id == failed_id
assert unchanged.state == "failed"
assert calls == 1
finally:
manager.close()

View File

@ -0,0 +1,377 @@
from __future__ import annotations
import hashlib
import threading
import time
from collections.abc import Callable
from dataclasses import replace
from pathlib import Path
import pytest
from k1link.sessions.models import ReplayCommand
from k1link.sessions.preparation import (
RecordingPreparationSnapshot,
SessionRecordingPreparationManager,
)
from k1link.sessions.recording import RecordingMaterializationError, SessionRecordingMaterializer
def _command(root: Path, session_id: str = "20260717T100000Z_viewer_live") -> ReplayCommand:
root.mkdir(parents=True)
source = root / "mqtt.raw.k1mqtt"
metadata = root / "mqtt.metadata.jsonl"
source.write_bytes(b"native-session-source")
metadata.write_text('{"record_type":"message","sequence":1}\n', encoding="utf-8")
return ReplayCommand(
session_id=session_id,
source_path=source,
allowed_root=root,
session_root=root,
replay_byte_length=source.stat().st_size,
metadata_byte_length=metadata.stat().st_size,
expected_source_sha256=None,
speed=1.0,
loop=False,
)
def _summary(source: Path, destination: Path, payload: bytes) -> dict[str, object]:
destination.write_bytes(payload)
return {
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
"rrd_bytes": len(payload),
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1_000_000_000,
}
def _wait_for_state(
manager: SessionRecordingPreparationManager,
session_id: str,
states: set[str],
timeout: float = 2.0,
) -> RecordingPreparationSnapshot:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
snapshot = manager.status(session_id)
if snapshot is not None and snapshot.state in states:
return snapshot
time.sleep(0.005)
raise AssertionError(f"preparation did not reach {states}")
def test_manager_returns_quick_job_deduplicates_and_reports_monotonic_progress(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "session")
started = threading.Event()
release = threading.Event()
calls = 0
def exporter(
source: Path,
destination: Path,
*,
cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None,
) -> dict[str, object]:
nonlocal calls
calls += 1
started.set()
deadline = time.monotonic() + 2
while not release.wait(timeout=0.005):
assert cancel_event is None or not cancel_event.is_set()
if activity_callback is not None:
activity_callback()
assert time.monotonic() < deadline
return _summary(source, destination, b"prepared-recording")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter),
heartbeat_interval_seconds=0.01,
)
try:
before = time.monotonic()
first = manager.enqueue(command)
elapsed = time.monotonic() - before
duplicate = manager.enqueue(command)
assert elapsed < 0.1
assert duplicate.preparation_id == first.preparation_id
assert started.wait(timeout=1)
exporting = _wait_for_state(manager, command.session_id, {"exporting"})
time.sleep(0.03)
heartbeat = manager.status(command.session_id)
assert heartbeat is not None
assert heartbeat.updated_at_utc > exporting.updated_at_utc
assert heartbeat.progress >= exporting.progress
assert heartbeat.cancellable is True
release.set()
ready = _wait_for_state(manager, command.session_id, {"ready"})
assert calls == 1
assert exporting.progress <= ready.progress == 1.0
assert ready.recording is not None
assert ready.updated_at_utc.endswith("Z")
finally:
release.set()
manager.close()
def test_manager_failure_is_retryable_and_retry_publishes_new_job(tmp_path: Path) -> None:
command = _command(tmp_path / "session")
calls = 0
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
if calls == 1:
raise OSError("simulated converter failure")
return _summary(source, destination, b"retry-recording")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
)
try:
first = manager.enqueue(command)
failed = _wait_for_state(manager, command.session_id, {"failed"})
retry = manager.enqueue(command, retry_failed=True)
ready = _wait_for_state(manager, command.session_id, {"ready"})
assert failed.retryable is True
assert failed.error == "Не удалось подготовить запись сессии."
assert retry.preparation_id != first.preparation_id
assert ready.preparation_id == retry.preparation_id
assert calls == 2
finally:
manager.close()
def test_manager_cancels_queued_job_without_exporting_it(tmp_path: Path) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260717T100001Z_viewer_live",
)
started = threading.Event()
release = threading.Event()
calls = 0
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
started.set()
assert release.wait(timeout=2)
return _summary(source, destination, b"recording")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
)
try:
manager.enqueue(first)
assert started.wait(timeout=1)
manager.enqueue(second)
assert manager.cancel(second.session_id) is True
cancelled = _wait_for_state(manager, second.session_id, {"cancelled"})
release.set()
_wait_for_state(manager, first.session_id, {"ready"})
time.sleep(0.02)
assert cancelled.cancellable is False
assert calls == 1
finally:
release.set()
manager.close()
def test_manager_can_restart_across_repeated_application_lifespans(tmp_path: Path) -> None:
command = _command(tmp_path / "session")
calls = 0
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
return _summary(source, destination, b"restartable-recording")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
)
try:
manager.enqueue(command)
_wait_for_state(manager, command.session_id, {"ready"})
manager.close()
manager.start()
resumed = manager.enqueue(command)
assert resumed.state == "ready"
assert calls == 1
finally:
manager.close()
def test_noncooperative_exporter_has_no_fake_heartbeat_and_restart_waits_for_old_writer(
tmp_path: Path,
) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260717T100002Z_viewer_live",
)
first_started = threading.Event()
release_first = threading.Event()
active = 0
max_active = 0
calls = 0
guard = threading.Lock()
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal active, calls, max_active
with guard:
calls += 1
active += 1
max_active = max(max_active, active)
call = calls
try:
if call == 1:
first_started.set()
assert release_first.wait(timeout=2)
return _summary(source, destination, f"recording-{call}".encode())
finally:
with guard:
active -= 1
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter),
heartbeat_interval_seconds=0.01,
)
try:
manager.enqueue(first)
assert first_started.wait(timeout=1)
blocked = _wait_for_state(manager, first.session_id, {"exporting"})
time.sleep(0.03)
unchanged = manager.status(first.session_id)
assert unchanged is not None
assert unchanged.updated_at_utc == blocked.updated_at_utc
assert unchanged.cancellable is False
manager.close(timeout=0.001)
manager.start()
queued = manager.enqueue(second)
assert queued.state == "queued"
time.sleep(0.03)
assert calls == 1
release_first.set()
ready = _wait_for_state(manager, second.session_id, {"ready"})
assert ready.recording is not None
assert calls == 2
assert max_active == 1
finally:
release_first.set()
manager.close()
def test_reconciler_retry_does_not_replace_operator_cancel_across_restart(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "session")
started = threading.Event()
release = threading.Event()
calls = 0
def exporter(source: Path, destination: Path) -> dict[str, object]:
nonlocal calls
calls += 1
started.set()
assert release.wait(timeout=2)
return _summary(source, destination, b"operator-cancelled")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
)
try:
original = manager.enqueue(command)
assert started.wait(timeout=1)
assert manager.cancel(
command.session_id,
preparation_id=original.preparation_id,
)
manager.close(timeout=0.001)
manager.start()
release.set()
cancelled = _wait_for_state(manager, command.session_id, {"cancelled"})
reconciled = manager.enqueue(command, retry_interrupted=True)
assert reconciled.preparation_id == cancelled.preparation_id
assert reconciled.state == "cancelled"
assert calls == 1
finally:
release.set()
manager.close()
def test_shared_preparation_command_ignores_per_request_playback_policy(tmp_path: Path) -> None:
command = _command(tmp_path / "session")
started = threading.Event()
release = threading.Event()
def exporter(source: Path, destination: Path) -> dict[str, object]:
started.set()
assert release.wait(timeout=2)
return _summary(source, destination, b"recording")
manager = SessionRecordingPreparationManager(
SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
)
try:
first = manager.enqueue(replace(command, speed=2.0, loop=True))
assert started.wait(timeout=1)
duplicate = manager.enqueue(replace(command, speed=7.0, loop=False))
assert duplicate.preparation_id == first.preparation_id
assert duplicate.command.speed == 1.0
assert duplicate.command.loop is False
finally:
release.set()
manager.close()
def test_launch_reservation_blocks_eviction_until_lease_expires(tmp_path: Path) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260717T100003Z_viewer_live",
)
def exporter(source: Path, destination: Path) -> dict[str, object]:
return _summary(source, destination, b"R" * (40 * 1024))
materializer = SessionRecordingMaterializer(
tmp_path / "private",
exporter=exporter,
cache_max_bytes=50 * 1024,
free_space_reserve_bytes=0,
)
manager = SessionRecordingPreparationManager(materializer)
try:
first_recording = materializer.materialize(first)
resolved = manager.resolve_cached(first)
assert resolved is not None and resolved.state == "ready"
reserved = manager.reserve_cached(first, lease_seconds=0.05)
assert reserved is not None and reserved.recording is not None
with pytest.raises(RecordingMaterializationError, match="cache quota"):
materializer.materialize(second)
assert first_recording.path.exists()
time.sleep(0.08)
second_recording = materializer.materialize(second)
assert second_recording.path.exists()
assert not first_recording.path.exists()
finally:
manager.close()

View File

@ -0,0 +1,522 @@
from __future__ import annotations
import hashlib
import json
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import pytest
import k1link.sessions.recording as recording_module
from k1link.sessions.models import ReplayCommand
from k1link.sessions.recording import (
CACHE_SCHEMA,
RERUN_RECORDING_MEDIA_TYPE,
RecordingMaterializationError,
SessionRecordingMaterializer,
)
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
class FakeExporter:
def __init__(self, *, delay_seconds: float = 0.0) -> None:
self.calls = 0
self.delay_seconds = delay_seconds
self._lock = threading.Lock()
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
self.calls += 1
sequence = self.calls
if self.delay_seconds:
time.sleep(self.delay_seconds)
destination.write_bytes(b"RRD" + sequence.to_bytes(2, "big") + source.read_bytes())
return {
"source_sha256": _sha256(source),
"rrd_sha256": _sha256(destination),
"rrd_bytes": destination.stat().st_size,
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 2_500_000_000,
}
def _command(tmp_path: Path) -> ReplayCommand:
tmp_path.mkdir(parents=True, exist_ok=True)
source = tmp_path / "mqtt.raw.k1mqtt"
source.write_bytes(b"native-source-recording")
metadata = tmp_path / "mqtt.metadata.jsonl"
metadata.write_text('{"record_type":"message","sequence":1}\n', encoding="utf-8")
return ReplayCommand(
session_id="20260716T205632Z_viewer_live",
source_path=source,
allowed_root=tmp_path,
session_root=tmp_path,
replay_byte_length=source.stat().st_size,
metadata_byte_length=metadata.stat().st_size,
expected_source_sha256=None,
speed=1.0,
loop=False,
)
def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Path) -> None:
command = _command(tmp_path)
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
first = materializer.materialize(command)
second = materializer.materialize(command)
assert first == second
assert exporter.calls == 1
assert first.media_type == RERUN_RECORDING_MEDIA_TYPE
assert first.timeline == "session_time"
assert first.timeline_start_ns == 0
assert first.timeline_end_ns == 2_500_000_000
assert first.path.is_relative_to(materializer.recordings_root)
assert first.path.stat().st_mode & 0o777 == 0o600
sidecar = first.path.with_name("scene.rrd.cache.json")
assert sidecar.stat().st_mode & 0o777 == 0o600
document = json.loads(sidecar.read_text(encoding="utf-8"))
assert document["schema_version"] == CACHE_SCHEMA
assert "source_path" not in document
assert "recording_path" not in document
assert str(tmp_path) not in sidecar.read_text(encoding="utf-8")
after_restart = SessionRecordingMaterializer(
tmp_path / "private",
exporter=exporter,
).materialize(command)
assert after_restart == first
assert exporter.calls == 1
@pytest.mark.parametrize(
"obsolete_schema",
[
"missioncore.derived-rerun-recording-cache/v1",
"missioncore.derived-rerun-recording-cache/v2",
"missioncore.derived-rerun-recording-cache/v4",
"missioncore.derived-rerun-recording-cache/v5",
],
)
def test_materializer_rebuilds_incompatible_recording_cache_schema(
tmp_path: Path,
obsolete_schema: str,
) -> None:
command = _command(tmp_path)
exporter = FakeExporter()
private_root = tmp_path / "private"
first = SessionRecordingMaterializer(private_root, exporter=exporter).materialize(command)
sidecar = first.path.with_name("scene.rrd.cache.json")
document = json.loads(sidecar.read_text(encoding="utf-8"))
document["schema_version"] = obsolete_schema
sidecar.write_text(json.dumps(document), encoding="utf-8")
rebuilt = SessionRecordingMaterializer(private_root, exporter=exporter).materialize(command)
assert exporter.calls == 2
assert rebuilt.path == first.path
assert json.loads(sidecar.read_text(encoding="utf-8"))["schema_version"] == CACHE_SCHEMA
def test_failed_rebuild_preserves_previously_published_cache(tmp_path: Path) -> None:
command = _command(tmp_path)
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
first = materializer.materialize(command)
published_bytes = first.path.read_bytes()
sidecar = first.path.with_name("scene.rrd.cache.json")
published_sidecar = sidecar.read_bytes()
command.source_path.write_bytes(b"new-source-that-requires-rebuild")
changed = replace(command, replay_byte_length=command.source_path.stat().st_size)
def fail_export(_source: Path, destination: Path) -> dict[str, object]:
destination.write_bytes(b"incomplete-candidate")
raise OSError("simulated export failure")
failing = SessionRecordingMaterializer(tmp_path / "private", exporter=fail_export)
with pytest.raises(RecordingMaterializationError):
failing.materialize(changed)
assert first.path.read_bytes() == published_bytes
assert sidecar.read_bytes() == published_sidecar
assert not tuple(first.path.parent.glob(".scene.*.candidate.rrd"))
def test_materializer_rebuilds_when_source_or_recording_changes(tmp_path: Path) -> None:
command = _command(tmp_path)
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
first = materializer.materialize(command)
original_recording_stat = first.path.stat()
corrupted = bytearray(first.path.read_bytes())
corrupted[-1] ^= 0x01
first.path.write_bytes(corrupted)
os.utime(
first.path,
ns=(original_recording_stat.st_atime_ns, original_recording_stat.st_mtime_ns),
)
repaired = materializer.materialize(command)
assert exporter.calls == 2
assert repaired.sha256 == _sha256(repaired.path)
assert repaired.sha256 != hashlib.sha256(corrupted).hexdigest()
command.source_path.write_bytes(b"new-native-source")
command = replace(command, replay_byte_length=command.source_path.stat().st_size)
updated = materializer.materialize(command)
assert exporter.calls == 3
assert updated.source_sha256 == _sha256(command.source_path)
def test_concurrent_materialization_exports_one_recording(tmp_path: Path) -> None:
command = _command(tmp_path)
exporter = FakeExporter(delay_seconds=0.05)
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
with ThreadPoolExecutor(max_workers=8) as executor:
recordings = tuple(executor.map(materializer.materialize, [command] * 8))
assert exporter.calls == 1
assert len({recording.sha256 for recording in recordings}) == 1
assert len({recording.path for recording in recordings}) == 1
def test_materializer_never_follows_cache_symlinks_outside_private_root(
tmp_path: Path,
) -> None:
command = _command(tmp_path)
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
outside = tmp_path / "outside"
outside.mkdir()
session_cache = materializer.recordings_root / command.session_id
session_cache.symlink_to(outside, target_is_directory=True)
with pytest.raises(RecordingMaterializationError, match="must not be a symlink"):
materializer.materialize(command)
assert list(outside.iterdir()) == []
session_cache.unlink()
session_cache.mkdir()
outside_recording = outside / "scene.rrd"
outside_recording.write_bytes(b"do-not-touch")
(session_cache / "scene.rrd").symlink_to(outside_recording)
recording = materializer.materialize(command)
assert recording.path.read_bytes().startswith(b"RRD")
assert outside_recording.read_bytes() == b"do-not-touch"
def test_materializer_revalidates_prepared_source_without_following_replaced_symlink(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "session")
exporter = FakeExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
outside = tmp_path / "outside.k1mqtt"
outside.write_bytes(command.source_path.read_bytes())
command.source_path.unlink()
command.source_path.symlink_to(outside)
with pytest.raises(RecordingMaterializationError, match="missing or unsafe"):
materializer.materialize(command)
assert exporter.calls == 0
assert outside.read_bytes() == b"native-source-recording"
def test_materializer_exports_only_validated_prefix_before_crash_tail(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "session")
committed_raw_bytes = command.replay_byte_length
committed_metadata_bytes = command.metadata_byte_length
with command.source_path.open("ab") as stream:
stream.write(b"uncommitted-raw-tail")
metadata = command.source_path.with_name("mqtt.metadata.jsonl")
with metadata.open("ab") as stream:
stream.write(b'{"record_type":"message"')
observed: list[tuple[int, int]] = []
class PrefixExporter(FakeExporter):
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
observed.append(
(source.stat().st_size, source.with_name("mqtt.metadata.jsonl").stat().st_size)
)
return super().__call__(source, destination)
exporter = PrefixExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
recording = materializer.materialize(command)
assert observed == [(committed_raw_bytes, committed_metadata_bytes)]
assert recording.source_sha256 == hashlib.sha256(b"native-source-recording").hexdigest()
assert command.source_path.read_bytes().endswith(b"uncommitted-raw-tail")
def test_global_singleflight_bounds_exports_across_different_sessions(tmp_path: Path) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260716T205633Z_viewer_live",
)
class ConcurrencyExporter(FakeExporter):
def __init__(self) -> None:
super().__init__()
self.active = 0
self.max_active = 0
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
time.sleep(0.03)
return super().__call__(source, destination)
finally:
with self._lock:
self.active -= 1
exporter = ConcurrencyExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
with ThreadPoolExecutor(max_workers=2) as executor:
tuple(executor.map(materializer.materialize, (first, second)))
assert exporter.calls == 2
assert exporter.max_active == 1
def test_cross_process_file_lock_serializes_independent_materializer_instances(
tmp_path: Path,
) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260716T205634Z_viewer_live",
)
class ConcurrencyExporter(FakeExporter):
def __init__(self) -> None:
super().__init__()
self.active = 0
self.max_active = 0
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
self.active += 1
self.max_active = max(self.max_active, self.active)
try:
time.sleep(0.03)
return super().__call__(source, destination)
finally:
with self._lock:
self.active -= 1
exporter = ConcurrencyExporter()
private_root = tmp_path / "private"
first_materializer = SessionRecordingMaterializer(private_root, exporter=exporter)
second_materializer = SessionRecordingMaterializer(private_root, exporter=exporter)
with ThreadPoolExecutor(max_workers=2) as executor:
first_future = executor.submit(first_materializer.materialize, first)
second_future = executor.submit(second_materializer.materialize, second)
assert first_future.result(timeout=2).path.exists()
assert second_future.result(timeout=2).path.exists()
assert exporter.calls == 2
assert exporter.max_active == 1
def test_export_scavenges_confined_crash_candidates_before_quota_check(
tmp_path: Path,
) -> None:
command = _command(tmp_path / "session")
private_root = tmp_path / "private"
materializer = SessionRecordingMaterializer(
private_root,
exporter=FakeExporter(),
cache_max_bytes=32 * 1024,
free_space_reserve_bytes=0,
)
session_cache = materializer.recordings_root / command.session_id
session_cache.mkdir()
stale_candidate = session_cache / ".scene.deadbeef.candidate.rrd"
stale_candidate.write_bytes(b"x" * (24 * 1024))
stale_export_temp = session_cache / "..scene.deadbeef.candidate.rrd.uuid.tmp"
stale_export_temp.write_bytes(b"x" * 1024)
stale_stage = session_cache / ".source.deadbeef.tmp"
stale_stage.mkdir()
(stale_stage / "mqtt.raw.k1mqtt").write_bytes(b"x" * 1024)
recording = materializer.materialize(command)
assert recording.path.exists()
assert not stale_candidate.exists()
assert not stale_export_temp.exists()
assert not stale_stage.exists()
def test_cached_recording_and_response_lease_do_not_wait_for_other_export(
tmp_path: Path,
) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260716T205633Z_viewer_live",
)
class BlockingSecondExporter(FakeExporter):
def __init__(self) -> None:
super().__init__()
self.second_started = threading.Event()
self.release_second = threading.Event()
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
next_call = self.calls + 1
if next_call == 2:
self.second_started.set()
if not self.release_second.wait(timeout=2):
raise AssertionError("test did not release the blocked export")
return super().__call__(source, destination)
exporter = BlockingSecondExporter()
materializer = SessionRecordingMaterializer(tmp_path / "private", exporter=exporter)
first_recording = materializer.materialize(first)
with ThreadPoolExecutor(max_workers=3) as executor:
exporting = executor.submit(materializer.materialize, second)
assert exporter.second_started.wait(timeout=1)
cached = executor.submit(materializer.materialize, first).result(timeout=0.2)
pinned, release = executor.submit(
materializer.materialize_pinned,
first,
).result(timeout=0.2)
assert cached == first_recording
assert pinned == first_recording
release()
exporter.release_second.set()
assert exporting.result(timeout=1).session_id == second.session_id
def test_cache_quota_evicts_lru_derived_recording_without_deleting_raw(
tmp_path: Path,
) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260716T205633Z_viewer_live",
)
class SizedExporter(FakeExporter):
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
self.calls += 1
destination.write_bytes(b"R" * (40 * 1024))
return {
"source_sha256": _sha256(source),
"rrd_sha256": _sha256(destination),
"rrd_bytes": destination.stat().st_size,
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1,
}
materializer = SessionRecordingMaterializer(
tmp_path / "private",
exporter=SizedExporter(),
cache_max_bytes=50 * 1024,
free_space_reserve_bytes=0,
)
first_recording = materializer.materialize(first)
first_source_bytes = first.source_path.read_bytes()
second_recording = materializer.materialize(second)
assert not first_recording.path.exists()
assert second_recording.path.exists()
assert first.source_path.read_bytes() == first_source_bytes
assert second.source_path.exists()
def test_pinned_cache_entry_survives_eviction_until_release(tmp_path: Path) -> None:
first = _command(tmp_path / "first")
second = replace(
_command(tmp_path / "second"),
session_id="20260716T205633Z_viewer_live",
)
class SizedExporter(FakeExporter):
def __call__(self, source: Path, destination: Path) -> dict[str, object]:
with self._lock:
self.calls += 1
destination.write_bytes(b"R" * (40 * 1024))
return {
"source_sha256": _sha256(source),
"rrd_sha256": _sha256(destination),
"rrd_bytes": destination.stat().st_size,
"timeline": "session_time",
"timeline_start_ns": 0,
"timeline_end_ns": 1,
}
materializer = SessionRecordingMaterializer(
tmp_path / "private",
exporter=SizedExporter(),
cache_max_bytes=50 * 1024,
free_space_reserve_bytes=0,
)
first_recording, release = materializer.materialize_pinned(first)
with pytest.raises(RecordingMaterializationError, match="cache quota"):
materializer.materialize(second)
assert first_recording.path.exists()
release()
second_recording = materializer.materialize(second)
assert not first_recording.path.exists()
assert second_recording.path.exists()
def test_cache_free_space_reserve_refuses_export_without_touching_raw(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
command = _command(tmp_path / "session")
source_bytes = command.source_path.read_bytes()
materializer = SessionRecordingMaterializer(
tmp_path / "private",
exporter=FakeExporter(),
cache_max_bytes=1024 * 1024,
free_space_reserve_bytes=1024,
)
monkeypatch.setattr(
recording_module.shutil,
"disk_usage",
lambda _path: SimpleNamespace(free=1023),
)
with pytest.raises(RecordingMaterializationError, match="free-space reserve"):
materializer.materialize(command)
assert command.source_path.read_bytes() == source_bytes

573
tests/test_session_store.py Normal file
View File

@ -0,0 +1,573 @@
from __future__ import annotations
import hashlib
import json
import shutil
import sqlite3
from pathlib import Path
import pytest
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC, iter_capture_frames
from k1link.sessions import (
LayoutConflictError,
SessionIntegrityError,
SessionNotFoundError,
SessionStore,
resolve_missioncore_evidence_dir,
)
def make_legacy_session(
sessions_root: Path,
session_id: str,
*,
created_at: str = "2026-07-16T20:56:32.699Z",
) -> Path:
session = sessions_root / session_id
capture = session / "captures" / "mqtt_live"
capture.mkdir(parents=True)
frames = [
("lixel/application/report/lio_pcl", b"point-frame"),
("lixel/application/report/lio_pose", b"pose-frame"),
]
raw = bytearray(RAW_MAGIC)
metadata: list[dict[str, object]] = []
for sequence, (topic, payload) in enumerate(frames, start=1):
topic_bytes = topic.encode()
frame_offset = len(raw)
raw.extend(FRAME_HEADER.pack(len(topic_bytes), len(payload)))
raw.extend(topic_bytes)
raw.extend(payload)
metadata.append(
{
"schema_version": 1,
"record_type": "message",
"sequence": sequence,
"received_at_utc": f"2026-07-16T20:56:{31 + sequence:02d}.699Z",
"received_at_epoch_ns": 1_784_235_391_699_000_000 + sequence * 1_000_000_000,
"received_monotonic_ns": 9_000_000_000 + sequence * 1_000_000_000,
"topic": topic,
"payload_bytes": len(payload),
"raw_frame_offset": frame_offset,
"raw_payload_offset": frame_offset + FRAME_HEADER.size + len(topic_bytes),
"raw_frame_bytes": FRAME_HEADER.size + len(topic_bytes) + len(payload),
}
)
raw_path = capture / "mqtt.raw.k1mqtt"
raw_path.write_bytes(raw)
raw_hash = hashlib.sha256(raw).hexdigest()
metadata_path = capture / "mqtt.metadata.jsonl"
metadata_path.write_text(
"".join(json.dumps(record, separators=(",", ":")) + "\n" for record in metadata),
encoding="utf-8",
)
metadata_hash = hashlib.sha256(metadata_path.read_bytes()).hexdigest()
(capture / "mqtt.summary.json").write_text(
json.dumps(
{
"created_at_utc": created_at,
"completed_at_utc": "2026-07-16T21:20:43.018Z",
"capture_elapsed_seconds": 1440.1,
"stop_reason": "external_stop",
"error": None,
"message_count": 2,
"raw_bytes": len(raw),
"payload_bytes": sum(len(payload) for _, payload in frames),
"topic_counts": {
"lixel/application/report/lio_pcl": 1,
"lixel/application/report/lio_pose": 1,
},
"artifact_hashes": {
"raw_sha256": raw_hash,
"metadata_jsonl_sha256": metadata_hash,
},
# This is deliberately sensitive and must not enter API DTOs.
"target_ipv4": "192.168.99.77",
}
),
encoding="utf-8",
)
(session / "manifest.redacted.json").write_text(
json.dumps({"started_at_utc": created_at, "target": "redacted"}),
encoding="utf-8",
)
return session
def test_evidence_root_is_private_and_configurable(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repository = tmp_path / "repo"
assert resolve_missioncore_evidence_dir(repository) == (
repository / ".runtime" / "mission-core" / "evidence" / "sessions"
).resolve()
configured = tmp_path / "external-evidence"
monkeypatch.setenv("MISSIONCORE_EVIDENCE_DIR", str(configured))
assert resolve_missioncore_evidence_dir(repository) == configured.resolve()
def test_catalog_reconciles_sessions_removed_from_one_evidence_root(tmp_path: Path) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
assert store.import_legacy_viewer_live(sessions) == (session.name,)
shutil.rmtree(session)
assert store.import_legacy_viewer_live(sessions) == ()
assert store.list_recent().items == ()
def make_recorded_camera_source(
session: Path,
source_id: str = "sensor.camera.left",
*,
complete: bool = True,
) -> Path:
epoch = session / "media" / source_id / "epoch-1"
segments = epoch / "segments"
segments.mkdir(parents=True)
(epoch / "init.mp4").write_bytes(b"ftyp-mission-core")
(segments / "1.m4s").write_bytes(b"moof-camera-frame")
(epoch / "index.jsonl").write_text(
json.dumps({"sequence": 1, "started_at_seconds": 0.0}) + "\n",
encoding="utf-8",
)
if complete:
(epoch / "summary.json").write_text(
json.dumps(
{
"schema_version": "missioncore.camera-recording/v1",
"source_id": source_id,
"segment_count": 1,
}
),
encoding="utf-8",
)
return epoch
def replace_summary_with_recovery_metadata(
session: Path,
*,
corrupt_trailing_line: bool = False,
) -> None:
capture = session / "captures" / "mqtt_live"
raw_path = capture / "mqtt.raw.k1mqtt"
timestamps = (
("2026-07-16T20:56:32.699Z", 1_784_235_392_699_000_000, 10_000_000_000),
("2026-07-16T20:56:35.199Z", 1_784_235_395_199_000_000, 12_500_000_000),
)
records = []
for frame, (timestamp, epoch_ns, monotonic_ns) in zip(
iter_capture_frames(raw_path),
timestamps,
strict=True,
):
records.append(
{
"schema_version": 1,
"record_type": "message",
"sequence": frame.sequence,
"received_at_utc": timestamp,
"received_at_epoch_ns": epoch_ns,
"received_monotonic_ns": monotonic_ns,
"topic": frame.topic,
"payload_bytes": frame.raw_frame_bytes
- FRAME_HEADER.size
- len(frame.topic.encode("utf-8")),
"raw_frame_offset": frame.raw_frame_offset,
"raw_payload_offset": frame.raw_payload_offset,
"raw_frame_bytes": frame.raw_frame_bytes,
}
)
metadata = b"".join(
(json.dumps(record, separators=(",", ":")) + "\n").encode("utf-8")
for record in records
)
if corrupt_trailing_line:
metadata += b'{"record_type":"message"'
(capture / "mqtt.metadata.jsonl").write_bytes(metadata)
(capture / "mqtt.summary.json").write_text("{corrupt", encoding="utf-8")
def serialized(value: object) -> str:
return json.dumps(value, ensure_ascii=False, default=str)
def test_store_uses_private_wal_database_and_idempotently_imports_legacy_session(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
make_legacy_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
first = store.import_legacy_viewer_live(sessions)
second = store.import_legacy_viewer_live(sessions)
assert first == second == ("20260716T205632Z_viewer_live",)
page = store.list_recent()
assert len(page.items) == 1
summary = page.items[0]
assert summary.status == "ready"
assert summary.modalities == ("point-cloud", "trajectory")
assert summary.replayable is True
assert summary.source_count == 2
assert "192.168.99.77" not in serialized(page.as_dict())
assert str(repository) not in serialized(page.as_dict())
detail = store.get_session(summary.session_id)
assert [source.source_id for source in detail.sources] == [
"sensor.lidar.primary",
"spatial.trajectory",
]
assert all(source.seekable for source in detail.sources)
assert str(repository) not in serialized(detail.as_dict())
command = store.prepare_replay(summary.session_id, speed=2.0, loop=True)
assert command.source_path.name == "mqtt.raw.k1mqtt"
assert command.speed == 2.0
assert command.loop is True
with sqlite3.connect(store.database_path) as connection:
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
assert store.database_path.stat().st_mode & 0o777 == 0o600
def test_recent_sessions_are_sorted_and_cursor_paginated(tmp_path: Path) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
make_legacy_session(
sessions,
"20260716T191025Z_viewer_live",
created_at="2026-07-16T19:10:25.352Z",
)
make_legacy_session(
sessions,
"20260716T205632Z_viewer_live",
created_at="2026-07-16T20:56:32.699Z",
)
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
first = store.list_recent(limit=1)
assert first.items[0].session_id == "20260716T205632Z_viewer_live"
assert first.next_cursor == "20260716T205632Z_viewer_live"
second = store.list_recent(limit=1, cursor=first.next_cursor)
assert second.items[0].session_id == "20260716T191025Z_viewer_live"
assert second.next_cursor is None
def test_legacy_import_adds_video_only_for_validated_recording_tree(tmp_path: Path) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
complete = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
incomplete = make_legacy_session(sessions, "20260716T205632Z_viewer_live_2")
make_recorded_camera_source(complete)
make_recorded_camera_source(incomplete, complete=False)
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
complete_detail = store.get_session(complete.name)
assert complete_detail.summary.modalities == ("point-cloud", "trajectory", "video")
assert complete_detail.summary.source_count == 3
camera = next(
source for source in complete_detail.sources if source.source_id == "sensor.camera.left"
)
assert camera.modality == "video"
assert camera.semantic_channel_id == "camera.video.recorded"
assert camera.seekable is True
video_artifact = next(
artifact
for artifact in complete_detail.artifacts
if artifact.artifact_id == camera.artifact_id
)
assert video_artifact.kind == "recorded-video"
assert video_artifact.integrity_status == "validated-structure"
assert str(repository) not in serialized(complete_detail.as_dict())
incomplete_detail = store.get_session(incomplete.name)
assert incomplete_detail.summary.modalities == ("point-cloud", "trajectory")
assert all(source.modality != "video" for source in incomplete_detail.sources)
def test_interrupted_capture_is_recovered_from_aligned_raw_and_metadata_prefix(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
replace_summary_with_recovery_metadata(session, corrupt_trailing_line=True)
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
detail = store.get_session(session.name)
assert detail.summary.status == "interrupted"
assert detail.summary.replayable is True
assert detail.summary.modalities == ("point-cloud", "trajectory")
assert detail.summary.started_at_utc == "2026-07-16T20:56:32.699Z"
assert detail.summary.completed_at_utc == "2026-07-16T20:56:35.199Z"
assert detail.summary.duration_seconds == 2.5
assert detail.artifacts[0].integrity_status == "validated-prefix"
assert store.prepare_replay(session.name).source_path.name == "mqtt.raw.k1mqtt"
def test_catalog_upsert_promotes_recovered_session_after_summary_is_completed(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
summary_path = session / "captures" / "mqtt_live" / "mqtt.summary.json"
metadata_path = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
completed_summary = summary_path.read_text(encoding="utf-8")
completed_metadata = metadata_path.read_text(encoding="utf-8")
replace_summary_with_recovery_metadata(session)
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
assert store.get_session(session.name).summary.status == "interrupted"
summary_path.write_text(completed_summary, encoding="utf-8")
metadata_path.write_text(completed_metadata, encoding="utf-8")
store.import_legacy_viewer_live(sessions)
promoted = store.get_session(session.name).summary
assert promoted.status == "ready"
assert promoted.duration_seconds == 1440.1
assert promoted.replayable is True
def test_interrupted_capture_does_not_trust_metadata_outside_session(tmp_path: Path) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
replace_summary_with_recovery_metadata(session)
metadata = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
outside = tmp_path / "outside.metadata.jsonl"
outside.write_bytes(metadata.read_bytes())
metadata.unlink()
metadata.symlink_to(outside)
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
detail = store.get_session(session.name)
assert detail.summary.status == "failed"
assert detail.summary.replayable is False
assert detail.summary.modalities == ()
def test_interrupted_capture_rejects_newline_terminated_metadata_corruption(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
replace_summary_with_recovery_metadata(session)
metadata = session / "captures" / "mqtt_live" / "mqtt.metadata.jsonl"
with metadata.open("ab") as stream:
stream.write(b"{corrupt\n")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
detail = store.get_session(session.name)
assert detail.summary.status == "failed"
assert detail.summary.replayable is False
def test_replay_resolution_rejects_artifact_symlink_escape(tmp_path: Path) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
outside = tmp_path / "outside.k1mqtt"
outside.write_bytes(raw.read_bytes())
raw.unlink()
raw.symlink_to(outside)
with pytest.raises(SessionIntegrityError, match="escapes"):
store.prepare_replay(session.name)
def test_completed_capture_is_not_replayable_when_declared_integrity_fails(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
corrupted = bytearray(raw.read_bytes())
corrupted[-1] ^= 0x01
raw.write_bytes(corrupted)
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
detail = store.get_session(session.name)
assert detail.summary.status == "failed"
assert detail.summary.replayable is False
assert detail.summary.modalities == ()
def test_completed_capture_is_not_replayable_when_declared_count_is_wrong(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
summary_path = session / "captures" / "mqtt_live" / "mqtt.summary.json"
summary = json.loads(summary_path.read_text(encoding="utf-8"))
summary["message_count"] = 3
summary_path.write_text(json.dumps(summary), encoding="utf-8")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
assert store.get_session(session.name).summary.replayable is False
def test_current_session_marker_keeps_active_capture_out_of_replay(tmp_path: Path) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
(sessions / ".current_session").write_text(
f"sessions/{session.name}\n",
encoding="utf-8",
)
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
with pytest.raises(SessionNotFoundError):
store.get_session(session.name)
(sessions / ".current_session").unlink()
store.import_legacy_viewer_live(sessions)
assert store.get_session(session.name).summary.replayable is True
def test_interrupted_capture_replays_only_committed_prefix_before_partial_raw_tail(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
replace_summary_with_recovery_metadata(session)
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
committed_bytes = raw.stat().st_size
with raw.open("ab") as stream:
stream.write(FRAME_HEADER.pack(12, 100)[:7])
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
detail = store.get_session(session.name)
command = store.prepare_replay(session.name)
assert detail.summary.status == "interrupted"
assert detail.summary.replayable is True
assert command.replay_byte_length == committed_bytes
assert command.replay_byte_length < command.source_path.stat().st_size
def test_interrupted_capture_rejects_non_frame_raw_tail(tmp_path: Path) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
replace_summary_with_recovery_metadata(session)
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
with raw.open("ab") as stream:
stream.write(FRAME_HEADER.pack(0, 0))
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
assert store.get_session(session.name).summary.replayable is False
def test_interrupted_capture_tolerates_bounded_group_commit_raw_tail(
tmp_path: Path,
) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
replace_summary_with_recovery_metadata(session)
raw = session / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt"
committed_bytes = raw.stat().st_size
with raw.open("ab") as stream:
for payload in (b"pending-one", b"pending-two"):
topic = b"RealtimePath"
stream.write(FRAME_HEADER.pack(len(topic), len(payload)))
stream.write(topic)
stream.write(payload)
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
command = store.prepare_replay(session.name)
assert store.get_session(session.name).summary.status == "interrupted"
assert command.replay_byte_length == committed_bytes
def test_failed_final_summary_falls_back_to_last_committed_prefix(tmp_path: Path) -> None:
repository = tmp_path / "repo"
sessions = repository / "sessions"
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
capture = session / "captures" / "mqtt_live"
metadata = capture / "mqtt.metadata.jsonl"
first_record = metadata.read_text(encoding="utf-8").splitlines(keepends=True)[0]
metadata.write_text(first_record, encoding="utf-8")
summary_path = capture / "mqtt.summary.json"
summary = json.loads(summary_path.read_text(encoding="utf-8"))
summary["error"] = "synthetic metadata fsync failure"
summary_path.write_text(json.dumps(summary), encoding="utf-8")
store = SessionStore(repository, data_dir=tmp_path / "data")
store.import_legacy_viewer_live(sessions)
detail = store.get_session(session.name)
assert detail.summary.status == "interrupted"
assert detail.summary.replayable is True
assert detail.summary.modalities == ("point-cloud",)
def test_layout_save_is_atomic_and_revision_checked(tmp_path: Path) -> None:
store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")
first = store.save_layout(
"observation.spatial",
schema_version=1,
expected_revision=0,
name="Операторская сцена",
layout={"visible_source_ids": ["sensor.lidar.primary"], "windows": []},
)
assert first.revision == 1
assert store.get_layout("observation.spatial") == first
with pytest.raises(LayoutConflictError, match="revision changed"):
store.save_layout(
"observation.spatial",
schema_version=1,
expected_revision=0,
name="Устаревшая запись",
layout={},
)
second = store.save_layout(
"observation.spatial",
schema_version=1,
expected_revision=1,
name="Операторская сцена",
layout={"visible_source_ids": [], "windows": []},
)
assert second.revision == 2
assert store.get_layout("observation.spatial").layout["visible_source_ids"] == []

View File

@ -16,6 +16,14 @@ def _write_native(path: Path, topic: str, payload: bytes) -> None:
)
def _append_native(path: Path, topic: str, payload: bytes) -> None:
topic_raw = topic.encode()
with path.open("ab") as stream:
stream.write(FRAME_HEADER.pack(len(topic_raw), len(payload)))
stream.write(topic_raw)
stream.write(payload)
def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
_write_native(capture, "lixel/application/report/lio_pose", b"pose")
@ -39,6 +47,35 @@ def test_native_replay_uses_aligned_metadata_timing(tmp_path: Path) -> None:
assert message.received_monotonic_ns == 456
def test_native_replay_stops_at_crash_truncated_metadata_tail(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
_write_native(capture, "RealtimePointcloud", b"first")
_append_native(capture, "RealtimePointcloud", b"uncommitted-tail")
first_record = {
"record_type": "message",
"sequence": 1,
"received_at_epoch_ns": 123_000_000_000,
"received_monotonic_ns": 456,
}
(tmp_path / "mqtt.metadata.jsonl").write_text(
json.dumps(first_record) + "\n" + '{"record_type":"message"',
encoding="utf-8",
)
messages = list(iter_replay_messages(capture))
assert [message.payload for message in messages] == [b"first"]
def test_native_replay_rejects_newline_terminated_metadata_corruption(tmp_path: Path) -> None:
capture = tmp_path / "mqtt.raw.k1mqtt"
_write_native(capture, "RealtimePointcloud", b"frame")
(tmp_path / "mqtt.metadata.jsonl").write_text("{corrupt\n", encoding="utf-8")
with pytest.raises(ReplayFormatError, match="not valid JSON"):
list(iter_replay_messages(capture))
def test_legacy_tsv_replay_validates_and_decodes_payload(tmp_path: Path) -> None:
capture = tmp_path / "payloads.tsv"
capture.write_bytes(b"1784124315.186225000\tRealtimePath\t4\t0001aaff\n")

View File

@ -1,12 +1,15 @@
from __future__ import annotations
import hashlib
import json
import sys
import time
from io import BytesIO
from pathlib import Path
import pytest
import k1link.web.xgrids_k1_camera as camera_module
from k1link.web.xgrids_k1_camera import (
CAMERA_MEDIA_TYPE,
XgridsK1CameraGateway,
@ -40,6 +43,63 @@ def _fake_ffmpeg(tmp_path: Path) -> Path:
return executable
def _burst_ffmpeg(tmp_path: Path) -> Path:
executable = tmp_path / "burst-ffmpeg"
executable.write_text(
f"#!{sys.executable}\n"
"import sys, time\n"
"def box(kind, payload=b''):\n"
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
"sys.stdout.buffer.flush()\n"
"time.sleep(0.2)\n"
"for index in range(10):\n"
" sys.stdout.buffer.write(box(b'moof') + box(b'mdat', bytes([index])))\n"
" sys.stdout.buffer.flush()\n"
" time.sleep(0.01)\n"
"time.sleep(10)\n",
encoding="utf-8",
)
executable.chmod(0o700)
return executable
def _buffered_tail_ffmpeg(tmp_path: Path, sentinel: Path, *, fragments: int) -> Path:
executable = tmp_path / "buffered-tail-ffmpeg"
executable.write_text(
f"#!{sys.executable}\n"
"import pathlib, sys, time\n"
"def box(kind, payload=b''):\n"
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov'))\n"
f"for index in range({fragments}):\n"
" sys.stdout.buffer.write(box(b'moof') + box(b'mdat', index.to_bytes(2, 'big')))\n"
"sys.stdout.buffer.flush()\n"
f"pathlib.Path({str(sentinel)!r}).write_text('ready')\n"
"time.sleep(10)\n",
encoding="utf-8",
)
executable.chmod(0o700)
return executable
def _incomplete_tail_ffmpeg(tmp_path: Path, sentinel: Path) -> Path:
executable = tmp_path / "incomplete-tail-ffmpeg"
executable.write_text(
f"#!{sys.executable}\n"
"import pathlib, sys, time\n"
"def box(kind, payload=b''):\n"
" return (8 + len(payload)).to_bytes(4, 'big') + kind + payload\n"
"sys.stdout.buffer.write(box(b'ftyp', b'isom') + box(b'moov') + box(b'moof'))\n"
"sys.stdout.buffer.flush()\n"
f"pathlib.Path({str(sentinel)!r}).write_text('ready')\n"
"time.sleep(10)\n",
encoding="utf-8",
)
executable.chmod(0o700)
return executable
def _gateway(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@ -48,6 +108,16 @@ def _gateway(
return XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
def _wait_until(predicate: object, *, timeout: float = 3.0) -> None:
assert callable(predicate)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(0.01)
raise AssertionError("condition was not reached before timeout")
def test_camera_selection_is_exclusive_and_hides_device_transport(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
@ -90,9 +160,7 @@ def test_camera_ffmpeg_command_is_allowlisted_copy_remux() -> None:
)
assert argv[0] == "/trusted/ffmpeg"
assert argv[argv.index("-i") + 1] == (
"rtsp://10.0.0.24:8554/live/chn_left_main"
)
assert argv[argv.index("-i") + 1] == ("rtsp://10.0.0.24:8554/live/chn_left_main")
assert argv[argv.index("-c:v") + 1] == "copy"
assert argv[argv.index("-allowed_media_types") + 1] == "video"
assert argv[argv.index("-flush_packets") + 1] == "1"
@ -145,6 +213,279 @@ def test_gateway_emits_init_and_complete_media_segments_without_transcoding(
gateway.close()
def test_acquisition_records_without_browser_and_source_switch_seals_epochs(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gateway = _gateway(tmp_path, monkeypatch)
session = tmp_path / "sessions" / "camera-acquisition"
try:
left = gateway.select("sensor.camera.left", "192.168.1.20")
with pytest.raises(ValueError, match="does not exist"):
gateway.start_recording(session)
assert session.exists() is False
session.mkdir(parents=True)
gateway.start_recording(session)
left_epoch = session / "media" / "sensor.camera.left" / f"epoch-{left['generation']}"
# No open_delivery/WebSocket exists: acquisition ownership alone starts
# FFmpeg and commits the init segment before any preview consumer.
_wait_until(
lambda: (
(left_epoch / "init.mp4").is_file()
and len(list((left_epoch / "segments").glob("*.m4s"))) == 1
)
)
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
right = gateway.select("sensor.camera.right", "192.168.1.20")
right_epoch = session / "media" / "sensor.camera.right" / f"epoch-{right['generation']}"
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
_wait_until(lambda: len(list((right_epoch / "segments").glob("*.m4s"))) == 1)
lease = gateway.open_delivery(right["generation"])
init_segment = lease.segments.get(timeout=1)
assert init_segment is not None and init_segment[0] == "init"
gateway.release_delivery(lease, client_closed=True)
# Browser disposal is not producer disposal while recording is active.
assert lease.process.poll() is None
assert gateway.snapshot()["recording"]["active"] is True
assert gateway.snapshot()["phase"] == "streaming"
stopped = gateway.stop_recording(status="complete")
assert stopped["recording"]["active"] is False
assert stopped["recording"]["completed_epochs"] == 2
summaries = []
for epoch in (left_epoch, right_epoch):
init = (epoch / "init.mp4").read_bytes()
entries = [
json.loads(line)
for line in (epoch / "index.jsonl").read_text(encoding="utf-8").splitlines()
]
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
summaries.append(summary)
assert [entry["kind"] for entry in entries] == ["media"]
assert all(
entry["length"] == len((epoch / entry["path"]).read_bytes())
and hashlib.sha256((epoch / entry["path"]).read_bytes()).hexdigest()
== entry["sha256"]
for entry in entries
)
assert summary["status"] == "complete"
assert summary["segment_count"] == 1
assert summary["entry_count"] == 1
assert summary["valid_bytes"] == len(init) + sum(
entry["length"] for entry in entries
)
assert summaries[0]["failure_code"] == "source-switch"
assert summaries[1]["failure_code"] is None
serialized = json.dumps(stopped)
assert "192.168.1.20" not in serialized
assert "rtsp://" not in serialized
finally:
gateway.close()
def test_camera_storage_open_failure_is_loud_and_never_starts_ffmpeg(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
gateway = _gateway(tmp_path, monkeypatch)
session = tmp_path / "session"
session.mkdir()
popen_called = False
class FailingArchive:
def __init__(self, *_: object, **__: object) -> None:
raise camera_module.CameraArchiveError("synthetic storage failure")
def forbidden_popen(*_: object, **__: object) -> object:
nonlocal popen_called
popen_called = True
raise AssertionError("FFmpeg must not start without durable storage")
monkeypatch.setattr(camera_module, "CameraArchiveWriter", FailingArchive)
monkeypatch.setattr(camera_module.subprocess, "Popen", forbidden_popen)
try:
gateway.select("sensor.camera.left", "192.168.1.20")
with pytest.raises(RuntimeError, match="хранилище"):
gateway.start_recording(session)
state = gateway.snapshot()
assert popen_called is False
assert state["phase"] == "error"
assert state["error"]["code"] == "camera-storage-failed"
assert "192.168.1.20" not in json.dumps(state)
finally:
gateway.close()
def test_camera_storage_append_failure_fails_producer_and_epoch_loudly(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
original_append = camera_module.CameraArchiveWriter.append
def failing_append(
writer: object,
kind: object,
payload: bytes,
**timestamps: object,
) -> dict[str, object]:
if kind == "media":
raise camera_module.CameraArchiveError("synthetic media commit failure")
return original_append(writer, kind, payload, **timestamps) # type: ignore[arg-type]
monkeypatch.setattr(camera_module.CameraArchiveWriter, "append", failing_append)
gateway = _gateway(tmp_path, monkeypatch)
session = tmp_path / "session"
session.mkdir()
try:
selected = gateway.select("sensor.camera.left", "192.168.1.20")
gateway.start_recording(session)
_wait_until(lambda: gateway.snapshot()["phase"] == "error")
state = gateway.snapshot()
assert state["error"]["code"] == "camera-storage-failed"
summary_path = (
session
/ "media"
/ "sensor.camera.left"
/ f"epoch-{selected['generation']}"
/ "summary.json"
)
_wait_until(
lambda: (
summary_path.is_file()
and json.loads(summary_path.read_text(encoding="utf-8"))["status"]
== "failed"
)
)
summary = json.loads(summary_path.read_text(encoding="utf-8"))
assert summary["status"] == "failed"
assert summary["failure_code"] == "camera-storage-failed"
assert "192.168.1.20" not in json.dumps(state)
finally:
gateway.close()
def test_slow_browser_is_dropped_without_stopping_archive_producer(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("MISSIONCORE_FFMPEG_BINARY", str(_burst_ffmpeg(tmp_path)))
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
session = tmp_path / "session"
session.mkdir()
try:
selected = gateway.select("sensor.camera.left", "192.168.1.20")
gateway.start_recording(session)
lease = gateway.open_delivery(selected["generation"])
# Deliberately never drain the bounded queue.
_wait_until(lambda: lease.failure_code == "consumer-too-slow")
_wait_until(lambda: gateway.snapshot()["phase"] == "streaming")
assert lease.process.poll() is None
assert gateway.snapshot()["recording"]["active"] is True
gateway.release_delivery(lease, client_closed=False)
epoch = (
session
/ "media"
/ "sensor.camera.left"
/ f"epoch-{selected['generation']}"
)
_wait_until(
lambda: len(list((epoch / "segments").glob("*.m4s"))) == 10,
)
gateway.stop_recording(status="complete")
summary_path = epoch / "summary.json"
summary = json.loads(summary_path.read_text(encoding="utf-8"))
assert summary["status"] == "complete"
assert summary["segment_count"] == 10
assert summary["media_segment_count"] == 10
finally:
gateway.close()
def test_clean_stop_drains_ffmpeg_stdout_before_sealing_archive(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
fragment_count = 100
sentinel = tmp_path / "ffmpeg-wrote-buffered-tail"
monkeypatch.setenv(
"MISSIONCORE_FFMPEG_BINARY",
str(_buffered_tail_ffmpeg(tmp_path, sentinel, fragments=fragment_count)),
)
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
session = tmp_path / "session"
session.mkdir()
try:
selected = gateway.select("sensor.camera.left", "192.168.1.20")
gateway.start_recording(session)
_wait_until(sentinel.is_file)
gateway.stop_recording(status="complete")
epoch = (
session
/ "media"
/ "sensor.camera.left"
/ f"epoch-{selected['generation']}"
)
summary = json.loads((epoch / "summary.json").read_text(encoding="utf-8"))
assert summary["status"] == "complete"
assert summary["segment_count"] == fragment_count
assert len(list((epoch / "segments").glob("*.m4s"))) == fragment_count
assert len((epoch / "index.jsonl").read_text(encoding="utf-8").splitlines()) == (
fragment_count
)
finally:
gateway.close()
def test_clean_stop_marks_incomplete_fragment_tail_interrupted(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
sentinel = tmp_path / "ffmpeg-wrote-incomplete-tail"
monkeypatch.setenv(
"MISSIONCORE_FFMPEG_BINARY",
str(_incomplete_tail_ffmpeg(tmp_path, sentinel)),
)
gateway = XgridsK1CameraGateway(tmp_path, XGRIDS_K1_PLUGIN_ID)
session = tmp_path / "session"
session.mkdir()
try:
selected = gateway.select("sensor.camera.left", "192.168.1.20")
gateway.start_recording(session)
_wait_until(sentinel.is_file)
gateway.stop_recording(status="complete")
summary_path = (
session
/ "media"
/ "sensor.camera.left"
/ f"epoch-{selected['generation']}"
/ "summary.json"
)
summary = json.loads(summary_path.read_text(encoding="utf-8"))
assert summary["status"] == "interrupted"
assert summary["failure_code"] == "incomplete-fmp4-fragment"
assert summary["segment_count"] == 0
state = gateway.snapshot()
assert state["phase"] == "error"
assert state["error"]["code"] == "incomplete-fmp4-fragment"
finally:
gateway.close()
def test_service_publishes_two_dynamic_camera_rows_and_stale_stop_is_safe(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,