Author SHA1 Message Date
DCCONSTRUCTIONS e436fb56d4 fix(observatory): offer only uncalculated profiles without completion badges 2026-09-03 10:16:01 +03:00
DCCONSTRUCTIONS c2710b55a7 docs(observatory): record cache activation and Docker memory recovery 2026-09-03 09:44:02 +03:00
DCCONSTRUCTIONS d777f3198b docs(observatory): record cache proof and pending activation 2026-09-03 09:15:12 +03:00
DCCONSTRUCTIONS 1e4ddc2cff feat(observatory): verify exact published result reuse 2026-09-03 09:14:54 +03:00
DCCONSTRUCTIONS 80fc0058cb docs(observatory): record claim-v3 repair and preserved queue history 2026-09-03 00:00:08 +03:00
DCCONSTRUCTIONS 43f1cdc862 fix(worker): preserve transport patch in Docker snapshot layers 2026-09-02 23:50:22 +03:00
DCCONSTRUCTIONS 0041e9fadb fix(worker): bind claim migration to the imported transport module 2026-09-02 23:45:06 +03:00
DCCONSTRUCTIONS cabd3081c7 fix(observatory): version non-binding idle claims and preserve grant history 2026-09-02 23:40:11 +03:00
DCCONSTRUCTIONS 049ed3eed4 docs(observatory): prioritize recorded laboratories and cached review before onboard profiles 2026-09-02 23:16:01 +03:00
DCCONSTRUCTIONS 35c1249631 fix(observatory): reject duplicate active portable computations atomically 2026-09-02 23:15:39 +03:00
DCCONSTRUCTIONS 941183d624 docs(perception): record TLS and direct TCP latency attribution 2026-09-02 22:40:08 +03:00
DCCONSTRUCTIONS 6af4c208ce test(perception): localize transport tails with bounded TLS record witnesses 2026-09-02 22:34:12 +03:00
DCCONSTRUCTIONS 75a5320756 docs(perception): record clock route A/B/A and continuous LAN canary 2026-09-02 22:14:58 +03:00
DCCONSTRUCTIONS 59a74a3677 test(perception): add bounded clock-only route and scheduler diagnostics 2026-09-02 21:57:02 +03:00
DCCONSTRUCTIONS 35d28418f3 docs(perception): record joint startup proof and remaining clock wait 2026-09-02 21:16:03 +03:00
DCCONSTRUCTIONS 48835a0499 fix(perception): require joint clock readiness before source activation 2026-09-02 21:03:33 +03:00
DCCONSTRUCTIONS 93ae1edbb4 docs(perception): record cross-host canary failure and joint startup gate 2026-09-02 20:44:08 +03:00
DCCONSTRUCTIONS 093df8b38f style(perception): format streaming lifecycle guards 2026-09-02 20:43:31 +03:00
DCCONSTRUCTIONS 35b6cd9e9e feat(perception): gate cross-host graph with acknowledged source clocks 2026-09-02 20:37:21 +03:00
DCCONSTRUCTIONS d80df61a7e docs(perception): record cross-host clock and control proof 2026-09-02 19:46:27 +03:00
DCCONSTRUCTIONS d7b8989e43 feat(perception): add scoped stream control and bounded clock observations 2026-09-02 19:41:43 +03:00
DCCONSTRUCTIONS 0cc6b96306 docs(perception): record full-graph network and recovery evidence 2026-09-02 19:05:13 +03:00
DCCONSTRUCTIONS 6469505c41 feat(perception): connect full graph to external streaming source and scene receiver 2026-09-02 19:04:41 +03:00
DCCONSTRUCTIONS 191612282b test(perception): record cross-host gRPC recovery and backpressure proof 2026-09-02 18:14:51 +03:00
DCCONSTRUCTIONS 0310592ee3 feat(perception): add bounded authenticated gRPC stream transport 2026-09-02 18:11:42 +03:00
DCCONSTRUCTIONS df0158e1ba docs(perception): record Worker observability repair and remaining stream gates 2026-09-02 17:40:45 +03:00
DCCONSTRUCTIONS 2ef8c08bf2 feat(perception): export bounded runtime observations outside heartbeat lane 2026-09-02 17:35:46 +03:00
DCCONSTRUCTIONS c570f7d938 fix(telemetry): restore worker agent delivery and truthful system status 2026-09-02 17:35:20 +03:00
DCCONSTRUCTIONS 03e8e71bea docs(perception): record host telemetry recovery and scoped inventory evidence 2026-09-02 16:56:22 +03:00
DCCONSTRUCTIONS 61cbdb30a0 feat(perception): connect scoped host telemetry to recoverable profile lifecycle 2026-09-02 16:50:57 +03:00
DCCONSTRUCTIONS e91721fe6a docs(perception): record full graph recovery and separate availability latency 2026-09-02 16:01:36 +03:00
DCCONSTRUCTIONS 70927ea585 feat(perception): resume full graph on fresh input without restarting models 2026-09-02 15:54:13 +03:00
DCCONSTRUCTIONS 03a7e730ae docs(perception): record recoverable input policy and decoder continuity proof 2026-09-02 15:33:53 +03:00
DCCONSTRUCTIONS c31c46caa5 feat(perception): preserve resident runtime across recoverable input gaps 2026-09-02 15:27:06 +03:00
DCCONSTRUCTIONS 06a66a3da8 docs(perception): record controller readiness boundary and fault evidence 2026-09-02 14:54:43 +03:00
DCCONSTRUCTIONS 92625bf48b feat(perception): gate streaming lifecycle on controller readiness 2026-09-02 14:49:36 +03:00
DCCONSTRUCTIONS b8ba0cae1e docs(perception): record exact parity and fixed-envelope IPC comparison 2026-09-02 14:28:58 +03:00
DCCONSTRUCTIONS f39f1ff0b6 perf(perception): borrow contiguous image buffers for local IPC 2026-09-02 14:20:39 +03:00
DCCONSTRUCTIONS 380b6eaba7 fix(perception): preserve quaternion alignment across binary ingress 2026-09-02 14:20:38 +03:00
DCCONSTRUCTIONS f6d73d8bfb docs(perception): record binary full-graph evidence and remaining gates 2026-09-02 14:02:28 +03:00
DCCONSTRUCTIONS 221e429c0a feat(perception): connect binary ingress to the supervised full graph 2026-09-02 13:53:25 +03:00
DCCONSTRUCTIONS a85aed2e03 docs(perception): record decoder parity and remaining full-graph bridge 2026-09-02 13:14:11 +03:00
DCCONSTRUCTIONS f102712f0b test(perception): verify incremental decoder parity and binary ingress on Worker 2026-09-02 13:07:33 +03:00
DCCONSTRUCTIONS 350366bae0 refactor(perception): decode bounded fMP4 without source file dependencies 2026-09-02 13:07:33 +03:00
DCCONSTRUCTIONS c23e86fd39 docs(perception): record bounded binary ingress evidence and remaining bridge 2026-09-02 12:37:25 +03:00
DCCONSTRUCTIONS 79ce55d197 test(perception): verify reuse of existing raw live ingress 2026-09-02 12:30:16 +03:00
DCCONSTRUCTIONS a937400942 test(perception): seal source end marker before IPC shutdown 2026-09-02 12:28:23 +03:00
DCCONSTRUCTIONS da60feff90 feat(perception): add bounded binary stream ingress 2026-09-02 12:21:25 +03:00
DCCONSTRUCTIONS 85349f42f9 docs(perception): record lifecycle ownership and expiry evidence 2026-09-02 12:05:00 +03:00
DCCONSTRUCTIONS ac69e3b444 test(perception): exercise lifecycle and lease loss in full profile 2026-09-02 11:55:57 +03:00
DCCONSTRUCTIONS 6acf468b25 feat(perception): fence streaming lifecycle with worker-local ownership 2026-09-02 11:55:56 +03:00
DCCONSTRUCTIONS d264cbce04 docs(perception): record stage-two scheduler and cell freshness proof 2026-09-02 11:03:26 +03:00
DCCONSTRUCTIONS 1f8101e4a5 feat(perception): expire costmap permissions per cell 2026-09-02 10:57:42 +03:00
DCCONSTRUCTIONS bcacb02a22 refactor(perception): share bounded streaming scheduler 2026-09-02 10:57:41 +03:00
DCCONSTRUCTIONS adcca7aa81 docs(perception): close stage-one baseline and record freshness proof 2026-09-02 10:21:44 +03:00
DCCONSTRUCTIONS 097e450a87 feat(perception): enforce six-layer freshness through receipt 2026-09-02 10:14:28 +03:00
DCCONSTRUCTIONS 4e633a662c docs(perception): record gpu clock latency proof 2026-09-02 09:42:40 +03:00
DCCONSTRUCTIONS c6c42c29e8 fix(perception): reject material ties and trace gpu pacing 2026-09-02 09:34:20 +03:00
DCCONSTRUCTIONS ce09d30c1a docs(perception): record latency and triton parity limits 2026-09-02 02:52:38 +03:00
DCCONSTRUCTIONS 34f13ba59e feat(perception): add bounded triton runtime diagnostics 2026-09-02 02:52:11 +03:00
DCCONSTRUCTIONS d9ea7c1129 perf(perception): reuse local surface cell observations 2026-09-02 02:03:09 +03:00
DCCONSTRUCTIONS a3c67e249c docs(perception): record rendezvous worker proof 2026-09-02 01:50:35 +03:00
DCCONSTRUCTIONS 294585936f fix(perception): rendezvous completed gpu handoff 2026-09-02 01:48:25 +03:00
DCCONSTRUCTIONS 380a13688c docs(perception): record preroll worker pilot 2026-09-02 01:40:06 +03:00
DCCONSTRUCTIONS fd8313899b fix(perception): separate preroll history from current sensor binding
Preserve rolling points and original clocks; expose per-modality ages, held pose and rejection reasons. Saved-lineage audit changes only the first admission (75 to 76 of 128). No new model/performance claim. 384 focused Python and 62 frontend tests passed.
2026-09-02 01:09:03 +03:00
DCCONSTRUCTIONS 05c99efb68 test(observatory): isolate legacy contracts from installed package pins
Freeze configuration-only legacy fixtures from 7025e17, update current installed-image assertions, and explicitly reject the prior agent image. Production admission and digest validation are unchanged.
2026-09-02 01:02:38 +03:00
DCCONSTRUCTIONS ffd6be5606 docs(observatory): record realtime plan and measured latency evidence 2026-09-02 00:59:48 +03:00
DCCONSTRUCTIONS bfe6ef4c77 feat(perception): add bounded full-graph streaming and latency pilots 2026-09-02 00:59:48 +03:00
DCCONSTRUCTIONS 4e04d06191 feat(perception): define stream-first profile and qualification contracts 2026-09-02 00:59:47 +03:00
DCCONSTRUCTIONS 5a78c997ac feat(observatory-ui): review portable results and retry publication
Checkpoint the existing generic result-viewing and publication lifecycle UI. Focused architecture and Observatory tests: 62 passed; no new visual changes or rebuild in this checkpoint.
2026-09-02 00:59:21 +03:00
DCCONSTRUCTIONS 62d5520c7a feat(worker): package independent installed LAB container steps
Preserve local installer, offline validation and pinned component adapters; 62 focused packaging tests pass. No deployment performed by this commit.
2026-09-02 00:58:51 +03:00
DCCONSTRUCTIONS a945d665dd feat(observatory): add installed package dispatch and durable publication
Checkpoint existing backend lifecycle changes. Focused verification found nine legacy fixture failures in portable LAB V1 executor/runtime tests; repair follows separately without rewriting this snapshot. ADR date retains its intentional Markdown hard break.
2026-09-02 00:58:37 +03:00
DCCONSTRUCTIONS d655d6998d feat(observatory): attest recording equipment and capture profiles 2026-09-02 00:57:27 +03:00
DCCONSTRUCTIONS 7025e173a3 fix(worker): accept omitted empty image command 2026-08-31 21:47:47 +03:00
DCCONSTRUCTIONS 10fe727561 fix(worker): preserve exec-form entrypoint 2026-08-31 21:45:51 +03:00
DCCONSTRUCTIONS 00ee5f163a fix(worker): keep commit labels shell-safe 2026-08-31 21:42:31 +03:00
DCCONSTRUCTIONS cbcc09afd6 fix(worker): mount generated shell scripts 2026-08-31 21:39:07 +03:00
DCCONSTRUCTIONS e958729e9d fix(worker): seal coordinator Python dependencies 2026-08-31 21:31:45 +03:00
DCCONSTRUCTIONS 0fcd037e4f fix(worker): compose staged source paths safely 2026-08-31 21:01:30 +03:00
DCCONSTRUCTIONS 11a146011f feat(worker): install combined observatory profiles 2026-08-31 20:56:29 +03:00
DCCONSTRUCTIONS 44ebbe7ca2 test(lab): import adapters in legacy runtimes 2026-08-31 20:04:31 +03:00
DCCONSTRUCTIONS a3299dcfc3 fix(lab): avoid runtime typing on Python 3.9 2026-08-31 20:01:49 +03:00
DCCONSTRUCTIONS 2989fba99c fix(lab): support legacy Python 3.9 adapters 2026-08-31 19:58:20 +03:00
DCCONSTRUCTIONS 142481f0d8 fix(lab): probe optional adapter image safely 2026-08-31 19:52:34 +03:00
DCCONSTRUCTIONS b2ae29b8d8 feat(lab): install thin adapter layers offline 2026-08-31 19:51:51 +03:00
DCCONSTRUCTIONS 8eced6c34d feat(worker): compose installed LAB V1 profile 2026-08-31 19:47:24 +03:00
DCCONSTRUCTIONS 732cc139fd refactor(worker): externalize runtime registries 2026-08-31 19:47:07 +03:00
DCCONSTRUCTIONS 104e54f4dd refactor(lab): separate release contract identity 2026-08-31 19:46:58 +03:00
DCCONSTRUCTIONS 7b021782d3 feat(lab): install sealed component adapters 2026-08-31 19:46:47 +03:00
DCCONSTRUCTIONS 759830b054 feat(lab): seal reusable EoMT runtime trees 2026-08-31 19:24:42 +03:00
DCCONSTRUCTIONS 19c0bd70d0 feat(lab): bind sealed component adapter images 2026-08-31 19:19:47 +03:00
DCCONSTRUCTIONS 35d99e40d5 perf(m49): verify source payloads during assembly 2026-08-31 19:06:59 +03:00
DCCONSTRUCTIONS bfc1f1bbed feat(worker): define unified agent image artifact 2026-08-31 19:00:06 +03:00
DCCONSTRUCTIONS f41428f907 feat(lab): add sealed local component runners 2026-08-31 18:59:47 +03:00
DCCONSTRUCTIONS 3744d79ad3 feat(lab): seal reusable ffmpeg runtime asset 2026-08-31 18:59:37 +03:00
DCCONSTRUCTIONS b57c3e94da perf(m49): defer fresh stage revalidation 2026-08-31 18:42:31 +03:00
DCCONSTRUCTIONS 73a7d28065 perf(lidar): avoid duplicate replay decode during build 2026-08-31 18:35:12 +03:00
DCCONSTRUCTIONS f64fbb1fa6 feat(worker): compose all ready observatory profiles 2026-08-31 18:31:54 +03:00
DCCONSTRUCTIONS 1ff527b264 perf(observatory): avoid duplicate camera verification 2026-08-31 18:25:09 +03:00
DCCONSTRUCTIONS def8c71410 perf(observatory): bound source destination planning 2026-08-31 18:19:04 +03:00
DCCONSTRUCTIONS 7e59176581 fix(observatory): disable source artifact compression 2026-08-31 18:05:34 +03:00
DCCONSTRUCTIONS 4cd94b5805 perf(observatory): pack camera epoch source transfer 2026-08-31 17:59:18 +03:00
DCCONSTRUCTIONS 1766c0bdb2 feat(observatory): reconcile quarantined recorded jobs 2026-08-31 17:59:03 +03:00
DCCONSTRUCTIONS 56bfeaee7b fix(observatory): admit camera archive metadata overhead 2026-08-31 17:19:45 +03:00
DCCONSTRUCTIONS 892d0085e3 refactor(worker): defer optional visualization imports 2026-08-31 17:15:13 +03:00
DCCONSTRUCTIONS e2de8188ab feat(observatory): bridge local M49 worker container 2026-08-31 16:58:03 +03:00
DCCONSTRUCTIONS 9c69d81296 feat(observatory): install local M49 worker path 2026-08-31 16:51:10 +03:00
DCCONSTRUCTIONS 2f6e45bc96 fix(observatory): validate M49 fixture filtering 2026-08-31 16:12:45 +03:00
DCCONSTRUCTIONS 8a1c578e0e fix(observatory): admit committed M49 executor candidates 2026-08-31 16:06:23 +03:00
DCCONSTRUCTIONS 9beb534108 feat(observatory): add portable calculation profiles 2026-08-31 15:42:56 +03:00
DCCONSTRUCTIONS d1b75efcea fix(observatory): fence portable catalog refresh 2026-08-31 11:11:36 +03:00
DCCONSTRUCTIONS c9302c78c2 feat(observatory): add portable LAB V1 foundation 2026-08-31 11:02:34 +03:00
DCCONSTRUCTIONS d67e86176e feat(observatory): add durable recorded compute queue 2026-08-31 01:04:25 +03:00
DCCONSTRUCTIONS e72609120f fix(observatory): align setup typography 2026-08-30 23:31:09 +03:00
DCCONSTRUCTIONS 177be8869f feat(local-service): allow exact worktree migration 2026-08-30 23:23:36 +03:00
DCCONSTRUCTIONS 2a5763d3fb feat(observatory): seal blocked run preparations 2026-08-30 23:14:05 +03:00
DCCONSTRUCTIONS 8d5aeb0533 feat(observatory): add laboratory setup preflight 2026-08-30 22:21:58 +03:00
DCCONSTRUCTIONS be83283ee0 fix(observatory): refine catalog and replay UX 2026-08-30 20:36:12 +03:00
DCCONSTRUCTIONS 023151c186 feat(observatory): admit canonical recorded replay 2026-08-30 19:21:27 +03:00
DCCONSTRUCTIONS e6a9846167 feat(observatory): add bounded M5.1 session review 2026-08-30 17:29:39 +03:00
DCCONSTRUCTIONS 81fdf6904a refactor(lab): объединить RAV004 в единый Rerun replay 2026-08-30 16:40:12 +03:00
DCCONSTRUCTIONS 9c5259dbc9 fix(lab): запускать RAV004 с первого кадра 2026-08-30 14:33:17 +03:00
DCCONSTRUCTIONS 35daf73d5c fix(lab): сжать и адресовать RAV004 overlay 2026-08-30 14:05:32 +03:00
DCCONSTRUCTIONS 07453142c2 fix(lab): стабилизировать нативный RAV004 replay 2026-08-30 13:28:33 +03:00
DCCONSTRUCTIONS f5ee42751d refactor(lab): перевести RAV004 на канонический Rerun pipeline 2026-08-30 12:59:16 +03:00
376 changed files with 111091 additions and 1187 deletions
+4 -5
View File
@@ -7,7 +7,6 @@
"": {
"name": "@nodedc/mission-core-control-station",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@noble/hashes": "^2.2.0",
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
@@ -15,7 +14,7 @@
"@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",
"@rerun-io/web-viewer": "0.34.1",
"@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1",
"playcanvas": "2.21.4",
"react": "^19.1.0",
@@ -900,9 +899,9 @@
"link": true
},
"node_modules/@rerun-io/web-viewer": {
"version": "0.34.1",
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.34.1.tgz",
"integrity": "sha512-2Oq9Mw3qOs765XArGq4e/0pAIksPFKlAqkgo+PDsRkPd77/KdnQhb835suRmYQ6tuqlbPeVCFhlNIXT0+TjmTg==",
"version": "0.36.3",
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
"integrity": "sha512-LMGnsxRmY5UwiGras2dZrMnEYkow5Xr4v+1hAUSspXWPPiilMqoz9G77jo8Ps/deAaX81TnOq123DFO8iX/Ulw==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
+1 -2
View File
@@ -4,7 +4,6 @@
"private": true,
"type": "module",
"scripts": {
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
@@ -18,7 +17,7 @@
"@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",
"@rerun-io/web-viewer": "0.34.1",
"@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1",
"playcanvas": "2.21.4",
"react": "^19.1.0",
+2
View File
@@ -806,6 +806,8 @@ export default function App() {
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control
) : activeDefinition.kind === "observatory" ? (
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
) : activeDefinition.root === "system" ? (
<SystemWorkspaceSelector
value={activeDefinition.id}
@@ -34,6 +34,7 @@ import {
RECORDED_BASE_POINT_COLOR_KEY,
canPublishRecordedPlaybackController,
createRecordedAutoplayGate,
createRecordedInitialSeekGate,
createRecordedOpenWatchdog,
} from "../core/observation/recordedRerunLifecycle";
import type {
@@ -59,6 +60,7 @@ export {
attemptRecordedAutoplay,
canPublishRecordedPlaybackController,
createRecordedAutoplayGate,
createRecordedInitialSeekGate,
createRecordedOpenWatchdog,
isRecordedPlaybackFullyBuffered,
isRecordedPlaybackPresentationReady,
@@ -143,14 +145,30 @@ interface RerunBlueprintChannel {
};
}
interface RerunNativeReceiver {
endpointUrl: string;
ready: () => boolean;
open: (sourceUrl: string) => void;
close: (sourceUrl: string) => void;
}
interface LoadedNativePerceptionSource {
receiver: RerunNativeReceiver;
descriptorUrl: string;
sourceUrl: string;
byteLength: number;
}
interface RecordedRerunIdentity {
applicationId: "nodedc_mission_core_recorded";
recordingId: string;
}
const RECORDED_RRD_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
const LAB_RECORDED_REPLAY_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-replay\.rrd$/;
const RECORDED_BLUEPRINT_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/;
const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
const LAB_RECORDED_PERCEPTION_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-overlay\.rrd$/;
const RECORDED_POINT_COLORS_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/point-colors\.rrd$/;
const MAX_BLUEPRINT_BYTES = 1_048_576;
const MAX_PERCEPTION_BYTES = 512 * 1024 * 1024;
@@ -203,12 +221,7 @@ export function resolveRerunSourceUrl(sourceUrl: string, origin: string): string
return new URL(normalized, `${base.origin}/`).href;
}
/**
* The native Rerun HTTP receiver is the only browser API in 0.34.1 with a
* persistent incremental RRD decoder. Bind it to the exact immutable
* generation selected by the launch contract; arbitrary `send_rrd` byte
* slices are independently decoded files and are therefore invalid.
*/
/** Bind the upstream Rerun HTTP receiver to one immutable source generation. */
export function resolveRecordedViewerSourceUrl(
descriptor: RecordedRrdArtifactDescriptor,
origin: string,
@@ -217,7 +230,8 @@ export function resolveRecordedViewerSourceUrl(
const expectedViewerSourceUrl = `${descriptor.sourceUrl}?generation=${descriptor.sha256}`;
const endpoint = new URL(descriptor.viewerSourceUrl, `${base.origin}/`);
if (
!RECORDED_RRD_PATH.test(descriptor.sourceUrl) ||
!(RECORDED_RRD_PATH.test(descriptor.sourceUrl)
|| LAB_RECORDED_REPLAY_PATH.test(descriptor.sourceUrl)) ||
descriptor.viewerSourceUrl !== expectedViewerSourceUrl ||
endpoint.origin !== base.origin ||
endpoint.pathname !== descriptor.sourceUrl ||
@@ -238,16 +252,23 @@ export function rerunViewerInitialSource(
return resolvedSourceUrl;
}
export function rerunViewerOpenOptions(
followLive: boolean,
): { follow_if_http: true } | null {
return followLive ? { follow_if_http: true } : null;
}
export function resolveRecordedBlueprintUrl(sourceUrl: string, origin: string): string | null {
export function resolveRecordedBlueprintUrl(
sourceUrl: string,
origin: string,
explicitSourceUrl?: string,
): string | null {
const normalized = sourceUrl.trim();
if (!RECORDED_RRD_PATH.test(normalized)) return null;
const base = new URL(origin);
if (explicitSourceUrl !== undefined) {
const explicit = explicitSourceUrl.trim();
if (
!LAB_RECORDED_REPLAY_PATH.test(normalized)
|| !RECORDED_BLUEPRINT_PATH.test(explicit)
) return null;
const endpoint = new URL(explicit, `${base.origin}/`);
return endpoint.origin === base.origin ? endpoint.href : null;
}
if (!RECORDED_RRD_PATH.test(normalized)) return null;
const endpoint = new URL(
normalized.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
`${base.origin}/`,
@@ -365,6 +386,9 @@ export async function fetchRecordedBlueprintRrd(
activeView = "spatial",
viewResetGeneration = 0,
followTrajectory = false,
semanticLayer,
unifiedPerception,
planView = false,
perceptionLayers = {
enabled: false,
detections2d: false,
@@ -379,10 +403,15 @@ export async function fetchRecordedBlueprintRrd(
activeView?: RecordedRerunView;
viewResetGeneration?: 0 | 1;
followTrajectory?: boolean;
semanticLayer?: "city" | "vegetation";
unifiedPerception?: boolean;
planView?: boolean;
perceptionLayers?: RecordedPerceptionLayers;
fetcher?: typeof globalThis.fetch;
},
): Promise<Uint8Array> {
const resolvedUnifiedPerception =
unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial");
const base = new URL(origin);
const endpoint = new URL(endpointUrl, base.origin);
if (
@@ -401,6 +430,9 @@ export async function fetchRecordedBlueprintRrd(
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
!["spatial", "perception", "perception3d", "metrics"].includes(activeView) ||
![0, 1].includes(viewResetGeneration) ||
(semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) ||
typeof resolvedUnifiedPerception !== "boolean" ||
typeof planView !== "boolean" ||
[
perceptionLayers.enabled,
perceptionLayers.detections2d,
@@ -435,8 +467,9 @@ export async function fetchRecordedBlueprintRrd(
active_view: activeView,
view_reset_generation: viewResetGeneration,
follow_trajectory: followTrajectory,
unified_perception:
perceptionLayers.detections2d || perceptionLayers.segmentation,
unified_perception: resolvedUnifiedPerception,
semantic_layer: semanticLayer ?? null,
plan_view: planView,
show_detections_2d: perceptionLayers.detections2d,
show_segmentation: perceptionLayers.segmentation,
show_cuboids_3d: perceptionLayers.cuboids3d,
@@ -487,7 +520,8 @@ export async function fetchRecordedPerceptionRrd(
endpoint.origin !== base.origin ||
endpoint.search ||
endpoint.hash ||
!RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
!(RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
LAB_RECORDED_PERCEPTION_PATH.test(endpoint.pathname)) ||
identity.applicationId !== "nodedc_mission_core_recorded" ||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId)
) {
@@ -554,6 +588,87 @@ export async function fetchRecordedPerceptionRrd(
return payload;
}
/** Bind a LAB sidecar to the exact immutable base recording generation. */
export function resolveRecordedPerceptionViewerSourceUrl(
endpointUrl: string,
identity: RecordedRerunIdentity,
baseGenerationSha256: string,
origin: string,
): string {
const base = new URL(origin);
const endpoint = new URL(endpointUrl, base.origin);
if (
endpoint.origin !== base.origin ||
endpoint.search ||
endpoint.hash ||
!LAB_RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
identity.applicationId !== "nodedc_mission_core_recorded" ||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId) ||
!/^[a-f0-9]{64}$/.test(baseGenerationSha256)
) {
throw new Error("Unsafe recorded perception viewer source");
}
endpoint.searchParams.set("application_id", identity.applicationId);
endpoint.searchParams.set("recording_id", identity.recordingId);
endpoint.searchParams.set("generation", baseGenerationSha256);
return endpoint.href;
}
/** Resolve the immutable overlay generation before Rerun opens its native URL. */
export async function probeRecordedPerceptionViewerSource(
sourceUrl: string,
{
origin,
signal,
fetcher = globalThis.fetch,
}: {
origin: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
},
): Promise<{ sourceUrl: string; byteLength: number }> {
const base = new URL(origin);
const endpoint = new URL(sourceUrl, base.origin);
const allowedParameters = ["application_id", "generation", "recording_id"];
if (
endpoint.origin !== base.origin ||
endpoint.hash ||
!LAB_RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
[...endpoint.searchParams.keys()].sort().join("\0") !== allowedParameters.join("\0") ||
endpoint.searchParams.get("application_id") !== "nodedc_mission_core_recorded" ||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(
endpoint.searchParams.get("recording_id") ?? "",
) ||
!/^[a-f0-9]{64}$/.test(endpoint.searchParams.get("generation") ?? "")
) {
throw new Error("Unsafe recorded perception viewer source");
}
const response = await fetcher(endpoint.href, {
method: "HEAD",
credentials: "same-origin",
headers: {
Accept: "application/vnd.rerun.rrd",
},
signal,
});
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
const declaredLength = Number(response.headers.get("Content-Length"));
const etag = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
if (
response.status !== 200 ||
contentType !== "application/vnd.rerun.rrd" ||
response.headers.get("X-Rerun-Format") !== "RRF2" ||
!etag ||
!Number.isSafeInteger(declaredLength) ||
declaredLength < 4 ||
declaredLength > MAX_PERCEPTION_BYTES
) {
throw new Error("Invalid recorded perception viewer response");
}
endpoint.searchParams.set("overlay_generation", etag);
return { sourceUrl: endpoint.href, byteLength: declaredLength };
}
export function RerunViewport({
profile,
onStatusChange,
@@ -588,6 +703,11 @@ export function RerunViewport({
segmentation: false,
cuboids3d: false,
};
const recordedBlueprintSourceUrl = recordedProfile?.blueprintSourceUrl;
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
const recordedSemanticLayer = recordedProfile?.semanticLayer;
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
const recordedPlanView = recordedProfile?.planView ?? false;
const recordedPerceptionRetryGeneration =
recordedProfile?.perceptionRetryGeneration ?? 0;
const lockPerceptionCameraInteraction =
@@ -610,7 +730,9 @@ export function RerunViewport({
const uiBuildStaleRef = useRef(false);
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
const perceptionReceiverRef = useRef<RerunNativeReceiver | null>(null);
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
const loadedNativePerceptionSourceRef = useRef<LoadedNativePerceptionSource | null>(null);
const appliedPointColorKeyRef = useRef<string | null>(null);
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
const blueprintSessionIdRef = useRef(crypto.randomUUID().replaceAll("-", ""));
@@ -619,11 +741,17 @@ export function RerunViewport({
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
const recordedBlueprintUrl = sourceUrl
? resolveRecordedBlueprintUrl(sourceUrl, window.location.origin)
: null;
const recordedPerceptionUrl = sourceUrl
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
? resolveRecordedBlueprintUrl(
sourceUrl,
window.location.origin,
recordedBlueprintSourceUrl,
)
: null;
const recordedPerceptionUrl = recordedPerceptionSourceUrl
? resolveRerunSourceUrl(recordedPerceptionSourceUrl, window.location.origin)
: sourceUrl
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
: null;
const recordedPointColorsUrl = sourceUrl
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin)
: null;
@@ -663,7 +791,8 @@ export function RerunViewport({
return;
}
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource);
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource)
|| LAB_RECORDED_REPLAY_PATH.test(normalizedSource);
let resolvedSource: string;
try {
if (isRecordedSource) {
@@ -722,8 +851,10 @@ export function RerunViewport({
let publishPlaybackBufferState: (() => void) | null = null;
let playbackState: RerunPlaybackState | null = null;
const recordedAutoplay = createRecordedAutoplayGate();
const recordedInitialSeek = createRecordedInitialSeekGate();
let blueprintChannel: RerunBlueprintChannel | null = null;
let perceptionChannel: RerunBlueprintChannel | null = null;
let perceptionReceiver: RerunNativeReceiver | null = null;
const unsubscribers: Array<() => void> = [];
const unsubscribeAll = () => {
while (unsubscribers.length > 0) {
@@ -980,6 +1111,9 @@ export function RerunViewport({
if (perceptionChannelRef.current === perceptionChannel) {
perceptionChannelRef.current = null;
}
if (perceptionReceiverRef.current === perceptionReceiver) {
perceptionReceiverRef.current = null;
}
recordedIdentityRef.current = null;
try {
blueprintChannel?.channel.close();
@@ -991,6 +1125,15 @@ export function RerunViewport({
} catch {
// The viewer may already have closed all auxiliary channels.
}
const loadedNativeSource = loadedNativePerceptionSourceRef.current;
if (loadedNativeSource?.receiver === perceptionReceiver) {
loadedNativePerceptionSourceRef.current = null;
try {
perceptionReceiver?.close(loadedNativeSource.sourceUrl);
} catch {
// The viewer may already have closed all native receivers.
}
}
}, () => {
try {
if (viewer.ready) viewer.close(resolvedSource);
@@ -1025,9 +1168,9 @@ export function RerunViewport({
}) => {
if (
disposed ||
recordingOpened ||
(isRecordedSource && event.application_id !== "nodedc_mission_core_recorded")
) return;
if (recordingOpened) return;
recordingOpened = true;
if (!isRecordedSource) {
// Store discovery only establishes a candidate. Admission is
@@ -1195,6 +1338,23 @@ export function RerunViewport({
playing = true;
}
}
if (!autoplayWhenReady && !followLive && readyToRender) {
recordedInitialSeek.attempt(
viewerStartResolved,
recordedBuffer.fullyBuffered,
presentationReady,
rangeNs,
(initialStartNs) => {
viewer.set_current_time(event.recording_id, timeline, initialStartNs);
currentNs = initialStartNs;
},
initialPlaybackStartSeconds === undefined
? expectedTimelineStartSeconds === undefined
? undefined
: expectedTimelineStartSeconds * 1_000_000_000
: initialPlaybackStartSeconds * 1_000_000_000,
);
}
emitPlayback({
rangeNs: followLive
? rangeNs
@@ -1264,7 +1424,7 @@ export function RerunViewport({
try {
const recordingId = viewer.get_active_recording_id();
if (!recordingId) return;
// Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its
// A live SDK receiver may ingest a store without forwarding its
// recording_open event to the JavaScript wrapper. The active store
// is the authoritative fallback and avoids hiding a ready canvas.
admitRecording({
@@ -1311,11 +1471,8 @@ export function RerunViewport({
);
try {
// `panel_state_overrides` is part of Rerun's runtime AppOptions but
// omitted from the public WebViewerOptions declaration in 0.34.1.
// Its generated TypeScript declaration says `hidden`, while the
// WASM constructor actually deserializes the Rust enum `Hidden`.
// Keep the post-start overrides below as a compatibility fallback.
// Keep all application chrome in the Mission Core shell. Rerun owns
// the synchronized canvas, data store and clock, not another window.
const viewerOptions = {
width: "100%",
height: "100%",
@@ -1336,7 +1493,6 @@ export function RerunViewport({
rerunViewerInitialSource(resolvedSource),
host,
viewerOptions,
rerunViewerOpenOptions(followLive),
);
if (disposed) {
disposeViewer();
@@ -1358,9 +1514,19 @@ export function RerunViewport({
setBlueprintChannelRevision((revision) => revision + 1);
}
if (recordedPerceptionUrl) {
const channel = viewer.open_channel("missioncore/recorded-perception");
perceptionChannel = { endpointUrl: recordedPerceptionUrl, channel };
perceptionChannelRef.current = perceptionChannel;
if (LAB_RECORDED_PERCEPTION_PATH.test(new URL(recordedPerceptionUrl).pathname)) {
perceptionReceiver = {
endpointUrl: recordedPerceptionUrl,
ready: () => viewer.ready,
open: (source) => viewer.open(source),
close: (source) => viewer.close(source),
};
perceptionReceiverRef.current = perceptionReceiver;
} else {
const channel = viewer.open_channel("missioncore/recorded-perception");
perceptionChannel = { endpointUrl: recordedPerceptionUrl, channel };
perceptionChannelRef.current = perceptionChannel;
}
setPerceptionChannelRevision((revision) => revision + 1);
}
@@ -1429,11 +1595,13 @@ export function RerunViewport({
recordedArtifact?.sha256,
recordedArtifact?.sourceUrl,
recordedArtifact?.viewerSourceUrl,
recordedPerceptionUrl,
retryNonce,
]);
useEffect(() => {
loadedPerceptionChannelRef.current = null;
loadedNativePerceptionSourceRef.current = null;
}, [recordedPerceptionUrl]);
useEffect(() => {
@@ -1453,11 +1621,92 @@ export function RerunViewport({
}
return;
}
const active = perceptionChannelRef.current;
const identity = recordedIdentityRef.current;
if (!identity) return;
if (LAB_RECORDED_PERCEPTION_PATH.test(new URL(recordedPerceptionUrl).pathname)) {
const receiver = perceptionReceiverRef.current;
if (
!receiver ||
receiver.endpointUrl !== recordedPerceptionUrl ||
!receiver.ready() ||
!recordedArtifact
) return;
let sourceUrl: string;
try {
sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
recordedPerceptionUrl,
identity,
recordedArtifact.sha256,
window.location.origin,
);
} catch {
onPerceptionLoadChange?.({
phase: "error",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "AI-слои не привязаны к поколению записи.",
});
return;
}
const loaded = loadedNativePerceptionSourceRef.current;
if (loaded?.receiver === receiver && loaded.descriptorUrl === sourceUrl) {
onPerceptionLoadChange?.({
phase: "ready",
receivedBytes: loaded.byteLength,
totalBytes: loaded.byteLength,
progress: 1,
message: "AI-слои подключены к Rerun.",
});
return;
}
const abort = new AbortController();
onPerceptionLoadChange?.({
phase: "loading",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "Подключаем AI-слои к Rerun.",
});
void probeRecordedPerceptionViewerSource(sourceUrl, {
origin: window.location.origin,
signal: abort.signal,
}).then(({ sourceUrl: immutableSourceUrl, byteLength }) => {
if (
abort.signal.aborted ||
perceptionReceiverRef.current !== receiver ||
recordedIdentityRef.current !== identity ||
!receiver.ready()
) return;
receiver.open(immutableSourceUrl);
loadedNativePerceptionSourceRef.current = {
receiver,
descriptorUrl: sourceUrl,
sourceUrl: immutableSourceUrl,
byteLength,
};
onPerceptionLoadChange?.({
phase: "ready",
receivedBytes: byteLength,
totalBytes: byteLength,
progress: 1,
message: "AI-слои подключены к Rerun.",
});
}).catch(() => {
if (abort.signal.aborted) return;
onPerceptionLoadChange?.({
phase: "error",
receivedBytes: 0,
totalBytes: null,
progress: null,
message: "AI-слои не загрузились. Можно повторить.",
});
});
return () => abort.abort();
}
const active = perceptionChannelRef.current;
if (
!active ||
!identity ||
active.endpointUrl !== recordedPerceptionUrl ||
!active.channel.ready
) return;
@@ -1484,6 +1733,7 @@ export function RerunViewport({
message: "Сервер готовит AI-слои.",
});
const pollPreparationStatus = () => {
if (!RECORDED_PERCEPTION_PATH.test(new URL(recordedPerceptionUrl).pathname)) return;
void fetchPerceptionPreparationStatus(
recordedPerceptionUrl,
identity.recordingId,
@@ -1597,6 +1847,7 @@ export function RerunViewport({
}, [
onPerceptionLoadChange,
perceptionChannelRevision,
recordedArtifact?.sha256,
recordedPerceptionLayers.enabled,
recordedPerceptionRetryGeneration,
recordedPerceptionUrl,
@@ -1704,6 +1955,9 @@ export function RerunViewport({
viewResetGeneration: recordedViewResetGeneration,
followTrajectory: recordedFollowTrajectory,
perceptionLayers: recordedPerceptionLayers,
semanticLayer: recordedSemanticLayer,
unifiedPerception: recordedUnifiedPerception,
planView: recordedPlanView,
}).then((payload) => {
if (
abort.signal.aborted ||
@@ -1725,6 +1979,9 @@ export function RerunViewport({
recordedView,
recordedViewResetGeneration,
recordedFollowTrajectory,
recordedSemanticLayer,
recordedUnifiedPerception,
recordedPlanView,
recordedPerceptionLayers.enabled,
recordedPerceptionLayers.detections2d,
recordedPerceptionLayers.segmentation,
@@ -9,6 +9,7 @@ export const CANONICAL_RECORDED_LAB_REPLAY_CONTRACT =
export interface CanonicalRecordedLabMode<T extends string> {
value: T;
label: string;
disabled?: boolean;
}
/**
@@ -90,6 +91,7 @@ export function CanonicalRecordedLabReplay<
mediaMultiLayer = false,
mediaContent,
spatialContent,
unifiedContent,
emptyMessage,
deckOverlays,
actions,
@@ -115,8 +117,10 @@ export function CanonicalRecordedLabReplay<
spatialLayerControls?: ReactNode;
spatialLeadingControl?: ReactNode;
mediaMultiLayer?: boolean;
mediaContent: ReactNode;
spatialContent: ReactNode;
mediaContent?: ReactNode;
spatialContent?: ReactNode;
/** One upstream Rerun viewer owns both panes and the shared playback clock. */
unifiedContent?: ReactNode;
emptyMessage: string;
deckOverlays?: ReactNode;
actions?: ReactNode;
@@ -135,6 +139,7 @@ export function CanonicalRecordedLabReplay<
value={mediaMode}
items={[...mediaModes]}
label="Видео и камера"
size="dense"
onChange={onMediaModeChange}
/>
</div>
@@ -145,6 +150,7 @@ export function CanonicalRecordedLabReplay<
value={spatialMode}
items={[...spatialModes]}
label="3D и план"
size="dense"
onChange={onSpatialModeChange}
/>
</div>
@@ -220,7 +226,13 @@ export function CanonicalRecordedLabReplay<
className="m4-replay-threat-visual__deck"
data-split={splitView ? "true" : undefined}
data-empty={mediaMode === "none" && spatialMode === "none" ? "true" : undefined}
data-native-rerun={unifiedContent ? "true" : undefined}
>
{unifiedContent ? (
<div className="m4-replay-threat-visual__unified-content">
{unifiedContent}
</div>
) : null}
<SplitPane
primary={mediaPane}
secondary={spatialPane ?? <div />}
@@ -229,8 +241,8 @@ export function CanonicalRecordedLabReplay<
orientation={splitOrientation}
minPrimarySize={splitView ? 24 : 0}
minSecondarySize={splitView ? 24 : 0}
resizable={splitView}
separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
resizable={splitView && !unifiedContent}
separatorLabel="Изменить размер видео/камеры и 3D/плана"
/>
{mediaMode === "none" && spatialMode === "none" ? (
<div className="l3-visual-audit__state" role="status">
@@ -0,0 +1,383 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
} from "react";
import {
ActivityIndicator,
Button,
Icon,
SegmentedControl,
} from "@nodedc/ui-react";
import { ObservationTimeline } from "../ObservationTimeline";
import {
RerunViewport,
isRecordedPlaybackPresentationReady,
type RerunPlaybackController,
type RerunPlaybackState,
type RerunViewportStatus,
} from "../RerunViewport";
import {
CanonicalRecordedLabReplay,
useCanonicalRecordedLabReplayState,
} from "../laboratory/CanonicalRecordedLabReplay";
import {
resolveCanonicalLabReplay,
type CanonicalLabReplayDescriptor,
} from "../../core/laboratory/canonicalLabReplay";
import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import { defaultSceneSettings } from "../../sceneSettings";
type MediaMode = "video" | "camera";
type SpatialMode = "3d" | "plan";
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
type SemanticLayer = "city" | "vegetation";
interface CanonicalReplayLaunch {
base: ObservationSessionReplayLaunch;
replay: CanonicalLabReplayDescriptor;
}
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
export function CanonicalVegetationRerunReplay({
resultId,
review,
}: {
resultId: string;
review: VegetationFullRouteReview;
}) {
const {
mediaMode,
spatialMode,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange,
onSpatialModeChange,
onSplitPrimarySizeChange,
onExpandedChange,
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
initialMediaMode: "video",
initialSpatialMode: "3d",
});
const splitView = mediaMode !== null && spatialMode !== null;
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
const [showSemantics, setShowSemantics] = useState(true);
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0);
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
const [playbackController, setPlaybackController] =
useState<RerunPlaybackController | null>(null);
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
const [launchError, setLaunchError] = useState<string | null>(null);
const viewerFrameRef = useRef<HTMLDivElement>(null);
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
const previousSplitViewRef = useRef(splitView);
const trackedLaunchSha256Ref = useRef<string | null>(null);
const stopNativeSplitTracking = useCallback(() => {
nativeSplitTrackingCleanupRef.current?.();
nativeSplitTrackingCleanupRef.current = null;
}, []);
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (
!splitView
|| event.button !== 0
|| !(event.target instanceof HTMLCanvasElement)
) return;
const frame = viewerFrameRef.current;
if (!frame) return;
const bounds = frame.getBoundingClientRect();
if (bounds.width <= 0) return;
const dividerX = bounds.left
+ bounds.width * nativeSplitPercentRef.current / 100;
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
stopNativeSplitTracking();
const pointerId = event.pointerId;
const update = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
const currentBounds = frame.getBoundingClientRect();
if (currentBounds.width <= 0) return;
const next = Math.min(90, Math.max(
10,
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
));
nativeSplitPercentRef.current = next;
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
};
const stop = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return;
stopNativeSplitTracking();
};
window.addEventListener("pointermove", update, true);
window.addEventListener("pointerup", stop, true);
window.addEventListener("pointercancel", stop, true);
nativeSplitTrackingCleanupRef.current = () => {
window.removeEventListener("pointermove", update, true);
window.removeEventListener("pointerup", stop, true);
window.removeEventListener("pointercancel", stop, true);
};
}, [splitView, stopNativeSplitTracking]);
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
useEffect(() => {
if (!splitView) stopNativeSplitTracking();
const launchSha256 = launch?.replay.sha256 ?? null;
if (
trackedLaunchSha256Ref.current !== launchSha256
|| (splitView && !previousSplitViewRef.current)
) {
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
}
trackedLaunchSha256Ref.current = launchSha256;
previousSplitViewRef.current = splitView;
const cameraPanePercent = mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100;
viewerFrameRef.current?.style.setProperty(
"--canonical-rerun-camera-pane",
`${cameraPanePercent}%`,
);
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
useEffect(() => {
const controller = new AbortController();
setLaunch(null);
setLaunchError(null);
setPlayback(null);
setPlaybackController(null);
setViewerStatus("idle");
void resolveObservationSessionReplay(review.sessionId, {
signal: controller.signal,
maximumWaitMs: 30 * 60 * 1000,
onUpdate: () => undefined,
}).then(async (value) => ({
base: value,
replay: await resolveCanonicalLabReplay(resultId, value, {
signal: controller.signal,
}),
})).then((value) => {
if (!controller.signal.aborted) setLaunch(value);
}).catch((caught: unknown) => {
if (!controller.signal.aborted) {
setLaunchError(
caught instanceof Error ? caught.message : "Каноническая запись RAV004 недоступна.",
);
}
});
return () => controller.abort();
}, [resultId, review.sessionId]);
const presentationReady = playbackController !== null
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
const presentationState = launchError || viewerStatus === "error"
? "error"
: presentationReady
? "ready"
: "loading";
const sceneSettings = useMemo(() => ({
...defaultSceneSettings,
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
showPoints: spatialMode !== null,
showTrajectory: spatialMode !== null,
showGrid: spatialMode !== null,
pointSize: 3.8,
}), [spatialLayer, spatialMode]);
const profile = launch ? recordedSessionRerunProfile({
sourceUrl: launch.replay.sourceUrl,
artifact: {
sourceUrl: launch.replay.sourceUrl,
viewerSourceUrl: launch.replay.viewerSourceUrl,
byteLength: launch.replay.byteLength,
sha256: launch.replay.sha256,
},
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
autoplayWhenReady: false,
presentationGate: "ready",
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
initialPlaybackStartSeconds: review.timelineStartSeconds,
view: mediaMode !== null ? "perception" : "spatial",
viewResetGeneration,
followTrajectory: true,
semanticLayer,
unifiedPerception: splitView,
planView: spatialMode === "plan",
perceptionLayers: {
enabled: mediaMode !== null,
detections2d: mediaMode === "video",
segmentation: mediaMode === "video" && showSemantics,
cuboids3d: false,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: mediaMode !== null,
}) : null;
const mediaLayerControls = (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
aria-label="Слои камеры и видео"
>
<Button
size="dense"
shape="pill"
variant={showSemantics ? "primary" : "secondary"}
aria-pressed={showSemantics}
onClick={() => setShowSemantics((visible) => !visible)}
>
СЕМАНТИКА
</Button>
<SegmentedControl
value={semanticLayer}
items={[
{ value: "city", label: "ГОРОД · EoMT" },
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
]}
label="Источник семантики"
size="dense"
onChange={(value) => {
setSemanticLayer(value);
setShowSemantics(true);
}}
/>
</div>
);
const spatialLayerControls = (
<div
className="m4-replay-threat-visual__pane-layer-controls"
role="group"
aria-label="Пространственные слои RAV004"
>
<SegmentedControl
value={spatialLayer}
items={[
{ value: "source", label: "ИСХ. ТОЧКИ" },
{ value: "local", label: "ЛОК. SLAM" },
{ value: "tgs", label: "TGS", disabled: true },
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
]}
label="Пространственные слои"
size="dense"
onChange={setSpatialLayer}
/>
</div>
);
const resetSpatialView = (
<Button
size="dense"
variant="ghost"
icon={<Icon name="refresh" size={14} />}
aria-label="Сбросить положение 3D камеры"
title="Сбросить положение 3D камеры"
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
>
</Button>
);
const transport = presentationReady && playback && playbackController ? (
<ObservationTimeline
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
active
sourceCount={3}
mode="recorded"
seekable
synchronization="shared-clock"
rangeNs={playback.rangeNs}
currentNs={playback.currentNs}
playing={playback.playing}
onSeek={playbackController.seek}
onPlayingChange={playbackController.setPlaying}
showJumpToEnd={false}
/>
) : undefined;
return (
<div
className="canonical-vegetation-rerun-replay"
data-presentation-state={presentationState}
aria-busy={presentationState === "loading"}
>
<CanonicalRecordedLabReplay
label="RAVNOVES004TREE · канонический повтор Rerun"
mediaMode={mediaMode ?? "none"}
mediaModes={[
{ value: "video", label: "ВИДЕО" },
{ value: "camera", label: "КАМЕРА" },
]}
spatialMode={spatialMode ?? "none"}
spatialModes={[
{ value: "3d", label: "3D" },
{ value: "plan", label: "ПЛАН" },
]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitOrientation}
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialLeadingControl={resetSpatialView}
mediaMultiLayer
unifiedContent={profile ? (
<div
ref={viewerFrameRef}
className="canonical-vegetation-rerun-replay__viewport-lock"
data-split-view={splitView ? "true" : undefined}
style={{
"--canonical-rerun-camera-pane": `${
mediaMode === null
? 0
: splitView
? nativeSplitPercentRef.current
: 100
}%`,
} as CSSProperties}
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
>
<RerunViewport
profile={profile}
sceneSettings={sceneSettings}
onStatusChange={setViewerStatus}
onPlaybackChange={setPlayback}
onPlaybackControllerChange={setPlaybackController}
/>
</div>
) : launchError ? (
<div className="l3-visual-audit__state" role="alert">
{launchError}
</div>
) : (
<div aria-hidden="true" />
)}
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
deckOverlays={presentationState === "loading" ? (
<div className="canonical-vegetation-rerun-replay__loading">
<ActivityIndicator label="Загружаем синхронизированную запись" />
</div>
) : undefined}
transport={transport}
onMediaModeChange={onMediaModeChange}
onSpatialModeChange={onSpatialModeChange}
onExpandedChange={onExpandedChange}
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
/>
</div>
);
}
@@ -0,0 +1,76 @@
import type { ObservationSessionReplayLaunch } from "../observation/sessionArchive";
import type { RecordedRrdArtifactDescriptor } from "../observation/viewerProfile";
const SAFE_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
const SAFE_SESSION_SOURCE = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
const MAX_CANONICAL_REPLAY_BYTES = 1024 * 1024 * 1024;
export interface CanonicalLabReplayDescriptor extends RecordedRrdArtifactDescriptor {
blueprintSourceUrl: string;
}
/**
* Resolve the one immutable RRD used by the canonical recorded LAB.
*
* The server caches the merge of the sealed spatial recording and the LAB AI
* evidence. Rerun therefore opens one source and cannot present the base store
* before a second receiver has finished decoding the semantic layer.
*/
export async function resolveCanonicalLabReplay(
resultId: string,
launch: ObservationSessionReplayLaunch,
{
origin = window.location.origin,
signal,
fetcher = globalThis.fetch,
}: {
origin?: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
} = {},
): Promise<CanonicalLabReplayDescriptor> {
const base = new URL(origin);
if (
!SAFE_RESULT_ID.test(resultId)
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
) {
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
}
const sourceUrl =
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`
+ "/canonical-replay.rrd";
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
descriptorUrl.searchParams.set("base_generation", launch.sha256);
if (descriptorUrl.origin !== base.origin) {
throw new Error("Канонический replay LAB должен быть same-origin.");
}
const response = await fetcher(descriptorUrl.href, {
method: "HEAD",
credentials: "same-origin",
headers: { Accept: "application/vnd.rerun.rrd" },
signal,
});
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
const byteLength = Number(response.headers.get("Content-Length"));
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
if (
response.status !== 200
|| contentType !== "application/vnd.rerun.rrd"
|| response.headers.get("X-Rerun-Format") !== "RRF2"
|| !sha256
|| !Number.isSafeInteger(byteLength)
|| byteLength < 4
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
) {
throw new Error("Единый replay LAB не прошёл проверку.");
}
return {
sourceUrl,
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
byteLength,
sha256,
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
};
}
@@ -37,7 +37,7 @@ export function laboratoryRecordedEvidenceDemand(
profile: LaboratoryRecordedEvidenceViewerProfile =
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
): LaboratoryRecordedEvidenceDemand {
if (profile.loadPolicy !== "visible-evidence-only") {
if (profile.loadPolicy !== "explicit-legacy-comparison-only") {
throw new Error("Unsupported LAB recorded evidence load policy");
}
const mediaVisible = visibility.mediaMode !== null;
@@ -1026,7 +1026,7 @@ export async function fetchVegetationRouteTgsAnchor(
};
}
export async function fetchVegetationShadowResult(
export async function fetchVegetationShadowResultMetadata(
resultId: string,
{
fetcher = fetch,
@@ -1048,6 +1048,17 @@ export async function fetchVegetationShadowResult(
resultId,
"/api/v1/laboratory/vegetation-shadow",
);
return result;
}
export async function fetchVegetationShadowResult(
resultId: string,
{
fetcher = fetch,
signal,
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
): Promise<VegetationShadowResult> {
const result = await fetchVegetationShadowResultMetadata(resultId, { fetcher, signal });
if (!result.routeFullReview) return result;
const timelineResponse = await fetcher(
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-timeline`,
@@ -0,0 +1,140 @@
export const OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA =
"missioncore.observatory-calculation-profile/v1" as const;
export type ObservationLabCalculationProfileOrigin =
| "archived-definition"
| "existing-result";
export interface ObservationLabCalculationProfile {
readonly schemaVersion: typeof OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA;
readonly setupId: string;
readonly displayName: string;
readonly origin: ObservationLabCalculationProfileOrigin;
readonly definitionId: string | null;
readonly definitionVersion: number | null;
readonly definitionSha256: string | null;
}
export class ObservationLabCalculationProfileContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservationLabCalculationProfileContractError";
}
}
const PROFILE_KEYS = new Set([
"schema_version",
"setup_id",
"display_name",
"origin",
"definition_id",
"definition_version",
"definition_sha256",
]);
const IDENTIFIER = /^[a-z][a-z0-9-]{2,95}$/;
const SHA256 = /^[a-f0-9]{64}$/;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function requireText(value: unknown, field: string, maximum: number): string {
if (
typeof value !== "string"
|| value.length === 0
|| value.length > maximum
|| value !== value.trim()
) {
throw new ObservationLabCalculationProfileContractError(
`Поле calculation_profile.${field} должно быть непустой строкой.`,
);
}
return value;
}
function requireIdentifier(value: unknown, field: string): string {
const identifier = requireText(value, field, 96);
if (!IDENTIFIER.test(identifier)) {
throw new ObservationLabCalculationProfileContractError(
`Поле calculation_profile.${field} содержит некорректный идентификатор.`,
);
}
return identifier;
}
export function decodeObservationLabCalculationProfile(
value: unknown,
sessionId: string,
): ObservationLabCalculationProfile | null {
if (value === null) return null;
if (!isRecord(value) || Object.keys(value).some((key) => !PROFILE_KEYS.has(key))) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} нарушил контракт полей.`,
);
}
if (
Object.keys(value).length !== PROFILE_KEYS.size
|| value.schema_version !== OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA
) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестную версию.`,
);
}
const origin = value.origin;
if (origin !== "archived-definition" && origin !== "existing-result") {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестное происхождение.`,
);
}
const definitionId = value.definition_id === null
? null
: requireIdentifier(value.definition_id, "definition_id");
const definitionVersion = value.definition_version === null
? null
: value.definition_version;
if (
definitionVersion !== null
&& (
typeof definitionVersion !== "number"
|| !Number.isInteger(definitionVersion)
|| definitionVersion < 1
)
) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} содержит некорректную версию определения.`,
);
}
const definitionSha256 = value.definition_sha256 === null
? null
: requireText(value.definition_sha256, "definition_sha256", 64);
if (definitionSha256 !== null && !SHA256.test(definitionSha256)) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} не содержит SHA-256 определения.`,
);
}
const definitionFieldCount = [
definitionId,
definitionVersion,
definitionSha256,
].filter((entry) => entry !== null).length;
if (
(origin === "existing-result" && definitionFieldCount !== 0)
|| (origin === "archived-definition" && definitionFieldCount !== 3)
) {
throw new ObservationLabCalculationProfileContractError(
`Профиль расчёта LAB-сессии ${sessionId} содержит неполную идентичность определения.`,
);
}
return {
schemaVersion: OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA,
setupId: requireIdentifier(value.setup_id, "setup_id"),
displayName: requireText(value.display_name, "display_name", 256),
origin,
definitionId,
definitionVersion,
definitionSha256,
};
}
@@ -0,0 +1,280 @@
export interface ObservationCanonicalLabReplayCapability {
schemaVersion: "missioncore.observation-lab-replay-capability/v1";
kind: "canonical-recorded-rerun";
viewerProfile: "recorded-session";
timeline: "session_time";
activation: "explicit";
commandsEnabled: false;
}
export interface ObservationPortableLabReplayCapability {
schemaVersion: "missioncore.observation-lab-replay-capability/v2";
kind: "portable-result-review";
viewerProfile: "portable-result";
timeline: "result-defined";
activation: "explicit";
commandsEnabled: false;
}
export type ObservationLabReplayCapability =
| ObservationCanonicalLabReplayCapability
| ObservationPortableLabReplayCapability;
export class ObservationLabReplayCapabilityContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservationLabReplayCapabilityContractError";
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const CAPABILITY_KEYS = new Set([
"schema_version",
"kind",
"viewer_profile",
"timeline",
"activation",
"commands_enabled",
]);
const CANONICAL_PROVENANCE_KEYS = new Set([
"schema_version",
"evidence_identity_sha256",
"result_document_sha256",
"replay_capability",
"authority",
"method",
]);
const PORTABLE_PROVENANCE_KEYS = new Set([
"schema_version",
"authority",
"calculation_profile",
"calculation_profile_sha256",
"job",
"source",
"run_definition",
"result_package",
"replay_capability",
"storage",
"method",
]);
const AUTHORITY_KEYS = new Set([
"commands_enabled",
"navigation_or_safety_accepted",
"actuation_accepted",
]);
const METHOD_KEYS = new Set([
"schema_version",
"completeness",
"execution_class",
"pipeline_id",
"components",
]);
const METHOD_COMPONENT_KEYS = new Set([
"kind",
"name",
"version",
"role",
"identity_sha256",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function assertExactKeys(
value: Record<string, unknown>,
expected: ReadonlySet<string>,
label: string,
): void {
const keys = Object.keys(value);
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
throw new ObservationLabReplayCapabilityContractError(
`${label} содержит неизвестные или пропущенные поля.`,
);
}
}
export function decodeLabReplayCapability(
value: unknown,
sessionId: string,
): ObservationLabReplayCapability | null {
if (value === null || value === undefined) return null;
if (!isRecord(value)) {
throw new ObservationLabReplayCapabilityContractError(
`Replay-возможность LAB-сессии ${sessionId} должна быть объектом или null.`,
);
}
assertExactKeys(value, CAPABILITY_KEYS, `Replay-возможность LAB-сессии ${sessionId}`);
if (
value.activation !== "explicit"
|| value.commands_enabled !== false
) {
throw new ObservationLabReplayCapabilityContractError(
`Replay-возможность LAB-сессии ${sessionId} нарушает observation-only контракт.`,
);
}
if (
value.schema_version === "missioncore.observation-lab-replay-capability/v1"
&& value.kind === "canonical-recorded-rerun"
&& value.viewer_profile === "recorded-session"
&& value.timeline === "session_time"
) {
return {
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commandsEnabled: false,
};
}
if (
value.schema_version === "missioncore.observation-lab-replay-capability/v2"
&& value.kind === "portable-result-review"
&& value.viewer_profile === "portable-result"
&& value.timeline === "result-defined"
) {
return {
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review",
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
commandsEnabled: false,
};
}
throw new ObservationLabReplayCapabilityContractError(
`Replay-возможность LAB-сессии ${sessionId} содержит неизвестный viewer capability.`,
);
}
export function decodeCanonicalLabReplayCapabilityProvenance(
provenance: Readonly<Record<string, unknown>>,
sessionId: string,
resultId: string,
): ObservationCanonicalLabReplayCapability {
assertExactKeys(provenance, CANONICAL_PROVENANCE_KEYS, `Canonical provenance LAB-сессии ${sessionId}`);
if (
provenance.schema_version !== "missioncore.canonical-recorded-lab-projection/v1"
|| typeof provenance.evidence_identity_sha256 !== "string"
|| !SHA256.test(provenance.evidence_identity_sha256)
|| resultId !== `lab-v1-vegetation-shadow-${provenance.evidence_identity_sha256}`
|| typeof provenance.result_document_sha256 !== "string"
|| !SHA256.test(provenance.result_document_sha256)
) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical provenance LAB-сессии ${sessionId} потеряла immutable identity.`,
);
}
if (!isRecord(provenance.authority)) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical provenance LAB-сессии ${sessionId} не содержит authority.`,
);
}
assertExactKeys(provenance.authority, AUTHORITY_KEYS, `Canonical authority LAB-сессии ${sessionId}`);
if (
provenance.authority.commands_enabled !== false
|| provenance.authority.navigation_or_safety_accepted !== false
|| provenance.authority.actuation_accepted !== false
) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical provenance LAB-сессии ${sessionId} нарушает observation-only authority.`,
);
}
if (!isRecord(provenance.method)) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical provenance LAB-сессии ${sessionId} не содержит method manifest.`,
);
}
assertExactKeys(provenance.method, METHOD_KEYS, `Canonical method LAB-сессии ${sessionId}`);
if (
provenance.method.schema_version !== "missioncore.laboratory-method/v1"
|| provenance.method.completeness !== "legacy-partial"
|| provenance.method.execution_class !== "ai-inference"
|| provenance.method.pipeline_id
!== "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1"
|| !Array.isArray(provenance.method.components)
|| provenance.method.components.length !== 1
) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical method LAB-сессии ${sessionId} не соответствует записанному прогону.`,
);
}
const component = provenance.method.components[0];
if (!isRecord(component)) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical method LAB-сессии ${sessionId} не содержит source component.`,
);
}
assertExactKeys(
component,
METHOD_COMPONENT_KEYS,
`Canonical source component LAB-сессии ${sessionId}`,
);
if (
component.kind !== "source"
|| component.name !== "sealed full-route LAB result"
|| component.version !== "missioncore.lab-v1-vegetation-shadow/v1"
|| component.role !== "immutable Session catalog projection"
|| component.identity_sha256 !== provenance.evidence_identity_sha256
) {
throw new ObservationLabReplayCapabilityContractError(
`Canonical source component LAB-сессии ${sessionId} потерял immutable identity.`,
);
}
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
if (capability === null || capability.kind !== "canonical-recorded-rerun") {
throw new ObservationLabReplayCapabilityContractError(
`Canonical provenance LAB-сессии ${sessionId} не содержит replay capability.`,
);
}
return capability;
}
export function decodePortableLabReplayCapabilityProvenance(
provenance: Readonly<Record<string, unknown>>,
sessionId: string,
resultId: string,
sourceSessionId: string,
definitionSha256: string,
): ObservationPortableLabReplayCapability {
assertExactKeys(
provenance,
PORTABLE_PROVENANCE_KEYS,
`Portable provenance LAB-сессии ${sessionId}`,
);
if (
provenance.schema_version !== "missioncore.observatory-portable-result-publication/v1"
|| !isRecord(provenance.source)
|| provenance.source.session_id !== sourceSessionId
|| !isRecord(provenance.run_definition)
|| provenance.run_definition.definition_sha256 !== definitionSha256
|| !isRecord(provenance.result_package)
|| typeof provenance.result_package.manifest_sha256 !== "string"
|| !SHA256.test(provenance.result_package.manifest_sha256)
|| typeof provenance.result_package.artifact_manifest_id !== "string"
|| !SHA256.test(provenance.result_package.artifact_manifest_id)
|| sessionId !== resultId
) {
throw new ObservationLabReplayCapabilityContractError(
`Portable provenance LAB-сессии ${sessionId} потеряла immutable identity.`,
);
}
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
if (capability === null || capability.kind !== "portable-result-review") {
throw new ObservationLabReplayCapabilityContractError(
`Portable provenance LAB-сессии ${sessionId} не содержит viewer capability.`,
);
}
return capability;
}
/** Rolling bridge for a new frontend against the pre-v2 catalog endpoint. */
export function decodeRollingCanonicalLabReplayCapability(
provenance: Readonly<Record<string, unknown>>,
sessionId: string,
resultId: string,
): ObservationLabReplayCapability | null {
if (!Object.prototype.hasOwnProperty.call(provenance, "replay_capability")) return null;
return decodeCanonicalLabReplayCapabilityProvenance(provenance, sessionId, resultId);
}
@@ -209,6 +209,49 @@ export function attemptRecordedAutoplay(
}
}
export function createRecordedInitialSeekGate(): {
attempt: (
viewerStarted: boolean,
fullyBuffered: boolean,
presentationReady: boolean,
rangeNs: { min: number; max: number } | null,
seekToStart: (startNs: number) => void,
preferredStartNs?: number,
) => boolean;
attempted: () => boolean;
} {
let consumed = false;
return {
attempt(
viewerStarted,
fullyBuffered,
presentationReady,
rangeNs,
seekToStart,
preferredStartNs,
) {
if (
consumed
|| !viewerStarted
|| !fullyBuffered
|| !presentationReady
|| !isUsableRecordedPlaybackRange(rangeNs)
) return false;
consumed = true;
const startNs = Number.isFinite(preferredStartNs)
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
: rangeNs.min;
try {
seekToStart(startNs);
return true;
} catch {
return false;
}
},
attempted: () => consumed,
};
}
export function createRecordedAutoplayGate(): {
attempt: (
viewerStarted: boolean,
@@ -0,0 +1,43 @@
export interface ObservationReplayAttempt {
readonly signal: AbortSignal;
isCurrent: () => boolean;
finish: () => boolean;
}
export interface ObservationReplayCoordinator {
begin: () => ObservationReplayAttempt;
cancel: () => void;
}
/** Latest explicit admission owns the viewer, even if an older promise settles 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() {
active?.abort();
// Keep settlement ownership until finish(). isCurrent() already fails,
// while begin() can replace this attempt immediately.
},
};
}
@@ -1,6 +1,20 @@
import {
MAX_RECORDED_CAMERA_SOURCES,
} from "./recordedSessionAdmission";
import {
decodeLabReplayCapability,
decodeRollingCanonicalLabReplayCapability,
ObservationLabReplayCapabilityContractError,
type ObservationLabReplayCapability,
} from "./labReplayCapability";
import {
decodeObservationLabCalculationProfile,
ObservationLabCalculationProfileContractError,
type ObservationLabCalculationProfile,
} from "./labCalculationProfile";
export type { ObservationLabReplayCapability } from "./labReplayCapability";
export type { ObservationLabCalculationProfile } from "./labCalculationProfile";
export type ObservationSessionStatus =
| "recording"
@@ -20,6 +34,8 @@ export interface ObservationLabInstance {
configSha256: string | null;
runCreatedAtUtc: string;
publishedAtUtc: string;
replayCapability: ObservationLabReplayCapability | null;
calculationProfile: ObservationLabCalculationProfile | null;
provenance: Readonly<Record<string, unknown>>;
}
@@ -32,10 +48,21 @@ export interface ObservationSessionSummary {
modalities: readonly string[];
durationSeconds: number;
replayable: boolean;
captureAttestation: ObservationCaptureAttestation | null;
preparation: ObservationSessionCatalogPreparation | null;
lab: ObservationLabInstance | null;
}
export interface ObservationCaptureAttestation {
readonly equipmentModelId: string;
readonly equipmentModelSha256: string;
readonly equipmentDisplayName: string;
readonly captureProfileId: string;
readonly captureProfileSha256: string;
readonly captureAttestationSha256: string;
readonly state: "attested" | "attested-with-legacy-evidence" | "unknown";
}
export interface ObservationSessionCatalog {
items: readonly ObservationSessionSummary[];
}
@@ -152,10 +179,11 @@ const ITEM_KEYS = new Set([
"modalities",
"duration_seconds",
"replayable",
"capture_attestation",
"preparation",
"lab",
]);
const LAB_KEYS = new Set([
const LEGACY_LAB_KEYS = new Set([
"lab_id",
"source_session_id",
"result_kind",
@@ -166,6 +194,27 @@ const LAB_KEYS = new Set([
"published_at_utc",
"provenance",
]);
const LAB_V2_KEYS = new Set([...LEGACY_LAB_KEYS, "replay_capability"]);
const LAB_V3_KEYS = new Set([
...LAB_V2_KEYS,
"calculation_profile",
]);
const CAPTURE_ATTESTATION_KEYS = new Set([
"schema_version",
"equipment_model",
"capture_profile",
"capture_attestation_sha256",
"state",
]);
const EQUIPMENT_MODEL_KEYS = new Set([
"equipment_model_id",
"equipment_model_sha256",
"display_name",
]);
const CAPTURE_PROFILE_KEYS = new Set([
"capture_profile_id",
"capture_profile_sha256",
]);
const CATALOG_PREPARATION_KEYS = new Set([
"preparation_id",
"state",
@@ -379,11 +428,103 @@ function decodeItem(value: unknown, index: number): ObservationSessionSummary {
modalities: requireModalities(value.modalities),
durationSeconds: value.duration_seconds,
replayable: value.replayable,
captureAttestation: decodeCaptureAttestation(value.capture_attestation, id),
preparation: decodeCatalogPreparation(value.preparation, id),
lab: decodeLabInstance(value.lab, id),
};
}
function decodeCaptureAttestation(
value: unknown,
sessionId: string,
): ObservationCaptureAttestation | null {
if (value === null || value === undefined) return null;
if (!isRecord(value)) {
throw new ObservationSessionContractError(
`Аттестация записи ${sessionId} должна быть объектом или null.`,
);
}
assertExactKeys(value, CAPTURE_ATTESTATION_KEYS, `Аттестация записи ${sessionId}`);
if (value.schema_version !== "missioncore.recorded-capture-attestation/v1") {
throw new ObservationSessionContractError(
`Аттестация записи ${sessionId} имеет неизвестную схему.`,
);
}
if (!isRecord(value.equipment_model) || !isRecord(value.capture_profile)) {
throw new ObservationSessionContractError(
`Аттестация записи ${sessionId} не содержит оборудование или профиль записи.`,
);
}
assertExactKeys(
value.equipment_model,
EQUIPMENT_MODEL_KEYS,
`Оборудование записи ${sessionId}`,
);
assertExactKeys(
value.capture_profile,
CAPTURE_PROFILE_KEYS,
`Профиль записи ${sessionId}`,
);
const equipmentModelId = requireString(
value.equipment_model.equipment_model_id,
"equipment_model_id",
128,
);
const equipmentModelSha256 = requireString(
value.equipment_model.equipment_model_sha256,
"equipment_model_sha256",
64,
);
const captureProfileId = requireString(
value.capture_profile.capture_profile_id,
"capture_profile_id",
128,
);
const captureProfileSha256 = requireString(
value.capture_profile.capture_profile_sha256,
"capture_profile_sha256",
64,
);
const captureAttestationSha256 = requireString(
value.capture_attestation_sha256,
"capture_attestation_sha256",
64,
);
if (
!SAFE_ID.test(equipmentModelId)
|| !SAFE_ID.test(captureProfileId)
|| !SHA256.test(equipmentModelSha256)
|| !SHA256.test(captureProfileSha256)
|| !SHA256.test(captureAttestationSha256)
) {
throw new ObservationSessionContractError(
`Аттестация записи ${sessionId} содержит некорректную immutable identity.`,
);
}
if (
value.state !== "attested"
&& value.state !== "attested-with-legacy-evidence"
&& value.state !== "unknown"
) {
throw new ObservationSessionContractError(
`Аттестация записи ${sessionId} содержит неизвестное состояние.`,
);
}
return {
equipmentModelId,
equipmentModelSha256,
equipmentDisplayName: requireString(
value.equipment_model.display_name,
"equipment display_name",
128,
),
captureProfileId,
captureProfileSha256,
captureAttestationSha256,
state: value.state,
};
}
function decodeLabInstance(
value: unknown,
sessionId: string,
@@ -394,7 +535,23 @@ function decodeLabInstance(
`LAB-привязка сессии ${sessionId} должна быть объектом или null.`,
);
}
assertExactKeys(value, LAB_KEYS, `LAB-привязка сессии ${sessionId}`);
const hasTypedCapability = Object.prototype.hasOwnProperty.call(
value,
"replay_capability",
);
const hasCalculationProfile = Object.prototype.hasOwnProperty.call(
value,
"calculation_profile",
);
assertExactKeys(
value,
hasCalculationProfile
? LAB_V3_KEYS
: hasTypedCapability
? LAB_V2_KEYS
: LEGACY_LAB_KEYS,
`LAB-привязка сессии ${sessionId}`,
);
const labId = requireString(value.lab_id, `lab(${sessionId}).lab_id`, 36);
if (!/^LAB [A-Z][A-Z0-9._-]{0,31}$/.test(labId)) {
throw new ObservationSessionContractError("Каталог содержит некорректный LAB-маркер.");
@@ -430,6 +587,28 @@ function decodeLabInstance(
if (!isRecord(value.provenance)) {
throw new ObservationSessionContractError("LAB provenance должен быть JSON-объектом.");
}
let replayCapability: ObservationLabReplayCapability | null;
try {
replayCapability = hasTypedCapability
? decodeLabReplayCapability(value.replay_capability, sessionId)
: decodeRollingCanonicalLabReplayCapability(value.provenance, sessionId, resultId);
} catch (error) {
if (error instanceof ObservationLabReplayCapabilityContractError) {
throw new ObservationSessionContractError(error.message);
}
throw error;
}
let calculationProfile: ObservationLabCalculationProfile | null;
try {
calculationProfile = hasCalculationProfile
? decodeObservationLabCalculationProfile(value.calculation_profile, sessionId)
: null;
} catch (error) {
if (error instanceof ObservationLabCalculationProfileContractError) {
throw new ObservationSessionContractError(error.message);
}
throw error;
}
return {
labId,
sourceSessionId,
@@ -445,6 +624,8 @@ function decodeLabInstance(
value.published_at_utc,
`lab(${sessionId}).published_at_utc`,
),
replayCapability,
calculationProfile,
provenance: value.provenance,
};
}
@@ -1181,6 +1362,7 @@ export async function fetchObservationSessionCatalog({
queryParameters.set("limit", String(Number(limit)));
}
if (scope !== "all") queryParameters.set("scope", scope);
if (scope === "laboratory") queryParameters.set("lab_contract", "v3");
const serializedQuery = queryParameters.toString();
const query = serializedQuery ? `?${serializedQuery}` : "";
let response: Response;
@@ -12,6 +12,16 @@ import {
type ObservationSessionScope,
type ObservationSessionSummary,
} from "./sessionArchive";
import {
createObservationReplayCoordinator,
type ObservationReplayCoordinator,
} from "./replayCoordinator";
export {
createObservationReplayCoordinator,
type ObservationReplayAttempt,
type ObservationReplayCoordinator,
} from "./replayCoordinator";
export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
@@ -41,17 +51,6 @@ export interface ObservationSessionsController {
remove: (sessionId: string) => Promise<boolean>;
}
export interface ObservationReplayAttempt {
readonly signal: AbortSignal;
isCurrent: () => boolean;
finish: () => boolean;
}
export interface ObservationReplayCoordinator {
begin: () => ObservationReplayAttempt;
cancel: () => void;
}
export async function deleteObservationSessionAfterTeardown(
sessionId: string,
{
@@ -93,41 +92,6 @@ export class ObservationPreparationStalledError extends Error {
}
}
/** 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() {
active?.abort();
// Keep ownership until the cancelled attempt reaches `finish()`. This
// lets its finally block settle a replacement that already passed
// onReplayBegin, while `isCurrent()` still fails immediately because the
// signal is aborted. A later `begin()` replaces and invalidates it.
},
};
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
@@ -67,20 +67,31 @@ export interface RecordedSessionRerunProfile {
viewResetGeneration: 0 | 1;
followTrajectory: boolean;
perceptionLayers: RecordedPerceptionLayers;
/** Explicit small blueprint endpoint when the viewer source is a merged LAB RRD. */
blueprintSourceUrl?: string;
/** Optional immutable RRD sidecar for LAB/model evidence on the same recording clock. */
perceptionSourceUrl?: string;
/** Selects one semantic entity without changing the sealed sidecar. */
semanticLayer?: "city" | "vegetation";
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
unifiedPerception?: boolean;
/** Requests the canonical top-down eye without changing the world coordinate frame. */
planView?: boolean;
perceptionRetryGeneration: number;
lockPerceptionCameraInteraction: boolean;
}
/**
* LAB recorded evidence is not a native Rerun mode. It owns a source-sequence
* clock and composes the existing sealed fMP4 and retained spatial primitives.
* Deprecated comparison-only contract for LAB artifacts that have not yet
* been republished as a native Rerun sidecar. It must never be selected by a
* canonical LAB route or start work in the background.
*/
export interface LaboratoryRecordedEvidenceViewerProfile {
kind: "lab-recorded-evidence";
clock: "source-sequence";
cameraTransport: "generation-bound-fmp4";
spatialTransport: "bounded-sealed-artifacts";
loadPolicy: "visible-evidence-only";
loadPolicy: "explicit-legacy-comparison-only";
workerRequired: false;
}
@@ -97,7 +108,7 @@ export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
clock: "source-sequence",
cameraTransport: "generation-bound-fmp4",
spatialTransport: "bounded-sealed-artifacts",
loadPolicy: "visible-evidence-only",
loadPolicy: "explicit-legacy-comparison-only",
workerRequired: false,
} satisfies LaboratoryRecordedEvidenceViewerProfile);
@@ -0,0 +1,261 @@
import {
fetchObservationSessionCatalog,
type ObservationLabInstance,
type ObservationSessionFetch,
type ObservationSessionSummary,
} from "../observation/sessionArchive";
import {
observatoryRecordedRunBinding,
type ObservatoryRecordedRunBinding,
} from "./recordedRun";
export interface ObservatoryEvidence {
readonly sessionId: string;
readonly label: string;
readonly status: ObservationSessionSummary["status"];
readonly publishedAtUtc: string;
readonly lab: ObservationLabInstance;
readonly recordedRun: ObservatoryRecordedRunBinding | null;
}
export interface ObservatorySession {
readonly source: ObservationSessionSummary;
readonly evidence: readonly ObservatoryEvidence[];
}
export interface ObservatoryCatalog {
readonly items: readonly ObservatorySession[];
readonly unresolvedEvidence: readonly ObservatoryEvidence[];
readonly window: {
readonly limit: number;
readonly sourceCount: number;
readonly laboratoryCount: number;
readonly sourceLimitReached: boolean;
readonly laboratoryLimitReached: boolean;
};
}
export type ObservatoryCatalogMutation =
| {
readonly kind: "rename";
readonly displayName: string;
readonly revision: number;
}
| {
readonly kind: "delete";
readonly revision: number;
};
export type ObservatoryCatalogMutationOverlay = ReadonlyMap<
string,
ObservatoryCatalogMutation
>;
export interface ObservatoryCatalogReconciliation {
readonly catalog: ObservatoryCatalog;
readonly overlay: ObservatoryCatalogMutationOverlay;
}
export class ObservatoryCatalogContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservatoryCatalogContractError";
}
}
export function findObservatoryEvidence(
catalog: ObservatoryCatalog,
sessionId: string,
): ObservatoryEvidence | null {
for (const item of catalog.items) {
const evidence = item.evidence.find(
(candidate) => candidate.sessionId === sessionId,
);
if (evidence) return evidence;
}
return catalog.unresolvedEvidence.find(
(candidate) => candidate.sessionId === sessionId,
) ?? null;
}
export function observatoryCatalogConfirmsEvidenceDeletion(
catalog: ObservatoryCatalog,
sessionId: string,
): boolean {
return !catalog.window.laboratoryLimitReached
&& findObservatoryEvidence(catalog, sessionId) === null;
}
export function applyObservatoryCatalogMutationOverlay(
catalog: ObservatoryCatalog,
overlay: ObservatoryCatalogMutationOverlay,
): ObservatoryCatalog {
if (overlay.size === 0) return catalog;
const removedSessionIds = new Set<string>();
const projectEvidence = (
evidence: ObservatoryEvidence,
): ObservatoryEvidence | null => {
const mutation = overlay.get(evidence.sessionId);
if (!mutation) return evidence;
if (mutation.kind === "delete") {
removedSessionIds.add(evidence.sessionId);
return null;
}
return evidence.label === mutation.displayName
? evidence
: { ...evidence, label: mutation.displayName };
};
const projectEvidenceList = (
evidence: readonly ObservatoryEvidence[],
): ObservatoryEvidence[] => evidence.flatMap((candidate) => {
const projected = projectEvidence(candidate);
return projected ? [projected] : [];
});
const items = catalog.items.map((item) => ({
...item,
evidence: projectEvidenceList(item.evidence),
}));
const unresolvedEvidence = projectEvidenceList(catalog.unresolvedEvidence);
return {
...catalog,
items,
unresolvedEvidence,
window: {
...catalog.window,
laboratoryCount: Math.max(
0,
catalog.window.laboratoryCount - removedSessionIds.size,
),
},
};
}
export function reconcileObservatoryCatalogMutationOverlay(
serverCatalog: ObservatoryCatalog,
overlay: ObservatoryCatalogMutationOverlay,
requestMutationRevision: number,
): ObservatoryCatalogReconciliation {
const remaining = new Map(overlay);
for (const [sessionId, mutation] of overlay) {
if (mutation.revision > requestMutationRevision) continue;
const evidence = findObservatoryEvidence(serverCatalog, sessionId);
const confirmed = mutation.kind === "rename"
? evidence?.label === mutation.displayName
: observatoryCatalogConfirmsEvidenceDeletion(serverCatalog, sessionId);
if (confirmed) remaining.delete(sessionId);
}
return {
catalog: applyObservatoryCatalogMutationOverlay(serverCatalog, remaining),
overlay: remaining,
};
}
function newestFirst(left: string, right: string): number {
return Date.parse(right) - Date.parse(left);
}
function boundedLimit(limit: number): number {
return Number.isFinite(limit)
? Math.min(100, Math.max(1, Math.floor(limit)))
: 100;
}
function evidenceFromSession(
session: ObservationSessionSummary,
): ObservatoryEvidence {
if (!session.lab) {
throw new ObservatoryCatalogContractError(
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
);
}
return {
sessionId: session.id,
label: session.label,
status: session.status,
publishedAtUtc: session.lab.publishedAtUtc,
lab: session.lab,
recordedRun: observatoryRecordedRunBinding(session.id, session.lab),
};
}
function sortEvidence(
evidence: readonly ObservatoryEvidence[],
): ObservatoryEvidence[] {
return [...evidence].sort((left, right) =>
newestFirst(left.publishedAtUtc, right.publishedAtUtc)
|| left.sessionId.localeCompare(right.sessionId));
}
export function buildObservatoryCatalog(
sourceSessions: readonly ObservationSessionSummary[],
laboratorySessions: readonly ObservationSessionSummary[],
limit = 100,
): ObservatoryCatalog {
const safeLimit = boundedLimit(limit);
const sources = new Map<string, ObservationSessionSummary>();
for (const source of sourceSessions) {
if (source.lab !== null) {
throw new ObservatoryCatalogContractError(
`Каталог источников вернул LAB-сессию ${source.id}.`,
);
}
sources.set(source.id, source);
}
const linked = new Map<string, ObservatoryEvidence[]>();
const unresolvedEvidence: ObservatoryEvidence[] = [];
for (const laboratorySession of laboratorySessions) {
const evidence = evidenceFromSession(laboratorySession);
const sourceId = evidence.lab.sourceSessionId;
if (!sources.has(sourceId)) {
// Both API projections are bounded newest-first windows without a
// cursor. Absence from the source window is not proof of a broken
// relationship and must not be presented as an integrity fault.
unresolvedEvidence.push(evidence);
continue;
}
const current = linked.get(sourceId) ?? [];
current.push(evidence);
linked.set(sourceId, current);
}
const items = [...sources.values()]
.sort((left, right) =>
newestFirst(left.startedAtUtc, right.startedAtUtc)
|| left.id.localeCompare(right.id))
.map((source) => ({
source,
evidence: sortEvidence(linked.get(source.id) ?? []),
}));
return {
items,
unresolvedEvidence: sortEvidence(unresolvedEvidence),
window: {
limit: safeLimit,
sourceCount: sourceSessions.length,
laboratoryCount: laboratorySessions.length,
sourceLimitReached: sourceSessions.length >= safeLimit,
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
},
};
}
export async function fetchObservatoryCatalog({
signal,
limit = 100,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
limit?: number;
fetcher?: ObservationSessionFetch;
} = {}): Promise<ObservatoryCatalog> {
const safeLimit = boundedLimit(limit);
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
]);
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
}
@@ -0,0 +1,210 @@
import type { ObservatoryRecordedRunBinding } from "./recordedRun";
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const RENAME_SCHEMA = "missioncore.observatory-lab-projection-rename/v1";
const PROJECTION_SCHEMA = "missioncore.observatory-lab-projection/v1";
export type ObservatoryCatalogMutationFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
export interface ObservatoryLabProjectionMutationResult {
readonly schemaVersion: typeof PROJECTION_SCHEMA;
readonly sessionId: string;
readonly displayName: string;
}
export class ObservatoryCatalogMutationError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "ObservatoryCatalogMutationError";
this.status = status;
}
}
export async function renameObservatoryLabProjection(
binding: ObservatoryRecordedRunBinding,
displayName: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryCatalogMutationFetch;
} = {},
): Promise<ObservatoryLabProjectionMutationResult> {
const sessionId = admittedProjectionId(binding);
const normalizedName = displayName.trim();
if (normalizedName.length < 1 || normalizedName.length > 160) {
throw new ObservatoryCatalogMutationError(
"Название лабораторного результата должно содержать от 1 до 160 символов.",
);
}
const response = await request(
fetcher,
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
{
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
schema_version: RENAME_SCHEMA,
display_name: normalizedName,
}),
signal,
},
"Не удалось переименовать лабораторный результат.",
);
const body = await responseBody(response);
if (!response.ok || response.status !== 200) {
throw apiError(
body,
`Переименование лабораторного результата вернуло HTTP ${response.status}.`,
response.status,
);
}
const decoded = decodeProjection(body);
if (decoded.sessionId !== sessionId || decoded.displayName !== normalizedName) {
throw new ObservatoryCatalogMutationError(
"Сервер не подтвердил точное переименование выбранного результата.",
);
}
return decoded;
}
export async function deleteObservatoryLabProjection(
binding: ObservatoryRecordedRunBinding,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryCatalogMutationFetch;
} = {},
): Promise<void> {
const sessionId = admittedProjectionId(binding);
const response = await request(
fetcher,
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
headers: { Accept: "application/json" },
signal,
},
"Не удалось удалить лабораторный результат из Обсерватории.",
);
const body = await responseBody(response);
if (!response.ok || response.status !== 204) {
throw apiError(
body,
`Удаление лабораторного результата вернуло HTTP ${response.status}.`,
response.status,
);
}
if (body !== undefined) {
throw new ObservatoryCatalogMutationError(
"Сервер вернул данные после подтверждённого удаления лабораторного результата.",
);
}
}
function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string {
const sessionId = binding.evidenceSessionId;
const admittedViewer = (
binding.kind === "canonical-recorded-rerun"
&& binding.viewerProfile === "recorded-session"
&& binding.timeline === "session_time"
) || (
binding.kind === "portable-result-review"
&& binding.viewerProfile === "portable-result"
&& binding.timeline === "result-defined"
);
if (
!admittedViewer
|| binding.activation !== "explicit"
|| binding.resultId !== sessionId
|| binding.sourceSessionId === sessionId
|| !SAFE_SESSION_ID.test(sessionId)
) {
throw new ObservatoryCatalogMutationError(
"Выбранный результат не допущен к изменению каталога Обсерватории.",
);
}
return sessionId;
}
async function request(
fetcher: ObservatoryCatalogMutationFetch,
input: string,
init: RequestInit,
fallback: string,
): Promise<Response> {
try {
return await fetcher(input, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservatoryCatalogMutationError(fallback);
}
}
async function responseBody(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) return undefined;
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function apiError(body: unknown, fallback: string, status: number): Error {
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
return new ObservatoryCatalogMutationError(body.detail.trim(), status);
}
if (typeof body === "string" && body.trim()) {
return new ObservatoryCatalogMutationError(body.trim(), status);
}
return new ObservatoryCatalogMutationError(fallback, status);
}
function decodeProjection(value: unknown): ObservatoryLabProjectionMutationResult {
if (!isRecord(value)) {
throw new ObservatoryCatalogMutationError(
"Сервер вернул некорректное подтверждение лабораторного результата.",
);
}
const keys = Object.keys(value).sort();
const expected = ["display_name", "schema_version", "session_id"];
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
throw new ObservatoryCatalogMutationError(
"Подтверждение лабораторного результата содержит неизвестные поля.",
);
}
if (
value.schema_version !== PROJECTION_SCHEMA
|| typeof value.session_id !== "string"
|| !SAFE_SESSION_ID.test(value.session_id)
|| typeof value.display_name !== "string"
|| value.display_name.trim() !== value.display_name
|| value.display_name.length < 1
|| value.display_name.length > 160
) {
throw new ObservatoryCatalogMutationError(
"Сервер вернул неподдерживаемый контракт лабораторного результата.",
);
}
return {
schemaVersion: PROJECTION_SCHEMA,
sessionId: value.session_id,
displayName: value.display_name,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,449 @@
export { fetchObservatoryPortableLaboratorySetups } from "./portableLaboratorySetups";
const CATALOG_SCHEMA = "missioncore.observatory-laboratory-setup-catalog/v1";
const PREFLIGHT_REQUEST_SCHEMA = "missioncore.observatory-run-preflight-request/v1";
const PREFLIGHT_SCHEMA = "missioncore.observatory-run-preflight/v1";
const SHA256 = /^[a-f0-9]{64}$/;
export type ObservatoryLaboratorySetupOrigin =
| "archived-definition"
| "existing-result"
| "portable-definition";
export type ObservatoryLaboratorySetupAction =
| "open-existing"
| "open-legacy"
| "check"
| "blocked";
export interface ObservatoryLaboratoryRunDefinition {
readonly definitionId: string;
readonly version: number;
readonly workId: string | null;
readonly definitionSha256: string;
readonly resultSchema: string | null;
readonly resultKind: string | null;
readonly models: readonly {
readonly name: string;
readonly releaseId: string;
readonly modelId: string;
readonly architecture: string;
}[];
readonly configuration: readonly {
readonly role: string;
readonly sha256: string;
}[];
}
export interface ObservatoryLaboratoryPreservedResult {
readonly resultId: string;
readonly resultKind: string;
readonly relation: string;
readonly access: "legacy-lab" | "observatory" | "evidence-only";
readonly createdAtUtc: string;
readonly observatoryProjectionAvailable: boolean;
}
export interface ObservatoryLaboratorySetup {
readonly setupId: string;
readonly displayName: string;
readonly description: string;
readonly origin: ObservatoryLaboratorySetupOrigin;
readonly runDefinition: ObservatoryLaboratoryRunDefinition | null;
readonly compatibility: {
readonly compatible: boolean;
readonly reasons: readonly { readonly code: string; readonly message: string }[];
};
readonly executor: {
readonly contourId: string;
readonly state: "not-installed" | "ready";
readonly reasonCode: string;
readonly reason: string;
};
readonly preservedResults: readonly ObservatoryLaboratoryPreservedResult[];
readonly preflight: {
readonly outcome: "existing" | "ready" | "blocked";
readonly action: ObservatoryLaboratorySetupAction;
readonly reason: string;
readonly submissionAllowed: boolean;
readonly existingResultIds: readonly string[];
};
}
export interface ObservatoryLaboratorySetupCatalog {
readonly sourceSessionId: string;
readonly setups: readonly ObservatoryLaboratorySetup[];
}
// Cache identity is verified by the portable catalog decoder/server, never by a LAB label.
export function selectableObservatorySetups(catalog: ObservatoryLaboratorySetupCatalog | null) {
return catalog?.setups.filter((setup) => (
setup.origin === "portable-definition"
&& setup.runDefinition !== null
&& setup.compatibility.compatible
&& setup.preflight.outcome !== "existing"
)) ?? [];
}
export interface ObservatoryLaboratoryRunPreflight {
readonly sourceSessionId: string;
readonly setupId: string;
readonly definitionSha256: string | null;
readonly checkSha256: string | null;
readonly outcome: "existing" | "queueable" | "blocked";
readonly submissionAllowed: boolean;
readonly checks: readonly {
readonly checkId: string;
readonly outcome: "pass" | "fail" | "not-applicable";
readonly reasonCode: string;
readonly message: string;
}[];
readonly existingResultIds: readonly string[];
}
export type ObservatoryLaboratorySetupFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
export class ObservatoryLaboratorySetupContractError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "ObservatoryLaboratorySetupContractError";
this.status = status;
}
}
export async function fetchObservatoryLaboratorySetups(
sourceSessionId: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryLaboratorySetupFetch;
} = {},
): Promise<ObservatoryLaboratorySetupCatalog> {
const response = await request(
fetcher,
`/api/v1/observatory/laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const catalog = decodeCatalog(body);
if (catalog.sourceSessionId !== sourceSessionId) {
throw new ObservatoryLaboratorySetupContractError(
"Каталог сетапов относится к другой исходной сессии.",
);
}
return catalog;
}
export async function preflightObservatoryLaboratorySetup(
sourceSessionId: string,
setup: ObservatoryLaboratorySetup,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryLaboratorySetupFetch;
} = {},
): Promise<ObservatoryLaboratoryRunPreflight> {
const response = await request(
fetcher,
"/api/v1/observatory/run-preflights",
{
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: PREFLIGHT_REQUEST_SCHEMA,
source_session_id: sourceSessionId,
setup_id: setup.setupId,
definition_sha256: setup.runDefinition?.definitionSha256 ?? null,
}),
signal,
},
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const preflight = decodePreflight(body);
if (preflight.sourceSessionId !== sourceSessionId || preflight.setupId !== setup.setupId) {
throw new ObservatoryLaboratorySetupContractError(
"Preflight относится к другому источнику или сетапу.",
);
}
if (preflight.definitionSha256 !== (setup.runDefinition?.definitionSha256 ?? null)) {
throw new ObservatoryLaboratorySetupContractError(
"Preflight относится к другой версии сетапа.",
);
}
return preflight;
}
function decodeCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
const row = record(value, "каталог сетапов");
exactKeys(row, ["authority", "schema_version", "setups", "source_session_id"], "каталог сетапов");
exact(row.schema_version, CATALOG_SCHEMA, "schema_version каталога сетапов");
observationAuthority(row.authority);
return {
sourceSessionId: text(row.source_session_id, "source_session_id"),
setups: array(row.setups, "setups").map(decodeSetup),
};
}
function decodeSetup(value: unknown): ObservatoryLaboratorySetup {
const row = record(value, "сетап");
exactKeys(row, [
"authority", "compatibility", "description", "display_name", "executor", "origin",
"preflight", "preserved_results", "run_definition", "setup_id", "source",
], "сетап");
observationAuthority(row.authority);
const origin = oneOf(row.origin, ["archived-definition", "existing-result"] as const, "origin");
const compatibility = record(row.compatibility, "compatibility");
const executor = record(row.executor, "executor");
const preflight = record(row.preflight, "preflight");
const source = record(row.source, "source");
const runDefinition = row.run_definition === null
? null
: decodeRunDefinition(row.run_definition);
if ((origin === "archived-definition") !== (runDefinition !== null)) {
throw new ObservatoryLaboratorySetupContractError(
"Происхождение сетапа не совпадает с RunDefinition.",
);
}
exactKeys(source, ["label", "required_modalities", "session_id"], "source");
text(source.session_id, "source.session_id");
text(source.label, "source.label");
array(source.required_modalities, "source.required_modalities").forEach((item) => text(item, "source modality"));
exactKeys(compatibility, ["compatible", "reasons"], "compatibility");
exactKeys(executor, ["contour_id", "reason", "reason_code", "state"], "executor");
exactKeys(preflight, ["action", "existing_result_ids", "outcome", "reason", "submission_allowed"], "preflight");
return {
setupId: text(row.setup_id, "setup_id"),
displayName: text(row.display_name, "display_name"),
description: text(row.description, "description"),
origin,
runDefinition,
compatibility: {
compatible: boolean(row.compatibility && compatibility.compatible, "compatible"),
reasons: array(compatibility.reasons, "compatibility.reasons").map((item) => {
const reason = record(item, "compatibility reason");
exactKeys(reason, ["code", "message"], "compatibility reason");
return { code: text(reason.code, "reason.code"), message: text(reason.message, "reason.message") };
}),
},
executor: {
contourId: text(executor.contour_id, "executor.contour_id"),
state: exact(executor.state, "not-installed", "executor.state"),
reasonCode: text(executor.reason_code, "executor.reason_code"),
reason: text(executor.reason, "executor.reason"),
},
preservedResults: array(row.preserved_results, "preserved_results").map(decodePreservedResult),
preflight: {
outcome: oneOf(preflight.outcome, ["existing", "blocked"] as const, "preflight.outcome"),
action: oneOf(preflight.action, ["open-existing", "open-legacy", "blocked"] as const, "preflight.action"),
reason: text(preflight.reason, "preflight.reason"),
submissionAllowed: exact(preflight.submission_allowed, false, "preflight.submission_allowed"),
existingResultIds: array(preflight.existing_result_ids, "preflight.existing_result_ids").map((item) => text(item, "existing result id")),
},
};
}
function decodeRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
const row = record(value, "RunDefinition");
exactKeys(row, [
"authority", "configuration", "definition_id", "definition_sha256", "schema_version",
"source", "version", "work_id",
], "RunDefinition");
exact(row.schema_version, "missioncore.observatory-run-definition/v1", "RunDefinition schema");
observationAuthority(row.authority);
const source = record(row.source, "RunDefinition source");
exactKeys(source, ["label", "required_modalities", "session_id"], "RunDefinition source");
text(source.session_id, "RunDefinition source.session_id");
text(source.label, "RunDefinition source.label");
array(source.required_modalities, "RunDefinition source.required_modalities")
.forEach((item) => text(item, "RunDefinition source modality"));
const digest = text(row.definition_sha256, "definition_sha256");
if (!SHA256.test(digest)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest RunDefinition.");
return {
definitionId: text(row.definition_id, "definition_id"),
version: positiveInteger(row.version, "definition version"),
workId: text(row.work_id, "work_id"),
definitionSha256: digest,
resultSchema: null,
resultKind: null,
models: [],
configuration: array(row.configuration, "configuration").map((item) => {
const reference = record(item, "configuration reference");
exactKeys(reference, ["role", "sha256"], "configuration reference");
const sha256 = text(reference.sha256, "configuration sha256");
if (!SHA256.test(sha256)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest конфигурации.");
return { role: text(reference.role, "configuration role"), sha256 };
}),
};
}
function decodePreservedResult(value: unknown): ObservatoryLaboratoryPreservedResult {
const row = record(value, "preserved result");
exactKeys(row, [
"access", "created_at_utc", "observatory_projection_available", "relation",
"result_id", "result_kind",
], "preserved result");
return {
resultId: text(row.result_id, "result_id"),
resultKind: text(row.result_kind, "result_kind"),
relation: text(row.relation, "relation"),
access: oneOf(row.access, ["legacy-lab", "observatory", "evidence-only"] as const, "result access"),
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
observatoryProjectionAvailable: boolean(row.observatory_projection_available, "observatory projection availability"),
};
}
function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight {
const row = record(value, "preflight");
const baseKeys = [
"authority", "checks", "definition_sha256", "executor", "existing_result_ids",
"outcome", "schema_version", "setup_id", "source_session_id", "submission_allowed",
] as const;
const hasPortableCheck = Object.hasOwn(row, "check_sha256");
exactKeys(
row,
hasPortableCheck ? [...baseKeys, "check_sha256"] : baseKeys,
"preflight",
);
exact(row.schema_version, PREFLIGHT_SCHEMA, "preflight schema");
observationAuthority(row.authority);
const digest = row.definition_sha256;
if (digest !== null && (typeof digest !== "string" || !SHA256.test(digest))) {
throw new ObservatoryLaboratorySetupContractError("Некорректный digest preflight.");
}
const checkDigest = hasPortableCheck ? row.check_sha256 : null;
if (
checkDigest !== null
&& (typeof checkDigest !== "string" || !SHA256.test(checkDigest))
) {
throw new ObservatoryLaboratorySetupContractError(
"Некорректный check digest preflight.",
);
}
const outcome = oneOf(
row.outcome,
["existing", "queueable", "blocked"] as const,
"preflight outcome",
);
const submissionAllowed = boolean(
row.submission_allowed,
"preflight submission_allowed",
);
if (
submissionAllowed !== (outcome === "queueable")
|| (hasPortableCheck && outcome === "queueable" && checkDigest === null)
) {
throw new ObservatoryLaboratorySetupContractError(
"Preflight содержит противоречивое разрешение постановки в очередь.",
);
}
return {
sourceSessionId: text(row.source_session_id, "preflight source_session_id"),
setupId: text(row.setup_id, "preflight setup_id"),
definitionSha256: digest,
checkSha256: checkDigest,
outcome,
submissionAllowed,
checks: array(row.checks, "preflight checks").map((item) => {
const check = record(item, "preflight check");
exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check");
return {
checkId: text(check.check_id, "check_id"),
outcome: oneOf(check.outcome, ["pass", "fail", "not-applicable"] as const, "check outcome"),
reasonCode: text(check.reason_code, "check reason_code"),
message: text(check.message, "check message"),
};
}),
existingResultIds: array(row.existing_result_ids, "existing_result_ids").map((item) => text(item, "existing result id")),
};
}
function observationAuthority(value: unknown): void {
const row = record(value, "authority");
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
exactKeys(row, keys, "authority");
for (const key of keys) {
exact(row[key], false, `authority.${key}`);
}
}
async function request(fetcher: ObservatoryLaboratorySetupFetch, input: string, init: RequestInit): Promise<Response> {
try {
return await fetcher(input, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservatoryLaboratorySetupContractError("Каталог сетапов недоступен.");
}
}
async function responseBody(response: Response): Promise<unknown> {
const textBody = await response.text();
if (!textBody) return undefined;
try { return JSON.parse(textBody) as unknown; } catch { return textBody; }
}
function apiError(body: unknown, status: number): ObservatoryLaboratorySetupContractError {
const detail = body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).detail
: null;
return new ObservatoryLaboratorySetupContractError(
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
status,
);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался массив.`);
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался текст.`);
return value;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался boolean.`);
return value;
}
function positiveInteger(value: unknown, label: string): number {
if (!Number.isInteger(value) || Number(value) < 1) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидалось положительное целое.`);
return Number(value);
}
function exact<T>(value: unknown, expected: T, label: string): T {
if (value !== expected) throw new ObservatoryLaboratorySetupContractError(`${label}: значение изменилось.`);
return expected;
}
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
if (typeof value !== "string" || !allowed.includes(value)) {
throw new ObservatoryLaboratorySetupContractError(`${label}: значение не поддерживается.`);
}
return value as T[number];
}
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
const actual = Object.keys(value).sort();
const sortedExpected = [...expected].sort();
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
throw new ObservatoryLaboratorySetupContractError(`${label}: обнаружены неизвестные поля.`);
}
}
@@ -0,0 +1,373 @@
import type {
ObservatoryLaboratoryPreservedResult,
ObservatoryLaboratoryRunDefinition,
ObservatoryLaboratorySetup,
ObservatoryLaboratorySetupCatalog,
} from "./laboratorySetups";
const PORTABLE_CATALOG_SCHEMA = "missioncore.observatory-portable-setup-catalog/v2";
const SHA256 = /^[a-f0-9]{64}$/;
export class ObservatoryPortableSetupDecodeError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservatoryPortableSetupDecodeError";
}
}
export function decodePortableCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
const row = record(value, "portable-каталог сетапов");
exactKeys(
row,
["authority", "schema_version", "setups", "source_session_id"],
"portable-каталог сетапов",
);
exact(row.schema_version, PORTABLE_CATALOG_SCHEMA, "schema_version portable-каталога");
observationAuthority(row.authority);
const sourceSessionId = text(row.source_session_id, "portable source_session_id");
return {
sourceSessionId,
setups: array(row.setups, "portable setups").map((item) => decodePortableSetup(item, sourceSessionId)),
};
}
function decodePortableSetup(value: unknown, sourceSessionId: string): ObservatoryLaboratorySetup {
const row = record(value, "portable-сетап");
exactKeys(row, [
"authority", "description", "display_name", "executor", "existing_results",
"origin", "preflight", "run_definition", "setup_id", "source_compatibility",
"source_requirements",
], "portable-сетап");
exact(row.origin, "portable-definition", "portable origin");
observationAuthority(row.authority);
decodePortableSourceRequirements(row.source_requirements);
const setupId = text(row.setup_id, "portable setup_id");
const runDefinition = decodePortableRunDefinition(row.run_definition);
const compatibility = record(row.source_compatibility, "portable source_compatibility");
exactKeys(
compatibility,
["compatible", "outcome", "reason", "reason_code"],
"portable source_compatibility",
);
const compatible = boolean(compatibility.compatible, "portable compatible");
exact(
compatibility.outcome,
compatible ? "pass" : "blocked",
"portable compatibility outcome",
);
const compatibilityReason = text(compatibility.reason, "portable compatibility reason");
const compatibilityReasonCode = text(
compatibility.reason_code,
"portable compatibility reason_code",
);
const executor = record(row.executor, "portable executor");
exactKeys(
executor,
["contour_id", "ready", "reason", "reason_code", "state"],
"portable executor",
);
const executorState = oneOf(
executor.state,
["not-installed", "ready"] as const,
"portable executor state",
);
const executorReady = boolean(executor.ready, "portable executor ready");
if (executorReady !== (executorState === "ready")) {
throw new ObservatoryPortableSetupDecodeError(
"Portable executor: состояние готовности противоречиво.",
);
}
const executorReason = executor.reason === null
? "Исполнитель установлен."
: text(executor.reason, "portable executor reason");
const executorReasonCode = executor.reason_code === null
? "portable-executor-ready"
: text(executor.reason_code, "portable executor reason_code");
if (
(executorReady && (executor.reason !== null || executor.reason_code !== null))
|| (!executorReady && (executor.reason === null || executor.reason_code === null))
) {
throw new ObservatoryPortableSetupDecodeError(
"Portable executor: причина недоступности противоречит состоянию.",
);
}
const preflight = record(row.preflight, "portable preflight");
exactKeys(preflight, [
"action", "existing_result_ids", "outcome", "reason", "submission_allowed",
], "portable preflight");
const preflightOutcome = oneOf(
preflight.outcome,
["existing", "ready", "blocked"] as const,
"portable preflight outcome",
);
const preflightAction = oneOf(
preflight.action,
["open-existing", "check", "blocked"] as const,
"portable preflight action",
);
const submissionAllowed = boolean(
preflight.submission_allowed,
"portable preflight submission_allowed",
);
if (
submissionAllowed !== (preflightOutcome === "ready")
|| (preflightOutcome === "ready" && preflightAction !== "check")
|| (preflightOutcome === "blocked" && preflightAction !== "blocked")
|| (preflightOutcome === "existing" && preflightAction !== "open-existing")
|| (submissionAllowed && (!compatible || !executorReady))
) {
throw new ObservatoryPortableSetupDecodeError(
"Portable preflight: состояние запуска противоречиво.",
);
}
const existingResults = array(row.existing_results, "portable existing_results").map(
(item) => decodePortableResult(item, sourceSessionId, setupId, runDefinition),
);
const existingResultIds = array(
preflight.existing_result_ids,
"portable existing_result_ids",
).map((item) => text(item, "portable existing result id"));
if (
(existingResults.length > 0) !== (preflightOutcome === "existing")
|| existingResultIds.length !== existingResults.length
|| new Set(existingResultIds).size !== existingResultIds.length
|| existingResults.some((result, index) => result.resultId !== existingResultIds[index])
) {
throw new ObservatoryPortableSetupDecodeError(
"Portable result: готовность не соответствует проверенным результатам.",
);
}
return {
setupId,
displayName: text(row.display_name, "portable display_name"),
description: text(row.description, "portable description"),
origin: "portable-definition",
runDefinition,
compatibility: {
compatible,
reasons: compatible
? []
: [{ code: compatibilityReasonCode, message: compatibilityReason }],
},
executor: {
contourId: text(executor.contour_id, "portable executor contour_id"),
state: executorState,
reasonCode: executorReasonCode,
reason: executorReason,
},
preservedResults: existingResults,
preflight: {
outcome: preflightOutcome,
action: preflightAction,
reason: text(preflight.reason, "portable preflight reason"),
submissionAllowed,
existingResultIds,
},
};
}
function decodePortableResult(
value: unknown, sourceSessionId: string, setupId: string,
definition: ObservatoryLaboratoryRunDefinition,
): ObservatoryLaboratoryPreservedResult {
const row = record(value, "portable result");
exactKeys(row, [
"result_id", "result_kind", "relation", "access", "created_at_utc",
"observatory_projection_available", "identity",
], "portable result");
exact(row.result_kind, definition.resultKind, "portable result kind");
exact(row.relation, "exact-recorded-computation", "portable result relation");
exact(row.access, "observatory", "portable result access");
exact(row.observatory_projection_available, true, "portable result availability");
const identity = record(row.identity, "portable result identity");
exactKeys(identity, [
"job_id", "source_session_id", "source_catalog_sha256", "source_bundle_sha256",
"source_capability_manifest_sha256", "setup_id", "definition_sha256",
"package_sha256", "artifact_manifest_id",
], "portable result identity");
exact(identity.source_session_id, sourceSessionId, "portable result source");
exact(identity.setup_id, setupId, "portable result setup");
exact(identity.definition_sha256, definition.definitionSha256, "portable result definition");
text(identity.job_id, "portable result job");
for (const key of [
"source_catalog_sha256", "source_bundle_sha256", "source_capability_manifest_sha256",
"definition_sha256", "package_sha256", "artifact_manifest_id",
]) {
if (!SHA256.test(text(identity[key], `portable result ${key}`))) {
throw new ObservatoryPortableSetupDecodeError(`Portable result: некорректный ${key}.`);
}
}
return {
resultId: text(row.result_id, "portable result id"),
resultKind: text(row.result_kind, "portable result kind"),
relation: "exact-recorded-computation",
access: "observatory",
createdAtUtc: text(row.created_at_utc, "portable result created_at_utc"),
observatoryProjectionAvailable: true,
};
}
function decodePortableSourceRequirements(value: unknown): void {
const row = record(value, "portable source_requirements");
exactKeys(row, [
"archive_id", "calibration_identity_sha256", "calibration_slot", "camera_height",
"camera_semantic_channel_id", "camera_source_id", "camera_width",
"exactly_one_media_epoch", "plugin_id", "recorded_media_init_sha256",
"recorded_media_type", "required_modalities", "seekable",
], "portable source_requirements");
for (const key of [
"archive_id", "calibration_slot", "camera_semantic_channel_id", "camera_source_id",
"plugin_id", "recorded_media_type",
]) text(row[key], `portable source_requirements.${key}`);
for (const key of ["calibration_identity_sha256", "recorded_media_init_sha256"]) {
const digest = text(row[key], `portable source_requirements.${key}`);
if (!SHA256.test(digest)) {
throw new ObservatoryPortableSetupDecodeError(
`portable source_requirements.${key}: некорректный digest.`,
);
}
}
positiveInteger(row.camera_width, "portable camera_width");
positiveInteger(row.camera_height, "portable camera_height");
exact(row.exactly_one_media_epoch, true, "portable exactly_one_media_epoch");
exact(row.seekable, true, "portable seekable");
array(row.required_modalities, "portable required_modalities")
.forEach((item) => text(item, "portable required modality"));
}
function decodePortableRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
const row = record(value, "portable RunDefinition");
exactKeys(row, [
"definition_id", "definition_sha256", "models", "result_kind", "result_schema",
"version",
], "portable RunDefinition");
const digest = text(row.definition_sha256, "portable definition_sha256");
if (!SHA256.test(digest)) {
throw new ObservatoryPortableSetupDecodeError(
"Portable RunDefinition: некорректный digest.",
);
}
return {
definitionId: text(row.definition_id, "portable definition_id"),
version: positiveInteger(row.version, "portable definition version"),
workId: null,
definitionSha256: digest,
resultSchema: text(row.result_schema, "portable result_schema"),
resultKind: text(row.result_kind, "portable result_kind"),
models: array(row.models, "portable models").map((item) => {
const model = record(item, "portable model");
exactKeys(
model,
["architecture", "model_id", "name", "release_id"],
"portable model",
);
return {
name: text(model.name, "portable model name"),
releaseId: text(model.release_id, "portable model release_id"),
modelId: text(model.model_id, "portable model model_id"),
architecture: text(model.architecture, "portable model architecture"),
};
}),
configuration: [],
};
}
function observationAuthority(value: unknown): void {
const row = record(value, "authority");
const keys = [
"commands_enabled",
"actuation_allowed",
"navigation_or_safety_accepted",
"production_accepted",
];
exactKeys(row, keys, "authority");
for (const key of keys) exact(row[key], false, `authority.${key}`);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: ожидался объект.`,
);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: ожидался массив.`,
);
}
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: ожидался текст.`,
);
}
return value;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") {
throw new ObservatoryPortableSetupDecodeError(
`${label}: ожидался boolean.`,
);
}
return value;
}
function positiveInteger(value: unknown, label: string): number {
if (!Number.isInteger(value) || Number(value) < 1) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: ожидалось положительное целое.`,
);
}
return Number(value);
}
function exact<T>(value: unknown, expected: T, label: string): T {
if (value !== expected) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: значение изменилось.`,
);
}
return expected;
}
function oneOf<const T extends readonly string[]>(
value: unknown,
allowed: T,
label: string,
): T[number] {
if (typeof value !== "string" || !allowed.includes(value)) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: значение не поддерживается.`,
);
}
return value as T[number];
}
function exactKeys(
value: Record<string, unknown>,
expected: readonly string[],
label: string,
): void {
const actual = Object.keys(value).sort();
const sortedExpected = [...expected].sort();
if (
actual.length !== sortedExpected.length
|| actual.some((key, index) => key !== sortedExpected[index])
) {
throw new ObservatoryPortableSetupDecodeError(
`${label}: обнаружены неизвестные поля.`,
);
}
}
@@ -0,0 +1,85 @@
import { decodePortableCatalog } from "./portableLaboratorySetupDecoder";
import type {
ObservatoryLaboratorySetupCatalog,
} from "./laboratorySetups";
export type ObservatoryPortableLaboratorySetupFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
class ObservatoryPortableLaboratorySetupContractError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "ObservatoryPortableLaboratorySetupContractError";
this.status = status;
}
}
export async function fetchObservatoryPortableLaboratorySetups(
sourceSessionId: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryPortableLaboratorySetupFetch;
} = {},
): Promise<ObservatoryLaboratorySetupCatalog> {
const response = await request(
fetcher,
`/api/v1/observatory/portable-laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
{ method: "GET", headers: { Accept: "application/json" }, signal },
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const catalog = decodePortableCatalog(body);
if (catalog.sourceSessionId !== sourceSessionId) {
throw new ObservatoryPortableLaboratorySetupContractError(
"Portable-каталог сетапов относится к другой исходной сессии.",
);
}
return catalog;
}
async function request(
fetcher: ObservatoryPortableLaboratorySetupFetch,
input: string,
init: RequestInit,
): Promise<Response> {
try {
return await fetcher(input, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservatoryPortableLaboratorySetupContractError(
"Portable-каталог сетапов недоступен.",
);
}
}
async function responseBody(response: Response): Promise<unknown> {
const textBody = await response.text();
if (!textBody) return undefined;
try {
return JSON.parse(textBody) as unknown;
} catch {
return textBody;
}
}
function apiError(
body: unknown,
status: number,
): ObservatoryPortableLaboratorySetupContractError {
const detail = body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).detail
: null;
return new ObservatoryPortableLaboratorySetupContractError(
typeof detail === "string" && detail.trim()
? detail
: `Observatory API вернул HTTP ${status}.`,
status,
);
}
@@ -0,0 +1,336 @@
const SUBMIT_SCHEMA = "missioncore.observatory-recorded-run-submit/v1";
const JOB_SCHEMA = "missioncore.observatory-recorded-job/v1";
const JOB_LIST_SCHEMA = "missioncore.observatory-recorded-job-list/v1";
const SHA256 = /^[a-f0-9]{64}$/;
export type ObservatoryRecordedJobState =
| "accepted"
| "queued"
| "claimed"
| "running"
| "paused"
| "preemption-pending"
| "succeeded"
| "failed"
| "reconciliation-required";
export type ObservatoryRecordedJobPublicationState =
| "not-required"
| "pending"
| "failed"
| "published";
export interface ObservatoryRecordedJob {
readonly jobId: string;
readonly idempotencyKey: string;
readonly sourceSessionId: string;
readonly setupId: string;
readonly definitionSha256: string;
readonly state: ObservatoryRecordedJobState;
readonly restartFromZero: boolean;
readonly resultId: string | null;
readonly terminalMessage: string | null;
readonly publication: {
readonly state: ObservatoryRecordedJobPublicationState;
readonly attempts: number;
readonly error: string | null;
readonly publishedAtUtc: string | null;
};
readonly createdAtUtc: string;
readonly updatedAtUtc: string;
}
export type ObservatoryRecordedJobFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
export class ObservatoryRecordedJobContractError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "ObservatoryRecordedJobContractError";
this.status = status;
}
}
export async function fetchObservatoryRecordedJobs(
sourceSessionId: string,
setupId: string,
{
signal,
definitionSha256,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
definitionSha256?: string;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<readonly ObservatoryRecordedJob[]> {
const query = new URLSearchParams({
source_session_id: sourceSessionId,
setup_id: setupId,
limit: "20",
});
if (definitionSha256 !== undefined) query.set("definition_sha256", definitionSha256);
const response = await request(fetcher, `/api/v1/observatory/runs?${query}`, {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const row = record(body, "список расчётов");
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
observationAuthority(row.authority);
return array(row.items, "items").map(decodeJob).filter((job) => (
job.sourceSessionId === sourceSessionId && job.setupId === setupId
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
));
}
export async function submitObservatoryRecordedJob(
sourceSessionId: string,
setupId: string,
idempotencyKey: string,
portableBinding: {
readonly definitionSha256: string;
readonly checkSha256: string;
} | null,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<ObservatoryRecordedJob> {
const response = await request(fetcher, "/api/v1/observatory/runs", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: SUBMIT_SCHEMA,
idempotency_key: idempotencyKey,
source_session_id: sourceSessionId,
setup_id: setupId,
...(portableBinding === null ? {} : {
definition_sha256: portableBinding.definitionSha256,
check_sha256: portableBinding.checkSha256,
}),
}),
signal,
});
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
throw new ObservatoryRecordedJobContractError(
"Расчёт относится к другой сессии, сетапу или версии профиля.",
);
}
return job;
}
export async function retryObservatoryRecordedJobPublication(
jobId: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<ObservatoryRecordedJob> {
const response = await request(
fetcher,
`/api/v1/observatory/runs/${encodeURIComponent(jobId)}/publication/retry`,
{
method: "POST",
headers: { Accept: "application/json" },
signal,
},
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
if (job.jobId !== jobId) {
throw new ObservatoryRecordedJobContractError(
"Повтор публикации вернул другой расчёт.",
);
}
return job;
}
function decodeJob(value: unknown): ObservatoryRecordedJob {
const row = record(value, "расчёт");
exactKeys(row, [
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
"idempotency_key", "identity_sha256", "job_id", "preemption_receipt_sha256",
"preemption_requested", "priority", "publication", "request_sha256", "restart_from_zero", "result",
"schema_version", "setup", "source", "state", "submission_receipt_sha256", "terminal",
"updated_at_utc",
], "расчёт");
exact(row.schema_version, JOB_SCHEMA, "schema_version расчёта");
observationAuthority(row.authority);
digest(row.request_sha256, "request_sha256");
digest(row.identity_sha256, "identity_sha256");
digest(row.submission_receipt_sha256, "submission_receipt_sha256");
const source = record(row.source, "source");
exactKeys(source, [
"adapter", "bundle_sha256", "capability_manifest_sha256", "catalog_sha256", "session_id",
], "source");
digest(source.catalog_sha256, "source.catalog_sha256");
digest(source.bundle_sha256, "source.bundle_sha256");
digest(source.capability_manifest_sha256, "source.capability_manifest_sha256");
const setup = record(row.setup, "setup");
exactKeys(setup, ["definition_id", "definition_sha256", "definition_version", "setup_id"], "setup");
digest(setup.definition_sha256, "setup.definition_sha256");
const state = oneOf(row.state, [
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
"succeeded", "failed", "reconciliation-required",
] as const, "state");
if (row.claim_lease !== null) {
const lease = record(row.claim_lease, "claim_lease");
exactKeys(
lease,
["claimed_at_utc", "expires_at_utc", "heartbeat_at_utc", "renewal_count"],
"claim_lease",
);
text(lease.claimed_at_utc, "claim_lease.claimed_at_utc");
text(lease.expires_at_utc, "claim_lease.expires_at_utc");
text(lease.heartbeat_at_utc, "claim_lease.heartbeat_at_utc");
nonNegativeInteger(lease.renewal_count, "claim_lease.renewal_count");
}
const result = row.result === null ? null : record(row.result, "result");
if (result !== null) exactKeys(result, ["result_id", "sha256"], "result");
const terminal = row.terminal === null ? null : record(row.terminal, "terminal");
if (terminal !== null) exactKeys(terminal, ["code", "message"], "terminal");
const publication = record(row.publication, "publication");
exactKeys(
publication,
["attempts", "error", "published_at_utc", "state"],
"publication",
);
const publicationState = oneOf(publication.state, [
"not-required", "pending", "failed", "published",
] as const, "publication.state");
const publicationError = publication.error === null
? null
: text(publication.error, "publication.error");
const publishedAtUtc = publication.published_at_utc === null
? null
: text(publication.published_at_utc, "publication.published_at_utc");
return {
jobId: text(row.job_id, "job_id"),
idempotencyKey: text(row.idempotency_key, "idempotency_key"),
sourceSessionId: text(source.session_id, "source.session_id"),
setupId: text(setup.setup_id, "setup.setup_id"),
definitionSha256: String(setup.definition_sha256),
state,
restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"),
resultId: result === null ? null : text(result.result_id, "result.result_id"),
terminalMessage: terminal === null || terminal.message === null
? null
: text(terminal.message, "terminal.message"),
publication: {
state: publicationState,
attempts: nonNegativeInteger(publication.attempts, "publication.attempts"),
error: publicationError,
publishedAtUtc,
},
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
updatedAtUtc: text(row.updated_at_utc, "updated_at_utc"),
};
}
function observationAuthority(value: unknown): void {
const row = record(value, "authority");
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
exactKeys(row, keys, "authority");
keys.forEach((key) => exact(row[key], false, `authority.${key}`));
}
async function request(fetcher: ObservatoryRecordedJobFetch, input: string, init: RequestInit): Promise<Response> {
try {
return await fetcher(input, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservatoryRecordedJobContractError("Очередь расчётов недоступна.");
}
}
async function responseBody(response: Response): Promise<unknown> {
const body = await response.text();
if (!body) return undefined;
try { return JSON.parse(body) as unknown; } catch { return body; }
}
function apiError(body: unknown, status: number): ObservatoryRecordedJobContractError {
const detail = body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).detail
: null;
return new ObservatoryRecordedJobContractError(
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
status,
);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ObservatoryRecordedJobContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) throw new ObservatoryRecordedJobContractError(`${label}: ожидался массив.`);
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ObservatoryRecordedJobContractError(`${label}: ожидался текст.`);
}
return value;
}
function digest(value: unknown, label: string): string {
const valueText = text(value, label);
if (!SHA256.test(valueText)) throw new ObservatoryRecordedJobContractError(`${label}: некорректный digest.`);
return valueText;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") throw new ObservatoryRecordedJobContractError(`${label}: ожидался boolean.`);
return value;
}
function nonNegativeInteger(value: unknown, label: string): number {
if (!Number.isInteger(value) || Number(value) < 0) {
throw new ObservatoryRecordedJobContractError(`${label}: ожидалось целое число.`);
}
return Number(value);
}
function exact<T>(value: unknown, expected: T, label: string): T {
if (value !== expected) throw new ObservatoryRecordedJobContractError(`${label}: значение изменилось.`);
return expected;
}
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
if (typeof value !== "string" || !allowed.includes(value)) {
throw new ObservatoryRecordedJobContractError(`${label}: значение не поддерживается.`);
}
return value as T[number];
}
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
const actual = Object.keys(value).sort();
const sortedExpected = [...expected].sort();
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
throw new ObservatoryRecordedJobContractError(`${label}: обнаружены неизвестные поля.`);
}
}
@@ -0,0 +1,335 @@
import type { LaboratoryFetch } from "../laboratory/advancedResults";
import {
fetchVegetationShadowResultMetadata,
type VegetationFullRouteReview,
type VegetationShadowResult,
} from "../laboratory/vegetationShadow";
import type { ObservationLabInstance } from "../observation/sessionArchive";
import {
decodeLabReplayCapability,
decodeCanonicalLabReplayCapabilityProvenance,
decodePortableLabReplayCapabilityProvenance,
ObservationLabReplayCapabilityContractError,
} from "../observation/labReplayCapability";
const CANONICAL_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
const CANONICAL_SOURCE_SESSION_ID = "20260828T130511Z_viewer_live";
interface ObservatoryRecordedRunBindingBase {
readonly evidenceSessionId: string;
readonly sourceSessionId: string;
readonly resultId: string;
readonly activation: "explicit";
}
export interface ObservatoryCanonicalRecordedRunBinding
extends ObservatoryRecordedRunBindingBase {
readonly kind: "canonical-recorded-rerun";
readonly viewerProfile: "recorded-session";
readonly timeline: "session_time";
}
export interface ObservatoryPortableResultBinding
extends ObservatoryRecordedRunBindingBase {
readonly kind: "portable-result-review";
readonly viewerProfile: "portable-result";
readonly timeline: "result-defined";
readonly definitionSha256: string;
}
export type ObservatoryRecordedRunBinding =
| ObservatoryCanonicalRecordedRunBinding
| ObservatoryPortableResultBinding;
export interface ObservatoryPortableResultReview {
readonly kind: "portable-result";
readonly resultId: string;
readonly sourceSessionId: string;
readonly resultKind: string;
readonly definitionSha256: string;
readonly calculationProfile: Readonly<Record<string, unknown>> | null;
readonly artifactManifestId: string;
readonly artifacts: readonly {
readonly role: string;
readonly mediaType: string;
readonly sha256: string;
readonly byteLength: number;
}[];
readonly resultDocument: Readonly<Record<string, unknown>>;
}
export type ObservatoryRecordedRunReview =
| { readonly kind: "canonical-recorded-rerun"; readonly review: VegetationFullRouteReview }
| ObservatoryPortableResultReview;
export class ObservatoryRecordedRunContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservatoryRecordedRunContractError";
}
}
export function observatoryRecordedRunBinding(
evidenceSessionId: string,
lab: ObservationLabInstance,
): ObservatoryRecordedRunBinding | null {
const capability = lab.replayCapability;
if (!capability) return null;
if (capability.kind === "portable-result-review") {
if (
evidenceSessionId !== lab.resultId
|| lab.sourceSessionId === evidenceSessionId
|| lab.configSha256 === null
|| !/^[a-f0-9]{64}$/.test(lab.configSha256)
|| capability.viewerProfile !== "portable-result"
|| capability.timeline !== "result-defined"
|| capability.activation !== "explicit"
|| capability.commandsEnabled !== false
) {
throw new ObservatoryRecordedRunContractError(
`Portable-результат ${lab.resultId} не допущен к универсальному viewer.`,
);
}
try {
decodePortableLabReplayCapabilityProvenance(
lab.provenance,
evidenceSessionId,
lab.resultId,
lab.sourceSessionId,
lab.configSha256,
);
} catch (error) {
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
throw new ObservatoryRecordedRunContractError(error.message);
}
return {
kind: "portable-result-review",
evidenceSessionId,
sourceSessionId: lab.sourceSessionId,
resultId: lab.resultId,
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
definitionSha256: lab.configSha256,
};
}
try {
decodeCanonicalLabReplayCapabilityProvenance(
lab.provenance,
evidenceSessionId,
lab.resultId,
);
} catch (error) {
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
throw new ObservatoryRecordedRunContractError(error.message);
}
if (
evidenceSessionId !== lab.resultId
|| lab.sourceSessionId !== CANONICAL_SOURCE_SESSION_ID
|| lab.labId !== "LAB V1"
|| lab.resultKind !== "recorded-perception-qualification"
|| !CANONICAL_RESULT_ID.test(lab.resultId)
|| lab.configSha256 !== null
|| lab.sourceResultId === null
|| !CANONICAL_RESULT_ID.test(lab.sourceResultId)
|| capability.kind !== "canonical-recorded-rerun"
|| capability.viewerProfile !== "recorded-session"
|| capability.timeline !== "session_time"
|| capability.activation !== "explicit"
|| capability.commandsEnabled !== false
) {
throw new ObservatoryRecordedRunContractError(
`LAB-результат ${lab.resultId} не допущен к каноническому recorded replay.`,
);
}
return {
kind: "canonical-recorded-rerun",
evidenceSessionId,
sourceSessionId: lab.sourceSessionId,
resultId: lab.resultId,
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
};
}
export async function fetchObservatoryRecordedRunReview(
binding: ObservatoryRecordedRunBinding,
{
selectedSourceSessionId,
fetcher = fetch,
signal,
}: {
selectedSourceSessionId: string;
fetcher?: LaboratoryFetch;
signal?: AbortSignal;
},
): Promise<ObservatoryRecordedRunReview> {
if (binding.sourceSessionId !== selectedSourceSessionId) {
throw new ObservatoryRecordedRunContractError(
"Запуск не связан с выбранной исходной сессией.",
);
}
if (binding.kind === "portable-result-review") {
return fetchPortableResultReview(binding, { fetcher, signal });
}
const result = await fetchVegetationShadowResultMetadata(binding.resultId, {
fetcher,
signal,
});
return {
kind: "canonical-recorded-rerun",
review: admitObservatoryRecordedRunReview(
binding,
selectedSourceSessionId,
result,
),
};
}
export function admitObservatoryRecordedRunReview(
binding: ObservatoryCanonicalRecordedRunBinding,
selectedSourceSessionId: string,
result: VegetationShadowResult,
): VegetationFullRouteReview {
if (binding.sourceSessionId !== selectedSourceSessionId) {
throw new ObservatoryRecordedRunContractError(
"Запуск не связан с выбранной исходной сессией.",
);
}
if (result.resultId !== binding.resultId) {
throw new ObservatoryRecordedRunContractError(
"Запечатанный результат не совпадает с выбранным запуском.",
);
}
const review = result.routeFullReview;
if (!review || review.sessionId !== binding.sourceSessionId) {
throw new ObservatoryRecordedRunContractError(
"Запечатанный результат потерял точную связь с исходной сессией.",
);
}
return review;
}
async function fetchPortableResultReview(
binding: ObservatoryPortableResultBinding,
{
fetcher,
signal,
}: {
fetcher: LaboratoryFetch;
signal?: AbortSignal;
},
): Promise<ObservatoryPortableResultReview> {
const response = await fetcher(
`/api/v1/observatory/portable-results/${encodeURIComponent(binding.resultId)}`,
{ headers: { Accept: "application/json" }, signal },
);
let body: unknown;
try {
body = await response.json();
} catch {
throw new ObservatoryRecordedRunContractError(
"Portable viewer получил некорректный ответ сервера.",
);
}
if (!response.ok) {
throw new ObservatoryRecordedRunContractError(
isRecord(body) && typeof body.detail === "string"
? body.detail
: `Portable viewer вернул HTTP ${response.status}.`,
);
}
return decodePortableResultReview(body, binding);
}
function decodePortableResultReview(
value: unknown,
binding: ObservatoryPortableResultBinding,
): ObservatoryPortableResultReview {
if (!isRecord(value)) {
throw new ObservatoryRecordedRunContractError("Portable viewer вернул не объект.");
}
const expected = new Set([
"schema_version",
"result_id",
"source_session_id",
"result_kind",
"definition_sha256",
"viewer_capability",
"calculation_profile",
"artifact_manifest_id",
"artifacts",
"result_document",
]);
if (Object.keys(value).some((key) => !expected.has(key)) || Object.keys(value).length !== expected.size) {
throw new ObservatoryRecordedRunContractError("Portable viewer изменил контракт ответа.");
}
let viewerCapability;
try {
viewerCapability = decodeLabReplayCapability(
value.viewer_capability,
binding.evidenceSessionId,
);
} catch (error) {
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
throw new ObservatoryRecordedRunContractError(error.message);
}
if (
value.schema_version !== "missioncore.observatory-portable-result-view/v1"
|| value.result_id !== binding.resultId
|| value.source_session_id !== binding.sourceSessionId
|| value.definition_sha256 !== binding.definitionSha256
|| typeof value.result_kind !== "string"
|| typeof value.artifact_manifest_id !== "string"
|| !/^[a-f0-9]{64}$/.test(value.artifact_manifest_id)
|| !Array.isArray(value.artifacts)
|| !isRecord(value.result_document)
|| !isRecord(value.calculation_profile)
|| viewerCapability === null
|| viewerCapability.kind !== "portable-result-review"
|| viewerCapability.viewerProfile !== binding.viewerProfile
|| viewerCapability.timeline !== binding.timeline
) {
throw new ObservatoryRecordedRunContractError("Portable viewer потерял identity результата.");
}
const artifacts = value.artifacts.map((item) => {
if (
!isRecord(item)
|| Object.keys(item).length !== 4
|| !["role", "media_type", "sha256", "byte_length"].every(
(key) => Object.prototype.hasOwnProperty.call(item, key),
)
|| typeof item.role !== "string"
|| typeof item.media_type !== "string"
|| typeof item.sha256 !== "string"
|| !/^[a-f0-9]{64}$/.test(item.sha256)
|| typeof item.byte_length !== "number"
|| !Number.isSafeInteger(item.byte_length)
|| item.byte_length < 0
) {
throw new ObservatoryRecordedRunContractError("Portable viewer получил неверный artifact.");
}
return {
role: item.role,
mediaType: item.media_type,
sha256: item.sha256,
byteLength: item.byte_length,
};
});
return {
kind: "portable-result",
resultId: binding.resultId,
sourceSessionId: binding.sourceSessionId,
resultKind: value.result_kind,
definitionSha256: binding.definitionSha256,
calculationProfile: value.calculation_profile,
artifactManifestId: value.artifact_manifest_id,
artifacts,
resultDocument: value.result_document,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
@@ -0,0 +1,148 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
applyObservatoryCatalogMutationOverlay,
fetchObservatoryCatalog,
reconcileObservatoryCatalogMutationOverlay,
type ObservatoryCatalog,
type ObservatoryCatalogMutationOverlay,
} from "./catalog";
export type ObservatoryCatalogState =
| "idle"
| "loading"
| "ready"
| "refreshing"
| "error";
export interface ObservatoryCatalogController {
readonly catalog: ObservatoryCatalog | null;
readonly state: ObservatoryCatalogState;
readonly error: string | null;
readonly refresh: () => Promise<ObservatoryCatalog | null>;
readonly applyEvidenceRename: (
sessionId: string,
displayName: string,
) => void;
readonly applyEvidenceDeletion: (sessionId: string) => void;
}
interface ActiveCatalogRequest {
readonly id: number;
readonly controller: AbortController;
}
type ObservatoryCatalogMutationDraft =
| { readonly kind: "rename"; readonly displayName: string }
| { readonly kind: "delete" };
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";
}
export function useObservatoryCatalog(): ObservatoryCatalogController {
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
const [state, setState] = useState<ObservatoryCatalogState>("idle");
const [error, setError] = useState<string | null>(null);
const catalogRef = useRef<ObservatoryCatalog | null>(null);
const overlayRef = useRef<ObservatoryCatalogMutationOverlay>(new Map());
const mutationRevisionRef = useRef(0);
const requestSequenceRef = useRef(0);
const activeRequestRef = useRef<ActiveCatalogRequest | null>(null);
const loadCatalog = useCallback(async (): Promise<ObservatoryCatalog | null> => {
const id = requestSequenceRef.current + 1;
requestSequenceRef.current = id;
activeRequestRef.current?.controller.abort();
const controller = new AbortController();
activeRequestRef.current = { id, controller };
const requestMutationRevision = mutationRevisionRef.current;
setState(catalogRef.current ? "refreshing" : "loading");
setError(null);
try {
const serverCatalog = await fetchObservatoryCatalog({
signal: controller.signal,
});
if (controller.signal.aborted || requestSequenceRef.current !== id) {
return null;
}
const reconciled = reconcileObservatoryCatalogMutationOverlay(
serverCatalog,
overlayRef.current,
requestMutationRevision,
);
overlayRef.current = reconciled.overlay;
catalogRef.current = reconciled.catalog;
activeRequestRef.current = null;
setCatalog(reconciled.catalog);
setState("ready");
return reconciled.catalog;
} catch (loadError: unknown) {
if (
controller.signal.aborted
|| requestSequenceRef.current !== id
|| isAbortError(loadError)
) {
return null;
}
activeRequestRef.current = null;
setError(errorMessage(loadError));
setState("error");
return null;
}
}, []);
useEffect(() => {
void loadCatalog();
return () => {
requestSequenceRef.current += 1;
activeRequestRef.current?.controller.abort();
activeRequestRef.current = null;
};
}, [loadCatalog]);
const applyMutation = useCallback((
sessionId: string,
mutation: ObservatoryCatalogMutationDraft,
) => {
const revision = mutationRevisionRef.current + 1;
mutationRevisionRef.current = revision;
const overlay = new Map(overlayRef.current);
overlay.set(sessionId, { ...mutation, revision });
overlayRef.current = overlay;
if (!catalogRef.current) return;
const projected = applyObservatoryCatalogMutationOverlay(
catalogRef.current,
overlay,
);
catalogRef.current = projected;
setCatalog(projected);
}, []);
const applyEvidenceRename = useCallback((
sessionId: string,
displayName: string,
) => {
applyMutation(sessionId, { kind: "rename", displayName });
}, [applyMutation]);
const applyEvidenceDeletion = useCallback((sessionId: string) => {
applyMutation(sessionId, { kind: "delete" });
}, [applyMutation]);
return {
catalog,
state,
error,
refresh: loadCatalog,
applyEvidenceRename,
applyEvidenceDeletion,
};
}
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
fetchObservatoryPortableLaboratorySetups,
preflightObservatoryLaboratorySetup,
selectableObservatorySetups,
type ObservatoryLaboratoryRunPreflight,
type ObservatoryLaboratorySetupCatalog,
} from "./laboratorySetups";
type SetupCatalogState = "idle" | "loading" | "ready" | "refreshing" | "error";
export type SetupPreflightState =
| { readonly kind: "idle" }
| { readonly kind: "checking" }
| { readonly kind: "ready"; readonly value: ObservatoryLaboratoryRunPreflight }
| { readonly kind: "error"; readonly message: string };
export function useObservatoryLaboratorySetups(sourceSessionId: string) {
const [catalog, setCatalog] = useState<ObservatoryLaboratorySetupCatalog | null>(null);
const [state, setState] = useState<SetupCatalogState>("idle");
const [error, setError] = useState<string | null>(null);
const [selectedSetupId, setSelectedSetupId] = useState("");
const [revision, setRevision] = useState(0);
const [preflight, setPreflight] = useState<SetupPreflightState>({ kind: "idle" });
const requestSequence = useRef(0);
const preflightRequest = useRef<AbortController | null>(null);
const activeCatalog = catalog?.sourceSessionId === sourceSessionId ? catalog : null;
const selectableSetups = useMemo(() => selectableObservatorySetups(activeCatalog), [activeCatalog]);
const selectedSetup = selectableSetups.find((setup) => setup.setupId === selectedSetupId) ?? null;
const selectedDefinitionSha256 = selectedSetup?.runDefinition?.definitionSha256 ?? null;
useEffect(() => {
preflightRequest.current?.abort();
preflightRequest.current = null;
if (!sourceSessionId) {
setCatalog(null);
setState("idle");
setError(null);
setSelectedSetupId("");
setPreflight({ kind: "idle" });
return;
}
const sequence = ++requestSequence.current;
const request = new AbortController();
setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading");
setError(null);
setPreflight({ kind: "idle" });
// Archived definitions belong to evidence, not to the calculation selector.
void fetchObservatoryPortableLaboratorySetups(sourceSessionId, { signal: request.signal })
.then((next) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setCatalog(next);
const selectable = selectableObservatorySetups(next);
setSelectedSetupId((current) => (
selectable.some((setup) => setup.setupId === current)
? current
: selectable[0]?.setupId ?? ""
));
setState("ready");
})
.catch((caught: unknown) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setState("error");
setError(caught instanceof Error && caught.message.trim()
? caught.message
: "Каталог профилей недоступен.");
});
return () => request.abort();
}, [revision, sourceSessionId]);
useEffect(() => () => preflightRequest.current?.abort(), []);
useEffect(() => {
preflightRequest.current?.abort();
preflightRequest.current = null;
setPreflight((current) => current.kind === "idle" ? current : { kind: "idle" });
}, [selectedSetupId, sourceSessionId, selectedDefinitionSha256]);
const selectSetup = useCallback((setupId: string) => {
preflightRequest.current?.abort();
preflightRequest.current = null;
setSelectedSetupId(setupId);
setPreflight({ kind: "idle" });
}, []);
const refresh = useCallback(() => setRevision((value) => value + 1), []);
const check = useCallback(async () => {
if (!sourceSessionId || !selectedSetup || preflight.kind === "checking") return;
preflightRequest.current?.abort();
const request = new AbortController();
preflightRequest.current = request;
setPreflight({ kind: "checking" });
try {
const value = await preflightObservatoryLaboratorySetup(
sourceSessionId,
selectedSetup,
{ signal: request.signal },
);
if (request.signal.aborted || preflightRequest.current !== request) return;
setPreflight({ kind: "ready", value });
} catch (caught) {
if (request.signal.aborted || preflightRequest.current !== request) return;
setPreflight({
kind: "error",
message: caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось проверить возможность запуска.",
});
} finally {
if (preflightRequest.current === request) preflightRequest.current = null;
}
}, [preflight.kind, selectedSetup, sourceSessionId]);
useEffect(() => {
if (state !== "ready" || !selectedSetup || preflight.kind !== "idle") return;
void check();
}, [check, preflight.kind, selectedSetup, state]);
return {
catalog: activeCatalog,
selectableSetups,
state,
error,
selectedSetupId: selectedSetup?.setupId ?? "",
selectedSetup,
selectSetup,
refresh,
preflight,
check,
};
}
@@ -0,0 +1,199 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchObservatoryRecordedJobs,
retryObservatoryRecordedJobPublication,
submitObservatoryRecordedJob,
type ObservatoryRecordedJob,
} from "./recordedJobs";
type RecordedJobsState =
| "idle"
| "loading"
| "ready"
| "refreshing"
| "submitting"
| "retrying-publication"
| "error";
interface JobSnapshot {
readonly selectionKey: string;
readonly jobs: readonly ObservatoryRecordedJob[];
readonly state: RecordedJobsState;
readonly error: string | null;
}
const EMPTY_JOBS = [] as const;
const OPEN_STATES = new Set([
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
"reconciliation-required",
]);
const POLL_INTERVAL_MS = 1_500;
export function useObservatoryRecordedJobs(
sourceSessionId: string,
setupId: string,
definitionSha256: string,
) {
const selectionKey = JSON.stringify([sourceSessionId, setupId, definitionSha256]);
const [snapshot, setSnapshot] = useState<JobSnapshot>({
selectionKey: "", jobs: EMPTY_JOBS, state: "idle", error: null,
});
const [revision, setRevision] = useState(0);
const requestRef = useRef<AbortController | null>(null);
const requestSequence = useRef(0);
const idempotencyKeys = useRef(new Map<string, string>());
const observedJobId = useRef<string | null>(null);
const hasSelection = Boolean(sourceSessionId && setupId && definitionSha256);
// A source/profile change must not display even one render of the previous queue.
const current = snapshot.selectionKey === selectionKey ? snapshot : null;
const jobs = current?.jobs ?? EMPTY_JOBS;
const state = current?.state ?? (hasSelection ? "loading" : "idle");
const error = current?.error ?? null;
useEffect(() => {
requestRef.current?.abort();
requestRef.current = null;
if (!hasSelection) {
setSnapshot({ selectionKey, jobs: EMPTY_JOBS, state: "idle", error: null });
return;
}
const sequence = ++requestSequence.current;
const request = new AbortController();
requestRef.current = request;
setSnapshot({
selectionKey, jobs,
state: jobs.length > 0 ? "refreshing" : "loading", error: null,
});
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, {
definitionSha256, signal: request.signal,
}).then((next) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
const pending = next.find((job) => OPEN_STATES.has(job.state));
if (pending) observedJobId.current = pending.jobId;
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null });
}).catch((caught: unknown) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setSnapshot({
selectionKey, jobs, state: "error",
error: caught instanceof Error && caught.message.trim()
? caught.message
: "Очередь расчётов недоступна.",
});
}).finally(() => {
if (requestRef.current === request) requestRef.current = null;
});
return () => request.abort();
}, [revision, selectionKey]);
useEffect(() => () => requestRef.current?.abort(), []);
const latestJob = jobs[0] ?? null;
const activeJob = jobs.find((job) => OPEN_STATES.has(job.state)) ?? null;
const publicationPending = jobs.some((job) => job.publication.state === "pending");
// Do not promote historical terminal logs to a new operator action's error.
const computationFailed = latestJob?.state === "failed"
&& latestJob.jobId === observedJobId.current;
useEffect(() => {
if (state !== "ready" || (!activeJob && !publicationPending)) return;
const timer = globalThis.setTimeout(
() => setRevision((value) => value + 1),
POLL_INTERVAL_MS,
);
return () => globalThis.clearTimeout(timer);
}, [activeJob, publicationPending, revision, state]);
useEffect(() => {
if (!latestJob || activeJob) return;
idempotencyKeys.current.delete(selectionKey);
}, [activeJob, latestJob, selectionKey]);
const refresh = useCallback(() => setRevision((value) => value + 1), []);
const submit = useCallback(async (
portableBinding: {
readonly definitionSha256: string;
readonly checkSha256: string;
} | null = null,
): Promise<ObservatoryRecordedJob | null> => {
if (!hasSelection || state !== "ready"
|| portableBinding?.definitionSha256 !== definitionSha256
|| publicationPending || latestJob?.publication.state === "failed") return null;
if (activeJob) return activeJob;
requestRef.current?.abort();
const request = new AbortController();
requestRef.current = request;
const key = idempotencyKeys.current.get(selectionKey) ?? createIdempotencyKey();
idempotencyKeys.current.set(selectionKey, key);
setSnapshot({ selectionKey, jobs, state: "submitting", error: null });
try {
const job = await submitObservatoryRecordedJob(
sourceSessionId, setupId, key, portableBinding, { signal: request.signal },
);
if (request.signal.aborted || requestRef.current !== request) return null;
observedJobId.current = job.jobId;
setSnapshot({
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
state: "ready", error: null,
});
return job;
} catch (caught) {
if (request.signal.aborted || requestRef.current !== request) return null;
setSnapshot({
selectionKey, jobs, state: "error",
error: caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось поставить расчёт в очередь.",
});
return null;
} finally {
if (requestRef.current === request) requestRef.current = null;
}
}, [activeJob, definitionSha256, hasSelection, jobs, latestJob,
publicationPending, selectionKey, setupId, sourceSessionId, state]);
const retryPublication = useCallback(async (): Promise<ObservatoryRecordedJob | null> => {
if (!latestJob || latestJob.publication.state !== "failed"
|| state === "retrying-publication") return null;
requestRef.current?.abort();
const request = new AbortController();
requestRef.current = request;
setSnapshot({ selectionKey, jobs, state: "retrying-publication", error: null });
try {
const job = await retryObservatoryRecordedJobPublication(latestJob.jobId, {
signal: request.signal,
});
if (request.signal.aborted || requestRef.current !== request) return null;
setSnapshot({
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
state: "ready", error: null,
});
return job;
} catch (caught) {
if (request.signal.aborted || requestRef.current !== request) return null;
setSnapshot({
selectionKey, jobs, state: "error",
error: caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось повторить публикацию результата.",
});
return null;
} finally {
if (requestRef.current === request) requestRef.current = null;
}
}, [jobs, latestJob, selectionKey, state]);
return {
jobs, latestJob, activeJob, publicationPending, computationFailed,
state, error, refresh, submit, retryPublication,
};
}
function createIdempotencyKey(): string {
const entropy = typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
return `observatory-ui:${entropy}`;
}
export type ObservatoryRecordedJobsController = ReturnType<typeof useObservatoryRecordedJobs>;
@@ -38,6 +38,8 @@ export function useWorkerTelemetry(
setError(null);
return;
}
setTelemetry(null);
setError(null);
const controller = new AbortController();
const stopPolling = startSequentialPolling(async () => {
setLoading(true);
@@ -48,6 +50,7 @@ export function useWorkerTelemetry(
setError(null);
} catch (reason: unknown) {
if (controller.signal.aborted) return;
setTelemetry(null);
setError(
reason instanceof Error
? reason.message
@@ -114,6 +114,9 @@ export interface WorkerTelemetry {
power_watts?: number;
temperature_celsius?: number;
memory_used_percent?: number;
sm_clock_mhz?: number | null;
memory_clock_mhz?: number | null;
driver_version?: string | null;
} | null;
triton: {
ready: boolean;
@@ -131,6 +134,13 @@ export interface WorkerTelemetry {
completed_runs: number | null;
failed_runs: number | null;
model_load_seconds: number | null;
profile_name?: string | null;
input_state?: string | null;
wait_reason?: string | null;
live_children?: number | null;
input_pauses?: number | null;
pending_bundles?: number | null;
buffer_bytes?: number | null;
stages: WorkerPipelineStage[];
};
network: {
@@ -189,7 +199,9 @@ export async function fetchWorkerTelemetry(
`/api/v1/system/contours/${encodeURIComponent(contourId)}/telemetry?history=90`,
{
method: "GET",
signal,
signal: signal
? AbortSignal.any([signal, AbortSignal.timeout(5_000)])
: AbortSignal.timeout(5_000),
},
),
"missioncore.worker-telemetry/v1",
+13 -1
View File
@@ -23,7 +23,8 @@ export type WorkspaceKind =
| "datasets"
| "artifact-health"
| "lab-archive"
| "simulations";
| "simulations"
| "observatory";
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
@@ -166,6 +167,17 @@ export const workspaces: WorkspaceDefinition[] = [
kind: "simulations",
groups: [],
},
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Проверка компьютерного зрения",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
},
{
id: "contour-health",
root: "fleet",
+1
View File
@@ -21,6 +21,7 @@
@import "./styles/device.css";
@import "./styles/responsive.css";
@import "./styles/observation.css";
@import "./styles/observatory.css";
@import "./styles/environment-settings.css";
@import "./styles/system-telemetry.css";
@import "./styles/artifact-health.css";
@@ -27,6 +27,88 @@
display: block;
}
.m4-replay-threat-visual__unified-content {
position: absolute;
z-index: 1;
inset: 0;
min-width: 0;
min-height: 0;
}
.m4-replay-threat-visual__unified-content > *,
.m4-replay-threat-visual__unified-content .rerun-viewport {
width: 100%;
height: 100%;
}
.m4-replay-threat-visual__deck[data-native-rerun="true"] > .nodedc-split-pane {
position: relative;
z-index: 2;
pointer-events: none;
}
.m4-replay-threat-visual__deck[data-native-rerun="true"]
.m4-replay-threat-visual__pane {
background: transparent;
pointer-events: none;
}
.m4-replay-threat-visual__deck[data-native-rerun="true"]
.nodedc-split-pane__separator,
.m4-replay-threat-visual__deck[data-native-rerun="true"]
.m4-replay-threat-visual__pane-toolbar,
.m4-replay-threat-visual__deck[data-native-rerun="true"]
.m4-replay-threat-visual__pane-toolbar * {
pointer-events: auto;
}
.canonical-vegetation-rerun-replay {
width: 100%;
min-width: 0;
}
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
.m4-replay-threat-visual__pane-toolbar,
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
.laboratory-evidence-viewer__controls,
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
.laboratory-evidence-viewer__transport {
visibility: hidden;
pointer-events: none;
}
.canonical-vegetation-rerun-replay[data-presentation-state="loading"]
.m4-replay-threat-visual__unified-content {
visibility: hidden;
}
.canonical-vegetation-rerun-replay__viewport-lock {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.canonical-vegetation-rerun-replay__viewport-lock
.rerun-viewport__camera-lock {
width: var(--canonical-rerun-camera-pane, 100%);
}
.canonical-vegetation-rerun-replay__viewport-lock[data-split-view="true"]
.rerun-viewport__camera-lock {
width: calc(var(--canonical-rerun-camera-pane, 46%) - 0.75rem);
}
.canonical-vegetation-rerun-replay__loading {
position: absolute;
z-index: 6;
inset: 0;
display: grid;
place-items: center;
background: var(--nodedc-canvas);
pointer-events: none;
}
.m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state {
position: absolute;
z-index: 1;
@@ -247,6 +329,10 @@
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
}
.canonical-vegetation-rerun-replay__timeline .observation-timeline__playback {
grid-template-columns: auto auto minmax(0, 1fr) auto;
}
.m4-replay-threat-visual__overlay {
box-sizing: border-box;
min-width: 0;
@@ -0,0 +1,401 @@
.observatory-workspace {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 1rem;
min-width: 0;
min-height: 100%;
padding: 1rem;
container-name: observatory-workspace;
container-type: inline-size;
}
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice,
.observatory-evidence > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.observatory-lead,
.observatory-notice {
flex-wrap: wrap;
}
.observatory-lead > div,
.observatory-catalog-bar__copy,
.observatory-evidence header > div {
min-width: 0;
}
.observatory-lead h2,
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-evidence h3 {
margin: 0.3rem 0 0;
}
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-evidence h3 {
font-size: var(--nodedc-font-size-lg);
letter-spacing: -0.02em;
line-height: 1.15;
}
.observatory-lead p,
.observatory-state p,
.observatory-evidence-empty p {
max-width: 52rem;
margin: 0.35rem 0 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-catalog-bar,
.observatory-session-summary {
align-self: start;
min-height: 0;
}
.observatory-catalog-bar__copy {
display: grid;
flex: 0 0 auto;
align-content: center;
gap: 0.1rem;
}
.observatory-catalog-bar h3 {
margin-top: 0.1rem;
}
.observatory-catalog-bar__controls {
min-width: 0;
flex: 1 1 auto;
flex-wrap: nowrap;
justify-content: flex-end;
}
.observatory-catalog-bar__controls .nodedc-select-anchor {
flex: 1 1 18rem;
min-width: 0;
}
.observatory-catalog-bar__controls > .nodedc-button {
flex: 0 0 auto;
}
.observatory-catalog-bar__run {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.55rem;
}
.observatory-state {
display: grid;
min-height: 19rem;
place-items: center;
align-content: center;
gap: 0.7rem;
text-align: center;
}
.observatory-state h3 {
max-width: 42rem;
margin: 0;
}
.observatory-session-stack {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 0.9rem;
container-name: observatory-session;
container-type: inline-size;
}
.observatory-session-summary {
display: grid;
grid-template-columns: minmax(11rem, 1.05fr) minmax(20rem, 1.6fr) minmax(11rem, auto);
align-items: center;
gap: 0.75rem;
}
.observatory-session-summary__identity {
display: grid;
min-width: 0;
gap: 0.22rem;
}
.observatory-session-summary__identity h3 {
margin: 0.1rem 0 0;
}
.observatory-session-summary__identity code {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.observatory-session-summary__facts {
display: grid;
grid-template-columns: repeat(3, minmax(6.5rem, 1fr));
gap: 0.75rem;
margin: 0;
}
.observatory-session-summary__facts > div {
display: grid;
min-width: 0;
gap: 0.24rem;
}
.observatory-session-summary__facts dt {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-session-summary__facts dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-session-summary__end {
display: grid;
min-width: 0;
justify-items: end;
gap: 0.4rem;
}
.observatory-modalities {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.45rem;
align-items: center;
color: var(--nodedc-text-muted);
}
.observatory-evidence {
display: grid;
gap: 0.65rem;
}
.observatory-evidence > header {
padding: 0 0.2rem;
}
.observatory-evidence-list {
display: grid;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.observatory-evidence-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 0.8rem;
min-height: 4.25rem;
padding: 0.7rem 0.85rem;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-glass-control-bg);
}
.observatory-evidence-card__icon {
display: grid;
width: 2.15rem;
height: 2.15rem;
place-items: center;
border-radius: var(--nodedc-radius-circle);
background: var(--nodedc-panel-icon-bg);
color: var(--nodedc-text-secondary);
}
.observatory-evidence-card__copy {
display: grid;
min-width: 0;
gap: 0.16rem;
}
.observatory-evidence-card__copy strong,
.observatory-evidence-card__copy span,
.observatory-evidence-card__copy small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.observatory-evidence-card__copy span {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
}
.observatory-evidence-card__copy small {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-evidence-card__actions,
.observatory-replay__header,
.observatory-replay-state,
.observatory-replay-state__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.85rem;
}
.observatory-replay-state {
flex-wrap: wrap;
}
.observatory-replay__header > div,
.observatory-replay-state > div {
min-width: 0;
}
.observatory-evidence-card__actions:empty {
display: none;
}
.observatory-replay,
.observatory-replay-state {
min-width: 0;
}
.observatory-replay {
display: grid;
gap: 0.85rem;
}
.observatory-replay__header {
padding: 0.25rem 0.25rem 0;
}
.observatory-replay__header h3,
.observatory-replay-state h3 {
margin: 0.3rem 0 0;
}
.observatory-replay__header p,
.observatory-replay-state p {
margin: 0.35rem 0 0;
color: var(--nodedc-text-muted);
}
.observatory-evidence-empty {
display: grid;
min-height: 13rem;
place-items: center;
align-content: center;
gap: 0.55rem;
text-align: center;
}
.observatory-notice {
justify-content: flex-start;
color: var(--nodedc-text-secondary);
}
.observatory-notice__copy {
min-width: 0;
flex: 1 1 auto;
}
.observatory-rename-form,
.observatory-delete-confirmation {
display: grid;
gap: 0.75rem;
}
.observatory-delete-confirmation p,
.observatory-mutation-error {
margin: 0;
line-height: 1.5;
}
.observatory-mutation-error {
color: rgb(var(--nodedc-danger-rgb));
font-size: var(--nodedc-font-size-sm);
}
@container observatory-session (max-width: 46rem) {
.observatory-session-summary {
grid-template-columns: minmax(0, 1fr);
align-items: start;
}
.observatory-session-summary__end,
.observatory-modalities {
justify-items: start;
justify-content: flex-start;
}
}
@container observatory-session (max-width: 48rem) {
.observatory-evidence-card {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.observatory-evidence-card > .nodedc-status {
display: none;
}
}
@container observatory-session (max-width: 38rem) {
.observatory-session-summary__facts {
grid-template-columns: minmax(0, 1fr);
}
.observatory-evidence-card__copy small {
white-space: normal;
}
}
@container observatory-workspace (max-width: 920px) {
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice {
align-items: stretch;
flex-direction: column;
}
.observatory-catalog-bar__controls {
flex-basis: auto;
}
.observatory-catalog-bar__controls .nodedc-select-anchor {
flex: 0 0 auto;
width: 100%;
min-width: 0;
}
.observatory-catalog-bar__run {
justify-content: flex-end;
}
}
@media (max-width: 920px) {
.observatory-replay__header,
.observatory-replay-state {
align-items: stretch;
flex-direction: column;
}
}
+1 -1
View File
@@ -78,7 +78,7 @@
overflow: hidden;
}
/* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its
/* The upstream Rerun canvas keeps three fixed 24px 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. */
@@ -41,6 +41,7 @@ import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
import { NetworkWorkspace } from "./system/NetworkWorkspace";
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
import { ObservatoryWorkspace } from "./observatory/ObservatoryWorkspace";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
if (status === "ready") return "accent";
@@ -1191,6 +1192,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
);
case "simulations":
return <SimulationWorkspace />;
case "observatory":
return <ObservatoryWorkspace definition={props.definition} />;
case "device":
return null;
}
@@ -0,0 +1 @@
export { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
@@ -54,6 +54,7 @@ import {
buildLaboratoryProfiles,
experimentOptionsForProfile,
freshestLaboratorySelection,
isLegacyPublishedLaboratoryWork,
workOptionsForExperiment,
} from "./laboratoryArchiveProfiles";
import {
@@ -499,12 +500,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
onDeleteBegin: props.sessionArchive.onDeleteBegin,
});
const publishedWorks = useMemo(
() => sessions.items.filter((session) => (
session.lab !== null
&& session.status === "ready"
&& session.replayable
&& session.modalities.includes("point-cloud")
)).sort((left, right) => (
() => sessions.items.filter(isLegacyPublishedLaboratoryWork).sort((left, right) => (
Date.parse(right.lab?.runCreatedAtUtc ?? right.startedAtUtc)
- Date.parse(left.lab?.runCreatedAtUtc ?? left.startedAtUtc)
)),
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import {
LaboratoryEvidence,
@@ -7,7 +7,6 @@ import {
LaboratoryWorkTemplate,
} from "../../components/laboratory/LaboratoryPresentation";
import {
vegetationFullRouteMaskUrl,
vegetationVideoMaskUrl,
type VegetationFullRouteReview,
type VegetationShadowResult,
@@ -17,13 +16,7 @@ import {
type M49TgsFullShadowResult,
} from "../../core/laboratory/m49TgsFullShadow";
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
import {
M4ReplayThreatVisual,
type M4ReplayClassifiedSpatialLayer,
type M4ReplayThreatSemanticLayer,
} from "./M4ReplayThreatVisual";
const VEGETATION_TIMELINE_ENDPOINT = "/api/v1/laboratory/vegetation-shadow";
import { CanonicalVegetationRerunReplay } from "./CanonicalVegetationRerunReplay";
function decimal(value: number, digits = 1): string {
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
@@ -36,56 +29,7 @@ function FullRouteReviewEvidence({
resultId: string;
review: VegetationFullRouteReview;
}) {
const semanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(() => ([
{
id: "city",
controlLabel: "ГОРОД · EoMT",
resultId,
spatialResultId: null,
taxonomy: review.city.taxonomy,
maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "city", sequence),
label: review.city.name,
maskAriaLabel: "EoMT city semantic prediction",
},
{
id: "vegetation",
controlLabel: "ПРИРОДА · DDRNet",
resultId,
spatialResultId: null,
taxonomy: review.vegetation.taxonomy,
maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "vegetation", sequence),
label: review.vegetation.name,
maskAriaLabel: "DDRNet nature semantic prediction",
},
]), [resultId, review.city, review.vegetation]);
const sealedSpatialGap = useMemo<M4ReplayClassifiedSpatialLayer>(() => ({
label: "RAVNOVES004TREE",
pointLayerLabel: "SOURCE POINTS",
cellLayerLabel: "TGS COSTMAP",
cellLayerAvailable: false,
expectedAtSequence: false,
frame: null,
loading: false,
error: null,
replacePointCloud: false,
}), []);
return (
<M4ReplayThreatVisual
resultId={resultId}
timelineEndpointRoot={VEGETATION_TIMELINE_ENDPOINT}
semanticLayers={semanticLayers}
initialSemanticLayerId="vegetation"
initialSpatialMode="3d"
classifiedSpatialLayer={sealedSpatialGap}
evidenceLabel="RAVNOVES004TREE"
playbackTransport="segmented"
spatialPlaybackTransport="sealed-binary"
recoverTimestampStalls
showReferenceMediaLayers
showSpatialOverlaySummary
/>
);
return <CanonicalVegetationRerunReplay resultId={resultId} review={review} />;
}
function FullRouteReviewResult({
@@ -102,16 +46,16 @@ function FullRouteReviewResult({
summary={(
<LaboratorySummary
title="LAB V1 · RAVNOVES004TREE · полный маршрут"
description="Принятый recorded-LAB инструмент воспроизводит RAV004 без отдельного viewer: одна media-clock timeline, RIGHT camera, source points, bounded Local SLAM и переключаемые EoMT/DDRNet."
status="FULL RECORDED REVIEW · truth отсутствует · commands OFF"
description="Принятый инструмент записанной LAB воспроизводит RAV004 без отдельного viewer: единый таймлайн, правая камера, исходные точки, ограниченный Local SLAM и переключаемые EoMT/DDRNet."
status="ПОЛНЫЙ ПРОСМОТР ЗАПИСИ · эталон отсутствует · команды ВЫКЛ"
statusTone="warning"
facts={[
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount}/${review.frameCount} camera frames` },
{ label: "3D", value: "1444 source cloud increments · gravity-stable RFU → body" },
{ label: "Город", value: `${review.city.name} · ${decimal(review.city.inferenceFps, 2)} fps` },
{ label: "Природа", value: `${review.vegetation.name} · ${decimal(review.vegetation.inferenceFps, 2)} fps` },
{ label: "TGS", value: "10 review anchors существуют · full-route artifact отсутствует" },
{ label: "Authority", value: `${rigLabel} · VISUAL REVIEW ONLY · commands OFF` },
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount}/${review.frameCount} кадров камеры` },
{ label: "3D", value: "1444 приращения исходного облака · стабильная по гравитации RFU → корпус" },
{ label: "Город", value: `${review.city.name} · ${decimal(review.city.inferenceFps, 2)} кадра/с` },
{ label: "Природа", value: `${review.vegetation.name} · ${decimal(review.vegetation.inferenceFps, 2)} кадра/с` },
{ label: "TGS", value: "существуют 10 контрольных якорей · артефакт полного маршрута отсутствует" },
{ label: "Полномочия", value: `${rigLabel} · ТОЛЬКО ВИЗУАЛЬНЫЙ ПРОСМОТР · команды ВЫКЛ` },
]}
brief={{
question: "Что реально видно на полном RAV004-прогоне с высокой травой, оврагами и переходом к городу?",
@@ -134,8 +78,8 @@ function FullRouteReviewResult({
)}
evidence={(
<LaboratoryEvidence
eyebrow="CANONICAL RECORDED LAB · RAVNOVES004TREE"
title="CAMERA + SOURCE POINTS + LOCAL SLAM + TGS COSTMAP + SEMANTICS · 6830/6830"
eyebrow="КАНОНИЧЕСКАЯ ЗАПИСАННАЯ LAB · RAVNOVES004TREE"
title="КАМЕРА + ИСХОДНЫЕ ТОЧКИ + ЛОКАЛЬНЫЙ SLAM + КАРТА TGS + СЕМАНТИКА · 6830/6830"
kind="recorded-replay"
resizable
>
@@ -145,18 +89,18 @@ function FullRouteReviewResult({
result={(
<LaboratoryResultSummary
title="RAV004 переведён на общий replay-каркас; safety evidence ещё не полно"
status="Recorded evidence · navigation/actuation OFF"
status="Записанные доказательства · навигация/управление ВЫКЛ"
statusTone="warning"
metrics={[
{ label: "Camera timeline", value: "6830 frames · ≈9.51 Hz", hint: "media clock owns video, overlays and spatial" },
{ label: "Source geometry", value: "1444 increments · ≈2 Hz", hint: "last proven spatial frame is held between source arrivals" },
{ label: "EoMT throughput", value: `${decimal(review.city.inferenceFps, 2)} fps`, hint: "изолированный full pass; не realtime stack" },
{ label: "DDRNet throughput", value: `${decimal(review.vegetation.inferenceFps, 2)} fps`, hint: "изолированный full pass; temporal stability не принята" },
{ label: "Таймлайн камеры", value: "6830 кадров · ≈9,51 Гц", hint: "единые часы управляют видео, слоями и пространством" },
{ label: "Исходная геометрия", value: "1444 приращения · ≈2 Гц", hint: "между поступлениями удерживается последний подтверждённый пространственный кадр" },
{ label: "Пропускная способность EoMT", value: `${decimal(review.city.inferenceFps, 2)} кадра/с`, hint: "изолированный полный прогон; не стек реального времени" },
{ label: "Пропускная способность DDRNet", value: `${decimal(review.vegetation.inferenceFps, 2)} кадра/с`, hint: "изолированный полный прогон; временная стабильность не принята" },
]}
conclusion={{
proved: "Camera, seek, spatial layers and semantic switching use one accepted reusable viewer and one media clock; RFU source geometry no longer inherits LiDAR roll/pitch.",
notProved: "Не доказаны continuous TGS, независимый detector/STOP, truth accuracy, temporal stability DDRNet и ≥10 FPS совместного live stack.",
decision: "Продолжать как visual audit. До запечатанного full-route TGS и detector/load gate navigation/actuation остаются OFF.",
proved: "Камера, перемотка, пространственные слои и переключение семантики используют один принятый переиспользуемый viewer и единые часы; исходная геометрия RFU больше не наследует крен и тангаж LiDAR.",
notProved: "Не доказаны непрерывная TGS, независимый детектор/STOP, точность относительно эталона, временная стабильность DDRNet и ≥10 кадров/с совместного стека реального времени.",
decision: "Продолжать как визуальный аудит. До запечатанной TGS полного маршрута и барьера детектора/нагрузки навигация и управление остаются выключенными.",
}}
/>
)}
@@ -459,6 +459,17 @@ function pipelineIdForSession(session: ObservationSessionSummary): string {
return session.lab?.resultKind ?? "legacy-perception";
}
/** Keep capability projections owned by Observatory out of the legacy LAB surface. */
export function isLegacyPublishedLaboratoryWork(
session: ObservationSessionSummary,
): boolean {
return session.lab !== null
&& session.lab.replayCapability === null
&& session.status === "ready"
&& session.replayable
&& session.modalities.includes("point-cloud");
}
export function buildLaboratoryCatalog({
rigLabel,
knownWorks,
@@ -0,0 +1,853 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import {
ActivityIndicator,
Button,
ConfirmationModal,
GlassSurface,
Icon,
IconButton,
Select,
StatusBadge,
TextField,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
import {
fetchObservatoryRecordedRunReview,
type ObservatoryRecordedRunBinding,
} from "../../core/observatory/recordedRun";
import {
findObservatoryEvidence,
observatoryCatalogConfirmsEvidenceDeletion,
type ObservatoryEvidence,
} from "../../core/observatory/catalog";
import {
deleteObservatoryLabProjection,
renameObservatoryLabProjection,
} from "../../core/observatory/catalogMutations";
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
import type { WorkspaceDefinition } from "../../productModel";
const EMPTY_OBSERVATORY_ITEMS = [] as const;
type ObservatoryRecordedRunReview = Awaited<
ReturnType<typeof fetchObservatoryRecordedRunReview>
>;
type ObservatoryMutationReconciliation =
| {
readonly kind: "rename";
readonly sessionId: string;
readonly displayName: string;
}
| {
readonly kind: "delete";
readonly sessionId: string;
};
type ObservatoryReplayState =
| { readonly kind: "closed" }
| {
readonly kind: "loading";
readonly binding: ObservatoryRecordedRunBinding;
}
| {
readonly kind: "ready";
readonly binding: ObservatoryRecordedRunBinding;
readonly review: ObservatoryRecordedRunReview;
}
| {
readonly kind: "error";
readonly binding: ObservatoryRecordedRunBinding;
readonly message: string;
};
const statusLabel: Record<ObservationSessionStatus, string> = {
recording: "Запись идёт",
ready: "Готова",
degraded: "С деградацией",
interrupted: "Прервана",
failed: "Ошибка",
};
const modalityLabel: Record<string, string> = {
"point-cloud": "Облако точек",
pose: "Поза",
trajectory: "Траектория",
video: "Видео",
image: "Изображения",
depth: "Глубина",
telemetry: "Телеметрия",
};
function statusTone(
status: ObservationSessionStatus,
): "success" | "accent" | "warning" | "danger" | "neutral" {
if (status === "ready") return "success";
if (status === "recording") return "accent";
if (status === "degraded" || status === "interrupted") return "warning";
return "danger";
}
function formatTimestamp(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeStyle: "short",
}).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 mutationErrorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Не удалось изменить лабораторный результат в Обсерватории.";
}
function evidenceResultSubtitle(evidence: ObservatoryEvidence): string {
const profileName = evidence.lab.calculationProfile?.displayName;
return profileName ? `${evidence.label} · ${profileName}` : evidence.label;
}
export function ObservatoryWorkspace({
definition,
}: {
definition: WorkspaceDefinition;
}) {
const controller = useObservatoryCatalog();
const [selectedSessionId, setSelectedSessionId] = useState("");
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
const recordedJobsController = useObservatoryRecordedJobs(
selectedSessionId,
setupController.selectedSetupId,
setupController.selectedSetup?.runDefinition?.definitionSha256 ?? "",
);
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
const [renameValue, setRenameValue] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
const [mutationPending, setMutationPending] = useState<"rename" | "delete" | null>(null);
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutationReconciliation, setMutationReconciliation] = useState<
ObservatoryMutationReconciliation | null
>(null);
const overviewRef = useRef<HTMLElement | null>(null);
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
const refreshedPublishedJobRef = useRef<string | null>(null);
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
const closeReplay = useCallback(() => {
replayCoordinatorRef.current.cancel();
setReplay((current) => current.kind === "closed" ? current : { kind: "closed" });
}, []);
const returnToOverview = useCallback(() => {
closeReplay();
overviewRef.current?.scrollIntoView({ block: "start" });
}, [closeReplay]);
useEffect(() => {
if (items.some((item) => item.source.id === selectedSessionId)) return;
closeReplay();
setSelectedSessionId(items[0]?.source.id ?? "");
}, [closeReplay, items, selectedSessionId]);
useEffect(() => () => replayCoordinatorRef.current.cancel(), []);
const selectedSession = items.find(
(item) => item.source.id === selectedSessionId,
) ?? null;
const presentedEvidence = selectedSession?.evidence ?? [];
const options = useMemo(() => items.map(({ source, evidence }) => ({
value: source.id,
label: source.label,
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
})), [items]);
const setupOptions = useMemo(() => (
setupController.selectableSetups.map((setup) => ({
value: setup.setupId,
label: setup.displayName,
description: setup.description,
}))
), [setupController.selectableSetups]);
const preflightCandidate = setupController.preflight.kind === "ready"
? setupController.preflight.value
: null;
const runPreflight = preflightCandidate
&& preflightCandidate.sourceSessionId === selectedSession?.source.id
&& preflightCandidate.setupId === setupController.selectedSetup?.setupId
&& preflightCandidate.definitionSha256
=== setupController.selectedSetup?.runDefinition?.definitionSha256
? preflightCandidate
: null;
const queueSubmissionAllowed = Boolean(
setupController.selectedSetup?.runDefinition
&& setupController.selectedSetup.compatibility.compatible
&& runPreflight?.outcome === "queueable"
&& runPreflight.submissionAllowed,
);
const presentedJob = recordedJobsController.activeJob ?? recordedJobsController.latestJob;
const publicationFailed = presentedJob?.publication.state === "failed";
const calculationPending = recordedJobsController.activeJob !== null
|| recordedJobsController.publicationPending
|| recordedJobsController.state === "submitting"
|| recordedJobsController.state === "retrying-publication";
const showCalculate = setupController.selectedSetup !== null
&& !calculationPending && !publicationFailed;
const canSubmitRecordedJob = queueSubmissionAllowed
&& setupController.state === "ready"
&& recordedJobsController.state === "ready"
&& !calculationPending && !publicationFailed;
const queueStatusError = setupController.preflight.kind === "error"
? setupController.preflight.message
: recordedJobsController.error
?? (publicationFailed
? presentedJob?.publication.error ?? "Не удалось опубликовать результат."
: recordedJobsController.computationFailed
? "Не удалось выполнить расчёт. Можно повторить запуск."
: runPreflight?.outcome === "blocked"
? runPreflight.checks.filter((check) => check.outcome === "fail")
.map((check) => check.message).join(" ") || "Запуск профиля недоступен."
: null);
const initialLoading = !controller.catalog
&& ["idle", "loading"].includes(controller.state);
const unavailable = !controller.catalog && controller.state === "error";
const replayEvidenceId = replay.kind === "closed"
? null
: replay.binding.evidenceSessionId;
const replayEvidence = replayEvidenceId === null
? null
: selectedSession?.evidence.find(
(evidence) => evidence.sessionId === replayEvidenceId,
) ?? null;
useEffect(() => {
const publishedJob = recordedJobsController.jobs.find(
(job) => job.publication.state === "published" && job.resultId !== null,
);
if (!publishedJob || refreshedPublishedJobRef.current === publishedJob.jobId) return;
refreshedPublishedJobRef.current = publishedJob.jobId;
void controller.refresh();
setupController.refresh();
}, [controller.refresh, recordedJobsController.jobs, setupController.refresh]);
useEffect(() => {
if (
replayEvidenceId === null
|| replayEvidence
) return;
closeReplay();
}, [closeReplay, replayEvidence, replayEvidenceId]);
useEffect(() => {
if (
mutationPending !== null
|| mutationReconciliation === null
|| controller.catalog === null
) return;
const evidence = findObservatoryEvidence(
controller.catalog,
mutationReconciliation.sessionId,
);
const confirmed = mutationReconciliation.kind === "rename"
? evidence?.label === mutationReconciliation.displayName
: observatoryCatalogConfirmsEvidenceDeletion(
controller.catalog,
mutationReconciliation.sessionId,
);
if (!confirmed) return;
setRenameTarget(null);
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
if (mutationReconciliation.kind === "delete") setupController.refresh();
}, [controller.catalog, mutationPending, mutationReconciliation, setupController.refresh]);
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
const attempt = replayCoordinatorRef.current.begin();
setReplay({ kind: "loading", binding });
void fetchObservatoryRecordedRunReview(binding, {
selectedSourceSessionId: selectedSessionId,
signal: attempt.signal,
}).then((review) => {
if (!attempt.isCurrent() || !attempt.finish()) return;
setReplay({ kind: "ready", binding, review });
}).catch((caught: unknown) => {
if (!attempt.isCurrent() || !attempt.finish()) return;
setReplay({
kind: "error",
binding,
message: caught instanceof Error && caught.message.trim()
? caught.message
: "Канонический визуальный разбор недоступен.",
});
});
}, [selectedSessionId]);
const selectSession = useCallback((sessionId: string) => {
closeReplay();
setSelectedSessionId(sessionId);
}, [closeReplay]);
const openRename = useCallback((evidence: ObservatoryEvidence) => {
if (!evidence.recordedRun) return;
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setRenameValue(evidence.label);
setRenameTarget(evidence);
}, []);
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
if (!evidence.recordedRun) return;
setRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setDeleteTarget(evidence);
}, []);
const submitRename = useCallback(async () => {
if (!renameTarget?.recordedRun || mutationPending !== null) return;
const requestedDisplayName = renameValue.trim();
const reconciliation: ObservatoryMutationReconciliation = {
kind: "rename",
sessionId: renameTarget.sessionId,
displayName: requestedDisplayName,
};
setMutationPending("rename");
setMutationError(null);
setMutationReconciliation(reconciliation);
try {
const result = await renameObservatoryLabProjection(
renameTarget.recordedRun,
renameValue,
);
controller.applyEvidenceRename(result.sessionId, result.displayName);
setRenameTarget(null);
setMutationReconciliation(null);
void controller.refresh();
} catch (error) {
const reconciled = await controller.refresh();
const evidence = reconciled
? findObservatoryEvidence(reconciled, reconciliation.sessionId)
: null;
if (reconciled && evidence?.label === reconciliation.displayName) {
setRenameTarget(null);
setMutationReconciliation(null);
} else {
setMutationError(mutationErrorMessage(error));
}
} finally {
setMutationPending(null);
}
}, [controller, mutationPending, renameTarget, renameValue]);
const confirmDelete = useCallback(async () => {
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
const reconciliation: ObservatoryMutationReconciliation = {
kind: "delete",
sessionId: deleteTarget.sessionId,
};
setMutationPending("delete");
setMutationError(null);
setMutationReconciliation(reconciliation);
try {
if (
replay.kind !== "closed"
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
) {
flushSync(() => {
closeReplay();
});
}
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
controller.applyEvidenceDeletion(deleteTarget.sessionId);
setupController.refresh();
setDeleteTarget(null);
setMutationReconciliation(null);
void controller.refresh();
} catch (error) {
const reconciled = await controller.refresh();
if (
reconciled
&& observatoryCatalogConfirmsEvidenceDeletion(
reconciled,
reconciliation.sessionId,
)
) {
setDeleteTarget(null);
setMutationReconciliation(null);
} else {
setMutationError(mutationErrorMessage(error));
}
} finally {
setMutationPending(null);
}
}, [closeReplay, controller, deleteTarget, mutationPending, replay, setupController.refresh]);
return (
<div
className="observatory-workspace"
data-observatory-authority="observation-only"
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
>
<section ref={overviewRef} className="observatory-lead">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>
Записанные источники и строго связанные результаты без запуска тяжёлого
визуализатора и без доступа к управлению аппаратом.
</p>
</div>
</section>
<GlassSurface className="observatory-catalog-bar" padding="sm">
<div className="observatory-catalog-bar__copy">
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
<h3>Сохранённая сессия</h3>
</div>
<div className="observatory-catalog-bar__controls">
<Select
label="Выбрать сохранённую сессию"
value={selectedSessionId}
options={options}
disabled={items.length === 0}
searchable
searchPlaceholder="Поиск по сессиям"
emptyLabel="Сессия не найдена"
minMenuWidth={360}
menuWidth={460}
onChange={selectSession}
/>
<Select
label="Выбрать сетап лаборатории"
value={setupController.selectedSetupId}
options={setupOptions}
disabled={!selectedSessionId || setupOptions.length === 0}
searchable
searchPlaceholder="Поиск по сетапам"
emptyLabel={setupController.state === "error" ? "Каталог профилей недоступен" : "Нет профилей для расчёта"}
minMenuWidth={360}
menuWidth={500}
onChange={setupController.selectSetup}
/>
<Button
size="compact"
variant="secondary"
disabled={
controller.state === "loading"
|| controller.state === "refreshing"
|| setupController.state === "loading"
|| setupController.state === "refreshing"
}
icon={<Icon name="refresh" />}
onClick={() => {
void controller.refresh();
setupController.refresh();
recordedJobsController.refresh();
}}
>
Обновить
</Button>
<div className="observatory-catalog-bar__run" aria-busy={calculationPending}>
{calculationPending ? (
<ActivityIndicator size="compact" label="Ожидание результата расчёта" />
) : null}
{showCalculate ? (
<Button
size="compact"
variant="primary"
disabled={!canSubmitRecordedJob}
onClick={() => {
void recordedJobsController.submit(
setupController.selectedSetup?.origin === "portable-definition"
&& runPreflight?.definitionSha256
&& runPreflight.checkSha256
? {
definitionSha256: runPreflight.definitionSha256,
checkSha256: runPreflight.checkSha256,
}
: null,
);
}}
>
Рассчитать
</Button>
) : null}
</div>
</div>
</GlassSurface>
{queueStatusError ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<span className="observatory-notice__copy">{queueStatusError}</span>
{publicationFailed ? (
<Button
size="compact"
variant="ghost"
disabled={recordedJobsController.state === "retrying-publication"}
onClick={() => void recordedJobsController.retryPublication()}
>
Повторить публикацию
</Button>
) : null}
</GlassSurface>
) : null}
{controller.error && controller.catalog ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<StatusBadge tone="warning">Показан последний срез</StatusBadge>
<span className="observatory-notice__copy">{controller.error}</span>
<Button size="compact" variant="ghost" onClick={controller.refresh}>Повторить</Button>
</GlassSurface>
) : null}
{setupController.error ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<span className="observatory-notice__copy">{setupController.error}</span>
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
</GlassSurface>
) : null}
{controller.catalog
&& (controller.catalog.window.sourceLimitReached
|| controller.catalog.window.laboratoryLimitReached) ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
<StatusBadge tone="warning">Срез ограничен</StatusBadge>
<span className="observatory-notice__copy">
Загружены последние {controller.catalog.window.sourceCount} исходных сессий и{
" "
}{controller.catalog.window.laboratoryCount} LAB-результатов. Полнота исторических
связей не подтверждена.
</span>
</GlassSurface>
) : null}
{initialLoading ? (
<GlassSurface className="observatory-state" padding="lg">
<ActivityIndicator label="Читаем каталог сессий" />
<h3>Читаем источники и связи результатов</h3>
<p>Визуализатор и лабораторные сцены при этом не запускаются.</p>
</GlassSurface>
) : unavailable ? (
<GlassSurface className="observatory-state" padding="lg" role="alert">
<Icon name="alert" size={20} />
<StatusBadge tone="danger">Каталог недоступен</StatusBadge>
<h3>{controller.error}</h3>
<Button size="compact" variant="secondary" onClick={controller.refresh}>Повторить</Button>
</GlassSurface>
) : items.length === 0 ? (
<GlassSurface className="observatory-state" padding="lg">
<Icon name="database" size={20} />
<h3>Сохранённых сессий пока нет</h3>
<p>После завершения записи источник появится здесь без создания демонстрационных данных.</p>
</GlassSurface>
) : selectedSession ? (
<section className="observatory-session-stack" aria-label="Выбранная сессия и связанные результаты">
<GlassSurface className="observatory-session-summary" padding="sm">
<div className="observatory-session-summary__identity">
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
<h3>{selectedSession.source.label}</h3>
<code>{selectedSession.source.id}</code>
</div>
<dl className="observatory-session-summary__facts">
<div><dt>Начало</dt><dd>{formatTimestamp(selectedSession.source.startedAtUtc)}</dd></div>
<div><dt>Длительность</dt><dd>{formatDuration(selectedSession.source.durationSeconds)}</dd></div>
<div>
<dt>Чтение</dt>
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
</div>
<div>
<dt>Оборудование</dt>
<dd>
{selectedSession.source.captureAttestation?.equipmentDisplayName
?? "Не аттестовано"}
</dd>
</div>
<div>
<dt>Профиль записи</dt>
<dd title={selectedSession.source.captureAttestation?.captureProfileSha256}>
{selectedSession.source.captureAttestation?.captureProfileId
?? "Не определён"}
</dd>
</div>
</dl>
<div className="observatory-session-summary__end">
<StatusBadge tone={statusTone(selectedSession.source.status)}>
{statusLabel[selectedSession.source.status]}
</StatusBadge>
<div className="observatory-modalities" aria-label="Каналы сессии">
{selectedSession.source.modalities.length > 0
? selectedSession.source.modalities.map((modality) => (
<StatusBadge key={modality} tone="neutral">
{modalityLabel[modality] ?? modality}
</StatusBadge>
))
: <span>Каналы не зафиксированы</span>}
</div>
</div>
</GlassSurface>
<section className="observatory-evidence">
<header>
<div>
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
<h3>Лабораторные доказательства</h3>
</div>
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
{selectedSession.evidence.length}
</StatusBadge>
</header>
{selectedSession.evidence.length > 0 ? (
<ol className="observatory-evidence-list">
{presentedEvidence.map((evidence) => (
<li key={evidence.sessionId}>
<div className="observatory-evidence-card">
<span className="observatory-evidence-card__icon" aria-hidden="true">
<Icon name="clipboard" size={18} />
</span>
<div className="observatory-evidence-card__copy">
<strong>{evidence.lab.labId}</strong>
<span>{evidenceResultSubtitle(evidence)}</span>
<small>
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
</small>
</div>
<div className="observatory-evidence-card__actions">
{evidence.recordedRun ? (
<>
<IconButton
label={`Удалить ${evidence.lab.labId} из Обсерватории`}
onClick={() => openDelete(evidence)}
>
<Icon name="trash" size={16} />
</IconButton>
<IconButton
label={`Переименовать ${evidence.lab.labId}`}
onClick={() => openRename(evidence)}
>
<Icon name="edit" size={16} />
</IconButton>
<IconButton
label={`Открыть визуальный разбор: ${evidence.lab.labId}`}
disabled={
replay.kind === "loading"
&& replay.binding.evidenceSessionId === evidence.sessionId
}
onClick={() => openReplay(evidence.recordedRun!)}
>
{replay.kind === "loading"
&& replay.binding.evidenceSessionId === evidence.sessionId
? <ActivityIndicator size="compact" />
: <Icon name="eye" size={16} />}
</IconButton>
</>
) : null}
</div>
</div>
</li>
))}
</ol>
) : (
<div className="observatory-evidence-empty">
<Icon name="clipboard" size={18} />
<strong>Связанных результатов нет</strong>
<p>
Наличие исходной записи само по себе не является выводом о качестве
компьютерного зрения или безопасности прохождения.
</p>
</div>
)}
</section>
</section>
) : null}
{replay.kind === "loading" ? (
<GlassSurface className="observatory-replay-state" padding="lg" role="status">
<ActivityIndicator label="Проверяем запечатанный результат" />
<div>
<h3>Проверяем точную связь результата с исходной сессией</h3>
<p>Визуализатор и данные маршрута ещё не запущены.</p>
</div>
<Button size="compact" variant="ghost" onClick={returnToOverview}>Отменить</Button>
</GlassSurface>
) : replay.kind === "error" ? (
<GlassSurface className="observatory-replay-state" padding="lg" role="alert">
<Icon name="alert" size={20} />
<div>
<StatusBadge tone="danger">Просмотр недоступен</StatusBadge>
<h3>{replay.message}</h3>
</div>
<div className="observatory-replay-state__actions">
<Button
size="compact"
variant="secondary"
onClick={() => openReplay(replay.binding)}
>
Повторить
</Button>
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
</div>
</GlassSurface>
) : replay.kind === "ready" ? (
<section className="observatory-replay" aria-label="Визуальный разбор результата">
<header className="observatory-replay__header">
<div>
<span className="section-eyebrow">
{replay.review.kind === "canonical-recorded-rerun"
? "ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ"
: "РЕЗУЛЬТАТ / ПРОВЕРЕННЫЙ ДОКУМЕНТ"}
</span>
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
<p>
{replay.review.kind === "canonical-recorded-rerun"
? "Записанный маршрут синхронизирован по общей временной шкале."
: "Показан проверенный документ результата и связанные с ним артефакты."}
</p>
</div>
<Button
size="compact"
variant="secondary"
icon={<Icon name="close" size={14} />}
onClick={returnToOverview}
>
Закрыть разбор
</Button>
</header>
{replay.review.kind === "canonical-recorded-rerun" ? (
<CanonicalVegetationRerunReplay
resultId={replay.binding.resultId}
review={replay.review.review}
/>
) : (
<GlassSurface className="observatory-replay-state" padding="lg">
<div>
<StatusBadge tone="success">Проверено</StatusBadge>
<h3>{replay.review.resultKind}</h3>
<p>Связанных артефактов: {replay.review.artifacts.length}</p>
<details>
<summary>Документ результата</summary>
<pre>{JSON.stringify(replay.review.resultDocument, null, 2)}</pre>
</details>
</div>
</GlassSurface>
)}
</section>
) : null}
{(controller.catalog?.unresolvedEvidence.length ?? 0) > 0 ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
<StatusBadge tone="warning">Вне среза</StatusBadge>
<span className="observatory-notice__copy">
{controller.catalog?.unresolvedEvidence.length} результатов ссылаются на источники
вне текущего загруженного среза и не приписаны к квалификационным доказательствам.
</span>
</GlassSurface>
) : null}
<Window
open={renameTarget !== null}
title="Переименовать лабораторный результат"
subtitle="Меняется только отображаемое название в Обсерватории"
size="sm"
closeOnBackdrop={mutationPending !== "rename"}
closeOnEscape={mutationPending !== "rename"}
onClose={() => {
if (mutationPending === "rename") return;
setRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
footer={(
<WindowFooterActions>
<Button
disabled={mutationPending === "rename"}
onClick={() => {
setRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
>
Отмена
</Button>
<Button
type="submit"
form="observatory-rename-form"
variant="primary"
disabled={mutationPending === "rename" || renameValue.trim().length === 0}
>
{mutationPending === "rename" ? "Сохраняем…" : "Сохранить"}
</Button>
</WindowFooterActions>
)}
>
<form
id="observatory-rename-form"
className="observatory-rename-form"
onSubmit={(event) => {
event.preventDefault();
void submitRename();
}}
>
<TextField
label="Название"
value={renameValue}
maxLength={160}
autoComplete="off"
autoFocus
disabled={mutationPending === "rename"}
onChange={(event) => setRenameValue(event.currentTarget.value)}
/>
{mutationError && renameTarget ? (
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
) : null}
</form>
</Window>
<ConfirmationModal
open={deleteTarget !== null}
title="Удалить результат из Обсерватории?"
description={deleteTarget ? (
<div className="observatory-delete-confirmation">
<p>
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
{" "}и её отображаемое название в Обсерватории.
</p>
<p>
Исходная сессия, запечатанный лабораторный результат и файлы доказательств
останутся неизменными.
</p>
{mutationError ? (
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
) : null}
</div>
) : null}
confirmLabel="Удалить из Обсерватории"
pendingLabel="Удаляем…"
danger
onClose={() => {
if (mutationPending === "delete") return;
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
onConfirm={confirmDelete}
/>
</div>
);
}
@@ -17,9 +17,14 @@ import {
} from "../../core/system/useWorkerTelemetry";
function pipelineStateLabel(state: string): string {
if (state === "busy") return "Выполняет задачу";
if (state === "busy" || state === "running") return "Выполняет задачу";
if (state === "waiting") return "Ожидает восстановления данных";
if (state === "synchronizing") return "Синхронизирует поток";
if (state === "starting") return "Загружает профиль";
if (state === "stopped" || state === "cancelled") return "Профиль остановлен";
if (state === "failed") return "Ошибка профиля";
if (state === "ready") return "Готов к задаче";
return "Нет live-состояния";
return "Нет свежих данных о задаче";
}
function formatResourcePair(
@@ -116,7 +121,7 @@ export function ComputeModulesWorkspace() {
{!legacyDiagnostic && !agentTelemetry ? (
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
<StatusBadge tone="warning">
Агентный data-plane ещё не опубликовал нормализованный срез этого контура.
Ожидаем свежую телеметрию выбранного узла.
</StatusBadge>
</GlassSurface>
) : null}
@@ -187,6 +192,8 @@ export function ComputeModulesWorkspace() {
? `${node.gpu.temperature_celsius} °C`
: "—"
}</dd></div>
<div><dt>Частота GPU</dt><dd>{node?.gpu?.sm_clock_mhz == null ? "—" : `${node.gpu.sm_clock_mhz} МГц`}</dd></div>
<div><dt>Частота памяти GPU</dt><dd>{node?.gpu?.memory_clock_mhz == null ? "—" : `${node.gpu.memory_clock_mhz} МГц`}</dd></div>
<div><dt>Мощность</dt><dd>{
typeof node?.gpu?.power_watts === "number"
? `${node.gpu.power_watts.toFixed(1)} Вт`
@@ -214,7 +221,7 @@ export function ComputeModulesWorkspace() {
<div>
<span className="section-eyebrow">PROCESSING RUNTIME</span>
<h3>Контейнеры Mission Core</h3>
<p>Два ограниченных runtime: inference server и прикладной perception pipeline.</p>
<p>Состояние контейнера и готовность вычислительного профиля проверяются отдельно.</p>
</div>
<StatusBadge tone={node?.triton.ready ? "success" : "danger"}>
{node?.triton.ready ? "Triton ready" : "Triton недоступен"}
@@ -233,19 +240,31 @@ export function ComputeModulesWorkspace() {
<span className="section-eyebrow">ТЕКУЩАЯ ЗАДАЧА</span>
<h3>{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}</h3>
<p>
{telemetry?.pipeline.profile_name ?? "Perception"}
{" · "}
{telemetry?.pipeline.active_request_id
? `Run ${telemetry.pipeline.active_request_id}`
: "Очередь свободна; модели остаются загруженными в persistent worker."}
: telemetry?.pipeline.service_state === "ready"
? "Готов к приёму данных."
: "Готовность моделей не подтверждена."}
</p>
</div>
<StatusBadge tone={
telemetry?.pipeline.service_state === "busy" ? "warning"
["busy", "running", "waiting", "synchronizing", "starting"].includes(telemetry?.pipeline.service_state ?? "") ? "warning"
: telemetry?.pipeline.service_state === "ready" ? "success"
: "danger"
}>
{telemetry?.pipeline.service_state ?? "unavailable"}
{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}
</StatusBadge>
</header>
{telemetry?.pipeline.profile_name ? (
<div className="worker-pipeline__summary">
<div><span>Процессы профиля</span><strong>{telemetry.pipeline.live_children ?? "—"}</strong></div>
<div><span>Ожиданий потока</span><strong>{telemetry.pipeline.input_pauses ?? "—"}</strong></div>
<div><span>Пакетов в очереди</span><strong>{telemetry.pipeline.pending_bundles ?? "—"}</strong></div>
<div><span>Входные буферы</span><strong>{formatBytes(telemetry.pipeline.buffer_bytes)}</strong></div>
</div>
) : null}
<div className="worker-pipeline__summary">
<div><span>Завершено запусков</span><strong>{telemetry?.pipeline.completed_runs ?? "—"}</strong></div>
<div><span>Ошибок запусков</span><strong>{telemetry?.pipeline.failed_runs ?? "—"}</strong></div>
@@ -11,6 +11,7 @@ let fetchM49TgsAnchorSpatial;
let AdvancedLaboratoryContractError;
let buildLaboratoryCatalog;
let buildLaboratoryProfiles;
let isLegacyPublishedLaboratoryWork;
let experimentOptionsForProfile;
let freshestLaboratorySelection;
let workOptionsForExperiment;
@@ -928,6 +929,7 @@ before(async () => {
({
buildLaboratoryCatalog,
buildLaboratoryProfiles,
isLegacyPublishedLaboratoryWork,
experimentOptionsForProfile,
freshestLaboratorySelection,
workOptionsForExperiment,
@@ -962,6 +964,31 @@ after(async () => {
await server?.close();
});
test("capability-owned projections never enter the legacy LAB catalog", () => {
const legacy = {
status: "ready",
replayable: true,
modalities: ["point-cloud", "trajectory"],
lab: { replayCapability: null },
};
const capabilityProjection = {
...legacy,
lab: {
replayCapability: {
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commandsEnabled: false,
},
},
};
assert.equal(isLegacyPublishedLaboratoryWork(legacy), true);
assert.equal(isLegacyPublishedLaboratoryWork(capabilityProjection), false);
});
test("LAB catalog is pipeline-scoped and ordered by real run time", () => {
const catalog = buildLaboratoryCatalog({
rigLabel: "K1",
@@ -105,23 +105,45 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
assert.match(e30HumanReviewCss, /\.e30-human-review/);
});
test("central composition files cannot silently become monoliths again", async () => {
const ratchets = [
["App.tsx", 1_250],
["workspaces/Workspaces.tsx", 1_200],
["workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", 1_000],
["core/laboratory/advancedResults.ts", 1_000],
["core/laboratory/e40ProductGate.ts", 500],
["styles/workspaces.css", 4_350],
["styles/laboratory.css", 900],
["styles/laboratory-reporting.css", 100],
];
test("Observatory owns a bounded explicit-open lifecycle around the shared recorded viewer", async () => {
const workspaceHub = await read("workspaces/Workspaces.tsx");
const observatory = await read("workspaces/observatory/ObservatoryWorkspace.tsx");
const observatoryCore = await read("core/observatory/catalog.ts");
const recordedRun = await read("core/observatory/recordedRun.ts");
const sharedReplay = await read(
"components/laboratory/CanonicalVegetationRerunReplay.tsx",
);
const workspaceCss = await read("styles/workspaces.css");
const observatoryCss = await read("styles/observatory.css");
for (const [relativePath, maximumLines] of ratchets) {
const lineCount = (await read(relativePath)).split("\n").length;
assert.ok(
lineCount <= maximumLines,
`${relativePath} has ${lineCount} lines; split the feature instead of raising ${maximumLines}`,
);
}
assert.match(
workspaceHub,
/case "observatory":[\s\S]*<ObservatoryWorkspace definition=\{props\.definition\} \/>/,
);
assert.match(observatory, /export function ObservatoryWorkspace/);
assert.match(observatory, /useObservatoryCatalog/);
assert.match(observatory, /data-observatory-authority="observation-only"/);
assert.match(
observatory,
/data-observatory-viewer={replay\.kind === "ready" \? "attached" : "detached"}/,
);
assert.doesNotMatch(
`${observatory}\n${observatoryCore}`,
/(?:core|workspaces)\/laboratory|\/api\/v1\/laboratory|RerunViewport|ObservationSessionSelect/,
);
assert.match(observatory, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
assert.match(observatory, /closeReplay\(\);[\s\S]*setSelectedSessionId/);
assert.doesNotMatch(
observatory,
/replayObservationSession|deleteObservationSession|useObservationSessions|useAdvancedLaboratoryCatalog/,
);
assert.match(recordedRun, /fetchVegetationShadowResultMetadata/);
assert.doesNotMatch(
recordedRun,
/advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport/,
);
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
assert.match(sharedReplay, /recordedSessionRerunProfile/);
assert.doesNotMatch(workspaceCss, /\.observatory-/);
assert.match(observatoryCss, /\.observatory-workspace/);
});
@@ -8,6 +8,7 @@ let server;
let canonicalMapGravityLocalPointToBodyGround;
let canonicalRecordedLabPackedTgsCells;
let canonicalRecordedLabTgsIsCurrent;
let resolveCanonicalLabReplay;
before(async () => {
server = await createServer({
@@ -20,6 +21,9 @@ before(async () => {
canonicalRecordedLabPackedTgsCells,
canonicalRecordedLabTgsIsCurrent,
} = await server.ssrLoadModule("/src/core/laboratory/canonicalRecordedLab.ts"));
({ resolveCanonicalLabReplay } = await server.ssrLoadModule(
"/src/core/laboratory/canonicalLabReplay.ts",
));
});
after(async () => {
@@ -76,3 +80,55 @@ test("recorded LAB spatial loading is shared, profile-bound and experiment-neutr
assert.doesNotMatch(scheduler, /RAVNOVES|vegetation|DDRNet/);
assert.doesNotMatch(vegetation, /fetchCanonicalRecordedLabSpatialFrame|CanonicalRecordedLabSpatialFrame/);
});
test("canonical LAB resolves one generation-bound merged RRD", async () => {
const baseGeneration = "a".repeat(64);
const replayGeneration = "b".repeat(64);
const resultId = `lab-v1-vegetation-shadow-${"c".repeat(64)}`;
let request;
const replay = await resolveCanonicalLabReplay(resultId, {
kind: "rerun-recording",
sessionId: "session-001",
sourceUrl: "/api/v1/observation-sessions/session-001/recording.rrd",
viewerSourceUrl:
`/api/v1/observation-sessions/session-001/recording.rrd?generation=${baseGeneration}`,
mediaType: "application/vnd.rerun.rrd",
timeline: "session_time",
timelineStartSeconds: 0,
timelineEndSeconds: 10,
seekable: true,
byteLength: 100,
sha256: baseGeneration,
playback: { speed: 1, loop: false },
mediaSources: [],
}, {
origin: "http://mission-core.test",
fetcher: async (url, options) => {
request = { url, options };
return new Response(null, {
status: 200,
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"Content-Length": "234567",
"ETag": `"${replayGeneration}"`,
"X-Rerun-Format": "RRF2",
},
});
},
});
const sourceUrl =
`/api/v1/laboratory/vegetation-shadow/${resultId}/canonical-replay.rrd`;
assert.equal(
request.url,
`http://mission-core.test${sourceUrl}?base_generation=${baseGeneration}`,
);
assert.equal(request.options.method, "HEAD");
assert.deepEqual(replay, {
sourceUrl,
viewerSourceUrl: `${sourceUrl}?generation=${replayGeneration}`,
byteLength: 234567,
sha256: replayGeneration,
blueprintSourceUrl: "/api/v1/observation-sessions/session-001/blueprint.rrd",
});
});
@@ -886,8 +886,8 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(canonical, /primary=\{mediaPane\}/);
assert.match(canonical, /secondary=\{spatialPane/);
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
assert.match(canonical, /resizable=\{splitView\}/);
assert.match(canonical, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/);
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonical, /secondaryMode=\{\{/);
assert.match(visual, /playback=\{playbackController\.playback\}/);
assert.match(visual, /clock: "external"/);
@@ -77,6 +77,70 @@ function session(overrides = {}) {
};
}
function canonicalLab(overrides = {}) {
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
return {
lab_id: "LAB V1",
source_session_id: "20260828T130511Z_viewer_live",
result_kind: "recorded-perception-qualification",
result_id: resultId,
source_result_id: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
config_sha256: null,
run_created_at_utc: "2026-08-29T18:05:11.329061+00:00",
published_at_utc: "2026-08-30T12:00:00.000Z",
replay_capability: {
schema_version: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewer_profile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commands_enabled: false,
},
provenance: {},
...overrides,
};
}
function legacyCalculationProfile(overrides = {}) {
return {
schema_version: "missioncore.observatory-calculation-profile/v1",
setup_id: "lab-v1-ravnoves004tree-final",
display_name: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
origin: "existing-result",
definition_id: null,
definition_version: null,
definition_sha256: null,
...overrides,
};
}
function canonicalProjectionProvenance(resultId, replayCapability) {
return {
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
evidence_identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
result_document_sha256: "a".repeat(64),
replay_capability: replayCapability,
authority: {
commands_enabled: false,
navigation_or_safety_accepted: false,
actuation_accepted: false,
},
method: {
schema_version: "missioncore.laboratory-method/v1",
completeness: "legacy-partial",
execution_class: "ai-inference",
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
components: [{
kind: "source",
name: "sealed full-route LAB result",
version: "missioncore.lab-v1-vegetation-shadow/v1",
role: "immutable Session catalog projection",
identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
}],
},
};
}
function replay(overrides = {}) {
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
const sourceUrl = overrides.source_url ??
@@ -134,6 +198,7 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
modalities: ["point-cloud", "pose"],
durationSeconds: 1_450.744,
replayable: true,
captureAttestation: null,
preparation: null,
lab: null,
}]);
@@ -141,6 +206,137 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
assert.equal("path" in catalog.items[0], false);
});
test("session catalog strictly decodes the explicit recorded LAB replay capability", () => {
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const decoded = decodeObservationSessionCatalog({
items: [session({ id: resultId, lab: canonicalLab() })],
}).items[0].lab;
assert.deepEqual(decoded.replayCapability, {
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commandsEnabled: false,
});
const rollingUpgradeLab = canonicalLab();
rollingUpgradeLab.provenance = canonicalProjectionProvenance(
resultId,
rollingUpgradeLab.replay_capability,
);
delete rollingUpgradeLab.replay_capability;
assert.deepEqual(
decodeObservationSessionCatalog({
items: [session({ id: resultId, lab: rollingUpgradeLab })],
}).items[0].lab.replayCapability,
decoded.replayCapability,
);
const invalidComponents = [
{ kind: "tool" },
{ name: "unsealed result" },
{ version: "missioncore.lab-v1-vegetation-shadow/v0" },
{ role: "mutable projection" },
{ identity_sha256: "0".repeat(64) },
{ unexpected: true },
];
for (const componentOverride of invalidComponents) {
const invalidRollingUpgradeLab = canonicalLab();
invalidRollingUpgradeLab.provenance = canonicalProjectionProvenance(
resultId,
invalidRollingUpgradeLab.replay_capability,
);
invalidRollingUpgradeLab.provenance.method.components = [{
...invalidRollingUpgradeLab.provenance.method.components[0],
...componentOverride,
}];
delete invalidRollingUpgradeLab.replay_capability;
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({ id: resultId, lab: invalidRollingUpgradeLab })],
}),
ObservationSessionContractError,
);
}
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({
id: resultId,
lab: canonicalLab({
replay_capability: {
...canonicalLab().replay_capability,
commands_enabled: true,
},
}),
})],
}),
ObservationSessionContractError,
);
});
test("session catalog decodes a typed calculation profile without inferring unknown results", () => {
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const decoded = decodeObservationSessionCatalog({
items: [session({
id: resultId,
lab: canonicalLab({ calculation_profile: legacyCalculationProfile() }),
})],
}).items[0].lab;
assert.deepEqual(decoded.calculationProfile, {
schemaVersion: "missioncore.observatory-calculation-profile/v1",
setupId: "lab-v1-ravnoves004tree-final",
displayName: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
origin: "existing-result",
definitionId: null,
definitionVersion: null,
definitionSha256: null,
});
assert.equal(
decodeObservationSessionCatalog({
items: [session({ id: resultId, lab: canonicalLab() })],
}).items[0].lab.calculationProfile,
null,
);
assert.equal(
decodeObservationSessionCatalog({
items: [session({
id: resultId,
lab: canonicalLab({ calculation_profile: null }),
})],
}).items[0].lab.calculationProfile,
null,
);
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({
id: resultId,
lab: canonicalLab({
calculation_profile: legacyCalculationProfile({
origin: "archived-definition",
definition_id: "lab-v1-portable",
}),
}),
})],
}),
ObservationSessionContractError,
);
assert.throws(
() => decodeObservationSessionCatalog({
items: [session({
id: resultId,
lab: canonicalLab({
calculation_profile: {
...legacyCalculationProfile(),
guessed_from_provenance: true,
},
}),
})],
}),
ObservationSessionContractError,
);
});
test("opened archive is named in the scene header and trash hover has no pill", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
const styles = await readFile(
@@ -246,7 +442,7 @@ test("source and laboratory catalogs are requested as disjoint backend projectio
assert.deepEqual(calls, [
"/api/v1/observation-sessions?limit=100&scope=source",
"/api/v1/observation-sessions?limit=100&scope=laboratory",
"/api/v1/observation-sessions?limit=100&scope=laboratory&lab_contract=v3",
]);
});
@@ -42,6 +42,8 @@ let resolveRecordedBlueprintUrl;
let fetchRecordedBlueprintRrd;
let resolveRecordedPerceptionUrl;
let fetchRecordedPerceptionRrd;
let resolveRecordedPerceptionViewerSourceUrl;
let probeRecordedPerceptionViewerSource;
let resolveRecordedPointColorsUrl;
let fetchRecordedPointColorsRrd;
let recordedPointColorKey;
@@ -110,6 +112,8 @@ before(async () => {
fetchRecordedBlueprintRrd,
resolveRecordedPerceptionUrl,
fetchRecordedPerceptionRrd,
resolveRecordedPerceptionViewerSourceUrl,
probeRecordedPerceptionViewerSource,
resolveRecordedPointColorsUrl,
fetchRecordedPointColorsRrd,
recordedPointColorKey,
@@ -407,6 +411,15 @@ test("recorded blueprint endpoint is derived only from canonical same-origin RRD
),
null,
);
assert.equal(
resolveRecordedBlueprintUrl(
`/api/v1/laboratory/vegetation-shadow/lab-v1-vegetation-shadow-${"a".repeat(64)}`
+ "/canonical-replay.rrd",
"http://127.0.0.1:5174",
"/api/v1/observation-sessions/session-1/blueprint.rrd",
),
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
);
});
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
@@ -485,6 +498,8 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
view_reset_generation: 1,
follow_trajectory: true,
unified_perception: true,
semantic_layer: null,
plan_view: false,
show_detections_2d: true,
show_segmentation: false,
show_cuboids_3d: true,
@@ -664,6 +679,74 @@ test("recorded perception fetch admits one complete same-origin RRD or no layer"
assert.equal(absent, null);
});
test("LAB perception sidecar is streamed by native Rerun from one generation-bound URL", async () => {
const endpoint =
`http://127.0.0.1:5174/api/v1/laboratory/vegetation-shadow/` +
`lab-v1-vegetation-shadow-${"a".repeat(64)}/canonical-overlay.rrd`;
const sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
endpoint,
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
"b".repeat(64),
"http://127.0.0.1:5174",
);
assert.equal(
sourceUrl,
`${endpoint}?application_id=nodedc_mission_core_recorded` +
`&recording_id=recording-001&generation=${"b".repeat(64)}`,
);
const overlayGeneration = "c".repeat(64);
const probe = await probeRecordedPerceptionViewerSource(sourceUrl, {
origin: "http://127.0.0.1:5174",
fetcher: async (input, init) => {
assert.equal(String(input), sourceUrl);
assert.equal(init.method, "HEAD");
assert.equal(new Headers(init.headers).get("Range"), null);
return new Response(null, {
status: 200,
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"Content-Length": "186058411",
"ETag": `"${overlayGeneration}"`,
"X-Rerun-Format": "RRF2",
},
});
},
});
assert.deepEqual(probe, {
sourceUrl: `${sourceUrl}&overlay_generation=${overlayGeneration}`,
byteLength: 186_058_411,
});
});
test("LAB native source rejects an unsealed overlay descriptor", async () => {
const endpoint =
`http://127.0.0.1:5174/api/v1/laboratory/vegetation-shadow/` +
`lab-v1-vegetation-shadow-${"a".repeat(64)}/canonical-overlay.rrd`;
const sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
endpoint,
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
"b".repeat(64),
"http://127.0.0.1:5174",
);
await assert.rejects(
probeRecordedPerceptionViewerSource(sourceUrl, {
origin: "http://127.0.0.1:5174",
fetcher: async () => new Response(null, {
status: 200,
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"Content-Length": "186058411",
"ETag": `"${"c".repeat(64)}"`,
"X-Rerun-Format": "RRF1",
},
}),
}),
/Invalid recorded perception viewer response/,
);
});
test("recorded replay creates an isolated source catalog without live device bindings", () => {
const sources = recordedObservationSources({
kind: "rerun-recording",
@@ -0,0 +1,375 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let applyObservatoryCatalogMutationOverlay;
let buildObservatoryCatalog;
let fetchObservatoryCatalog;
let findObservatoryEvidence;
let observatoryCatalogConfirmsEvidenceDeletion;
let reconcileObservatoryCatalogMutationOverlay;
let ObservatoryCatalogContractError;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
applyObservatoryCatalogMutationOverlay,
buildObservatoryCatalog,
fetchObservatoryCatalog,
findObservatoryEvidence,
observatoryCatalogConfirmsEvidenceDeletion,
reconcileObservatoryCatalogMutationOverlay,
ObservatoryCatalogContractError,
} = await server.ssrLoadModule("/src/core/observatory/catalog.ts"));
});
after(async () => {
await server?.close();
});
function source(id, startedAtUtc) {
return {
id,
label: `Источник ${id}`,
startedAtUtc,
completedAtUtc: startedAtUtc,
status: "ready",
modalities: ["point-cloud", "video"],
durationSeconds: 42,
replayable: true,
preparation: null,
lab: null,
};
}
function evidence(id, sourceSessionId, publishedAtUtc) {
return {
id,
label: `Результат ${id}`,
startedAtUtc: publishedAtUtc,
completedAtUtc: publishedAtUtc,
status: "ready",
modalities: ["video"],
durationSeconds: 12,
replayable: true,
preparation: null,
lab: {
labId: `LAB-${id}`,
sourceSessionId,
resultKind: "recorded-evidence",
resultId: `result-${id}`,
sourceResultId: null,
configSha256: "a".repeat(64),
runCreatedAtUtc: publishedAtUtc,
publishedAtUtc,
replayCapability: null,
calculationProfile: null,
provenance: { verdict: "must-not-be-inferred" },
},
};
}
function canonicalProvenance(resultId) {
const identitySha256 = resultId.slice("lab-v1-vegetation-shadow-".length);
return {
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
evidence_identity_sha256: identitySha256,
result_document_sha256: "a".repeat(64),
replay_capability: {
schema_version: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewer_profile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commands_enabled: false,
},
authority: {
commands_enabled: false,
navigation_or_safety_accepted: false,
actuation_accepted: false,
},
method: {
schema_version: "missioncore.laboratory-method/v1",
completeness: "legacy-partial",
execution_class: "ai-inference",
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
components: [{
kind: "source",
name: "sealed full-route LAB result",
version: "missioncore.lab-v1-vegetation-shadow/v1",
role: "immutable Session catalog projection",
identity_sha256: identitySha256,
}],
},
};
}
test("Observatory joins evidence only by sourceSessionId and keeps deterministic order", () => {
const catalog = buildObservatoryCatalog(
[
source("older", "2026-08-28T10:00:00Z"),
source("newer", "2026-08-29T10:00:00Z"),
],
[
evidence("old-result", "newer", "2026-08-29T11:00:00Z"),
evidence("new-result", "newer", "2026-08-29T12:00:00Z"),
evidence("orphan", "missing", "2026-08-29T13:00:00Z"),
],
);
assert.deepEqual(catalog.items.map(({ source: item }) => item.id), ["newer", "older"]);
assert.deepEqual(
catalog.items[0].evidence.map((item) => item.sessionId),
["new-result", "old-result"],
);
assert.deepEqual(catalog.unresolvedEvidence.map((item) => item.sessionId), ["orphan"]);
assert.deepEqual(catalog.window, {
limit: 100,
sourceCount: 2,
laboratoryCount: 3,
sourceLimitReached: false,
laboratoryLimitReached: false,
});
assert.equal("verdict" in catalog.items[0].evidence[0], false);
assert.equal(catalog.items[1].evidence.length, 0);
});
test("Observatory fails visibly when the laboratory projection loses its typed link", () => {
assert.throws(
() => buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[source("not-lab", "2026-08-29T11:00:00Z")],
),
ObservatoryCatalogContractError,
);
});
test("Observatory fetches disjoint read-only source and laboratory projections", async () => {
const calls = [];
const fetcher = async (input, init) => {
calls.push({ input: String(input), method: init?.method });
return new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const catalog = await fetchObservatoryCatalog({ fetcher, limit: 50 });
assert.deepEqual(catalog, {
items: [],
unresolvedEvidence: [],
window: {
limit: 50,
sourceCount: 0,
laboratoryCount: 0,
sourceLimitReached: false,
laboratoryLimitReached: false,
},
});
assert.deepEqual(
calls.map(({ input }) => input).sort(),
[
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3",
"/api/v1/observation-sessions?limit=50&scope=source",
],
);
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
});
test("Observatory exposes bounded-window uncertainty without inventing a broken link", () => {
const catalog = buildObservatoryCatalog(
[
source("newer", "2026-08-29T10:00:00Z"),
source("older", "2026-08-28T10:00:00Z"),
],
[
evidence("linked", "newer", "2026-08-29T11:00:00Z"),
evidence("outside-window", "older-than-window", "2026-08-29T12:00:00Z"),
],
2,
);
assert.equal(catalog.window.sourceLimitReached, true);
assert.equal(catalog.window.laboratoryLimitReached, true);
assert.deepEqual(
catalog.unresolvedEvidence.map((item) => item.sessionId),
["outside-window"],
);
});
test("Observatory projects a typed canonical run only through its exact sourceSessionId", () => {
const canonicalResultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const canonical = evidence(
canonicalResultId,
"20260828T130511Z_viewer_live",
"2026-08-29T18:05:11Z",
);
canonical.lab = {
...canonical.lab,
labId: "LAB V1",
resultKind: "recorded-perception-qualification",
resultId: canonicalResultId,
sourceResultId: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
configSha256: null,
provenance: canonicalProvenance(canonicalResultId),
replayCapability: {
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commandsEnabled: false,
},
};
const catalog = buildObservatoryCatalog(
[source("20260828T130511Z_viewer_live", "2026-08-28T13:05:11Z")],
[canonical],
);
assert.deepEqual(catalog.items[0].evidence[0].recordedRun, {
kind: "canonical-recorded-rerun",
evidenceSessionId: canonicalResultId,
sourceSessionId: "20260828T130511Z_viewer_live",
resultId: canonicalResultId,
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
});
});
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
const catalog = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[evidence("result", "source", "2026-08-29T11:00:00Z")],
);
const original = catalog.items[0].evidence[0];
const projected = applyObservatoryCatalogMutationOverlay(
catalog,
new Map([["result", {
kind: "rename",
displayName: "Операторское имя",
revision: 1,
}]]),
);
const renamed = findObservatoryEvidence(projected, "result");
assert.equal(renamed.label, "Операторское имя");
assert.equal(renamed.sessionId, original.sessionId);
assert.equal(renamed.lab, original.lab);
assert.equal(renamed.recordedRun, original.recordedRun);
assert.equal(projected.items[0].source, catalog.items[0].source);
assert.equal(projected.window.laboratoryCount, 1);
});
test("Observatory tombstone removes only its catalog evidence projection", () => {
const catalog = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[
evidence("remove", "source", "2026-08-29T12:00:00Z"),
evidence("keep", "source", "2026-08-29T11:00:00Z"),
],
);
const projected = applyObservatoryCatalogMutationOverlay(
catalog,
new Map([["remove", { kind: "delete", revision: 1 }]]),
);
assert.equal(projected.items.length, 1);
assert.equal(projected.items[0].source, catalog.items[0].source);
assert.deepEqual(
projected.items[0].evidence.map(({ sessionId }) => sessionId),
["keep"],
);
assert.equal(projected.window.sourceCount, 1);
assert.equal(projected.window.laboratoryCount, 1);
});
test("Observatory confirms an ambiguous delete only from a complete LAB window", () => {
const bounded = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[evidence("other", "source", "2026-08-29T11:00:00Z")],
1,
);
const complete = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[],
);
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(bounded, "result"), false);
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(complete, "result"), true);
});
test("Observatory reconciliation cannot let a stale in-flight fetch undo a local mutation", () => {
const oldCatalog = buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[evidence("result", "source", "2026-08-29T11:00:00Z")],
);
const renameOverlay = new Map([["result", {
kind: "rename",
displayName: "Новое имя",
revision: 1,
}]]);
const staleRename = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
renameOverlay,
0,
);
assert.equal(findObservatoryEvidence(staleRename.catalog, "result").label, "Новое имя");
assert.equal(staleRename.overlay.has("result"), true);
const reconciledOldRename = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
renameOverlay,
1,
);
assert.equal(findObservatoryEvidence(reconciledOldRename.catalog, "result").label, "Новое имя");
assert.equal(reconciledOldRename.overlay.has("result"), true);
const renamedEvidence = evidence("result", "source", "2026-08-29T11:00:00Z");
renamedEvidence.label = "Новое имя";
const confirmedRename = reconcileObservatoryCatalogMutationOverlay(
buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[renamedEvidence],
),
staleRename.overlay,
1,
);
assert.equal(findObservatoryEvidence(confirmedRename.catalog, "result").label, "Новое имя");
assert.equal(confirmedRename.overlay.size, 0);
const deleteOverlay = new Map([["result", { kind: "delete", revision: 2 }]]);
const staleDelete = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
deleteOverlay,
1,
);
assert.equal(findObservatoryEvidence(staleDelete.catalog, "result"), null);
assert.equal(staleDelete.overlay.has("result"), true);
const reconciledOldDelete = reconcileObservatoryCatalogMutationOverlay(
oldCatalog,
deleteOverlay,
2,
);
assert.equal(findObservatoryEvidence(reconciledOldDelete.catalog, "result"), null);
assert.equal(reconciledOldDelete.overlay.has("result"), true);
const confirmedDelete = reconcileObservatoryCatalogMutationOverlay(
buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[],
),
staleDelete.overlay,
2,
);
assert.equal(findObservatoryEvidence(confirmedDelete.catalog, "result"), null);
assert.equal(confirmedDelete.overlay.size, 0);
});
@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let deleteObservatoryLabProjection;
let renameObservatoryLabProjection;
let ObservatoryCatalogMutationError;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
deleteObservatoryLabProjection,
renameObservatoryLabProjection,
ObservatoryCatalogMutationError,
} = await server.ssrLoadModule("/src/core/observatory/catalogMutations.ts"));
});
after(async () => {
await server?.close();
});
const sessionId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const binding = {
kind: "canonical-recorded-rerun",
evidenceSessionId: sessionId,
sourceSessionId: "20260828T130511Z_viewer_live",
resultId: sessionId,
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
};
test("Observatory rename sends the closed projection-only contract", async () => {
const calls = [];
const result = await renameObservatoryLabProjection(binding, " Новый разбор ", {
fetcher: async (input, init) => {
calls.push({ input, init });
return Response.json({
schema_version: "missioncore.observatory-lab-projection/v1",
session_id: sessionId,
display_name: "Новый разбор",
});
},
});
assert.deepEqual(result, {
schemaVersion: "missioncore.observatory-lab-projection/v1",
sessionId,
displayName: "Новый разбор",
});
assert.equal(calls.length, 1);
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
assert.equal(calls[0].init.method, "PATCH");
assert.equal(calls[0].init.headers.Accept, "application/json");
assert.equal(calls[0].init.headers["Content-Type"], "application/json");
assert.deepEqual(JSON.parse(calls[0].init.body), {
schema_version: "missioncore.observatory-lab-projection-rename/v1",
display_name: "Новый разбор",
});
});
test("Observatory delete accepts only an empty 204 response", async () => {
const calls = [];
await deleteObservatoryLabProjection(binding, {
fetcher: async (input, init) => {
calls.push({ input, init });
return new Response(null, { status: 204 });
},
});
assert.equal(calls.length, 1);
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
assert.equal(calls[0].init.method, "DELETE");
await assert.rejects(
deleteObservatoryLabProjection(binding, {
fetcher: async () => new Response("unexpected", { status: 200 }),
}),
ObservatoryCatalogMutationError,
);
});
test("Observatory mutations fail closed before network access", async () => {
let calls = 0;
const fetcher = async () => {
calls += 1;
return new Response(null, { status: 204 });
};
await assert.rejects(
renameObservatoryLabProjection(binding, " ", { fetcher }),
/от 1 до 160/,
);
await assert.rejects(
deleteObservatoryLabProjection({ ...binding, resultId: "different" }, { fetcher }),
/не допущен/,
);
assert.equal(calls, 0);
});
test("Observatory mutations reject response drift and preserve API detail", async () => {
await assert.rejects(
renameObservatoryLabProjection(binding, "Разбор", {
fetcher: async () => Response.json({
schema_version: "missioncore.observatory-lab-projection/v1",
session_id: sessionId,
display_name: "Разбор",
unexpected: true,
}),
}),
/неизвестные поля/,
);
await assert.rejects(
deleteObservatoryLabProjection(binding, {
fetcher: async () => Response.json({ detail: "Проекция не принадлежит Обсерватории." }, { status: 409 }),
}),
(error) => error instanceof ObservatoryCatalogMutationError
&& error.status === 409
&& error.message === "Проекция не принадлежит Обсерватории.",
);
});
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import React from "react";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let useObservatoryRecordedJobs;
let useObservatoryLaboratorySetups;
before(async () => {
server = await createServer({
appType: "custom", logLevel: "silent", server: { middlewareMode: true },
});
({ useObservatoryRecordedJobs } = await server.ssrLoadModule("/src/core/observatory/useObservatoryRecordedJobs.ts"));
({ useObservatoryLaboratorySetups } = await server.ssrLoadModule("/src/core/observatory/useObservatoryLaboratorySetups.ts"));
});
after(async () => { await server?.close(); });
// Deliberately inspect the render BEFORE effects/cleanup: stale state must already be hidden.
function renderBeforeEffects(hook, args, stateSlots) {
const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
const previous = internals.H;
let stateIndex = 0;
internals.H = {
useState: (initial) => [
stateIndex < stateSlots.length ? stateSlots[stateIndex++]
: typeof initial === "function" ? initial() : initial,
() => {},
],
useRef: (value) => ({ current: value }),
useMemo: (factory) => factory(),
useCallback: (fn) => fn,
useEffect: () => {},
};
try { return hook(...args); } finally { internals.H = previous; }
}
test("queue data and errors cannot survive a source/setup/version change for one render", () => {
const original = ["source-a", "profile-a", "a".repeat(64)];
const activeJob = { state: "running", publication: { state: "not-required" } };
const snapshot = {
selectionKey: JSON.stringify(original), jobs: [activeJob], state: "ready", error: "old error",
};
assert.equal(renderBeforeEffects(useObservatoryRecordedJobs, original, [snapshot]).activeJob, activeJob);
for (const next of [
["source-b", original[1], original[2]],
[original[0], "profile-b", original[2]],
[original[0], original[1], "b".repeat(64)],
["", "", ""],
]) {
const view = renderBeforeEffects(useObservatoryRecordedJobs, next, [snapshot]);
assert.deepEqual(view.jobs, []);
assert.equal(view.activeJob, null);
assert.equal(view.latestJob, null);
assert.equal(view.error, null);
assert.notEqual(view.state, "ready");
}
});
test("published, incompatible and previous-source profiles have no effective selection", () => {
const profile = {
setupId: "profile-a", origin: "portable-definition",
compatibility: { compatible: true },
runDefinition: { definitionSha256: "a".repeat(64) },
preflight: { outcome: "ready" },
};
function render(sourceId, setups) {
return renderBeforeEffects(useObservatoryLaboratorySetups, [sourceId], [
{ sourceSessionId: "source-a", setups }, "ready", null, "profile-a", 0, { kind: "idle" },
]);
}
assert.equal(render("source-a", [profile]).selectedSetupId, "profile-a");
for (const view of [
render("source-b", [profile]),
render("source-a", [{ ...profile, preflight: { outcome: "existing" } }]),
render("source-a", [{ ...profile, compatibility: { compatible: false } }]),
render("source-a", []),
]) {
assert.equal(view.selectedSetupId, "");
assert.equal(view.selectedSetup, null);
assert.deepEqual(view.selectableSetups, []);
}
});
test("historical failures are not presented as errors of a new operator action", () => {
const selection = ["source-a", "profile-a", "a".repeat(64)];
const snapshot = {
selectionKey: JSON.stringify(selection),
jobs: [{ jobId: "old-run", state: "failed", publication: { state: "not-required" } }],
state: "ready", error: null,
};
const view = renderBeforeEffects(useObservatoryRecordedJobs, selection, [snapshot]);
assert.equal(view.computationFailed, false);
assert.equal(view.error, null);
assert.equal(view.activeJob, null);
});
@@ -0,0 +1,591 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let fetchObservatoryLaboratorySetups;
let fetchObservatoryPortableLaboratorySetups;
let preflightObservatoryLaboratorySetup;
let selectableObservatorySetups;
let ObservatoryLaboratorySetupContractError;
const authority = {
commands_enabled: false,
actuation_allowed: false,
navigation_or_safety_accepted: false,
production_accepted: false,
};
function definition() {
return {
schema_version: "missioncore.observatory-run-definition/v1",
definition_id: "m49-tgs-full-shadow",
version: 1,
work_id: "m49-tgs-full-shadow",
source: {
session_id: "source-a",
label: "RAVNOVES00",
required_modalities: ["point-cloud", "trajectory", "video"],
},
configuration: [{ role: "primary-profile", sha256: "a".repeat(64) }],
authority,
definition_sha256: "b".repeat(64),
};
}
function setup() {
return {
setup_id: "m49-tgs-full-shadow-v1",
display_name: "M4.9T5",
description: "Сохранённый полный source-paced shadow.",
origin: "archived-definition",
source: {
session_id: "source-a",
label: "RAVNOVES00",
required_modalities: ["point-cloud", "trajectory", "video"],
},
run_definition: definition(),
compatibility: { compatible: true, reasons: [] },
executor: {
contour_id: "worker-006",
state: "not-installed",
reason_code: "laboratory-runner-adapter-not-installed",
reason: "Адаптер не установлен.",
},
preserved_results: [{
result_id: `m49-tgs-full-shadow-${"c".repeat(64)}`,
result_kind: "recorded-source-paced-tgs-shadow",
relation: "primary-visual",
access: "legacy-lab",
created_at_utc: "2026-08-26T20:27:19Z",
observatory_projection_available: false,
}],
preflight: {
outcome: "blocked",
action: "open-legacy",
reason: "Точный результат сохранён в legacy LAB.",
submission_allowed: false,
existing_result_ids: [],
},
authority,
};
}
function portableSetup() {
return {
setup_id: "lab-v1-eomt-ddrnet-portable-v1",
display_name: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
description: "Проверка записанной K1-сессии моделями EoMT и DDRNet; только наблюдение.",
origin: "portable-definition",
source_requirements: {
plugin_id: "nodedc.device.xgrids-lixelkity-k1",
archive_id: "xgrids-k1.viewer-live.evidence",
required_modalities: ["point-cloud", "trajectory", "video"],
camera_source_id: "sensor.camera.right",
camera_semantic_channel_id: "camera.video.recorded",
recorded_media_type: 'video/mp4; codecs="avc1.641028"',
recorded_media_init_sha256: "1".repeat(64),
camera_width: 800,
camera_height: 600,
calibration_slot: "camera_1",
calibration_identity_sha256: "2".repeat(64),
exactly_one_media_epoch: true,
seekable: true,
},
run_definition: {
definition_id: "lab-v1-eomt-ddrnet-portable",
version: 1,
definition_sha256: "3".repeat(64),
result_schema: "missioncore.recorded-eomt-ddrnet-review/v2",
result_kind: "recorded-perception-qualification",
models: [
{
name: "EoMT Cityscapes Large 1024",
release_id: "eomt-cityscapes-large-1024-v1",
model_id: "tue-mps/cityscapes_semantic_eomt_large_1024",
architecture: "EomtForUniversalSegmentation",
},
{
name: "DDRNet-39",
release_id: "lab-v1-ddrnet-39-goose-fine-64-v1",
model_id: "goose-ddrnet-class-512",
architecture: "ddrnet_39",
},
],
},
source_compatibility: {
outcome: "pass",
compatible: true,
reason_code: "source-compatible",
reason: "Запись соответствует требованиям EoMT + DDRNet.",
},
executor: {
contour_id: "worker-006",
state: "not-installed",
ready: false,
reason_code: "eomt-executor-release-unsealed",
reason: "Immutable executor release не установлен.",
},
existing_results: [],
preflight: {
outcome: "blocked",
action: "blocked",
reason: "Вычислительный контур LAB V1 пока недоступен.",
submission_allowed: false,
existing_result_ids: [],
},
authority,
};
}
function portableM49Setup() {
const profile = portableSetup();
profile.setup_id = "m49-tgs-portable-v2";
profile.display_name = "M4.9T5 · TRAVEL TGS · CPU-only, без ML";
profile.description = "Динамический TGS-разбор записанной K1-сессии без ML; только наблюдение.";
profile.run_definition = {
definition_id: "m49-tgs-portable",
version: 2,
definition_sha256: "4".repeat(64),
result_schema: "missioncore.recorded-travel-tgs-review/v2",
result_kind: "recorded-source-paced-tgs-shadow",
models: [],
};
profile.source_compatibility.reason = "Запись соответствует требованиям TRAVEL TGS.";
profile.executor.reason_code = "m49-executor-release-unsealed";
profile.executor.reason = "Immutable executor release не установлен.";
profile.preflight.reason = "Переносимый вычислительный контур M4.9T5 пока недоступен.";
return profile;
}
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
fetchObservatoryLaboratorySetups,
fetchObservatoryPortableLaboratorySetups,
preflightObservatoryLaboratorySetup,
selectableObservatorySetups,
ObservatoryLaboratorySetupContractError,
} = await server.ssrLoadModule("/src/core/observatory/laboratorySetups.ts"));
});
after(async () => {
await server?.close();
});
test("Observatory setup catalog keeps definition identity separate from executor availability", async () => {
const calls = [];
const fetcher = async (input, init) => {
calls.push({ input: String(input), init });
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [setup()],
authority,
}), { status: 200, headers: { "Content-Type": "application/json" } });
};
const catalog = await fetchObservatoryLaboratorySetups("source-a", { fetcher });
assert.equal(catalog.setups[0].runDefinition.definitionSha256, "b".repeat(64));
assert.equal(catalog.setups[0].executor.state, "not-installed");
assert.equal(catalog.setups[0].preflight.submissionAllowed, false);
assert.equal(catalog.setups[0].preservedResults[0].access, "legacy-lab");
assert.equal(
calls[0].input,
"/api/v1/observatory/laboratory-setups?source_session_id=source-a",
);
assert.equal(calls[0].init.method, "GET");
});
test("portable profiles report compatible source separately from unavailable Worker", async () => {
const calls = [];
const selected = (await fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async (input, init) => {
calls.push({ input: String(input), init });
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a",
setups: [portableSetup(), portableM49Setup()],
authority,
}), { status: 200 });
},
})).setups[0];
assert.equal(selected.origin, "portable-definition");
assert.equal(selected.compatibility.compatible, true);
assert.equal(selected.executor.state, "not-installed");
assert.equal(selected.preflight.submissionAllowed, false);
assert.deepEqual(selected.runDefinition.models.map((model) => model.name), [
"EoMT Cityscapes Large 1024",
"DDRNet-39",
]);
const m49 = (await fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a",
setups: [portableSetup(), portableM49Setup()],
authority,
}), { status: 200 }),
})).setups[1];
assert.equal(m49.displayName, "M4.9T5 · TRAVEL TGS · CPU-only, без ML");
assert.deepEqual(m49.runDefinition.models, []);
assert.equal(
calls[0].input,
"/api/v1/observatory/portable-laboratory-setups?source_session_id=source-a",
);
let preflightRequest;
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
fetcher: async (input, init) => {
preflightRequest = { input: String(input), init };
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-run-preflight/v1",
source_session_id: "source-a",
setup_id: selected.setupId,
definition_sha256: "3".repeat(64),
check_sha256: null,
outcome: "blocked",
submission_allowed: false,
checks: [{
check_id: "executor",
outcome: "fail",
reason_code: "eomt-executor-release-unsealed",
message: "Immutable executor release не установлен.",
}],
existing_result_ids: [],
executor: portableSetup().executor,
authority,
}), { status: 200 });
},
});
assert.equal(preflightRequest.input, "/api/v1/observatory/run-preflights");
assert.equal(preflightRequest.init.method, "POST");
assert.equal(preflight.outcome, "blocked");
assert.equal(preflight.submissionAllowed, false);
assert.equal(preflight.checkSha256, null);
assert.equal(preflight.checks[0].outcome, "fail");
});
test("portable profile accepts a consistent ready projection", async () => {
const ready = portableSetup();
ready.executor = {
contour_id: "worker-006",
state: "ready",
ready: true,
reason_code: null,
reason: null,
};
ready.preflight = {
outcome: "ready",
action: "check",
reason: "Запись готова к постановке в очередь.",
submission_allowed: true,
existing_result_ids: [],
};
const catalog = await fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a",
setups: [ready],
authority,
}), { status: 200 }),
});
assert.equal(catalog.setups[0].executor.state, "ready");
assert.equal(catalog.setups[0].preflight.outcome, "ready");
assert.equal(catalog.setups[0].preflight.submissionAllowed, true);
});
test("portable LAB V1 rejects an unbound existing result projection", async () => {
const spoofed = portableSetup();
spoofed.existing_results = [{
result_id: `legacy-vegetation-${"4".repeat(64)}`,
result_kind: "recorded-perception-qualification",
created_at_utc: "2026-08-30T18:16:00Z",
}];
spoofed.preflight = {
outcome: "existing",
action: "open-existing",
reason: "Результат якобы готов.",
submission_allowed: false,
existing_result_ids: [spoofed.existing_results[0].result_id],
};
await assert.rejects(
fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a",
setups: [spoofed],
authority,
}), { status: 200 }),
}),
/значение изменилось|значение не поддерживается|обнаружены неизвестные поля/,
);
});
test("portable LAB V1 rejects heavyweight compatibility evidence", async () => {
const heavyweight = portableSetup();
heavyweight.source_compatibility.evidence = {
frame_count: 6830,
timeline_start_seconds: 0,
timeline_end_seconds: 819,
};
await assert.rejects(
fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a",
setups: [heavyweight],
authority,
}), { status: 200 }),
}),
/неизвестные поля/,
);
});
function cachedPortableSetup() {
const setup = portableSetup();
setup.existing_results = [{
result_id: "portable-result-001",
result_kind: setup.run_definition.result_kind,
relation: "exact-recorded-computation",
access: "observatory",
created_at_utc: "2026-09-03T00:00:00Z",
observatory_projection_available: true,
identity: {
job_id: "observatory-run-001",
source_session_id: "source-a",
source_catalog_sha256: "a".repeat(64),
source_bundle_sha256: "b".repeat(64),
source_capability_manifest_sha256: "c".repeat(64),
setup_id: setup.setup_id,
definition_sha256: setup.run_definition.definition_sha256,
package_sha256: "d".repeat(64),
artifact_manifest_id: "e".repeat(64),
},
}];
setup.preflight = {
outcome: "existing", action: "open-existing",
reason: "Точный результат проверен.", submission_allowed: false,
existing_result_ids: [setup.existing_results[0].result_id],
};
return setup;
}
function fetchCachedPortable(setup) {
return fetchObservatoryPortableLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
source_session_id: "source-a", setups: [setup], authority,
}), { status: 200 }),
});
}
test("portable exact cached result is readable without an installed executor", async () => {
const catalog = await fetchCachedPortable(cachedPortableSetup());
const setup = catalog.setups[0];
assert.equal(setup.executor.state, "not-installed");
assert.equal(setup.preflight.outcome, "existing");
assert.equal(setup.preflight.submissionAllowed, false);
assert.deepEqual(setup.preflight.existingResultIds, ["portable-result-001"]);
assert.equal(setup.preservedResults[0].access, "observatory");
});
test("selector contains only compatible uncalculated portable definitions", async () => {
const uncached = (await fetchCachedPortable(portableSetup())).setups[0];
const cached = (await fetchCachedPortable(cachedPortableSetup())).setups[0];
const currentVersion = {
...uncached,
runDefinition: { ...uncached.runDefinition, version: 2, definitionSha256: "f".repeat(64) },
};
const catalog = (setups) => ({ sourceSessionId: "source-a", setups });
assert.deepEqual(selectableObservatorySetups(null), []);
assert.deepEqual(selectableObservatorySetups(catalog([])), []);
assert.deepEqual(selectableObservatorySetups(catalog([cached])), []);
// Same display label and setup ID, but no verified cache hit for the new definition.
assert.deepEqual(selectableObservatorySetups(catalog([currentVersion])), [currentVersion]);
assert.deepEqual(selectableObservatorySetups(catalog([
cached,
{ ...uncached, setupId: "remaining-profile" },
])).map((s) => s.setupId), ["remaining-profile"]);
assert.deepEqual(selectableObservatorySetups(catalog([
{ ...uncached, compatibility: { compatible: false, reasons: [] } },
{ ...uncached, origin: "archived-definition" },
{ ...uncached, origin: "existing-result" },
{ ...uncached, runDefinition: null },
])), []);
// Worker availability does not masquerade as completed computation.
assert.equal(uncached.executor.state, "not-installed");
assert.deepEqual(selectableObservatorySetups(catalog([uncached])), [uncached]);
// A missing/deleted/unpublished projection is not a completed result.
assert.deepEqual(selectableObservatorySetups(catalog([{
...uncached, preservedResults: cached.preservedResults,
}])).map((s) => s.setupId), [uncached.setupId]);
});
for (const [label, change] of [
["another source", (s) => { s.existing_results[0].identity.source_session_id = "source-b"; }],
["another setup", (s) => { s.existing_results[0].identity.setup_id = "another-profile"; }],
["another version", (s) => { s.existing_results[0].identity.definition_sha256 = "f".repeat(64); }],
["bad package digest", (s) => { s.existing_results[0].identity.package_sha256 = "not-a-digest"; }],
["unavailable artifact", (s) => { s.existing_results[0].observatory_projection_available = false; }],
["missing binding", (s) => { delete s.existing_results[0].identity; }],
["unrelated result ID", (s) => { s.preflight.existing_result_ids = ["different-result"]; }],
["duplicate result", (s) => {
s.existing_results.push(s.existing_results[0]);
s.preflight.existing_result_ids.push(s.preflight.existing_result_ids[0]);
}],
["contradictory action", (s) => { s.preflight.action = "check"; }],
["cached but queueable", (s) => { s.preflight.submission_allowed = true; }],
]) {
test(`portable cached result rejects ${label}`, async () => {
const setup = cachedPortableSetup();
change(setup);
await assert.rejects(fetchCachedPortable(setup));
});
}
test("Observatory preflight sends the exact selected definition and never submits a run", async () => {
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [setup()],
authority,
}), { status: 200 }),
})).setups[0];
let request;
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
fetcher: async (input, init) => {
request = { input: String(input), init };
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-run-preflight/v1",
source_session_id: "source-a",
setup_id: selected.setupId,
definition_sha256: "b".repeat(64),
check_sha256: null,
outcome: "blocked",
submission_allowed: false,
checks: [{
check_id: "executor",
outcome: "fail",
reason_code: "laboratory-runner-adapter-not-installed",
message: "Адаптер не установлен.",
}],
existing_result_ids: [],
executor: setup().executor,
authority,
}), { status: 200 });
},
});
assert.equal(request.input, "/api/v1/observatory/run-preflights");
assert.equal(request.init.method, "POST");
assert.deepEqual(JSON.parse(request.init.body), {
schema_version: "missioncore.observatory-run-preflight-request/v1",
source_session_id: "source-a",
setup_id: "m49-tgs-full-shadow-v1",
definition_sha256: "b".repeat(64),
});
assert.equal(preflight.outcome, "blocked");
assert.equal(preflight.submissionAllowed, false);
assert.equal(preflight.checkSha256, null);
});
test("Observatory dynamic preflight admits only an explicit queueable response", async () => {
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [setup()],
authority,
}), { status: 200 }),
})).setups[0];
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-run-preflight/v1",
source_session_id: "source-a",
setup_id: selected.setupId,
definition_sha256: "b".repeat(64),
outcome: "queueable",
submission_allowed: true,
checks: [{
check_id: "durable-queue",
outcome: "pass",
reason_code: "exact-binding-ready",
message: "Точный источник и очередь готовы.",
}],
existing_result_ids: [],
executor: setup().executor,
authority,
}), { status: 200 }),
});
assert.equal(preflight.outcome, "queueable");
assert.equal(preflight.submissionAllowed, true);
assert.equal(preflight.checkSha256, null);
});
test("Observatory setup contract rejects authority escalation and response drift", async () => {
const escalated = setup();
escalated.authority = { ...authority, commands_enabled: true };
await assert.rejects(
fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [escalated],
authority,
}), { status: 200 }),
}),
ObservatoryLaboratorySetupContractError,
);
const drifted = setup();
drifted.unexpected = true;
await assert.rejects(
fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [drifted],
authority,
}), { status: 200 }),
}),
ObservatoryLaboratorySetupContractError,
);
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [setup()],
authority,
}), { status: 200 }),
})).setups[0];
await assert.rejects(
preflightObservatoryLaboratorySetup("source-a", selected, {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-run-preflight/v1",
source_session_id: "source-a",
setup_id: selected.setupId,
definition_sha256: "c".repeat(64),
outcome: "blocked",
submission_allowed: false,
checks: [],
existing_result_ids: [],
executor: setup().executor,
authority,
}), { status: 200 }),
}),
ObservatoryLaboratorySetupContractError,
);
});
@@ -0,0 +1,277 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let fetchObservatoryRecordedJobs;
let retryObservatoryRecordedJobPublication;
let submitObservatoryRecordedJob;
let ObservatoryRecordedJobContractError;
const authority = {
commands_enabled: false,
actuation_allowed: false,
navigation_or_safety_accepted: false,
production_accepted: false,
};
function job(state = "queued") {
return {
schema_version: "missioncore.observatory-recorded-job/v1",
job_id: "observatory-run-0123456789abcdef0123456789abcdef",
idempotency_key: "observatory-ui:source-a:m49-tgs:request-a",
request_sha256: "1".repeat(64),
identity_sha256: "2".repeat(64),
submission_receipt_sha256: "3".repeat(64),
source: {
session_id: "source-a",
catalog_sha256: "4".repeat(64),
bundle_sha256: "5".repeat(64),
capability_manifest_sha256: "6".repeat(64),
adapter: {
adapter_id: "ravnoves00-m49-source-pack",
version: 1,
adapter_sha256: "7".repeat(64),
},
},
setup: {
setup_id: "m49-tgs",
definition_id: "m49-tgs",
definition_version: 1,
definition_sha256: "8".repeat(64),
},
executor: {
release_id: "m49-tgs-release",
release_sha256: "9".repeat(64),
image_sha256: "a".repeat(64),
model_release_ids: [],
learned_models: [],
model_manifest_sha256: "b".repeat(64),
resource_profile_id: "m49-tgs-cpu",
resource_profile_sha256: "c".repeat(64),
},
checkpoint_policy: {
mode: "non-checkpointable",
allowed_checkpoints: [],
last_checkpoint_id: null,
},
priority: { class: "recorded", rank: 100, server_owned: true },
state,
preemption_requested: state === "preemption-pending",
restart_from_zero: state === "paused",
preemption_receipt_sha256: null,
claim_generation: 0,
claim_lease: null,
result: state === "succeeded"
? { result_id: "m49-result", sha256: "d".repeat(64) }
: null,
publication: {
state: "not-required",
attempts: 0,
error: null,
published_at_utc: null,
},
terminal: state === "failed"
? { code: "worker-failed", message: "Worker завершил расчёт с ошибкой." }
: null,
created_at_utc: "2026-08-31T10:00:00Z",
updated_at_utc: "2026-08-31T10:00:01Z",
authority,
};
}
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
fetchObservatoryRecordedJobs,
retryObservatoryRecordedJobPublication,
submitObservatoryRecordedJob,
ObservatoryRecordedJobContractError,
} = await server.ssrLoadModule("/src/core/observatory/recordedJobs.ts"));
});
after(async () => {
await server?.close();
});
test("Observatory reads the exact session/setup queue without polling", async () => {
const calls = [];
const jobs = await fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
fetcher: async (input, init) => {
calls.push({ input: String(input), init });
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-recorded-job-list/v1",
items: [job("running")],
authority,
}), { status: 200 });
},
});
assert.equal(calls.length, 1);
assert.equal(
calls[0].input,
"/api/v1/observatory/runs?source_session_id=source-a&setup_id=m49-tgs&limit=20",
);
assert.equal(calls[0].init.method, "GET");
assert.equal(jobs[0].state, "running");
assert.equal(jobs[0].definitionSha256, "8".repeat(64));
});
test("Observatory submits only public identities and accepts every durable state", async () => {
const states = [
"accepted",
"queued",
"claimed",
"running",
"paused",
"preemption-pending",
"succeeded",
"failed",
"reconciliation-required",
];
for (const state of states) {
let request;
const result = await submitObservatoryRecordedJob(
"source-a",
"m49-tgs",
"observatory-ui:source-a:m49-tgs:request-a",
null,
{
fetcher: async (input, init) => {
request = { input: String(input), init };
return new Response(JSON.stringify(job(state)), { status: 200 });
},
},
);
assert.equal(result.state, state);
assert.equal(request.input, "/api/v1/observatory/runs");
assert.equal(request.init.method, "POST");
assert.deepEqual(JSON.parse(request.init.body), {
schema_version: "missioncore.observatory-recorded-run-submit/v1",
idempotency_key: "observatory-ui:source-a:m49-tgs:request-a",
source_session_id: "source-a",
setup_id: "m49-tgs",
});
}
});
test("Observatory queue contract rejects authority escalation and response drift", async () => {
const escalated = job();
escalated.authority = { ...authority, commands_enabled: true };
await assert.rejects(
fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-recorded-job-list/v1",
items: [escalated],
authority,
}), { status: 200 }),
}),
ObservatoryRecordedJobContractError,
);
const drifted = job();
drifted.worker_command = "forbidden";
await assert.rejects(
submitObservatoryRecordedJob(
"source-a",
"m49-tgs",
"observatory-ui:source-a:m49-tgs:request-a",
null,
{ fetcher: async () => new Response(JSON.stringify(drifted), { status: 200 }) },
),
ObservatoryRecordedJobContractError,
);
});
test("portable submission carries the exact definition/check fence", async () => {
let request;
await submitObservatoryRecordedJob(
"source-a",
"lab-v1-eomt-ddrnet-portable-v1",
"observatory-ui:source-a:lab-v1:request-a",
{
definitionSha256: "e".repeat(64),
checkSha256: "f".repeat(64),
},
{
fetcher: async (input, init) => {
request = { input: String(input), init };
const response = job("queued");
response.setup.setup_id = "lab-v1-eomt-ddrnet-portable-v1";
response.setup.definition_sha256 = "e".repeat(64);
response.source.session_id = "source-a";
return new Response(JSON.stringify(response), { status: 202 });
},
},
);
assert.deepEqual(JSON.parse(request.init.body), {
schema_version: "missioncore.observatory-recorded-run-submit/v1",
idempotency_key: "observatory-ui:source-a:lab-v1:request-a",
source_session_id: "source-a",
setup_id: "lab-v1-eomt-ddrnet-portable-v1",
definition_sha256: "e".repeat(64),
check_sha256: "f".repeat(64),
});
});
test("publication retry never submits a second compute request", async () => {
let request;
const response = job("succeeded");
response.publication = {
state: "published",
attempts: 2,
error: null,
published_at_utc: "2026-09-01T12:00:00Z",
};
const retried = await retryObservatoryRecordedJobPublication(response.job_id, {
fetcher: async (input, init) => {
request = { input: String(input), init };
return new Response(JSON.stringify(response), { status: 200 });
},
});
assert.equal(
request.input,
`/api/v1/observatory/runs/${response.job_id}/publication/retry`,
);
assert.equal(request.init.method, "POST");
assert.equal(request.init.body, undefined);
assert.equal(retried.publication.state, "published");
});
test("queue query and response are fenced to the selected definition", async () => {
const oldVersion = job("succeeded");
oldVersion.setup.definition_sha256 = "f".repeat(64);
const otherSource = job("running");
otherSource.source.session_id = "source-b";
const otherSetup = job("running");
otherSetup.setup.setup_id = "other-setup";
let url;
const jobs = await fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
definitionSha256: "8".repeat(64),
fetcher: async (input) => {
url = new URL(String(input), "http://localhost");
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-recorded-job-list/v1",
items: [oldVersion, otherSource, otherSetup, job("queued")], authority,
}));
},
});
assert.equal(url.searchParams.get("definition_sha256"), "8".repeat(64));
assert.deepEqual(jobs.map((j) => j.state), ["queued"]);
});
test("portable submit rejects a successful response for an old definition", async () => {
await assert.rejects(submitObservatoryRecordedJob(
"source-a", "m49-tgs", "test-request",
{ definitionSha256: "f".repeat(64), checkSha256: "e".repeat(64) },
{ fetcher: async () => new Response(JSON.stringify(job("succeeded"))) },
), ObservatoryRecordedJobContractError);
});
@@ -0,0 +1,292 @@
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 admitObservatoryRecordedRunReview;
let fetchObservatoryRecordedRunReview;
let observatoryRecordedRunBinding;
let ObservatoryRecordedRunContractError;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
admitObservatoryRecordedRunReview,
fetchObservatoryRecordedRunReview,
observatoryRecordedRunBinding,
ObservatoryRecordedRunContractError,
} = await server.ssrLoadModule("/src/core/observatory/recordedRun.ts"));
});
after(async () => {
await server?.close();
});
const sourceSessionId = "20260828T130511Z_viewer_live";
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
function canonicalProvenance() {
const identitySha256 = resultId.slice("lab-v1-vegetation-shadow-".length);
return {
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
evidence_identity_sha256: identitySha256,
result_document_sha256: "a".repeat(64),
replay_capability: {
schema_version: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewer_profile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commands_enabled: false,
},
authority: {
commands_enabled: false,
navigation_or_safety_accepted: false,
actuation_accepted: false,
},
method: {
schema_version: "missioncore.laboratory-method/v1",
completeness: "legacy-partial",
execution_class: "ai-inference",
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
components: [{
kind: "source",
name: "sealed full-route LAB result",
version: "missioncore.lab-v1-vegetation-shadow/v1",
role: "immutable Session catalog projection",
identity_sha256: identitySha256,
}],
},
};
}
function lab(overrides = {}) {
return {
labId: "LAB V1",
sourceSessionId,
resultKind: "recorded-perception-qualification",
resultId,
sourceResultId: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
configSha256: null,
runCreatedAtUtc: "2026-08-29T18:05:11.329061+00:00",
publishedAtUtc: "2026-08-30T12:00:00.000Z",
replayCapability: {
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
kind: "canonical-recorded-rerun",
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
commandsEnabled: false,
},
provenance: canonicalProvenance(),
...overrides,
};
}
function portableCapability() {
return {
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review",
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
commandsEnabled: false,
};
}
function portableCapabilityDocument() {
return {
schema_version: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review",
viewer_profile: "portable-result",
timeline: "result-defined",
activation: "explicit",
commands_enabled: false,
};
}
function portableLab(portableResultId, definitionSha256) {
return lab({
labId: "M4.9",
resultId: portableResultId,
resultKind: "recorded-route-analysis",
sourceResultId: null,
configSha256: definitionSha256,
replayCapability: portableCapability(),
provenance: {
schema_version: "missioncore.observatory-portable-result-publication/v1",
authority: {},
calculation_profile: {},
calculation_profile_sha256: "b".repeat(64),
job: {},
source: { session_id: sourceSessionId },
run_definition: { definition_sha256: definitionSha256 },
result_package: {
manifest_sha256: "c".repeat(64),
artifact_manifest_id: "d".repeat(64),
},
replay_capability: portableCapabilityDocument(),
storage: {},
method: {},
},
});
}
test("Observatory admits only the exact typed canonical recorded run", () => {
const binding = observatoryRecordedRunBinding(resultId, lab());
assert.deepEqual(binding, {
kind: "canonical-recorded-rerun",
evidenceSessionId: resultId,
sourceSessionId,
resultId,
viewerProfile: "recorded-session",
timeline: "session_time",
activation: "explicit",
});
assert.equal(
observatoryRecordedRunBinding(resultId, lab({ replayCapability: null })),
null,
);
assert.throws(
() => observatoryRecordedRunBinding("different-session", lab()),
ObservatoryRecordedRunContractError,
);
assert.throws(
() => observatoryRecordedRunBinding(
resultId,
lab({ sourceSessionId: "RAVNOVES004TREE" }),
),
ObservatoryRecordedRunContractError,
);
assert.throws(
() => observatoryRecordedRunBinding(
resultId,
lab({ configSha256: "a".repeat(64) }),
),
ObservatoryRecordedRunContractError,
);
assert.throws(
() => observatoryRecordedRunBinding(
resultId,
lab({ sourceResultId: "legacy-result" }),
),
ObservatoryRecordedRunContractError,
);
const invalidProvenance = canonicalProvenance();
invalidProvenance.method.components[0].identity_sha256 = "0".repeat(64);
assert.throws(
() => observatoryRecordedRunBinding(
resultId,
lab({ provenance: invalidProvenance }),
),
ObservatoryRecordedRunContractError,
);
});
test("Observatory rechecks the selected source and sealed review before mounting replay", () => {
const binding = observatoryRecordedRunBinding(resultId, lab());
const review = { sessionId: sourceSessionId, frameCount: 6830 };
assert.equal(
admitObservatoryRecordedRunReview(
binding,
sourceSessionId,
{ resultId, routeFullReview: review },
),
review,
);
assert.throws(
() => admitObservatoryRecordedRunReview(
binding,
"another-session",
{ resultId, routeFullReview: review },
),
ObservatoryRecordedRunContractError,
);
assert.throws(
() => admitObservatoryRecordedRunReview(
binding,
sourceSessionId,
{ resultId, routeFullReview: { ...review, sessionId: "another-session" } },
),
ObservatoryRecordedRunContractError,
);
});
test("Observatory selects and strictly decodes a portable result viewer by capability", async () => {
const portableResultId = "portable-result-a";
const definitionSha256 = "e".repeat(64);
const binding = observatoryRecordedRunBinding(
portableResultId,
portableLab(portableResultId, definitionSha256),
);
assert.deepEqual(binding, {
kind: "portable-result-review",
evidenceSessionId: portableResultId,
sourceSessionId,
resultId: portableResultId,
viewerProfile: "portable-result",
timeline: "result-defined",
activation: "explicit",
definitionSha256,
});
let requestedUrl = null;
const review = await fetchObservatoryRecordedRunReview(binding, {
selectedSourceSessionId: sourceSessionId,
fetcher: async (url) => {
requestedUrl = url;
return {
ok: true,
status: 200,
json: async () => ({
schema_version: "missioncore.observatory-portable-result-view/v1",
result_id: portableResultId,
source_session_id: sourceSessionId,
result_kind: "recorded-route-analysis",
definition_sha256: definitionSha256,
viewer_capability: portableCapabilityDocument(),
calculation_profile: { name: "M4.9" },
artifact_manifest_id: "d".repeat(64),
artifacts: [{
role: "result-document",
media_type: "application/json",
sha256: "f".repeat(64),
byte_length: 42,
}],
result_document: { verdict: "reviewed" },
}),
};
},
});
assert.equal(
requestedUrl,
`/api/v1/observatory/portable-results/${portableResultId}`,
);
assert.equal(review.kind, "portable-result");
assert.deepEqual(review.resultDocument, { verdict: "reviewed" });
assert.deepEqual(review.artifacts, [{
role: "result-document",
mediaType: "application/json",
sha256: "f".repeat(64),
byteLength: 42,
}]);
});
test("Observatory run admission remains metadata-only until explicit UI activation", async () => {
const source = await readFile(
new URL("../src/core/observatory/recordedRun.ts", import.meta.url),
"utf8",
);
assert.match(source, /fetchVegetationShadowResultMetadata/);
assert.doesNotMatch(
source,
/fetchVegetationShadowResult\(|advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport|recording\.rrd/,
);
});
@@ -0,0 +1,294 @@
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 productModel;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
productModel = await server.ssrLoadModule("/src/productModel.ts");
});
after(async () => {
await server?.close();
});
async function read(relativePath) {
return readFile(new URL(`../src/${relativePath}`, import.meta.url), "utf8");
}
test("Observatory is the third independent Polygon workspace", () => {
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
);
assert.deepEqual(
productModel.workspaceById("observatory"),
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Проверка компьютерного зрения",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
},
);
assert.equal(productModel.workspaceById("lab-archive").kind, "lab-archive");
});
test("Observatory mounts the one shared canonical replay only after explicit admission", async () => {
const [
app,
workspaceHub,
workspace,
hook,
recordedRun,
sharedReplay,
legacyReplayWrapper,
viewerProfiles,
styles,
] = await Promise.all([
read("App.tsx"),
read("workspaces/Workspaces.tsx"),
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("core/observatory/useObservatoryCatalog.ts"),
read("core/observatory/recordedRun.ts"),
read("components/laboratory/CanonicalVegetationRerunReplay.tsx"),
read("workspaces/laboratory/CanonicalVegetationRerunReplay.tsx"),
read("core/observation/viewerProfile.ts"),
read("styles/observatory.css"),
]);
assert.match(app, /activeDefinition\.kind === "observatory"[\s\S]*Только наблюдение/);
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
assert.match(workspace, /useObservatoryCatalog/);
assert.match(workspace, /Связанных результатов нет/);
assert.match(workspace, /не является выводом о качестве/);
assert.match(workspace, /presentedEvidence = selectedSession\?\.evidence \?\? \[\]/);
assert.doesNotMatch(workspace, /MAX_PRESENTED_EVIDENCE|\.evidence\.slice\(/);
assert.match(workspace, /Полнота исторических/);
assert.match(workspace, /вне текущего загруженного среза/);
assert.match(workspace, /observatory-notice__copy/);
assert.match(workspace, /observatory-evidence-card/);
assert.match(workspace, /observatory-session-stack/);
assert.match(workspace, /observatory-session-summary__facts/);
assert.match(workspace, /observatory-evidence-card__copy/);
assert.match(
workspace,
/function evidenceResultSubtitle[\s\S]*calculationProfile\?\.displayName[\s\S]*`\$\{evidence\.label\} · \$\{profileName\}`[\s\S]*: evidence\.label/,
);
assert.match(
workspace,
/<span>\{evidenceResultSubtitle\(evidence\)\}<\/span>/,
);
assert.match(
workspace,
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
);
assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/);
assert.match(workspace, /Открыть визуальный разбор/);
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
assert.match(workspace, /replay\.review\.kind === "canonical-recorded-rerun"[\s\S]*РЕЗУЛЬТАТ \/ ПРОВЕРЕННЫЙ ДОКУМЕНТ/);
assert.match(workspace, /Связанных артефактов: \{replay\.review\.artifacts\.length\}/);
assert.match(workspace, /<summary>Документ результата<\/summary>/);
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
assert.match(workspace, /Проверяем точную связь результата/);
assert.match(workspace, /role="alert"/);
assert.match(workspace, /Повторить/);
assert.match(workspace, /Закрыть разбор/);
assert.match(
workspace,
/const returnToOverview[\s\S]*closeReplay\(\);[\s\S]*scrollIntoView\(\{ block: "start" \}\)/,
);
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
assert.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/);
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
assert.match(workspace, /data-observatory-authority="observation-only"/);
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
assert.doesNotMatch(
`${workspace}\n${hook}`,
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|resolveCanonicalLabReplay|deleteObservationSession|setInterval|setTimeout|useObservationSessions|useAdvancedLaboratoryCatalog|advanced-index|prefetch|preload/,
);
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
assert.match(sharedReplay, /recordedSessionRerunProfile/);
assert.match(sharedReplay, /timelineStartSeconds/);
assert.doesNotMatch(sharedReplay, /live-acquisition|lab-recorded-evidence/);
assert.match(
legacyReplayWrapper,
/export \{ CanonicalVegetationRerunReplay \} from "\.\.\/\.\.\/components\/laboratory\/CanonicalVegetationRerunReplay"/,
);
assert.match(recordedRun, /fetchVegetationShadowResultMetadata/);
assert.doesNotMatch(recordedRun, /advanced-index|recording\.rrd|canonical-replay\.rrd/);
assert.doesNotMatch(workspace, /workspaces\/laboratory|LaboratoryRecordedClipPlayer|SimulationViewport/);
assert.match(viewerProfiles, /kind: "live-acquisition"/);
assert.match(viewerProfiles, /kind: "recorded-session"/);
assert.match(viewerProfiles, /kind: "lab-recorded-evidence"/);
assert.match(
styles,
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
);
assert.match(
styles,
/\.observatory-session-summary \{[\s\S]*grid-template-columns:[\s\S]*\.observatory-evidence-card \{[\s\S]*background: var\(--nodedc-glass-control-bg\);/,
);
assert.match(
styles,
/\.observatory-session-stack \{[\s\S]*container-name: observatory-session;[\s\S]*container-type: inline-size;/,
);
assert.match(
styles,
/\.observatory-workspace \{[\s\S]*grid-auto-rows: max-content;[\s\S]*align-content: start;/,
);
assert.match(
styles,
/\.observatory-session-summary__facts dd \{[\s\S]*font-size: var\(--nodedc-font-size-sm\);/,
);
assert.match(
styles,
/\.observatory-catalog-bar h3,[\s\S]*\.observatory-session-summary h3,[\s\S]*\.observatory-evidence h3 \{[\s\S]*font-size: var\(--nodedc-font-size-lg\);/,
);
assert.match(
styles,
/@container observatory-session \(max-width: 46rem\) \{[\s\S]*\.observatory-session-summary \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
);
assert.match(
styles,
/@container observatory-session \(max-width: 38rem\) \{[\s\S]*\.observatory-session-summary__facts \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
);
assert.doesNotMatch(styles, /\.observatory-evidence-card[\s\S]*background:\s*(?:#0{3,6}|black|rgb\(0[ ,])/i);
assert.doesNotMatch(styles, /nodedc-glass-surface|nodedc-status-badge/);
});
test("Observatory rename and delete use admitted projection mutations and canonical windows", async () => {
const [workspace, hook] = await Promise.all([
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("core/observatory/useObservatoryCatalog.ts"),
]);
assert.match(
workspace,
/deleteObservatoryLabProjection,[\s\S]*renameObservatoryLabProjection,[\s\S]*from "\.\.\/\.\.\/core\/observatory\/catalogMutations"/,
);
assert.match(workspace, /<Window[\s\S]*title="Переименовать лабораторный результат"/);
assert.match(workspace, /<TextField[\s\S]*label="Название"[\s\S]*maxLength=\{160\}/);
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
assert.match(
workspace,
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
);
assert.match(
workspace,
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*evidence\?\.label === reconciliation\.displayName[\s\S]*setRenameTarget\(null\);/,
);
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
const deleteFlowEnd = workspace.indexOf("}, [closeReplay, controller", deleteFlowStart);
assert.ok(deleteFlowStart >= 0 && deleteFlowEnd > deleteFlowStart);
const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
const teardown = deleteFlow.indexOf("closeReplay();");
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
const refresh = deleteFlow.indexOf("void controller.refresh();");
assert.ok(teardown >= 0 && teardown < remove, "active replay must unmount before projection delete");
assert.ok(remove < tombstone, "local tombstone must follow the exact empty 204");
assert.ok(tombstone < refresh, "reconciliation refresh must follow the local tombstone");
assert.match(deleteFlow, /flushSync\(\(\) => \{[\s\S]*closeReplay\(\);[\s\S]*\}\);/);
assert.match(
deleteFlow,
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*observatoryCatalogConfirmsEvidenceDeletion\([\s\S]*setDeleteTarget\(null\);/,
);
assert.match(workspace, /mutationError[\s\S]*role="alert"/);
assert.match(hook, /activeRequestRef\.current\?\.controller\.abort\(\)/);
assert.match(hook, /requestSequenceRef\.current !== id/);
assert.match(hook, /const requestMutationRevision = mutationRevisionRef\.current/);
assert.match(hook, /reconcileObservatoryCatalogMutationOverlay\(/);
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
});
test("Observatory keeps one compact selector axis without the obsolete setup detail", async () => {
const [workspace, styles, setupHook, jobsHook] = await Promise.all([
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("styles/observatory.css"),
read("core/observatory/useObservatoryLaboratorySetups.ts"),
read("core/observatory/useObservatoryRecordedJobs.ts"),
]);
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
assert.match(workspace, /setupController\.error \? \(/);
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
assert.match(workspace, /useObservatoryRecordedJobs/);
assert.match(
workspace,
/runPreflight\?\.outcome === "queueable"[\s\S]*runPreflight\.submissionAllowed/,
);
assert.match(
workspace,
/showCalculate \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
);
assert.doesNotMatch(workspace, /recordedJobStatus|presentedJobStatus|Расчёт завершён|Результат опубликован|Вычислено ·/);
assert.match(workspace, /setupController\.selectableSetups\.map/);
assert.match(workspace, /showCalculate = setupController\.selectedSetup !== null/);
assert.match(workspace, /disabled=\{!canSubmitRecordedJob\}/);
assert.match(workspace, /aria-busy=\{calculationPending\}/);
const runBar = workspace.slice(workspace.indexOf('<div className="observatory-catalog-bar__run"'), workspace.indexOf("{queueStatusError ?"));
assert.doesNotMatch(runBar, /StatusBadge/);
assert.match(setupHook, /fetchObservatoryPortableLaboratorySetups/);
assert.doesNotMatch(setupHook, /fetchObservatoryLaboratorySetups|mergeSetupCatalogs|legacyResult/);
assert.match(setupHook, /selectableObservatorySetups\(next\)/);
assert.match(setupHook, /selectable\[0\]\?\.setupId \?\? ""/);
assert.match(setupHook, /selectedSetupId, sourceSessionId, selectedDefinitionSha256/);
assert.match(
workspace,
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
);
assert.match(
jobsHook,
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
);
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
assert.match(jobsHook, /JSON\.stringify\(\[sourceSessionId, setupId, definitionSha256\]\)/);
assert.match(jobsHook, /snapshot\.selectionKey === selectionKey \? snapshot : null/);
assert.doesNotMatch(jobsHook, /observatory-ui:[^`]*sourceSessionId|\.slice\(0, 160\)/);
assert.match(workspace, /recordedJobsController\.refresh\(\)/);
assert.match(jobsHook, /POLL_INTERVAL_MS = 1_500/);
assert.match(jobsHook, /globalThis\.setTimeout/);
assert.doesNotMatch(jobsHook, /setInterval/);
assert.match(workspace, /job\.publication\.state === "published"/);
assert.match(workspace, /void controller\.refresh\(\);[\s\S]*setupController\.refresh\(\);/);
assert.match(
styles,
/\.observatory-catalog-bar__controls \{[\s\S]*min-width: 0;[\s\S]*flex: 1 1 auto;[\s\S]*flex-wrap: nowrap;[\s\S]*justify-content: flex-end;/,
);
assert.match(
styles,
/\.observatory-catalog-bar__run \{[\s\S]*display: flex;[\s\S]*align-items: center;/,
);
assert.match(
styles,
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar,[\s\S]*\.observatory-catalog-bar__controls,[\s\S]*flex-direction: column;/,
);
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
assert.match(setupHook, /preflightRequest\.current !== request/);
assert.match(
setupHook,
/state !== "ready" \|\| !selectedSetup \|\| preflight\.kind !== "idle"[\s\S]*void check\(\)/,
);
});
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations"],
["lab-archive", "simulations", "observatory"],
);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
@@ -27,6 +27,19 @@ test("top navigation has no Center and Park owns contour health first", () => {
);
assert.equal(productModel.workspacesForRoot("fleet")[0]?.id, "contour-health");
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
);
assert.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive");
assert.deepEqual(
{
root: productModel.workspaceById("observatory")?.root,
kind: productModel.workspaceById("observatory")?.kind,
icon: productModel.workspaceById("observatory")?.icon,
},
{ root: "polygon", kind: "observatory", icon: "eye" },
);
});
test("every laboratory result uses the shared evidence template", async () => {
@@ -13,7 +13,6 @@ let liveTimelineNeedsSynchronization;
let liveRerunReceiverBindingIdentity;
let recordedOpenWatchdogTimeoutMs;
let rerunViewerInitialSource;
let rerunViewerOpenOptions;
let resolveRecordedViewerSourceUrl;
before(async () => {
@@ -31,7 +30,6 @@ before(async () => {
liveRerunReceiverBindingIdentity,
recordedOpenWatchdogTimeoutMs,
rerunViewerInitialSource,
rerunViewerOpenOptions,
resolveRecordedViewerSourceUrl,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
});
@@ -71,9 +69,21 @@ test("only the canonical digest-bound generation URL reaches the native Rerun re
}
});
test("only the live receiver opens on the native following edge", () => {
assert.deepEqual(rerunViewerOpenOptions(true), { follow_if_http: true });
assert.equal(rerunViewerOpenOptions(false), null);
test("one canonical LAB replay generation reaches the same native receiver", () => {
const labSource =
`/api/v1/laboratory/vegetation-shadow/lab-v1-vegetation-shadow-${"c".repeat(64)}`
+ "/canonical-replay.rrd";
const descriptor = {
sourceUrl: labSource,
viewerSourceUrl: `${labSource}?generation=${sha256}`,
byteLength: 300_000_000,
sha256,
};
assert.equal(
resolveRecordedViewerSourceUrl(descriptor, "http://mission-core.test"),
`http://mission-core.test${descriptor.viewerSourceUrl}`,
);
});
test("live presentation waits for the exact receiver to expose a usable range", () => {
@@ -216,7 +226,7 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
assert.doesNotMatch(source, /recordedChannel/);
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
assert.match(source, /rerunViewerOpenOptions\(followLive\)/);
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
assert.match(
source,
/recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/,
@@ -6,6 +6,7 @@ import { createServer } from "vite";
let server;
let canPublishRecordedPlaybackController;
let createRecordedAutoplayGate;
let createRecordedInitialSeekGate;
let isRecordedPlaybackReady;
let isRecordedPlaybackPresentationReady;
let isUsableRecordedPlaybackRange;
@@ -22,6 +23,7 @@ before(async () => {
({
canPublishRecordedPlaybackController,
createRecordedAutoplayGate,
createRecordedInitialSeekGate,
isRecordedPlaybackReady,
isRecordedPlaybackPresentationReady,
isUsableRecordedPlaybackRange,
@@ -159,6 +161,31 @@ test("recorded autoplay waits for the full range and then runs exactly once", ()
assert.equal(gate.attempted(), true);
});
test("paused recorded replay seeks once to its first presentable frame", () => {
const gate = createRecordedInitialSeekGate();
const seeks = [];
const range = { min: 0, max: 535_717_620_042 };
assert.equal(gate.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
39_215_263_458,
), true);
assert.equal(gate.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
50_000_000_000,
), false);
assert.deepEqual(seeks, [39_215_263_458]);
assert.equal(gate.attempted(), true);
});
test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => {
const gate = createRecordedAutoplayGate();
const seeks = [];
@@ -1,81 +1,36 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import test from "node:test";
import makeRerunRuntime from "../vendor/rerun-web-viewer-0.34.1/re_viewer.nodedc.js";
const root = resolve(import.meta.dirname, "..");
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
const sha256 = (path) =>
createHash("sha256").update(readFileSync(path)).digest("hex");
test("Mission Core uses the exact upstream Rerun 0.36.3 web package", () => {
const application = readJson(resolve(root, "package.json"));
const installed = readJson(resolve(packageRoot, "package.json"));
test("NODE.DC Rerun runtime is the audited 0.34.1 spatial camera build", () => {
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
assert.equal(manifest.version, "0.34.1");
const expectedWasm = "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469";
const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339";
assert.equal(sha256(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")), expectedWasm);
assert.equal(sha256(resolve(vendorRoot, "re_viewer.nodedc.js")), expectedGlue);
assert.equal(sha256(resolve(packageRoot, "re_viewer_bg.wasm")), expectedWasm);
assert.equal(sha256(resolve(packageRoot, "re_viewer.js")), expectedGlue);
assert.equal(application.dependencies["@rerun-io/web-viewer"], "0.36.3");
assert.equal(installed.version, "0.36.3");
assert.equal(application.scripts.postinstall, undefined);
});
test("custom JavaScript glue references only exports present in its paired WASM", () => {
const wasmPath = resolve(vendorRoot, "re_viewer_bg.nodedc.wasm");
const gluePath = resolve(vendorRoot, "re_viewer.nodedc.js");
const module = new WebAssembly.Module(readFileSync(wasmPath));
const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name));
const imports = WebAssembly.Module.imports(module);
const glue = readFileSync(gluePath, "utf8");
const referencedExports = new Set(
[...glue.matchAll(/\bwasm\.([A-Za-z_$][\w$]*)/g)].map((match) => match[1]),
);
const missingExports = [...referencedExports].filter((name) => !exports.has(name));
test("the active application never imports or installs the archived vendor fork", () => {
const application = readFileSync(resolve(root, "package.json"), "utf8");
const viewport = readFileSync(resolve(root, "src/components/RerunViewport.tsx"), "utf8");
assert.equal(imports.length, 927);
assert.equal(exports.size, 79);
assert.deepEqual(missingExports, []);
assert.match(glue, /export default function\(\)/);
assert.match(glue, /if \(!wasm\) return;/);
assert.doesNotMatch(application, /patch-rerun-web-viewer/);
assert.doesNotMatch(application, /vendor\/rerun-web-viewer/);
assert.doesNotMatch(viewport, /vendor\/rerun-web-viewer/);
assert.doesNotMatch(viewport, /0\.34\.1/);
});
test("custom Rerun WASM initializes and grows its externref table", () => {
const runtime = makeRerunRuntime();
runtime.initSync({
module: readFileSync(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")),
});
test("upstream WebViewer exposes one three-argument start contract", () => {
const declaration = readFileSync(resolve(packageRoot, "index.d.ts"), "utf8");
assert.equal(typeof runtime.WebHandle, "function");
runtime.deinit();
});
test("source patch carries pointer navigation, persistent follow, and camera continuity tests", () => {
const patch = readFileSync(resolve(vendorRoot, "NODEDC_ZOOM_TO_CURSOR.patch"), "utf8");
assert.match(patch, /fn pointer_ray_direction/);
assert.match(patch, /fn zoom_orbit_towards_pointer/);
assert.match(patch, /near_limit_hands_excess_zoom_to_cursor_directed_dolly/);
assert.match(patch, /crossing_near_limit_preserves_unconsumed_scene_scaled_zoom/);
assert.match(patch, /remaining_zoom_factor\.ln\(\) \* self\.speed/);
assert.match(patch, /off_center_pointer_stays_on_the_same_view_ray/);
assert.match(patch, /fn rotate_radians_around_anchor/);
assert.match(patch, /orbit_drag_anchor/);
assert.match(patch, /minimum_orbital_navigation_speed/);
assert.match(patch, /orbital_rotation_keeps_selected_anchor_on_the_same_view_ray/);
assert.match(patch, /orbital_navigation_speed_floor_tracks_scene_scale/);
assert.match(patch, /NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY/);
assert.match(patch, /nodedc_rig_orbit_tracking_is_persistent/);
assert.match(patch, /restore_persistent_orbit_eye_after_blueprint_update/);
assert.match(
patch,
/persistent_rig_follow_restores_the_last_rendered_eye_after_blueprint_update/,
declaration,
/start\(rrd: string \| string\[\] \| null, parent: HTMLElement \| null, options: WebViewerOptions \| null\): Promise<void>/,
);
assert.match(patch, /explicit_blueprint_pose_is_not_replaced_by_the_previous_eye/);
assert.match(patch, /previous_picking_result/);
});
@@ -124,6 +124,11 @@ test("Compute-contour telemetry remains a bounded system feature slice", async (
assert.match(pollIntervalContract, /MIN_TELEMETRY_POLL_INTERVAL_SECONDS\s*=\s*1/);
assert.match(telemetryPolling, /normalizeWorkerTelemetryPollMilliseconds/);
assert.match(telemetryPolling, /startSequentialPolling/);
assert.match(telemetryPolling, /catch \(reason: unknown\)[\s\S]*?setTelemetry\(null\)/);
assert.match(computeWorkspace, /sm_clock_mhz/);
assert.match(computeWorkspace, /memory_clock_mhz/);
assert.match(computeWorkspace, /Ожидает восстановления данных/);
assert.doesNotMatch(computeWorkspace, /Очередь свободна; модели остаются загруженными/);
assert.doesNotMatch(telemetryPolling, /if\s*\(\s*!enabled\s*\|\|\s*loading\s*\)/);
assert.match(pollingScheduler, /A transient failure must not stop polling/);
assert.match(styles, /system-telemetry\.css/);
@@ -451,8 +451,8 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity
assert.deepEqual(frame.bodyFrame.originMapXyzM, [33, 4, 1]);
});
test("vegetation realtime LAB and archival benchmark use separate admitted instruments", async () => {
const [resultSource, benchmarkSource, m49Source, canonicalSource] = await Promise.all([
test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival review separate", async () => {
const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource, replayStyles] = await Promise.all([
readFile(
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
"utf8",
@@ -469,6 +469,14 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
"utf8",
),
readFile(
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
"utf8",
),
readFile(
new URL("../src/styles/m4-replay-threat.css", import.meta.url),
"utf8",
),
]);
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
assert.match(resultSource, /M49TgsFullShadowEvidence/);
@@ -477,23 +485,54 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
assert.match(m49Source, /controlLabel: "SEMANTICS"/);
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
assert.doesNotMatch(resultSource, /RAVNOVES004TREE mixed route review/);
assert.match(resultSource, /CANONICAL RECORDED LAB · RAVNOVES004TREE/);
assert.match(resultSource, /<M4ReplayThreatVisual/);
assert.match(resultSource, /timelineEndpointRoot=\{VEGETATION_TIMELINE_ENDPOINT\}/);
assert.match(resultSource, /playbackTransport="segmented"/);
assert.match(resultSource, /recoverTimestampStalls/);
assert.match(resultSource, /КАНОНИЧЕСКАЯ ЗАПИСАННАЯ LAB · RAVNOVES004TREE/);
assert.match(resultSource, /<CanonicalVegetationRerunReplay/);
assert.doesNotMatch(resultSource, /RerunViewport/);
assert.doesNotMatch(resultSource, /cacheRef|pumpRef|desiredRef/);
assert.doesNotMatch(resultSource, /LaboratoryRecordedClipPlayer|M48EvidenceModeRail/);
assert.doesNotMatch(resultSource, /assets\.tgs|<img/);
assert.match(resultSource, /SOURCE POINTS/);
assert.match(resultSource, /TGS COSTMAP/);
assert.match(rerunSource, /<RerunViewport/);
assert.match(rerunSource, /ИСХ\. ТОЧКИ/);
assert.match(rerunSource, /ЛОК\. SLAM/);
assert.match(rerunSource, /resolveCanonicalLabReplay/);
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
assert.match(rerunSource, /unifiedPerception: splitView/);
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
assert.match(rerunSource, /!splitView[\s\S]*event\.button !== 0/);
assert.match(rerunSource, /if \(!splitView\) stopNativeSplitTracking\(\)/);
assert.match(rerunSource, /event\.target instanceof HTMLCanvasElement/);
assert.match(rerunSource, /--canonical-rerun-camera-pane/);
assert.match(rerunSource, /onPointerDownCapture=\{splitView \? trackNativeSplit : undefined\}/);
assert.match(rerunSource, /data-split-view=\{splitView \? "true" : undefined\}/);
assert.match(rerunSource, /mediaMode === null[\s\S]*\? 0[\s\S]*: splitView[\s\S]*\? nativeSplitPercentRef\.current[\s\S]*: 100/);
assert.match(rerunSource, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
assert.match(rerunSource, /presentationReady && playback && playbackController/);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay:not\(\[data-presentation-state="ready"\]\)[\s\S]*laboratory-evidence-viewer__controls/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__timeline \.observation-timeline__playback \{[\s\S]*grid-template-columns: auto auto minmax\(0, 1fr\) auto;/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__viewport-lock[\s\S]*rerun-viewport__camera-lock \{[\s\S]*width: var\(--canonical-rerun-camera-pane, 100%\);/,
);
assert.match(
replayStyles,
/canonical-vegetation-rerun-replay__viewport-lock\[data-split-view="true"\][\s\S]*width: calc\(var\(--canonical-rerun-camera-pane, 46%\) - 0\.75rem\);/,
);
assert.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
assert.match(resultSource, /cellLayerAvailable: false/);
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: true/);
assert.match(canonicalSource, /primary=\{mediaPane\}/);
assert.match(canonicalSource, /secondary=\{spatialPane/);
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
assert.match(canonicalSource, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonicalSource, /resizable=\{splitView && !unifiedContent\}/);
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
assert.match(resultSource, /linkedTgsResultId/);
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
@@ -75,13 +75,13 @@ test("recorded session profile owns progressive admission and on-demand layers",
assert.equal(profile.perceptionLayers.enabled, false);
});
test("LAB recorded evidence remains outside native Rerun lifecycle", () => {
test("the old LAB transport is fenced as explicit legacy comparison only", () => {
assert.deepEqual(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, {
kind: "lab-recorded-evidence",
clock: "source-sequence",
cameraTransport: "generation-bound-fmp4",
spatialTransport: "bounded-sealed-artifacts",
loadPolicy: "visible-evidence-only",
loadPolicy: "explicit-legacy-comparison-only",
workerRequired: false,
});
assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true);
+10
View File
@@ -0,0 +1,10 @@
# Archived viewer comparison source
`rerun-web-viewer-0.34.1/` is retained only as rollback-era comparison evidence
for ADR 0045. The active application neither imports it nor runs the historical
patch scripts during install or build. Canonical Control Station routes use the
unmodified `@rerun-io/web-viewer` dependency declared in `package.json`.
Do not update or reactivate this tree. Remove it with the remaining custom
fMP4/Three.js replay implementation after the canonical Rerun migration passes
operator visual acceptance.
+22
View File
@@ -0,0 +1,22 @@
{
"schema_version": "missioncore.equipment-model-registry/v1",
"models": [
{
"schema_version": "missioncore.equipment-model/v1",
"equipment_model_id": "xgrids.lixelkity-k1",
"equipment_model_version": 1,
"plugin_id": "nodedc.device.xgrids-lixelkity-k1",
"vendor": "XGRIDS",
"display_name": "XGRIDS LixelKity K1",
"category": "mobile-lidar-scanner",
"capability_ids": [
"recorded.camera.video",
"recorded.lidar.point-cloud",
"recorded.pose.trajectory"
],
"authority": {
"commands_enabled": false
}
}
]
}
+97
View File
@@ -0,0 +1,97 @@
{
"schema_version": "missioncore.observatory-laboratory-setup-registry/v1",
"setups": [
{
"setup_id": "m49-tgs-full-shadow-v1",
"display_name": "M4.9T5 · TRAVEL TGS · CPU-only, без ML",
"description": "Сохранённая конфигурация RAVNOVES00: TRAVEL TGS без ML-моделей, 4 489 кадров, causal rolling 1 s, полный визуальный разбор и отдельное доказательство integrated graph.",
"origin": "archived-definition",
"source": {
"session_id": "20260720T065719Z_viewer_live",
"label": "RAVNOVES00",
"required_modalities": [
"point-cloud",
"trajectory",
"video"
]
},
"run_definition": {
"definition_id": "m49-tgs-full-shadow",
"version": 1,
"work_id": "m49-tgs-full-shadow",
"configuration": [
{
"role": "primary-profile",
"path": "config/perception/m49-tgs-full-shadow-v1.json",
"sha256": "c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"
}
]
},
"executor": {
"contour_id": "worker-006",
"state": "not-installed",
"reason_code": "laboratory-runner-adapter-not-installed",
"reason": "Повторный запуск этого сетапа ещё не подключён к общему контуру расчёта."
},
"preserved_results": [
{
"result_id": "m49-tgs-full-shadow-ef98de7db7596d48e8c8c0549ce68e6704ee03e87c8c4bcf1e3e748b7ccb032e",
"result_kind": "recorded-source-paced-tgs-shadow",
"relation": "primary-visual",
"access": "legacy-lab",
"created_at_utc": "2026-08-26T20:27:19.076865Z"
},
{
"result_id": "m49-tgs-integrated-graph-shadow-75e48fd8acf0246be16613e087fcf26fe909f7834718d217c74785a57f494b24",
"result_kind": "source-paced-integrated-graph-shadow",
"relation": "compute-successor",
"access": "evidence-only",
"created_at_utc": "2026-08-27T08:50:05.5622153+00:00"
}
],
"authority": {
"commands_enabled": false,
"actuation_allowed": false,
"navigation_or_safety_accepted": false,
"production_accepted": false
}
},
{
"setup_id": "lab-v1-ravnoves004tree-final",
"display_name": "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
"description": "Точный RAVNOVES004TREE result: 6 830 кадров EoMT Cityscapes Large 1024 + DDRNet-39 и синхронный записанный replay; полный TGS и независимый YOLOX отсутствуют.",
"origin": "existing-result",
"source": {
"session_id": "20260828T130511Z_viewer_live",
"label": "RAVNOVES004TREE",
"required_modalities": [
"point-cloud",
"trajectory",
"video"
]
},
"run_definition": null,
"executor": {
"contour_id": "worker-006",
"state": "not-installed",
"reason_code": "durable-dispatch-definition-unavailable",
"reason": "Для готового результата не сохранён повторно запускаемый сетап; доступен записанный разбор."
},
"preserved_results": [
{
"result_id": "lab-v1-vegetation-shadow-8c8f387599955dd79a16ded2c2d7cfd7f9f308d704f52c42fc26bd39d6da2d60",
"result_kind": "recorded-perception-qualification",
"relation": "canonical-projection",
"access": "observatory",
"created_at_utc": "2026-08-29T18:05:11.329061Z"
}
],
"authority": {
"commands_enabled": false,
"actuation_allowed": false,
"navigation_or_safety_accepted": false,
"production_accepted": false
}
}
]
}
@@ -0,0 +1,61 @@
{
"schema_version": "missioncore.observatory-m49-recorded-queue-binding/v1",
"binding_id": "m49-ravnoves00-recorded-queue-v1",
"source": {
"session_id": "20260720T065719Z_viewer_live",
"label": "RAVNOVES00",
"required_modalities": [
"point-cloud",
"trajectory",
"video"
],
"source_pack": {
"artifact_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b",
"sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944",
"byte_length": 72996000,
"media_type": "application/vnd.nodedc.lidar-source-pack+npz",
"expected_timeline_frames": 4489,
"expected_available_lidar_frames": 3928
}
},
"setup": {
"setup_id": "m49-tgs-full-shadow-v1",
"definition_id": "m49-tgs-full-shadow",
"definition_version": 1,
"definition_sha256": "836a66639e69f7ea00de2a9111c1c6c9c3c00f1abd726f1df596aeb4e3a88ae6"
},
"source_adapter": {
"adapter_id": "ravnoves00-m49-source-pack",
"version": 1
},
"executor": {
"release_id": "m49-tgs-full-shadow-worker-release",
"artifact_sha256": "5e0ea16c7a5cc760463836718b0cd8b0006ffc4b202e5f706a20a86ef2f912ab",
"code_revision": "40c850b167dda366d8aa45d828520168affaf9fd",
"service_installed": false,
"image_set": {
"schema_version": "missioncore.observatory-executor-image-set/v1",
"images": {
"travel": "7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f",
"parity": "ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0"
}
},
"learned_models": []
},
"resource_profile": {
"schema_version": "missioncore.observatory-recorded-resource-profile/v1",
"profile_id": "worker006-cpu-single-run-v1",
"contour_id": "worker-006",
"concurrency": 1,
"checkpoint_policy": "non-checkpointable",
"restart_from_zero": true,
"staging_discard_required": true,
"resource_release_receipt_required": true
},
"authority": {
"commands_enabled": false,
"actuation_allowed": false,
"navigation_or_safety_accepted": false,
"production_accepted": false
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
{
"schema_version": "missioncore.recorded-capture-profile-registry/v1",
"profiles": [
{
"schema_version": "missioncore.recorded-capture-profile/v1",
"capture_profile_id": "xgrids-k1.viewer-live.fw-3.0.2.v1",
"capture_profile_version": 1,
"equipment_model_id": "xgrids.lixelkity-k1",
"equipment_model_sha256": "c95d7183ffd2e425892fd9ddfc4cd8dcbccc0231289127ddc14db6b52bbb92aa",
"plugin_id": "nodedc.device.xgrids-lixelkity-k1",
"archive_id": "xgrids-k1.viewer-live.evidence",
"firmware_compatibility_profile_id": "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
"modalities": [
"point-cloud",
"trajectory",
"video"
],
"semantic_channels": [
"camera.video.recorded",
"spatial.point-cloud.recorded",
"spatial.pose.recorded"
],
"camera_media": {
"source_id": "sensor.camera.right",
"media_type": "video/mp4; codecs=\"avc1.641028\"",
"width": 800,
"height": 600,
"initialization_sha256": "e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38"
},
"calibration": {
"slot_id": "camera_1",
"sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
},
"media_epoch_policy": "exactly-one-complete-epoch",
"seekable": true,
"adapter": {
"adapter_id": "xgrids-k1-recorded-observatory-v2",
"version": 2,
"adapter_sha256": "4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de"
}
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,206 @@
{
"schema_version": "missioncore.perception-profile-candidate/v1",
"profile_id": "k1-perception-ddrnet39-rfdetr-tgs-prototype/v1",
"display_name": "K1 Perception — DDRNet-39 + RF-DETR + TGS",
"status": "stage-1-candidate-not-installed-not-qualified",
"experiment_policy": {
"bounded_lab_overload_allowed": true,
"retain_profiles_that_fail_current_hardware_realtime": true,
"realtime_failure_blocks_experimental_packaging": false,
"qualification_scope": ["profile-version", "effective-config", "hardware", "source", "transport"],
"preserve_original_source_clock": true,
"report_drops_and_staleness": true,
"overload_is_realtime_pass": false,
"future_onboard_placement_requires_new_measurement": true
},
"packaging": {
"image": "ndc-k1-perception-ddrnet39-rfdetr-tgs:prototype-v1",
"image_sha256": null,
"single_container": true,
"external_model_server_required": false,
"host_model_cache_required": false,
"runtime_model_download_allowed": false,
"model_environment_isolation": "supervised-processes-one-gpu-scheduler",
"common_interpreter_required": false
},
"hardware_baseline": {
"gpu": "NVIDIA GeForce RTX 4090",
"active_profiles_per_worker": 1,
"sources_per_worker": 1,
"gpu_inference_policy": "serialized",
"other_gpu_workloads_allowed": false
},
"cpu_execution_policy": {
"OPENBLAS_NUM_THREADS": 1,
"OMP_NUM_THREADS": 1,
"MKL_NUM_THREADS": 1,
"reason": "bounded-single-thread-numeric-libraries-before-whole-graph-qualification"
},
"operating_envelope": {
"status": "bounded-latency-reference-not-full-qualification",
"checker": "k1link.perception.worker_operating_envelope.operating_envelope_failures",
"reference_conditions": {
"envelope_id": "worker006-4090-610.47-stock-clock-reference/v1",
"gpu_name": "NVIDIA GeForce RTX 4090",
"driver_version": "610.47",
"cpu_limit_millicores": 8000,
"memory_limit_mib": 8192,
"minimum_sm_clock_mhz": 2610,
"minimum_memory_clock_mhz": 10251,
"maximum_snapshot_age_ms": 1000
},
"check_phase": "post-warmup-before-timed-window-plus-runtime-monitoring",
"container_may_set_host_clocks": false,
"persistent_host_clock_policy_installed": false,
"readiness_is_realtime_qualification": false,
"unknown_ownership_or_telemetry_is_pass": false,
"failed_envelope_allows_labelled_experiment": true
},
"output_freshness_contract": {
"schema_version": "missioncore.perception-scene-freshness/v1",
"required_layers": ["segmentation", "objects", "geometry", "motion", "costmap", "policy"],
"retain_original_layer_identity": true,
"propagate_required_input_age": true,
"recheck_at_publication_receipt_and_use": true,
"clock_uncertainty_adds_to_age": true,
"unavailable_or_stale_required_layer": "no-go-advisory-only",
"history_can_independently_authorize_terrain": false,
"transport_streamstart_and_lease_fencing_required_separately": true
},
"input_contract": {
"schema_version": "missioncore.perception-stream-start/v1",
"channels": ["camera", "point-cloud", "pose"],
"camera": {
"width": 800,
"height": 600,
"projection": "KB4",
"decoded_pixel_format": "bgr8",
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
"valid_fov_sha256": "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"
},
"point_cloud": {
"representation": "vendor-registered-current-increment",
"coordinate_frame": "map",
"units": "metres",
"scalar_type": "float32-little-endian",
"raw_fields_retained": true
},
"pose": {"parent_frame": "map", "child_frame": "sensor"},
"clock_mapping_required": true,
"source_end_or_duration_required": false,
"full_source_transfer_required": false,
"full_source_hash_pass_required": false,
"full_decode_or_png_cache_required": false,
"previous_perception_artifacts_allowed": false
},
"stream_policy": {
"replay_speed": 1.0,
"release_clock": "original-source-timestamps",
"inference_stride": 1,
"sampling_status": "strict-every-frame-initial-baseline-no-silent-multirate",
"first_result_requires_eof": false,
"lookahead_allowed": false,
"reconnect": "new-epoch-drop-old-backlog-reacquire-codec-keyframe",
"overflow": "bounded-drop-with-ledger-degraded-and-failed-runtime-gate",
"archive_export_on_critical_path": false,
"transport_candidate": "grpc-bidirectional-protobuf-binary-payloads",
"transport_qualified": false
},
"components": {
"segmentation": {
"model": "ddrnet_39-goose-fine-64",
"checkpoint_sha256": "b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6",
"precision": "existing-fp32-baseline",
"preprocessing": "center-600-square-to-512-rgb-tensor-0-1",
"outside_crop": "undefined-not-free",
"class_count": 64,
"runtime": "python-3.9-pytorch-1.13.1-cu117-super-gradients-3.2.0"
},
"objects": {
"model": "rf_detr_large_native_kb4",
"checkpoint_sha256": "0f4e20e19a99c0f8a62b5685f57f6c8b5c371c59081feda6752a0561a79ccf38",
"engine_sha256": "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695",
"runtime": "tensorrt-11-fp16-native-uint8",
"minimum_score": 0.25,
"minimum_box_area_pixels": 64,
"maximum_box_area_fraction": 0.5,
"minimum_valid_fov_fraction": 0.5,
"require_center_inside_valid_fov": true,
"required_existing_classes": ["person", "cat", "dog"],
"fine_static_object_classifier_required": false,
"filter_quality_status": "existing-unchanged-near-large-and-small-object-risk-unqualified"
},
"geometry": {
"online_surface": "K1LocalSurfaceShadowEstimator",
"prepared_local_surface_allowed": false,
"generic_static_class": "static.unknown",
"detected_object_range": "median-camera-z-of-owned-current-points/v1",
"geometry_only_range": "nearest-euclidean-sensor-distance/v1",
"vehicle_clearance_claim": false,
"missing_support": "unavailable-not-zero-not-free"
},
"motion": {
"reuse": "BoundedSpatialTemporalProvider+ClassIndependentMotionEstimator",
"semantic_class_implies_motion": false
},
"costmap": {
"algorithm": "TRAVEL-TGS",
"revision": "95dc2fbd66a343efd9060c45a5711b6307a950a4",
"execution": "cpu",
"rolling_history_seconds": 1.0,
"states": ["UNOBSERVED", "GROUND_SUPPORT", "NONGROUND_OCCUPIED", "UNKNOWN_REJECTED"],
"policy_input": true
},
"policy": {
"scope": "rural-prototype-advisory-only",
"hard_surface": "ALLOW-candidate-subject-to-geometry-and-freshness",
"fine_road_sidewalk_bikeway_distinction_required": false,
"effective_material_rules_required": true,
"unknown_or_occupied_overrides_material_allowance": true,
"sensor_to_vehicle_mount": "simulation-only-until-measured"
}
},
"reference_files": [
{"path": "config/perception/rf-detr-large-native-kb4-risk-shadow-v0.json", "sha256": "dbf4da5dbad6c3c22b1280b46ffcad81719bd183c81c263a4859847d829019b6"},
{"path": "config/perception/lab-v1-goose-vegetation-benchmark-v1.json", "sha256": "96a427a8baae387b827ec9c0bf7ca42e3fb9114b8fa9a8671bbc9d10877670b9"},
{"path": "config/perception/lab-v1-vegetation-provider-label-map-v1.json", "sha256": "f2b69046b6a740fd9532d2d88e7fabae7c20fb662f783c9502adc9026406f352"},
{"path": "config/perception/lab-v1-vegetation-mission-policy-v1.json", "sha256": "b75c4ac841d7b4bcc57f7a9c8417ca2317d8ecfa499e72a9af8a8591a2ec0d35"},
{"path": "config/perception/m49-tgs-full-shadow-v1.json", "sha256": "c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"},
{"path": "config/perception/m4-geometry-association-v1.json", "sha256": "cc666c9389a5e221957faddec89584709b66918d14abaf646f1832e001421999"}
],
"reference_usage": "reuse-model-parameters-and-algorithms-only-not-recording-ids-or-precomputed-results",
"realtime_contract": {
"schema_version": "missioncore.perception-realtime-contract/v1",
"budget_status": "engineering-targets-worker-pilot-measured-not-whole-path-qualified-not-safety-approved",
"budgets": {
"maximum_start_metadata_bytes": 65536,
"maximum_chunk_bytes": 1048576,
"maximum_inflight_bytes": 16777216,
"maximum_pending_camera_frames": 2,
"maximum_codec_preroll_ms": 500.0,
"maximum_pose_age_ms": 100.0,
"maximum_clock_uncertainty_ms": 5.0,
"maximum_release_lag_ms": 25.0,
"maximum_output_age_p95_ms": 125.0,
"maximum_output_age_p99_ms": 125.0,
"maximum_layer_age_ms": 250.0,
"maximum_first_result_ms": 1000.0,
"maximum_warmup_seconds": 120.0,
"maximum_stop_seconds": 5.0,
"maximum_vram_mib": 22000,
"maximum_rss_mib": 8192,
"maximum_backlog_growth_ms": 25.0
},
"budget_origin": "125ms-historical-envelope-retained-as-target-now-applied-to-whole-path; other-limits-explicit-initial-engineering-guards",
"capacity_drop_count_max": 0,
"unaccounted_input_count_max": 0,
"full_input_transfer_before_first_result_allowed": false,
"independent_quality_and_physical_live_status": "pending"
},
"authority": {
"commands_enabled": false,
"actuation_allowed": false,
"navigation_or_safety_accepted": false,
"production_accepted": false
}
}
@@ -0,0 +1,162 @@
{
"schema_version": "missioncore.lab-v1-eomt-ddrnet-portable-profile/v2",
"profile_id": "lab-v1-eomt-ddrnet-portable-v2",
"lab_id": "LAB-V1",
"worker_id": "worker-006",
"source_binding": {
"mode": "admitted-k1-recording",
"camera_source_id": "sensor.camera.right",
"camera_timeline": "dynamic",
"filesystem_paths": "executor-resolved",
"expected_width": 800,
"expected_height": 600,
"expected_frame_count": "source-derived",
"frame_indices": "source-derived-representative",
"crop_contract": "center-600-square-to-512; outside-crop-is-undefined"
},
"effective_config_contract": {
"schema_version": "missioncore.lab-v1-goose-vegetation-benchmark/v1",
"dynamic_field": "ravnoves",
"source_id": "sealed-source-derived",
"source_sha256": "sealed-camera-input-derived",
"base_m4_result_id": null
},
"runtime": {
"super_gradients_version": "3.2.0",
"super_gradients_revision": "54d062ecb1081944a672ce447cf3e96a36708ff9",
"python_version": "3.9",
"numpy_version": "1.23.0",
"cmake_version": "3.31.6",
"onnxsim_version": "0.4.36",
"opencv_python_version": "4.8.1.78",
"pytorch_version": "1.13.1",
"torchvision_version": "0.14.1",
"pytorch_cuda_version": "11.7",
"container_base": "nvidia/cuda:12.8.1-cudnn-devel-ubuntu22.04@sha256:ad6d59a3bbf3e82c1c849c9ac09cfc2a3e0bbb8655042fd899be6681b3fe2a85",
"miniconda_installer": "Miniconda3-py39_24.11.1-0-Linux-x86_64.sh",
"miniconda_installer_sha256": "3ea8373098d72140e08aac9217822b047ec094eb457e7f73945af7c6f68bf6f5"
},
"dataset": {
"dataset_id": "goose-2d-validation-visible-rgb",
"relative_root": "goose-2d/validation",
"mapping_relative_path": "goose_label_mapping.csv",
"mapping_sha256": "88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f",
"image_glob": "images/val/**/*_windshield_vis.png",
"expected_pair_count": 962,
"input_size": [
512,
512
],
"preprocessing": [
"center-square-crop",
"nearest-neighbor-resize",
"rgb-to-tensor-0-1"
]
},
"candidates": {
"ddrnet": {
"candidate_id": "goose-ddrnet-class-512",
"model_names": [
"ddrnet_39"
],
"checkpoint_relative_path": "models/goose/ddrnet_class_512.pth",
"checkpoint_size_bytes": 259419077,
"checkpoint_sha256": "b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6",
"published_validation_miou_percent": 46.53
}
},
"vegetation_class_names": [
"leaves",
"forest",
"bush",
"moss",
"tree_crown",
"tree_trunk",
"crops",
"low_grass",
"high_grass",
"scenery_vegetation",
"hedge",
"tree_root"
],
"visual_case_contract": {
"selection_basis": "ground-truth-class-support-only",
"case_count": 12,
"minimum_focus_pixels": 2048,
"strata": [
{
"class_name": "high_grass",
"count": 2
},
{
"class_name": "low_grass",
"count": 2
},
{
"class_name": "bush",
"count": 2
},
{
"class_name": "tree_trunk",
"count": 2
},
{
"class_name": "tree_crown",
"count": 1
},
{
"class_name": "hedge",
"count": 1
},
{
"class_name": "forest",
"count": 1
},
{
"class_name": "crops",
"count": 1
}
],
"error_overlay": {
"correct_material_rgba": [
34,
197,
94,
72
],
"missed_vegetation_rgba": [
239,
68,
68,
220
],
"false_vegetation_rgba": [
245,
158,
11,
220
],
"wrong_vegetation_material_rgba": [
168,
85,
247,
220
]
}
},
"policy_action_colors": {
"ALLOW": "#22c55e",
"HIGH_COST": "#f59e0b",
"NO_GO": "#ef4444"
},
"invariants": {
"one_heavy_candidate_at_a_time": true,
"raw_fisheye_is_immutable": true,
"outside_center_crop_is_free": false,
"missing_or_unknown_is_free": false,
"camera_semantics_can_clear_rigid_geometry": false,
"navigation_authority": false,
"actuation_authority": false,
"canonical_triton_mutation_allowed": false
}
}
@@ -0,0 +1,60 @@
{
"schema_version": "missioncore.m49-tgs-portable-profile/v2",
"profile_id": "m49-tgs-portable-v2",
"source_binding": {
"mode": "admitted-k1-recording",
"camera_timeline": "dynamic",
"lidar_replay": "dynamic",
"trajectory": "dynamic",
"frame_counts": "source-derived",
"filesystem_paths": "executor-resolved"
},
"alignment": {
"timeline": "recorded-camera-host-arrival",
"lidar_selection": "latest-not-newer-than-camera-frame",
"maximum_lidar_age_seconds": 1.0,
"future_frames_allowed": false
},
"tgs": {
"max_range_m": 80.0,
"min_range_m": 1.0,
"resolution_m": 8.0,
"num_iterations": 3,
"num_lowest_representative_points": 5,
"minimum_points": 10,
"seed_threshold_m": 0.5,
"distance_threshold_m": 0.125,
"outlier_threshold_m": 0.3,
"normal_threshold": 0.94,
"weight_threshold": 200.0,
"lcc_normal_similarity": 0.03,
"lcc_planar_distance_m": 0.1,
"obstacle_height_m": 1.0,
"refine_mode": true
},
"rolling_profile": {
"history_seconds": 1.0,
"local_radius_m": 12.0,
"missing_lidar_policy": "all-cells-unobserved"
},
"costmap": {
"coordinate_frame": "map-gravity-local",
"cell_size_m": 0.45,
"radius_m": 12.0,
"state_priority": [
"NONGROUND_OCCUPIED",
"UNKNOWN_REJECTED",
"GROUND_SUPPORT",
"UNOBSERVED"
]
},
"invariants": {
"aos_allowed": false,
"gpu_allowed": false,
"missing_support_means_free": false,
"missing_lidar_means_unobserved": true,
"unobserved_cells_are_emitted": true,
"camera_projection_is_authoritative": false,
"navigation_or_actuation_allowed": false
}
}
+33 -2
View File
@@ -20,10 +20,23 @@ The normalizer exposes a read-only normalized telemetry adapter on
Timescale credentials, and Timescale is not published on a host port.
The normalizer uses a separate `missioncore_normalizer` database role with only
`SELECT`, `INSERT`, `UPDATE`, and bounded retention `DELETE` on the telemetry
hypertable. Raw telemetry older than 30 days is removed at most once per day by the
normalizer. This stays compatible with the Apache-licensed Timescale image without
hypertable. Raw telemetry older than 30 days is removed in batches of at most
1000 rows per minute, on a separate connection/thread (2s statement / 250ms lock
deadline). Retention never runs inside the MQTT callback. This stays compatible
with the Apache-licensed Timescale image without
depending on the Timescale License retention scheduler.
The 1 GiB database container explicitly sets 128 MiB shared buffers and 64 MiB
maintenance memory. The HA image's automatic tuning sees the Docker VM instead
of this container limit; do not inherit its multi-GiB defaults.
For a memory-constrained local repair, `compose.source.yaml` mounts the reviewed
normalizer source read-only into the **existing** image. Set
`MISSIONCORE_NORMALIZER_SOURCE` to the absolute `normalizer.py` path, pass both
Compose files and the existing private `--env-file` / `--project-directory`, and
use `up -d --no-deps --no-build normalizer`. This is an explicit local source
override, not a rebuilt portable image. Normal restart/reboot retains the mount.
The stack is one deployment contour, not one multi-process container. Keeping broker,
normalizer and database in separate containers preserves independent health checks,
least-privilege boundaries and rollback while Compose provides one operator lifecycle:
@@ -136,6 +149,24 @@ globally unique because Mosquitto ACL ownership is username-based.
## Worker agent
MQTT outputs use `startup_error_behavior="retry"`, with a 2000-metric buffer and
the configured flush interval. A missing broker at agent startup must not end
the service. See [Telegraf's startup policy](https://docs.influxdata.com/telegraf/v1/configuration/plugin-options/).
If Docker lost a published listener while the saved LAN address is unchanged,
the explicit broker Apply action reconciles that listener; a telemetry GET never
restarts infrastructure. Node connectivity does not prove a profile is ready.
The streaming profile can export one bounded (8 KiB) current observation using
`--telemetry-snapshot /telemetry/current.json`, with `/telemetry` mounted to
`C:\ProgramData\NDC\MissionCore\telemetry-agent\perception` on Worker. The existing
collector reads it with a 5s freshness limit and forwards it over the same MQTT
pipeline topic. It overrides the legacy service only when explicitly present;
an expired/broken file reports unavailable rather than relabeling a legacy model.
The publisher runs independently from heartbeat/inference and never renews a
lease, grants GPU ownership, or qualifies real-time. Queue memory includes active
input and decoder scratch. Stage timings are unavailable until actually measured.
No camera, point cloud, model result, MQTT secret, or motor command goes here.
Worker 006 uses the official Windows Telegraf distribution as the host service
`NDC Mission Core Telemetry Agent`. Install or update it with:
@@ -0,0 +1,9 @@
# Local, read-only source override for repairing the existing normalizer without
# rebuilding an image on a memory-constrained operator machine. No extra service.
services:
normalizer:
volumes:
- type: bind
source: ${MISSIONCORE_NORMALIZER_SOURCE:?Set the absolute normalizer.py path}
target: /app/normalizer.py
read_only: true
+2
View File
@@ -49,6 +49,8 @@ services:
timescale:
image: timescale/timescaledb-ha:pg16.14-ts2.28.2-all-oss
# The HA image auto-tunes for the Docker VM, not this 1 GiB container.
command: ["postgres", "-c", "shared_buffers=128MB", "-c", "work_mem=4MB", "-c", "maintenance_work_mem=64MB", "-c", "effective_cache_size=512MB", "-c", "max_parallel_workers_per_gather=0", "-c", "max_connections=32"]
container_name: ndc-mission-core-telemetry-timescaledb
restart: unless-stopped
security_opt:
+39 -16
View File
@@ -31,7 +31,8 @@ SAFE_IDENTIFIER: Final = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
MAX_PAYLOAD_BYTES: Final = 1024 * 1024
MAX_TAGS: Final = 256
MAX_SERIES_KEY_BYTES: Final = 4096
RETENTION_INTERVAL_SECONDS: Final = 86_400
RETENTION_INTERVAL_SECONDS: Final = 60
RETENTION_BATCH_ROWS: Final = 1000
ALLOWED_TELEMETRY_TAGS: Final = frozenset(
{
"agent_id",
@@ -434,6 +435,39 @@ def _start_query_server(
return server
def _retention_batch(connection: Any) -> int:
"""Preserve the existing 30-day policy without an unbounded startup DELETE."""
with connection.transaction(), connection.cursor() as cursor:
cursor.execute("SET LOCAL statement_timeout = '2s'")
cursor.execute("SET LOCAL lock_timeout = '250ms'")
cursor.execute(
"""
WITH expired AS (
SELECT tableoid, ctid FROM contour_telemetry_samples
WHERE observed_at < CURRENT_TIMESTAMP - INTERVAL '30 days'
ORDER BY observed_at LIMIT %s
)
DELETE FROM contour_telemetry_samples AS samples USING expired
WHERE samples.tableoid = expired.tableoid AND samples.ctid = expired.ctid
""",
(RETENTION_BATCH_ROWS,),
)
return cursor.rowcount
def _retention_loop(dsn: str) -> None:
import psycopg # type: ignore[import-not-found]
while True:
# Neither a retention timeout nor database recovery blocks MQTT I/O.
time.sleep(RETENTION_INTERVAL_SECONDS)
try:
with psycopg.connect(dsn, connect_timeout=3) as connection:
_retention_batch(connection)
except psycopg.Error:
print("telemetry retention deferred", flush=True)
def main() -> None:
import paho.mqtt.client as mqtt
import psycopg # type: ignore[import-not-found]
@@ -447,7 +481,9 @@ def main() -> None:
password = _required("MISSIONCORE_MQTT_PASSWORD")
runtime_health = TelemetryRuntimeHealth()
connection = psycopg.connect(dsn, autocommit=True)
last_retention_monotonic = 0.0
threading.Thread(
target=_retention_loop, args=(dsn,), name="telemetry-retention", daemon=True
).start()
runtime_health.set_database(True)
_start_query_server(dsn, runtime_health)
client = mqtt.Client(
@@ -480,7 +516,7 @@ def main() -> None:
runtime_health.set_mqtt(False, f"MQTT disconnected: {reason_code}")
def on_message(_client: Any, _userdata: object, message: Any) -> None:
nonlocal connection, last_retention_monotonic
nonlocal connection
try:
row = _normalize(message.topic, message.payload)
except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
@@ -489,19 +525,6 @@ def main() -> None:
for attempt in range(2):
try:
with connection.cursor() as cursor:
now_monotonic = time.monotonic()
if (
now_monotonic - last_retention_monotonic
>= RETENTION_INTERVAL_SECONDS
):
cursor.execute(
"""
DELETE FROM contour_telemetry_samples
WHERE observed_at
< CURRENT_TIMESTAMP - INTERVAL '30 days'
"""
)
last_retention_monotonic = now_monotonic
cursor.execute(
"""
INSERT INTO contour_telemetry_samples (
@@ -1,7 +1,8 @@
[CmdletBinding()]
param(
[string]$ContainerName = "ndc-mission-core-perception-worker",
[string]$HealthUrl = "http://127.0.0.1:18020/health"
[string]$HealthUrl = "http://127.0.0.1:18020/health",
[string]$RuntimeSnapshot = "C:\ProgramData\NDC\MissionCore\telemetry-agent\perception\current.json"
)
$ErrorActionPreference = "Stop"
@@ -18,6 +19,46 @@ $stageIds = @(
)
$health = $null
$collectorState = "unavailable"
# A fresh full-profile export supersedes the historical service. Presence of an
# expired/broken export is NOT permission to relabel a legacy model as current.
if (Test-Path -LiteralPath $RuntimeSnapshot -PathType Leaf) {
$runtime = $null
try {
$item = Get-Item -LiteralPath $RuntimeSnapshot
$age = ([DateTime]::UtcNow - $item.LastWriteTimeUtc).TotalSeconds
if ($item.Length -le 8192 -and $age -ge -1 -and $age -le 5) {
$stream = [IO.File]::Open($RuntimeSnapshot, 'Open', 'Read', 'ReadWrite')
try {
$bytes = New-Object byte[] 8193
$count = $stream.Read($bytes, 0, $bytes.Length)
if ($count -le 8192) {
$runtime = [Text.Encoding]::UTF8.GetString($bytes, 0, $count) | ConvertFrom-Json
}
} finally { $stream.Dispose() }
if ($runtime.schema_version -ne 'missioncore.perception-runtime-observation/v1') {
$runtime = $null
}
}
} catch { $runtime = $null }
$samples = foreach ($stageId in $stageIds) {
$sample = [ordered]@{
stage_id = $stageId
stage_state = 'unavailable'
service_state = 'unavailable'
collector_state = 'unavailable'
}
if ($runtime) {
$sample.collector_state = 'live'
foreach ($name in @('service_state', 'profile_name', 'active_request_id',
'input_state', 'wait_reason', 'live_children', 'input_pauses', 'pending_bundles', 'buffer_bytes')) {
$sample[$name] = $runtime.$name
}
}
[pscustomobject]$sample
}
@($samples) | ConvertTo-Json -Compress -Depth 4
exit 0
}
try {
$healthJson = docker exec $ContainerName python3 -c `
"import urllib.request;print(urllib.request.urlopen('$HealthUrl',timeout=2).read().decode())" `
@@ -68,13 +109,13 @@ $completedRuns = if ($health -and $null -ne $health.completed_runs) {
[int64]$health.completed_runs
}
else {
[int64]0
[int64]-1
}
$failedRuns = if ($health -and $null -ne $health.failed_runs) {
[int64]$health.failed_runs
}
else {
[int64]0
[int64]-1
}
$modelLoadSeconds = if ($health -and $null -ne $health.model_load_seconds) {
[double]$health.model_load_seconds
@@ -1,5 +1,8 @@
[agent]
interval = "${MISSIONCORE_TELEMETRY_INTERVAL}"
flush_interval = "${MISSIONCORE_TELEMETRY_INTERVAL}"
metric_batch_size = 200
metric_buffer_limit = 2000
round_interval = true
omit_hostname = false
@@ -26,6 +29,7 @@
total = true
[[outputs.mqtt]]
startup_error_behavior = "retry"
servers = ["tcp://${MISSIONCORE_MQTT_HOST}:${MISSIONCORE_MQTT_PORT}"]
topic = "mission-core/v1/contours/${MISSIONCORE_CONTOUR_ID}/agents/${MISSIONCORE_AGENT_ID}/host"
username = "${MISSIONCORE_MQTT_USERNAME}"
@@ -1,5 +1,8 @@
[agent]
interval = "${MISSIONCORE_TELEMETRY_INTERVAL}"
flush_interval = "${MISSIONCORE_TELEMETRY_INTERVAL}"
metric_batch_size = 200
metric_buffer_limit = 2000
round_interval = true
omit_hostname = false
@@ -30,6 +33,7 @@
[[inputs.docker]]
endpoint = "npipe:////./pipe/docker_engine"
container_name_include = []
container_state_include = ["running", "exited", "restarting", "paused"]
[[inputs.http_response]]
urls = ["http://127.0.0.1:8000/v2/health/ready"]
@@ -59,6 +63,9 @@
"current_stage",
"active_request_id",
"collector_state",
"profile_name",
"input_state",
"wait_reason",
]
[[inputs.tail]]
@@ -73,6 +80,7 @@
data_type = "string"
[[outputs.mqtt]]
startup_error_behavior = "retry"
servers = ["tcp://${MISSIONCORE_MQTT_HOST}:${MISSIONCORE_MQTT_PORT}"]
topic = "mission-core/v1/contours/${MISSIONCORE_CONTOUR_ID}/agents/${MISSIONCORE_AGENT_ID}/host"
username = "${MISSIONCORE_MQTT_USERNAME}"
@@ -83,6 +91,7 @@
namedrop = ["missioncore_pipeline", "missioncore_pipeline_event"]
[[outputs.mqtt]]
startup_error_behavior = "retry"
servers = ["tcp://${MISSIONCORE_MQTT_HOST}:${MISSIONCORE_MQTT_PORT}"]
topic = "mission-core/v1/contours/${MISSIONCORE_CONTOUR_ID}/agents/${MISSIONCORE_AGENT_ID}/pipeline"
username = "${MISSIONCORE_MQTT_USERNAME}"
+1 -1
View File
@@ -172,7 +172,7 @@ On the first live or adapter file-replay session, `RerunBridge`:
At every session start it resets the trajectory, current point count, metrics,
blueprint and session-local visible time, then feeds the new source through the
same recording. This process-wide lifecycle is intentional: the Rerun 0.34.1
same recording. This process-wide lifecycle is intentional: the upstream Rerun 0.36.3
browser receiver can remain connected after canvas teardown, so restarting the
native listener on the same port is not a reliable session boundary.
+103
View File
@@ -185,6 +185,15 @@ The evidence slot has two admitted renderers:
- `diagnostic-model` — a specialized visual result such as the LAB E28 L2.6
surface/timeline/review viewer.
ADR 0045 amends the full-session `recorded-replay` transport. A canonical
recorded LAB uses one unmodified upstream Rerun viewer, recording identity and
timeline for camera, semantic and spatial evidence. The source RRD owns pose,
point cloud and trajectory; a digest-bound RRD sidecar may add only immutable
derived entities which are absent from the source. Model or layer selection is
a blueprint/profile change, not a new player or renderer. Missing full-route
TGS, semantic 3D, boxes or cuboids remain disabled instead of being inferred
from sparse review artifacts.
Recorded camera clips used for review are a frozen sub-contract of the admitted
viewer, `missioncore.laboratory-recorded-clip-viewer/v1`, implemented by
`LaboratoryRecordedClipPlayer`. It owns the generation-bound fMP4 manifest,
@@ -197,6 +206,10 @@ Forward frame progression may roll an already-buffered segment target; a
backward seek or clip loop must perform an explicit decoder seek and remain
decoder-ready without exposing a per-frame loader.
That clip contract remains available to historical bounded clip instruments.
It is legacy comparison transport for a migrated full-session LAB and must not
be mounted, prefetched or run in parallel with the canonical Rerun profile.
When recorded camera and frame-indexed spatial evidence are both required for
one review question, the shared player presents them simultaneously on the same
media clock. The camera remains the clock owner; a bounded experiment-neutral
@@ -262,6 +275,96 @@ execution canaries. Once they prove one automatic run-to-evidence path, the LAB
canonicalization slice closes and remaining historical work stays `legacy` unless
an integrity or product need justifies a targeted migration.
## Observatory durable recorded queue and Worker dispatch
Selecting a source and a laboratory setup in Observatory is not itself a run.
The exact legacy submission contains only `source_session_id`, `setup_id` and an
idempotency key. A portable submission additionally returns the server-owned
`definition_sha256` and one content-bound `check_sha256` obtained from preflight;
both must be echoed unchanged during submit. Neither request can supply commands,
executable text, filesystem paths, container images, model identities, resource
limits or priority. The server resolves the allowlisted identities and seals all
executable identity into one durable record:
- the current source-catalog snapshot captured at admission, plus immutable source
bundle and source-capability-manifest SHA-256 identities;
- source-adapter id, version and SHA-256;
- setup RunDefinition id, version and SHA-256;
- executor release and image SHA-256 identities;
- learned-model manifest and resource-profile identities;
- checkpoint policy and the server-owned priority class.
An exact retry returns the existing job. Reusing an idempotency key for another
identity fails closed. The ordinary lifecycle advances through `accepted`,
`queued`, `claimed` and `running`, then terminates as `succeeded` or `failed`.
`paused`, `preemption-pending` and `reconciliation-required` are explicit safety
branches rather than hidden retries. The durable queue serializes one recorded
compute owner and does not equate a queued receipt with a Worker execution
receipt.
The only pair currently admitted to the durable queue is the exact `RAVNOVES00`
source and `M4.9T5 · TRAVEL TGS · CPU-only, без ML` RunDefinition. It seals an
explicitly empty learned-model list because TGS is an algorithmic CPU pipeline,
not an unknown model dependency. The binding pins the exact source pack rather
than a volatile whole-catalog digest; the server captures and seals the current
catalog snapshot into each admitted job.
Two source-independent definitions are projected by the portable catalog:
- `LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39` seals the exact
model/component/resource identities and the
`missioncore.recorded-eomt-ddrnet-review/v2` result contract;
- `M4.9T5 · TRAVEL TGS · CPU-only, без ML` v2 seals an explicitly empty model
manifest, dynamic source-derived frame counts, causal TGS invariants and the
`missioncore.recorded-tgs-costmap-review/v2` result contract.
Neither portable definition contains a source Session id, label, fixed frame
count or filesystem path. Compatibility is derived independently for every
setup from the selected Session's real K1 capabilities. A compatible source may
therefore report capability `pass` while that setup's executor remains blocked.
Blocked definitions stay projectable and do not prevent an unrelated ready
definition from entering the durable queue allowlist.
The server now owns a definition-SHA/check-SHA fenced portable check/submit
boundary. Submission still fails closed unless that exact definition has a
sealed `ready` executor release and image and is present in the durable queue.
Both repository portable definitions currently declare `not-installed`, so
their preflight remains blocked; no release or image hash is fabricated. The
generic v2 assemblers, verified publisher and Worker transport exist as a
dormant fail-closed implementation, but exact executor installation, central
storage, authentication, tunnel acceptance and end-to-end canaries remain
pending. The old `missioncore.lab-v1-vegetation-shadow/v1` result and the exact
RAVNOVES00 M4.9 result remain only in their existing immutable catalogs;
neither is reclassified as a result of a portable definition.
Recorded work has priority rank `100`. A future live K1 lease has rank `0` and
closes new recorded claims while it is pending or active. Cooperative executors
yield only at an allowlisted checkpoint. A non-checkpointable monolith must be
cancelled by the scheduler, discard all staging output and later restart from
zero. Cancellation uses a crash-safe two-phase protocol: Mission Core first
persists one stable cancellation intent and activates live work only after an
identity-bound receipt proves resource release and staging discard. A retry uses
the same cancellation identity. A missing callback or receipt remains durably
pending for retry; a conflicting or indeterminate receipt enters reconciliation.
Every unresolved form blocks live activation and concurrent ownership.
An explicit live terminal trigger requeues paused recorded work.
ADR 0046 records this decision. Durable queue and identity contracts, the
authenticated Worker pull protocol and transport-agnostic Worker agent exist as
foundation. The production app hard-disables the Worker router even when a valid
credential is present until an expiring claim lease and verified result publisher
are accepted. Installation of the exact executors, Worker deployment and wiring
from the real K1 lifecycle to live-lease triggers remain pending. Therefore a
submitted exact legacy M4.9T5 job may honestly wait in `queued` without implying
that Worker 006 can execute it yet. Portable LAB V1 and portable M4.9T5 cannot
currently be submitted because their executor states are `not-installed` and
the authenticated Worker dispatch gate is closed.
Worker telemetry remains secondary observation evidence. It does not replace the
authoritative queue ledger, result validation or common laboratory receipt. K1
acquisition and control, the legacy LAB archive, and Simulation/Gaussian runtimes
are outside this boundary and are not restarted, migrated or modified by it.
## Planning discipline
A planned LAB number is not a placeholder page. Before execution it must name:
@@ -149,7 +149,8 @@ second mounted source.
- [x] Harden the Control Station application boundary before A3: consume the
Design Guideline packages as the only visual platform, isolate the LAB
feature and CSS, add typed workspace contracts and enforce one-way imports
plus composition-size ratchets.
across the application layers. Line-count limits were subsequently removed:
they are not an architectural invariant.
- [x] Validate the real A2 generation:
`e30-review-pack-faec915a771022cceaf4ee62bece698afc8018d09b6b0ac7602157216fbb3686`
→
@@ -160,6 +160,15 @@ history of rejected approaches belong in Ops.
Primary visual evidence is hosted in one reusable viewer frame.
For canonical full-session recorded replay, ADR 0045 fixes one native upstream
Rerun viewer beneath this frame. The accepted product chrome and switching
logic remain Mission Core UI, while Rerun alone owns playback time, video
decoding, 2D annotations, point-cloud rendering, trajectory and 3D camera.
VIDEO/CAMERA, SOURCE POINTS/LOCAL SLAM/TGS COSTMAP/SEMANTICS and 3D/PLAN select
entities, visible time ranges and blueprints in that mounted viewer. They never
start independent transports or render loops. A LAB may rename model buttons or
add an admitted layer button, but it may not change this switching architecture.
### Frozen Milestone 4 perception instruments
Milestone 4 admits exactly two operator instruments inside the shared LAB page
@@ -234,6 +243,13 @@ The shared player may retain a rolling target only for the same or a later
segment. Rewind and clip-loop transitions must seek backward explicitly while
keeping the admitted generation and decoder owner mounted.
The preceding fMP4 rule applies to historical bounded clip instruments. A
migrated full-session recorded LAB instead uses the native Rerun `AssetVideo`
and `VideoFrameReference` contract from ADR 0045. It must not mount the clip
player or a custom Three.js scene alongside Rerun. The visual controls and LAB
template are identical in both cases; the canonical profile determines the one
active transport.
### 3D and 2D policy
Choose the default representation from the operator question:
+12 -10
View File
@@ -249,8 +249,7 @@ vocabulary and executable contracts. They do not create a second runtime model.
- no upward imports from core/components into workspaces or App;
- no visual adapter imports from core;
- no local vendor icon library or direct Design Guideline source imports;
- LAB code and CSS remain outside the central workspace buckets;
- central composition files cannot silently return to their previous size.
- LAB code and CSS remain outside the central workspace buckets.
`test/laboratoryProductUi.test.mjs` additionally enforces the versioned LAB
report fields and shared result component across bounded LAB modules.
@@ -260,8 +259,9 @@ new unclassified experiment branches while allowing a declared bounded
experimental adapter. The gate protects core composition; it does not forbid a
novel research stack.
The line limits are ratchets, not quality targets. When a file reaches a limit,
split a feature; do not raise the limit to accommodate unrelated behavior.
File length is not an architectural boundary and is not enforced. Refactoring
is justified by ownership, cohesion, dependency direction, lifecycle or test
isolation, not by a line-count threshold.
From `apps/control-station` run:
@@ -273,13 +273,15 @@ npm run build
## Known bounded debt
- `App.tsx` remains a large shell orchestrator. Its current size is frozen by a
ratchet; future shell behavior must extract a controller/hook or panel module.
- `App.tsx` remains a large shell orchestrator. Future shell behavior should
preserve its orchestration ownership and extract modules only where they
acquire an independent responsibility or lifecycle.
- `Workspaces.tsx` still contains several established generic workspaces. New
domains must be separate modules, and existing ones may be extracted when
their behavior changes.
- `LaboratoryArchiveWorkspace.tsx` is now physically isolated but at its
ratchet. A3 receives its own component/module instead of growing that file.
domains should respect the existing dependency direction; extraction is a
design decision rather than a response to file length.
- `LaboratoryArchiveWorkspace.tsx` is physically isolated. Further LAB work
must preserve the feature boundary without imposing a size quota on the
implementation.
- Design Guideline dependencies are mutable local `file:` links until a
portable package/distribution decision is implemented.
+177
View File
@@ -0,0 +1,177 @@
# Observatory — product-surface brief
Status: accepted by the product owner for the first M5.1 vertical slice on 2026-08-30.
## Operator and job story
The operator is an engineer qualifying the perception stack before any control authority is put on
an unmanned platform. After a source session has been recorded, the engineer needs one lightweight
place to identify that immutable source and see which laboratory results are actually linked to it.
The first slice is used repeatedly while recordings and results are being produced; it is not a
mission-planning surface and it never sends navigation, braking or actuation commands.
## Placement decision
The accepted placement is `Тестировочный контур → Обсерватория`, with the operator-facing
description `Сессии и квалификация`. It is a third workspace alongside the existing
`Лабораторные контуры` and `Симуляции` entries.
Alternatives considered:
1. Replace or refactor `Лабораторные контуры` in place. Rejected for M5.1 because the current LAB
archive is a working, documented legacy projector and rollback reference.
2. Add an Observatory mode to `Данные → Сессии и записи`. Rejected because that surface owns
storage and replay, while Observatory owns the recurring qualification-review job.
3. Add a dedicated workspace under `Тестировочный контур`. Selected because it creates a clean
composition boundary without changing K1, Simulation or the legacy LAB lifecycle.
## Entity, evidence and authority
The source entity is an immutable observation Session. Its visible facts are limited to the
validated Session catalog contract: identity, timestamps, status, duration, modalities and
readability. Laboratory evidence is linked only by the typed `lab.sourceSessionId` relationship.
Source and LAB projections are independent bounded newest-first windows. A LAB result whose source
is absent from the loaded source window remains unresolved and visible as `вне среза`; it is never
attached heuristically and its absence is not presented as a broken relationship. Reaching the
100-item boundary marks historical completeness as unknown until cursor pagination exists.
The presence of a Session or linked LAB result is not a computer-vision pass and is not evidence of
safe navigation. M5.1 does not infer a verdict from provenance, labels or result kind. The workspace
is observation-only: command, navigation and safety authority are all absent.
## Viewer-profile boundary
The existing source-specific viewer contracts remain separate and unchanged:
| Source job | Existing profile | Clock / load ownership |
| --- | --- | --- |
| Live equipment | `live-acquisition` | `stream_time`; live receiver and recovery authority |
| Historical Sessions | `recorded-session` | `session_time`; explicit preparation and progressive admission |
| Legacy LAB evidence | `lab-recorded-evidence` | `source-sequence`; explicit comparison-only loading |
Canonical LAB compositions may configure the recorded Rerun engine inside the bounded LAB slice,
but that does not make Observatory a LAB or live consumer. The first Observatory slice mounts no
viewer while its catalog is opened, refreshed or navigated. Its bounded canary may activate only
the historical `recorded-session` profile after a separate explicit action and acceptance proof.
It does not add a fourth profile or silently collapse the three existing lifecycles.
## M5.1 canary: canonical recorded replay
The accepted viewer canary reuses the stabilized full-route RAV004 composition; it does not create
a second viewer or a second recorded-data pipeline. The sealed vegetation result is first projected
into the generic Session/LAB catalog through `SessionStore.publish_lab_instance`. Its exact
`route_full_review.session_id` is the only source relationship. Labels, `latest`, result prefixes and
the advanced LAB index are not valid joins.
The immutable LAB projection carries a versioned replay capability:
```json
{
"schema_version": "missioncore.observation-lab-replay-capability/v1",
"kind": "canonical-recorded-rerun",
"viewer_profile": "recorded-session",
"timeline": "session_time",
"activation": "explicit",
"commands_enabled": false
}
```
Catalog entry, Session selection and result selection remain lightweight. Only the explicit
`Открыть визуальный разбор` action reads and revalidates the sealed result metadata. Only after that
admission does the application mount the shared canonical replay component, which follows the
existing `resolveObservationSessionReplay → resolveCanonicalLabReplay → recordedSessionRerunProfile
→ RerunViewport` path. Closing the review or changing Session unmounts that component and aborts
pending admission. At most one viewer and one `session_time` clock exist.
The publication command verifies the registered result root, schema, content-addressed identity,
artifact hashes, exact RAV004 source Session and all false authority flags before adding an
idempotent SQLite projection. The validated source snapshot is compared again inside the same
SQLite write transaction, so reconciliation cannot swap the Session between admission and copy.
The default v1 catalog continues to hide capability-owned projections from legacy consumers; the
explicit v2 LAB catalog returns the typed capability. A narrowly matched rolling migration types
the already-published canonical projection and leaves ordinary legacy LAB rows untouched. The
command does not run inference, ffmpeg, RRD merge or replay
materialization; source/result evidence is not copied or rewritten. Legacy LAB continues to import
the same shared replay through a compatibility wrapper.
## Information hierarchy and states
1. Catalog authority and refresh state.
2. Explicit source Session selection.
3. Selected Session identity, timing, modalities and readability.
4. Strictly linked immutable LAB evidence.
5. Fail-visible bounded-window and unresolved-evidence status.
The admitted states are initial loading, ready with items, ready empty, refreshing with the last
valid snapshot, and unavailable/error with retry. If a selected Session disappears after refresh,
selection moves to the first valid source or to the empty state. No demo rows, fabricated progress,
placeholder actions or hidden polling are allowed.
## Session-to-setup configurator slice
The next bounded slice adds an explicit laboratory Setup selection beside the source Session. A
Setup is a versioned, immutable compatibility contract, not a mutable bag of UI parameters. The
catalog keeps two independently named historical facts:
- `M4.9T5 · TRAVEL TGS · CPU-only, без ML` is an archived RunDefinition bound exactly to
`20260720T065719Z_viewer_live`. Its configuration references are content-addressed, its primary
visual result remains in legacy LAB, and the integrated-graph result is retained as a compute
successor rather than presented as a second viewer.
- `LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39` is the current exact RAV004 result bound to
`20260828T130511Z_viewer_live`. It predates the product RunDefinition contract and therefore keeps
`run_definition = null`; the UI identifies it as an existing result and never fabricates a config
digest for it.
Changing Session fetches only the small setup catalog. Changing Setup performs no computation.
Compatibility requires the exact source Session identity, the preserved source label and all
required modalities. An explicit `Проверить совместимость` action sends the selected source, setup
and RunDefinition digest to a read-only preflight. Digest drift fails closed. The preflight may
report an exact existing result or a blocked executor, but it cannot enqueue work.
The calculation action remains absent until a durable dispatcher exists with an idempotent
submission key, immutable RunDefinition receipt, lifecycle ledger and exact result publication
receipt. The currently preserved bespoke Worker scenario is not silently treated as that adapter.
This boundary also keeps live equipment, historical replay and legacy LAB Rerun profiles separate.
## Design Guideline composition
The existing `ApplicationShell`, `AdminNavigationPanel` and `ApplicationPanel` composition remains
the owner of navigation and workspace framing. The feature reuses canonical `Select`, `Button`,
`Icon`, `StatusBadge`, `GlassSurface` and `ActivityIndicator` primitives. The semantic Session and
evidence projection belongs to `workspaces/observatory`; feature styles own layout only and do not
introduce a parallel control or surface grammar. The registered `eye` icon identifies the new
workspace.
## First-slice acceptance
- Polygon navigation order is `Лабораторные контуры → Симуляции → Обсерватория`.
- Opening Observatory loads only the typed source and laboratory Session catalogs.
- Opening Observatory mounts no Rerun, canvas, WebGL, RRD, WebSocket or LAB result route.
- Source and LAB records are joined only by `sourceSessionId`; evidence outside the independently
bounded source window remains visible as unresolved and never becomes a false integrity verdict.
- Reaching either 100-item catalog boundary explicitly marks historical completeness as unknown.
- The selected source presents at most six newest linked results while retaining the exact total;
the historical tail remains in legacy LAB and is never mounted into a long Observatory DOM.
- Loading, empty, refreshing, error/retry and ready states contain no synthetic data.
- Linked evidence is never presented as a CV or safety pass.
- Existing LAB, Simulation, K1 and Data/Sessions operator behavior remains unchanged.
- Live, historical Session and legacy LAB viewer-profile contracts remain distinct.
- Session and Setup are independently explicit selections; neither selection submits Worker work.
- Both the archived M4.9T5 setup and the current final RAV004 result remain visible and immutable.
- A pre-RunDefinition result never receives a fabricated configuration hash.
- Preflight is read-only, digest-fenced and reports the missing executor adapter instead of
fabricating queued or running states.
## Canary acceptance
- RAV004 appears as one exact LAB result linked to `20260828T130511Z_viewer_live` in the Session
catalog; Observatory never reads `/api/v1/laboratory/advanced-index`.
- Entering Observatory and changing Session perform no replay POST, RRD HEAD/GET, viewer mount or
canvas creation.
- Explicit open validates the result/source binding, then uses the existing canonical RRD,
blueprint, cache, `recorded-session` profile and `session_time` clock.
- One `RerunViewport` is mounted; legacy clip/Three renderers, a second clock and a second RRD
builder are absent.
- Close, Session change and run replacement abort pending work and fully unmount the viewer.
- K1, Worker 006/add-worker, Simulation/Gaussian, raw Sessions and legacy LAB behavior remain
unchanged.
@@ -0,0 +1,545 @@
# Observatory: четыре этапа — записанные лаборатории → переносимые профили → борт
## Актуальное решение владельца — 2026-09-02
**Этот раздел заменяет прежний порядок и GO-зависимости плана ниже.** Сначала
доводим лабораторный продукт на записях, затем экспериментируем с составом
Docker-профилей, затем переносим выбранный профиль на подходящий бортовой
компьютер. Сетевые 125 ms, Ethernet, LTE и наличие беспилотника больше не
являются условиями готовности Observatory. Старый этап 1 и инкременты 1–18
сохраняются как выполненная инженерная работа; отрицательные realtime-замеры
не переименовываются в PASS.
Основной путь: **запись → совместимый ещё не рассчитанный профиль → Рассчитать
→ фактический прогресс → проверенная публикация → сохранённый просмотр**.
Просмотр, Refresh и выбор записи не запускают модели. Готовые результаты всех
версий остаются в «Лабораторных доказательствах». Если рассчитаны все текущие
совместимые профили, выбор пуст/недоступен, кнопки «Рассчитать» нет, «Обновить»
остаётся. Старое имя LAB не доказывает совпадение версии конфигурации.
### Этап 1 — Идентичность расчёта и переиспользование результата (в работе)
Первый инкремент: portable submit защищён атомарной проверкой существующей
job identity. Другой ключ запроса не создаёт дубликат active/reconciliation
или pending/failed-publication job; точный retry идемпотентен, failed compute
можно повторить. 88 focused backend/queue/Worker-API/publication tests PASS;
Ruff lint и mypy трёх изменённых source files PASS. Полный frontend/GPU-прогон
не выполнялся: UI и модели не изменены. Cache projection/селектор/прогресс ещё
не реализованы, этап не закрыт. Решение: [ADR 0050](adr/0050-recorded-observatory-first.md).
Runtime check 23:15 МСК: canonical8000 перезапущен на новом коде, PID55765,
health/portable catalog HTTP200,8765 отсутствует. Модельных заданий не создано;
durable queue содержит10 failed и1 succeeded/not-required, live leases0.
Обнаружен существовавший ДО перезапуска blocker:50000 claim receipts при
лимите50000, из них49985 пустых,15 с job. Последний receipt2026-09-01T17:28:39.238Z;
новые Worker polls продолжают получать503. Каталог всё ещё рекламирует ready,
что не доказывает работоспособность claim-пути. Следующий инкремент этапа1:
исправить bounded empty-poll lifecycle и readiness без удаления audit history
и нарушения идемпотентности старых claim IDs; затем verified-cache projection.
Не повышать бесконечно лимит и не чистить receipts вслепую.
**Ремонт claim-пути выполнен, 23:54 МСК.** Backend и оба текущих control agents
переведены на claim/v3: пустой poll не создаёт receipt; реальная выдача получает
durable grant с прежними ownership/retry guards. Все 50000 legacy receipts,
11 jobs и остальные старые таблицы сохранены побайтно на уровне canonical row
hashes. Новый положительный ledger имеет отдельный конечный лимит50000;
старый лимит не поднят, общий storage bound128MiB прежний.16 Worker→Mac idle
probes PASS, обычные polls204, v3 rows0, agent restarts0.126 focused tests,
Ruff/mypy PASS. Канонический8000/PID57796 работает; модели/UI не запускались.
Изменена только schema version в реально импортируемом transport-модуле;
compute packages/registries сохранены. Старые agents stopped/restart=no
оставлены для rollback. Source of truth транспорта — pinned create declarations
отдельного control release, не прежний package launcher.
[Отчёт и rollback](../experiments/perception/OBSERVATORY_RECORDED_CLAIM_REPAIR_2026-09-02.md).
Следующий шаг: verified published-cache projection. Этап1 по-прежнему открыт;
готовый cache/selector/progress и полный расчёт записей ещё не доказаны.
**Третий инкремент, 2026-09-03: verified cache реализован в коде.** Каталог и
preflight принимают только successful/published job с точным source snapshot,
RunDefinition, Session provenance и целым пакетом артефактов. Фронтенд читает
проверенную привязку вместо прежнего запрета непустых `existing_results`.
Готовый результат доступен без dispatch/подготовки raw source; повреждённый
не скрывает возможность нового расчёта. Повторный submit и гонка с публикацией
защищены внутри queue INSERT transaction; холодная проверка больших файлов
требует Refresh и не выполняется под write lock. История не удаляется.
120 focused backend tests,23 frontend/architecture tests, Ruff, mypy пяти
source files и focused TypeScript check PASS. Полная production build и browser
QA **пока не выполнены**: Mac memory-pressure=2 при swap~7.6–7.8GiB, чужие постоянные
сервисы не остановлены. Canonical8000/PID57796 оставлен на прежнем согласованном
backend/frontend release, health200; новый cache ещё не активирован. Worker,
модели и живые очереди не изменялись. Полный LAB-run не запускался.
[Проверки и следующий шаг](../experiments/perception/OBSERVATORY_PUBLISHED_CACHE_2026-09-03.md).
Первоначальный ресурсный blocker выше снят следующим инкрементом. Не повторять
уже закрытый ремонт claim/v3 и не возвращаться к сетевым latency canary.
**Активация cache-инкремента, 2026-09-03.** По разрешению владельца перезапущена
старая Docker VM: footprint14GB→1565MB, pressure2→1, swap7722→2746MiB. Браузеры и
Little Snitch не трогали. Три прежних Mission Core контейнера healthy;12 Plane
restart overrides возвращены, Plane выключен. Состояния45 non-Core контейнеров,
image/mount identities сохранены; лимиты Docker и данные не менялись.
Полный typecheck,714 frontend tests и production build PASS, последовательно.
Согласованная версия на8000/PID67747, health200; UI `/assets/index-DSuuMkq4.js`.
Каталоги004TREE/00 совместимы с двумя profiles;01 заблокирован отсутствующим
capture attestation. Точных опубликованных cache hits пока нет; legacy
succeeded/not-required не принят за новый результат. Все6 queue-table row hashes
сохранены. Worker/model jobs не создавались. Browser: каталог, выбор, Refresh,
normal/expanded и Escape проверены, console warnings/errors0.
Этап1 пользовательски ещё не закрыт: текущий селектор смешивает архивные entries
и portable definitions, а source/setup-only выбор последней job показывает
старое «Расчёт завершён» рядом с новым «Рассчитать». Это не текущий cache hit.
Следующий шаг на стыке этапов1–2: exact-definition queue/status binding,
селектор только нерассчитанных profiles, прогресс и полный цикл на записях.
Положительный publish→cache→view пока доказан synthetic tests, не новым LAB-run.
Подробности/rollback в том же отчёте выше; повторять сборку без изменения кода
или снова освобождать память не требуется.
- Сверить каталог, очередь, публикацию и просмотр для текущих M4.9T5 и LAB V1.
- Связать готовность с точной исходной записью и immutable RunDefinition,
включающей image/model/config/adapter/result-contract identities.
- Подавлять повторную постановку активного идентичного расчёта на backend;
опубликованный кэш признавать только при проверенной привязке и доступных
артефактах. Failed/partial/unpublished не считать готовым результатом.
- Отделить архивные результаты от исполняемых профилей и не прятать новую
версию профиля из-за старого результата с похожим названием.
Выход: контрактные тесты для другой записи/версии, повторного клика, гонки,
ошибки публикации и отсутствующих артефактов; актуальные документы. Это ещё
не полный пользовательский acceptance.
### Этап 2 — Полный пользовательский цикл записанной лаборатории
- Единый селектор только совместимых нерассчитанных профилей; все доступные
результаты ниже, без скрытого ограничения первыми шестью карточками.
- Очередь, реальные счётчики/этапы прогресса, ошибки, повтор публикации без
inference, автоматическое обновление после публикации.
- Полный расчёт с сохранением результатов и исходной временной шкалы;
скорость расчёта может быть ниже скорости записи. Ограниченные очереди и
backpressure вместо накопления всей записи в RAM. Разделить режим полноты
записанного анализа и строгий realtime-rehearsal: не отключать freshness
и drop-политику live глобально и не терять кадры ради wall-clock темпа
в режиме полного анализа.
- Последовательно проверить текущие два профиля на совместимых записях:
публикация, перезапуск приложения, повторный просмотр без модели/GPU,
отсутствие дубликатов. Не объявлять LAB V1 полным detector/distance/TGS
профилем: сейчас это последовательные EoMT и DDRNet; M4.9T5 — CPU TGS.
Выход: настоящий путь через canonical8000 и Worker, полный сохранённый
результат и воспроизводимый просмотр. До такого proof этап открыт.
### Этап 3 — Эксперименты с переносимыми Docker-конфигурациями
- Переиспользовать наработанный streaming runtime и общий контроль исполнения.
В первую очередь новый DDRNet + RF-DETR + LiDAR/distance + motion +
TGS/costmap + policy-shadow, без зависимости от чужих архивных overlays.
- Один Worker006/4090 — один активный профиль. EoMT сохраняется; альтернативы
не выполняются параллельно. Понятные profile/image имена с `ndc-` и digest.
- Измерять отдельно подготовку, прогрев, Worker compute/full-graph latency,
доставку, публикацию и wall time всего задания. Перегрузка — результат
эксперимента, а не повод запретить лабораторный расчёт.
- Условный прогноз onboard FPS относится только к измеренным железу, образу,
настройкам и составу; не выводится из viewer FPS или времени скачивания.
Выход: сравнимые результаты конфигураций на нескольких записях, известные
ошибки, воспроизводимые образы; не обязательный remote realtime PASS.
### Этап 4 — Отбор профиля и проверка переноса на борт
- Выбрать удачный immutable образ/config по лабораторной матрице качества и
производительности; исключить checkout и скрытые model caches из зависимостей.
- Проверить тот же образ и входной контракт на фактическом бортовом компьютере
после его появления. Другая архитектура CPU/GPU может потребовать отдельной
сборки и новой квалификации; Docker не обещает NVIDIA→Apple Silicon перенос
без изменений.
- Physical-live, контроллеры/моторы, аварийная остановка и автономная навигация
имеют отдельную приёмку. Пока только observation-only/policy-shadow.
Выход сейчас: пакет кандидата, матрица evidence и явные ограничения; реальный
перенос остаётся pending до появления оборудования, не блокируя этапы 1–3.
## История прежнего realtime-first плана (не текущие команды и зависимости)
Ниже сохранены прежние измерения, этапы и решения. Все слова «следующий»,
«GO», «не открыты» и старые номера этапов в этой истории относятся к состоянию
до решения выше. Приоритет имеют четыре актуальных этапа выше.
Дата: 2026-09-01; обновлено 2026-09-02 22:35 МСК. **Этап1 закрыт; этап2 продолжается. Инкремент18: существенный транспортный хвост локализован ниже Perception-приложения; full-profile latency не пересчитывалась.** Код `6af4c20`: bounded TLS-record witnesses в CPU-only diagnostic. A/B/A без/с/без свидетелей сохраняет RPC p95 96.093/93.895/95.145ms.424/424 encrypted records и192/192 RPC сопоставлены;288 raw clock exchanges пересчитаны. Из13 post-start tails >50ms восемь приходятся на request transit, пять на response transit; начальный TLS/startup sample не получает выдуманного clock mapping.
Прямой TCP к подтверждённому SSH22 Worker без gRPC/Docker/forwarding тоже даёт67.921–91.142ms пики (7/16 >50ms). Native Worker loopback после первого sample0.240–0.299ms; первый8.229ms сохранён. Оба сетевых интерфейса — Wi-Fi: Mac en0 и Worker Realtek8812BU USB. Конкретная причина среди radio/AP/OS/driver/power policy не доказана; Wi-Fi и866.7Mbps link rate сами по себе не определяют ни usable throughput, ни latency.82 local/82 Worker tests PASS;142 staged files/132 Python exact. [Отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_TLS_ATTRIBUTION_2026-09-02.md),43 artifacts, manifest `abc3ac677b81e0d78f8d5891517022e33ac7fa962166bc399b4009ee48d87d8e`.
Предыдущий инкремент17: непрерывность короткого LAN-прогона32/32 PASS; latency FAIL. Код `59a74a3` добавляет только bounded clock-only диагностику. CPU A/B/A без моделей и scene: SSH/Tailscale48/96 ready → проверенный LAN94/96, непрерывно после warmup → Tailscale53/96. Все288 обменов независимо пересчитаны; ~100ms хвосты RPC остаются и без scene. Это не доказательство DERP relay, универсального ускорения LAN или конкретной причины transport tails.
Один полный32-кадровый1× LAN-canary:32/32 byte-exact/reference-exact результата, без WAIT, drops, sync skips и reconnect. Все50 обменов часов пересчитаны; после двух начальных warmup48 ready, uncertainty публикаций3.645–4.771ms при прежнем пределе5ms. p95/p99 source→consumer-ready205.864/213.751ms при бюджете125ms — FAIL. Полностью свежих scene12, доступных sensor pairs17;30 результатов до EOF. PeakVRAM2357MiB, не предел24GiB. Модели/effective config и129 прежних runtime/test source files не менялись; маршрут выбран только для этого запуска, не зашит в Docker и не назначен продуктовым default.245 local/202 Worker tests PASS,2 Worker-only skip локально,131 source hashes совпадают. [Отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_LAN_ROUTE_2026-09-02.md),67 artifacts, manifest `2645a0faacb89b5af5d3756feea05550a12cc64820be926d32f2103b5f225caf`.
Предыдущий инкремент16: двусторонний старт принят, непрерывный real-time canary FAIL. Код `48835a0`: обе стороны прогревают часы до source anchor; явное подтверждение Worker и data grant обязательны до старта1×. Потерянный ACK не меняет уже предложенный/принятый anchor.241 local/198 Worker tests PASS,2 Worker-only skip локально;129 измеренных source hashes совпадают.
Один32-кадровый canary теперь принял начало0–5 и после WAIT/resync30–31:8 результатов,1 compute discard,9 source WAIT и14 sync skips, без неучтённых кадров. p95/p99 до consumer-ready215.767ms,5 полных свежих scene,6 результатов до EOF. Все55 raw t1–t6 обменов независимо пересчитаны;41 ready. На работающем источнике uncertainty5.031–5.667ms превысила неизменные5ms; RPC errors0, Worker telemetry33/33 PASS, lease1/PIDs сохранены. PeakVRAM2363MiB, не предел24GiB. [Отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_JOINT_START_2026-09-02.md),53 artifacts, manifest `9cb369fe7ae185d1a92f6050bc1018a878483f5eec169267f100843245428691`.
Предыдущий инкремент15: полный Mac↔Worker graph подключён к continuous clock/freshness; прежний canary FAIL. Добавлены ответное подтверждение часов Worker, immutable source anchor, uncertainty для всех слоёв/ячеек и WAIT без потери локального владельца. Исправлены EOF ordering, повторное чтение часов внутри одного результата и ранний disconnect до application OPEN.297 local/173 Worker tests PASS; ранний reconnect дополнительно12/12 PASS.
Финальный32-кадровый1× canary:13 кадров пропущены при ожидании допуска,17 при keyframe/sensor resync, приняты только30–31; оба результата после конца короткого source window,143.881/155.743ms. Все пропуски учтены, но availability/latency/result-before-EOF gates не пройдены.78 clock samples,28 ready; условная uncertainty median5.312ms при неизменном пределе5ms. Peak VRAM2365MiB — не свидетельство предела24GiB. Более ранняя попытка вернула30 exact результатов, но имела2 compute discards и EOF error; её нельзя выдавать за финальный PASS. [Отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_CROSSHOST_GRAPH_2026-09-02.md), код `35b6cd9`, evidence198 artifacts/manifest `7b435cac111b3e0e789aa137ba064651e6d344a5f8fb8fa35b0abbd0e7cb1d74`.
Предыдущий инкремент14: межмашинная выдача grant и измеренные границы часов. TLS control endpoint доставляет уже выданный Worker-контроллером одноразовый data grant, но не умеет получать/продлевать GPU lease, выбирать epoch, перезапускать модели или actuate. Четыре timestamp дают диапазон remote-minus-local без предположения симметрии сети; до16 наблюдений, expiry2s, нарушение envelope карантинит clock session.
CPU-only Mac↔Worker006 proof:72/72 timestamp-наблюдения независимо пересчитаны,60/72 соответствуют условному uncertainty≤5ms; min/median/max3.883/4.469/27.118ms. Восемь synthetic32KiB events exact8/8. Обрыв2.202s сохранил PID/lease1, старое clock evidence истекло, новый epoch/grant восстановили вход. Это доказывает механизм WAIT/recovery, но не full-graph cross-host freshness: readiness переключалась и после warmup. Поэтому5ms не ослаблен, а модельный прогон с неподключённым uncertainty gate не запускался. [Отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_CONTROL_CLOCK_2026-09-02.md); evidence manifest SHA-256 `cd479c577fa3da78b1b01a6a90e03e8c742ecb53561e606d939408120a53a622`.
Предыдущий инкремент13 — полный graph с реальным сетевым входом и выходом внутри одного Worker. CPU source/receiver вынесен в отдельный контейнер без GPU; модельный контейнер не получает запись через mounts. Через gRPC/TLS возвращаются настоящая маска512×512, detections/distance/motion и grid/costmap/policy. Обычный1× прогон:128/128 exact raw к reference, p95/p99 до готового consumer-view106.150/115.349ms, max126.910ms (один кадр выше125ms).76/128 полных свежих scene;52 исходные sensor gaps не скрыты.
Отдельный обрыв2.2s:102 результатов +26 учтённых пропусков24–49, resume50 с теми же четырьмя PID и lease generation1; одна очистка temporal history, без backlog. p95/p99 принятых результатов107.098/110.817ms; отдельный gap доступности2.725s. Начальный лишний reconnect из-за раннего init исправлен без увеличения idle-timeout и проверен повтором.146 focused tests PASS,2 больших Worker-only теста пропущены локально. Максимальный ответ460248B остаётся внутри1MiB; peak tracked13,217,791B, после закрытия0.
Граница доказательства: два контейнера одного Worker с проверенным общим Linux monotonic clock, временные штатные частоты2610/10251MHz; **не Mac↔Worker full-graph, не radio/live и не standalone**. История зависимых слоёв после gap закономерно отличается от uninterrupted reference. Все230 опубликованных scene/масок проверены на стороне приёмника и повторно сверены. [Подробный отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_NETWORK_2026-09-02.md). Evidence236 artifacts: `.runtime/perception-stage2-network-graph-20260902T1541Z/manifest.json`, SHA-256 `d1bc7e26850a2d9050ee1d8f8a5ece2e6b8c53e2e7dba381cbd4445d68a5ec17`.
Следующий проверяемый результат этапа2: per-frame source packing/write → ingress/decode/admission → GPU/CPU queues → publication/result read/parse. В исходнике найден отдельный10ms polling готовых ответов: сначала измерить фактическую цену и bounded lifecycle/backpressure, затем проверить event-driven замену. CPU transport attribution уже доказала существенный tail за границами приложения; не менять модели/VRAM ради этого симптома и не вычитать p95 разных выборок. При доступном Ethernet выполнить контроль без обоих Wi-Fi участков; это не блокирует оптимизацию и упаковку runtime при честном network-specific FAIL. Пороги5ms/125ms/source1× прежние, backlog не догонять. Затем controlled gap/slow-consumer, длинный поток и standalone без developer/model mounts внутри этапа2. Этапы3–4/registry/UI cutover не открыты. Временные CPU containers/relays/tunnel18561/key/bootstrap удалены; GPU/сервисные настройки в инкременте18 не менялись. Ollama/Frigate exited/restart=no; Mac8000/Worker telemetry работают.
Предыдущий инкремент12 (`0310592`, `1916122`): CPU-only gRPC/TLS proof Mac↔Worker,16/16 payload/reply; синтетический source clock и CPU sentinel, моделей0. Same-Mac RTT14.360/23.919/120.815ms min/median/max — не one-way age/FPS.105 local и20 Worker tests PASS, проверены bounded slow-reader/quarantine. Evidence `.runtime/perception-stage2-grpc-20260902T1500Z/manifest.json`, SHA-256 `adf5eaf092feaed6721f66e2adaceded0cdbf55754e1f9953f54623bfb52d331`.
Предыдущий инкремент11 (`c570f7d`, `2ef8c08`): восстановлены System/Worker telemetry plane и независимый runtime exporter. Телеметрия остаётся отдельным наблюдателем, а не выдающим lease/GPU authority контуром.
104 Python tests, 703 frontend tests, typecheck и production build PASS. Полный frontend проверен последовательно на CPU Worker из-за Mac memory pressure=2; собранный UI установлен на canonical8000. Визуальная приёмка по `mission-core-product-ui` отложена до освобождения памяти, не объявлена пройденной. Расширен существующий Worker, без новых страниц и без product profile/registry cutover. MQTT outage/startup проверен с сохранением PID9268; CPU canary сохранил lease generation1 и освободил его после завершения. Evidence `.runtime/worker-telemetry-20260902T1422Z/manifest.json`, SHA-256 `825b1d8035dbf907b2b731d35ede4c5742b81e653a6f24670f4775981f853492`.
Предыдущий модельный замер `61cbdb3`: 404 focused tests PASS, Ruff/format/mypy PASS. Normal exact raw128/128, 50 real observations, p95/p99 91.157/97.946ms. При задержке host reply:102 обработанных +26 учтённых пропусков, resume seq50, прежние PID/lease сохранены; p95/p99 принятых кадров88.643/91.236ms. Между последней старой и первой свежей scene2.719s — отдельная метрика доступности. Оба измерения условны для временных штатных clocks2610/10251MHz, не доказывают overhead regression или общий real-time. В инкременте11 модели не запускались; overhead нового observability exporter пока не измерен на полном графе. Исходные sensor gaps, auto-clock FAIL, непроверенные native-host inventory, network и standalone остаются.
Текущий evidence: [отчёт этапа 1](../experiments/perception/PERCEPTION_STREAM_STAGE1_2026-09-01.md), [ADR 0049](adr/0049-stream-first-perception-profiles.md), [candidate manifest](../config/perception/k1-perception-ddrnet39-rfdetr-tgs-prototype-v1.json). Запрет full-source preload закреплён в новом контракте; старый batch materializer пока не удалён и продуктовый путь не переключён.
## Цель и граница завершения
Собрать отдельный самостоятельный Docker-профиль полного восприятия рига: DDRNet-39 GOOSE + RF-DETR native + LiDAR-ассоциация/метрические расстояния + TGS/costmap + temporal/motion + диагностическая оценка препятствий и mission-policy. Совместимый источник поступает потоком на выделенный Worker; все выходы рассчитываются в этом запуске, без подмешивания ранее рассчитанных масок, детекций, local-surface или threat-ledgers. Запись подаёт наблюдения по исходному времени с темпом 1× и заменяет физический источник, но не меняет вычислительный граф.
Сценарий владельца: записывать множество маршрутов K1 с камерой, точками и pose; в Observatory последовательно применять разные профили и сравнивать результаты; затем выбрать проверенную версию того же профиля в будущей live-миссии с зарегистрированным оборудованием. Конкретная запись — вход запуска, а не зашитый компонент Docker. Размер коллекции (в том числе 500 записей) не требует 500 приложений или 500 исполняемых адаптеров. Во время будущего движения профиль обрабатывает новые живые наблюдения, а не воспроизводит старые решения для маршрута.
Первый результат — один полный, измеренный на RTX 4090 прототип с общим runtime и потоковыми результатами, пригодный для последующей проверки на полигоне. Управление моторами, автопилот, построение маршрута и реализация всего конфигуратора миссии в этот прототип не входят. EoMT-L Cityscapes сохраняется как отдельный резервный вариант; его упаковка/оптимизация не блокирует первый профиль и не запускается рядом с DDRNet. Сборка Docker и совпадение digest не являются доказательствами real-time; успешный replay не является допуском к автономному движению.
Приёмка первого прототипа требует: самостоятельного образа; реального расчёта всех заявленных слоёв; одного live-compatible контракта для replay и будущего физического источника; воспроизведения нескольких совместимых записей без правки кода; bounded latency/queues/memory; явных unknown/degraded состояний; сохранённого отчёта производительности и обнаруженных ошибок. Отсутствующий сейчас беспилотник не блокирует приёмку replay-прототипа, но physical-live, качество в поле и actuation остаются явно непроверенными.
## Авторитетный контекст и подтверждённое CURRENT
Рабочий репозиторий: `/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory`. На момент обзора: ветка `codex/m5-1-observatory`, HEAD `7025e173a3370a1b70afaad8579db20903436157`, множество существующих tracked/untracked изменений. Выводы относятся к прочитанному рабочему дереву, не только к HEAD. Чужие изменения сохраняются.
Источники и их назначение:
- [Последний отчёт](/Users/dcconstructions/Desktop/MISSING_CORE_OBSERVATORY_DOCKER_LABS_FINAL_STATUS_2026-09-01.md) — история предыдущих запусков и актуализированная цель полного профиля. Исторические измерения не являются новой проверкой текущего состояния Worker.
- [Предыдущая архитектурная итерация](/Users/dcconstructions/Desktop/MISSING_CORE_OBSERVATORY_DOCKER_LABS_ARCHITECTURE_2026-09-01.md) — история и идеи, не распоряжения к выполнению и не актуальное подтверждение готовности.
- [Правила репозитория](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/AGENTS.md), [существующие gates проекта](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/docs/01_IMPLEMENTATION_PLAN.md) — действующие ограничения. Этот план не открывает навигационные, actuator- или quality/truth-gates проекта.
- `/Users/dcconstructions/Desktop/CODEX_V5` — прочитаны все пять документов. Применены разделение CURRENT/TARGET, приоритет цели и доказательств над процедурами, этапы с проверяемым выходом и один поддерживаемый ExecPlan. Шаблоны не копируются поверх правил проекта.
- Последние уточнения владельца от 2026-09-01 имеют приоритет: один активный полный профиль, внутри него детектор + выбранная сегментация + LiDAR/TGS; EoMT и DDRNet остаются альтернативами. Не переносить старое ошибочное ограничение «одна модель/AI-процесс» на состав полного профиля.
Проверенная по локальному коду карта:
| Участок | CURRENT | Следствие для TARGET |
| --- | --- | --- |
| Portable definition / preflight | Есть идентичность source/model/executor и проверка совместимости, но нет полного timing-контракта и измеренного real-time допуска; `ready` в основном структурный | Сохранить идентичность и admission, добавить отдельную квалификацию конкретного профиля на конкретном runtime/hardware |
| Передача источника / runtime | Полная материализация → выполнение → финальная публикация | Инкрементальный data plane с ограниченными буферами, без обязательной подготовки всей записи |
| Installed LAB package | Одноразовые контейнеры: prepare → весь EoMT → весь DDRNet → assemble | Сначала один постоянный полный DDRNet/RF-DETR/LiDAR/TGS runtime; EoMT — отдельный будущий вариант, без зависимости DDRNet от его результата |
| Модельные адаптеры | Все кадры, PNG/маски/overlay и итоговые архивы на критическом пути | Обработка поступающих наблюдений и выдача результата до окончания источника; архивирование не задаёт темп inference |
| Backend / Worker | В claim есть глобальный запрет второго активного job; Worker 006 зашит в части контракта | Эксклюзивность и fencing на уровне `worker_id`; не глобальный запрет работы независимых Worker |
| Frontend | Выбор setup и terminal job/result; нет достаточного контракта потокового состояния | Общие состояния и renderer capabilities, без отдельного микроприложения для каждой модели |
| Ранее существовавшие эксперименты | Есть pacing 1×, bounded queues, freshness/age и multirate измерения | Переиспользовать проверенные механизмы, но не совмещать EoMT с DDRNet или разные профили; RF-DETR и DDRNet входят в один полный профиль. Чужие бюджеты автоматически не копируются |
Опорные файлы для продолжения:
- [Portable contracts](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/portable_run_definitions.py), [runtime](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/portable_worker_runtime.py), [source transport](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/worker_http_transport.py).
- [Queue / ownership](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/recorded_jobs.py), [Worker agent](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/worker_agent.py), [preflight API](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/web/observatory_api.py).
- [Installed runner](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/installed_lab_package_runner.py), [combined package builder](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/experiments/perception/worker/observatory_portable/promote_installed_lab_v1_package.py).
- [EoMT adapter](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/experiments/perception/worker/observatory_portable/run_portable_lab_v1_eomt_component.py), [DDRNet adapter](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/experiments/perception/worker/observatory_portable/run_portable_lab_v1_ddrnet_component.py).
- [Observation graph](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/perception/graph.py), [recorded source](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/perception/recorded_source.py), [E9 runner](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/experiments/perception/worker/run_e9_multirate_perception.py), [E21 envelope](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/experiments/perception/e21_realtime_envelope_profile.json).
- [Equipment/capture compatibility](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/sessions/equipment.py), [Observatory workspace](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx).
Из последнего отчёта: источник `20260828T130511Z_viewer_live` содержит 6830 кадров за 808.779495667 s, средняя частота ≈8.4448 FPS. Остановленный запуск после примерно 32 min 54 s ещё не дошёл до DDRNet/публикации. Это свидетельство непригодности прежнего полного пути для поставленной задачи, но не изолированный benchmark самой модели.
Последующий read-only аудит 2026-09-01 проверил Docker inventory, mounts и фактические installed manifests на Worker 006 через strict-pinned `mission-gpu`. Portable M4.9 содержит TGS и `models: []`; LAB V1 выполняет `prepare → EoMT → DDRNet → assemble`, без detector/geometry/threat stages. Legacy perception Worker и Triton существуют отдельно, со значимыми host mounts; это не самостоятельный полный portable-профиль. Никакие контейнеры при аудите не запускались и не останавливались.
Скриншот M4.9T5 показывает композицию результатов: собственный CPU TGS, отдельный M4 detector/geometry/threat и заранее рассчитанный E47/EoMT. Отдельный [RF-DETR reference runtime](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/perception/m48s_reference_graph_runtime.py:209) реально собирает geometry, temporal, motion, rolling и threat. Сохранённый [M49 integrated result](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/.runtime/m49-integrated-12fps-003/result.json:72) сообщает 4489/4489 кадров, 11.860865 FPS и p95 совместной готовности 46.825886 ms. Это historical RF-DETR + CPU TGS shadow на подготовленных входах, не полный новый профиль: TGS не меняет graph state, EoMT там не вычисляется, `local-surface.npz` подготовлен заранее.
Ближайший сохранённый [multirate candidate](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/config/perception/lab-v1-vegetation-integrated-multirate-phased-shadow-v3.json:12) добавляет DDRNet 6 Hz к RF-DETR/TGS timeline 12 Hz с phase offset 40 ms, но `semantic_output_persisted=false`: это исходная точка исследования нагрузки, а не доказанная сквозная реализация нового продукта.
Во время read-only проверки на Worker также работали `sentinel-frigate` и `sentinel-ollama` с GPU DeviceRequests; общая занятая VRAM составляла 9256 MiB. Это не доказывает их одновременный inference, но чистое GPU-окно не установлено. Перед измерением требуется согласованное освобождение ресурса; этот план не разрешает самостоятельно останавливать сервисы другого продукта.
## Жёсткие границы и принятый TARGET
Уточнение владельца 2026-09-02 (МСК): проект — экспериментальная платформа
переносимых профилей, применяемых к разным совместимым записям. Текущий приоритет —
снижение задержек всей цепочки, прозрачное измерение и оптимизация runtime,
IPC/сети, копирований и оркестрации. Вычислительно тяжёлый профиль не удаляется
из-за FAIL на 4090: результат сохраняется вместе с hardware/transport-specific
квалификацией. Возможен перенос на более мощный Worker или бортовой компьютер.
Локальное размещение устраняет внешний сетевой участок, но не само по себе
декодирование, внутренний IPC, очереди и вычисления.
Лабораторный эксперимент с ограниченной перегрузкой допустим и не блокирует
разработку упаковки/общего runtime. Разделяем **функциональную готовность**,
**готовность к лабораторным сравнениям** и **real-time qualification на конкретной
связке profile/config/hardware/source/transport**. Бюджеты 125 ms и остальные
исходные критерии остаются измерительными ориентирами, а FAIL не превращается
в PASS. По-прежнему обязательны bounded memory/queues, учёт drops/unknown,
исходный clock 1×, отсутствие подмены старых данных новыми и отсутствие actuation.
- Текущий аппаратный baseline — один Worker 006 с RTX 4090, один активный полный профиль одного источника. RF-DETR и DDRNet являются компонентами этого профиля и могут быть прогреты одновременно; GPU-работа в первом прототипе подчинена одному сериализованному scheduler. CPU-геометрия/TGS и транспорт имеют свои ограниченные очереди, не создавая второй конкурирующий AI-профиль. Нет EoMT+DDRNet вместе, фоновых моделей, обучения или параллельных benchmark. Неактивный профиль не удерживает GPU. Новый профиль не стартует до подтверждённого освобождения предыдущего.
- Один беспилотник обслуживается одним независимым Worker. Масштабирование на десять устройств означает десять Worker, а не десять потоков на 4090. Сейчас не строится полноценная система управления флотом; контракты и тесты не должны закрепить глобальный singleton.
- Профиль — полный вычислительный граф и его входные/выходные требования, а не одна модель. DDRNet и EoMT — альтернативные сегментационные ветви разных профилей. Общий runtime/SDK и инфраструктурный агент допустимы; профильные backend/frontend приложения — нет. На первом этапе нужен один новый полный Docker-профиль, не обязательная разработка сразу двадцати вариантов.
- Переносимы образ/контракт/конфигурация, а не произвольный hardware performance claim. RTX 5090 и Apple Silicon не являются условиями успеха. Для иной GPU/платформы нужна отдельная проверка; CUDA-образ не объявляется автоматически совместимым с Metal.
- Запись и live различаются адаптером входа, не реализацией inference. Реальная совместимость определяется объявленными каналами, форматами, временем, калибровкой и capture-параметрами, не названием LAB или единственным session ID.
- Работа остаётся в локальном контуре Mission Core + Worker 006; нет Synology/external-server rollout и deploy-canon workflow. План не является командой на запуск Worker или изменение оборудования. На Mac нет модельной нагрузки, тяжёлых параллельных работ и новых временных серверов; канонический сервис 8000 сохраняется.
- Существующие записи, raw evidence, секреты, результаты и рабочие изменения сохраняются. Нет управления движением, изменения scanner-протокола или самостоятельного запуска физического сканирования. Старые доказательства не переименовываются в новые успешные испытания.
Предлагаемая схема имён, согласованная с обязательным namespace `ndc-`:
| Название профиля в приложении | Docker image | Экземпляр на Worker 006 |
| --- | --- | --- |
| K1 Perception — DDRNet-39 + RF-DETR + TGS | `ndc-k1-perception-ddrnet39-rfdetr-tgs:prototype-v1` | `ndc-k1-perception-ddrnet39-rfdetr-tgs-worker006` |
| K1 Perception — EoMT-L + RF-DETR + TGS, резервный TARGET | `ndc-k1-perception-eomt-rfdetr-tgs:<version>` | `ndc-k1-perception-eomt-rfdetr-tgs-worker006` |
Машинный profile ID стабилен и связан с display name; runtime выбирается по sealed manifest/image digest, не только по изменяемому tag. Существенное изменение весов, preprocessing, output labels, precision или execution policy меняет версию профиля и требует нового свидетельства. `K1` в названии описывает capture-совместимость, но не должен зашивать K1 в общий runtime.
Первый прототип поставляется одним самостоятельным образом и запускается одним контейнером: runtime, обе модели, TGS, preprocessing/postprocessing, конфигурация и manifest находятся внутри образа. Нет зависимости от чужого Triton, checkout, скрытого host-cache, готовой LAB или скачивания моделей на первом запуске. Внутренние supervised процессы/изолированные Python environments допустимы, если решают одну задачу восприятия и управляются общим lifecycle; broker, БД и приложение в этот образ не добавляются. Снаружи только Docker/NVIDIA runtime, входной поток/запись, выходы и явно переданные секреты. Shared base layers допустимы для будущих профильных образов. Сохраняются ownership labels `com.nodedc.product`, `com.nodedc.stack`, `com.nodedc.role`, `com.nodedc.managed-by`. Текущие контейнеры не переименовываются этим планом.
## Контракт первого полного профиля
| Слой | Обязательный выход | Граница интерпретации |
| --- | --- | --- |
| DDRNet-39 GOOSE | Semantic mask, существующее coarse material mapping, freshness и исходные class IDs для evidence | В первом сельском прототипе `hard_surface` допустим без разделения асфальта/тротуара/велодорожки; геометрическое препятствие всё равно блокирует |
| RF-DETR native | Объекты, class, confidence, bbox и source frame identity | Person/cat/dog и существующие классы; точное название статического препятствия не требуется, дополнительный bollard-классификатор не нужен |
| LiDAR + calibration + pose | 3D support, расстояние с определённым estimator/frame, неизвестные геометрические препятствия и связь с детекциями | Нет подходящих точек/калибровки/синхронизации — range unavailable, а не придуманное расстояние или free |
| Temporal / motion | Track identity, оценка движения, current/held/stale/unknown | Тип объекта не доказывает движение: стоящая машина и движущийся человек требуют измерения во времени |
| TGS + rolling geometry | Ground support, occupied, rejected, unobserved; локальная costmap и свежесть каждой ячейки | Ground support не равен traversable; map может запретить, но сама не выполняет объезд |
| Fusion / policy shadow | Общая scene, согласованные слои, объяснимый allowed-candidate/high-cost/blocked/unknown и advisory events | Не direct motor/control commands; unknown/stale не создают разрешение двигаться |
Входы: camera frames/encoded chunks, registered point increments, pose, intrinsics/extrinsics, coordinate frames и timestamps с clock mapping. Нужно различать sensor→camera калибровку и будущую sensor→vehicle/body калибровку. Для прототипа виртуальный footprint допускается только с явной отметкой simulation; реальный зазор до корпуса не заявляется до измеренного mount/vehicle profile.
Решение владельца для первого сельского прототипа: используем существующий coarse `hard_surface` (`asphalt`, `sidewalk`, `bikeway`, `cobble`) как допустимый материал. Разделение дорог, тротуаров и велодорожек, городские правила движения, дополнительная сегментационная модель и обязательный новый road-only классификатор не нужны и не блокируют реализацию. Сохранять исходные class IDs полезно для evidence, но создавать отдельную fine-grained policy сейчас не требуется. Остальные материалы определяются явным выбранным preset/config; слово «сельский» само по себе не разрешает все грунты/растительность. Camera semantics никогда не снимает occupied/unknown запрет геометрии.
Для столбиков и других неподвижных препятствий переиспользуется существующее представление `static_obstacle` / `static.unknown`, уже присутствующее в vocabulary и визуальном слое; точное имя предмета не требуется. Пользователь ссылается на сохранённый RAV004 как имеющий это поведение. Задача нового профиля — воспроизвести этот слой из текущего потока и проверить его, а не изобрести ещё одну taxonomy, обучить распознаватель столбиков или загрузить старые masks вместо вычисления. Геометрическое препятствие сохраняется независимо от того, нашёл ли detector узнаваемое имя.
Профиль содержит измерительные модели/алгоритмы и возможности policy; выбранное правило миссии, оборудование, footprint и thresholds входят в effective run configuration. Пара `(immutable profile + effective mission/equipment configuration)` одинакова в Observatory и последующем live; изменение значимого параметра требует нового сравнения/квалификации. Запись не содержит «разрешение будущему автопилоту», только воспроизводимые входы и свидетельство проверки.
## Этап 1 — Полный состав профиля, контракты и технический baseline
**Цель:** закрыть состав первого полного профиля и оставшиеся технические разрывы до изменения продуктового пути; получить исходную точку для измерений на 4090.
**Вход:** локальный обзор, оба отчёта, существующие pinned weights и source-paced эксперименты. Новые измерения требуют доступного выделенного Worker без другой AI-нагрузки.
**Работа:**
- Завершить матрицу CURRENT → TARGET для backend, frontend, Worker, recording/live adapters, model runtime и хранения. Зафиксировать keep/replace/deprecate, не переписывая устойчивые admission/publication механизмы.
- Описать versioned observation/result contracts: source/stream/epoch/sequence, timestamp/clock domain, payload formats, camera/cloud/pose, calibration и coordinate-frame references; на выходе все слои таблицы выше. Для полного первого профиля cloud и pose необходимы. Не привязывать совместимость к одному session ID или байтам media-init, не существенным для capabilities.
- Описать lifecycle с одним владельцем Worker, правило переключения профиля, incremental result contract и различие «установлен», «работоспособен», «прошёл real-time квалификацию», «экспериментальный/не прошёл». Состояния availability, qualification и running/busy не сводить в один флаг `ready`.
- Зафиксировать до приёмки численные бюджеты каждого профиля: входной поток, требуемая частота результатов, p95/p99 end-to-end age, допустимые пропуски, очередь, startup/warmup, память и stop-time. Не снижать требования задним числом ради зелёного результата. Неопределённые продуктовые компромиссы выносить владельцу, а не скрывать в scheduler.
- Разделить reusable online algorithms и recorded-only подготовку. Проследить, чем заменяются чтение готового `local-surface.npz`, заранее подготовленные TGS rolling inputs, E47 masks и M4 ledgers. Vendor-registered исходные точки/pose допустимы как вход K1; собственные perception-результаты должны вычисляться текущим запуском.
- Проверить совместимость зависимостей в одном образе: текущий DDRNet использует Python 3.9 / PyTorch 1.13.1 cu117 / super-gradients 3.2.0, RF-DETR — TensorRT 11, TGS — C++. Не объявлять их совместимыми в одном interpreter без проверки. Выбрать минимальную изоляцию внутри одного контейнера либо доказанное преобразование модели с parity check; не менять веса/качество молча ради сборки.
- После согласованного освобождения GPU выполнить короткие baseline компонентов первого профиля последовательно и пилот общего расписания. Разделить decode/preprocess, H2D, inference каждой модели, online geometry/TGS, fusion/policy и доставку результата. Отдельно измерить cold startup и warmed steady state; не начинать с полного старого batch-run.
- Включить в preflight/qualification фактические GPU/VRAM clocks, power state/limit, driver и CPU/RAM quotas. Один image на auto/fixed clocks не имеет одинакового измеренного operating envelope. Host-wide настройки меняются только явно разрешённым owner-scoped механизмом с rollback; контейнеру не выдаётся неограниченная власть над GPU ради воспроизводимости.
- Сохранить существующие EoMT artifacts и точный checkpoint. Его отдельное исследование/упаковка — последующий вариант профиля, не обязательная ветка первого прототипа. Не запускать EoMT во время работ DDRNet-профиля.
- Проверить текущие class/score/box-size/FOV фильтры RF-DETR на требования прототипа, включая близкий крупный объект и маленькое животное; наличие имени класса в модели не доказывает достаточное обнаружение. Не менять пороги без новой версии и сравнительного evidence.
**Результат и evidence:** точный manifest первого полного профиля, карта входов/выходов и owner boundaries, план единого образа с проверенной dependency strategy, численные инженерные критерии и baseline первого состава. Зафиксированы class coverage, distance estimators, unknown policy и отсутствие motor authority.
**Выход / зависимость:** GO на этап 2 после фиксации состава, существенных контрактных границ и воспроизводимого baseline. По уточнению владельца от 2026-09-02 performance FAIL на текущем железе не является запретом на развитие лабораторной платформы/упаковки и не требует выбрасывать профиль. На 2026-09-01 выполнены карта/reuse, executable contract, candidate manifest, component baseline и совместный causal pilot полного графа. Дальше измеряем и уменьшаем задержки, исправляем preroll/layered freshness и сохраняем отрицательные результаты; real-time статус выдаётся отдельно, только по факту прохождения критериев. Network whole-path и самостоятельная упаковка относятся к следующему runtime и не объявляются проверенными по локальному collector. Диагностический контейнер с mounts не является runtime этапа 2.
**Gate на 2026-09-02 10:16 МСК: GO (engineering, not qualification).** Shared output freshness и operating-envelope contract зафиксированы, выполнен bounded полный reference pilot. Обязательная стадия исследования состава/baseline завершена. Не следует бесконечно удерживать упаковку на этапе 1 из-за уже явно измеренных overload/source-gap/quality ограничений. В этап 2 перенесены реализация controller lease/continuous readiness, transport, supervisor, per-cell aging и самостоятельная упаковка; положительный real-time допуск по-прежнему требует отдельных измерений. Cat/dog/close-object качество, mount/clearance, другая и полная запись остаются непройденными gates квалификации, а не новым training scope.
## Этап 2 — Самостоятельный Docker с полным потоковым вычислением
**Текущий статус, 16:52 МСК:** в работе. Полный pilot получает реальные GPU clocks/driver, Docker image/limits и bounded inventory через отдельный host controller. В контейнере нет Docker socket; response mount read-only и вне writable outputs. Effective config связывает mode/envelope/inventory scope. Пропавшие/задержавшиеся факты не возобновляют local lease и не выгружают модели; подтверждённый конфликт остаётся terminal. Normal и delayed-reply full graph проверены, raw parity сохранена; далее внешний transport/auth/recovery с отдельным availability budget и standalone packaging. GO на этап3 пока нет.
**Цель:** один самостоятельный контейнер принимает поток и реально рассчитывает DDRNet, детекции, расстояния, motion, TGS/costmap и policy-shadow; ничего не дорисовывается из старых LAB.
**Вход:** контракты и baseline этапа 1.
**Работа:**
- Реализовать общий lifecycle open/warmup/process/flush/stop, stage interfaces и supervisor. RF-DETR и DDRNet загружаются один раз при активации полного профиля. GPU inference сериализован; CPU TGS/geometry и I/O не блокируют GPU через неограниченную очередь. Разные частоты слоёв задаются явно и измеряются; retained mask не считается новым inference.
- Подключить recorded adapter с исходными timestamp и pacing 1×; live adapter подаёт тот же тип наблюдений без знания длительности/конца записи. Не требовать полного tar/download, конкатенации всей камеры, полного PNG-cache или полного source hash-pass перед первым результатом. Допустить ограниченный декодерный pre-roll для codec dependencies, не просмотр будущих наблюдений моделью.
- Выбрать и проверить бинарный data plane по реальным объёмам video/cloud, CPU cost и latency. Control plane остаётся отдельным. У модели нет произвольного доступа к сети/host; поток приходит через контролируемый proxy/IPC или явно ограниченный transport. Не включать privileged/host-network ради удобства.
- Реализовать online local-surface/geometry и causal rolling TGS из поступивших points/pose. Сохранять неизвестные геометрические препятствия без semantic class. Camera–LiDAR association, distance estimator, track/motion и footprint references выдаются явно; отсутствие поддержки не подменяется нулём/бесконечностью.
- Связать результаты слоёв в общий scene contract во время исполнения, а не только склеить тайминги после завершения. Привязать semantic labels к текущей геометрии с temporal/coordinate validity и bounded TTL. TGS и semantic policy становятся реальными входами диагностического rule evaluation, не только соседними слоями viewer.
- Подключить существующее coarse material mapping и простую advisory policy: `hard_surface` — допустимый материал для первого сельского прототипа; geometry/TGS occupied и missing/stale evidence имеют приоритет над этим допуском. Тротуар и велодорожка не являются отдельными отказными случаями. Не добавлять второй segmenter или новую fine-grained road policy. Допуск поверхности не выбирает объезд и не выдаёт motor commands.
- Сделать ограниченные очереди, явные cadence/drop/TTL правила, backpressure и stop/cancel. Завершение lease, смерть процесса и смена владельца должны прекращать старое исполнение; новый профиль не стартует, пока старый GPU-владелец не освобождён. Restart не воспроизводит накопившийся устаревший live backlog.
- Для временного input/network gap сохранять прогретые процессы и локального владельца. Replay clock продолжает 1×; очередь не накапливает прошлое. Повторное подключение создаёт отдельную input epoch без смены model activation/lease generation. До resume нужны свежий декодированный keyframe, causal pose/points, сброшенные tracking/motion/rolling TGS/costmap и повторная проверка свежести. Деградация телеметрии допускает ожидание без выдачи qualified результатов; подтверждённый ownership/runtime fault не маскируется как сетевой лаг. Физический stop/hold и политика возобновления ровера — отдельный будущий onboard safety gate.
- Выдавать результаты и технические метрики до EOF; хранение/overlay/video export не блокируют inference. Для сохранения evidence тоже задаётся ограничение ресурсов и честное состояние при переполнении. Не выдавать повтор предыдущей маски за новое измерение.
- Упаковать первый полный образ с pinned model assets, TGS/runtime и нужными библиотеками. Проверить запуск без developer checkout, host model cache, внешнего Triton и model downloads. Не добавлять backend/БД/broker в контейнер. EoMT остаётся сохранённым отдельным вариантом вне активного первого профиля.
**Результат и evidence:** короткий replay 1× выдаёт все заявленные результаты до конца источника, память и очереди ограничены. Каждый слой имеет trace от inputs этого запуска; старые E47/M4/local-surface artifacts не предоставляются. Проверяются person/cat/dog, существующее static-obstacle представление без точного имени, допустимый `hard_surface` и препятствие поверх него, missing LiDAR, stale mask, burst/gap/out-of-order/cancel/lease-loss. Synthetic tests на Mac не загружают тяжёлые модели; реальные модельные случаи проверяются на Worker.
**Выход / зависимость:** GO на этап 3 после подтверждения работоспособности самостоятельного полного образа, streaming lifecycle и GPU ownership. Если полный граф не проходит короткий budget, сначала локализуется причина внутри этапа; FPS одного DDRNet и добавление UI не закрывают этот gate.
## Этап 3 — Сквозное подключение backend и frontend без LAB-микроприложений
**Цель:** приложение выбирает совместимый профиль и показывает его работу через общие контракты.
**Вход:** полный standalone runtime и образ первого профиля из этапа 2.
**Работа:**
- Расширить generic registry/preflight/claim: требуемые input capabilities, pinned executor identity, актуальная доступность Worker и соответствующее performance evidence. Совместимость, установленность и real-time квалификация проверяются отдельно; несовпадение capture/weights/runtime/hardware делает прежний допуск неприменимым.
- Привязать leases, fencing, ограничения активности и live/recorded ownership к конкретному `worker_id`. Исключить двойной запуск на одном Worker. Независимые Worker не блокируют друг друга глобальным SQL/константой. Проверить эту независимость лёгкими fake-worker тестами, не параллельными GPU-запусками.
- Интегрировать incremental results/progress/cancel и последующую immutable publication. Потоковое отображение не ожидает terminal archive. Существующие provenance, digest validation и восстановление публикации сохраняются.
- В едином Observatory показать полный состав профиля и понятное название, совместимость источника, effective mission-rule, занятость Worker и qualification status. Один recording можно запускать на разных версиях профиля, один профиль — на разных совместимых recordings. Наличие двадцати профилей не создаёт двадцать приложений.
- Расширить общие renderer/result capabilities: semantic mask, boxes/class/track, LiDAR support/ranges, TGS/costmap, motion и policy-shadow с явной свежестью. Все слои ссылаются на текущий run; исторический overlay разрешён только как явно выбранное сравнение, не скрытый fallback. EoMT и DDRNet не считаются одной ontology.
- Сохранять run matrix с recording/capture/profile/effective mission configuration, техническими результатами и инженерной оценкой ошибок. Подготовить versioned profile reference для будущего mission configurator: он должен выбирать тот же immutable image/config, а не другой «live вариант» с тем же названием. Сам конфигуратор миссии и управление оборудованием в этом этапе не реализуются.
- Применить UI skill и действующие архитектурные/UI документы перед UI-реализацией. Технические counters/p95/drop reasons размещать в соответствующих деталях, не превращать основное рабочее место в консоль отладки.
**Результат и evidence:** несколько совместимых recordings последовательно запускаются через приложение на одном полном профиле без правки кода; до конца записи видны все слои текущего run. Несовместимый источник и второй профиль на занятом Worker отклоняются. Видимый qualification-status совпадает с evidence; результат и сравнение версий сохраняются после перезапуска. Второй реальный модельный профиль не требуется придумывать ради этого gate: независимость каталога проверяется контрактными fixtures.
**Выход / зависимость:** GO на этап 4 после интеграционных, контрактных и последовательных browser-проверок общего пользовательского пути. Успешный UI smoke ещё не означает full-session real-time acceptance.
## Этап 4 — Квалификация полного прототипа на записях и подготовка к полигону
**Цель:** доказать вычислительную работоспособность полного профиля на 4090, найти его ошибки на записях и передать воспроизводимый прототип для последующего полигона, не объявляя готовую автономию.
**Вход:** сквозной путь этапа 3; численные критерии заморожены до испытаний.
**Работа:**
- Перед каждым профилем/испытанием проверить единственного владельца GPU, версии, питание/ограничения и warmup. Запуски строго последовательные. Сначала bounded canary; полный recording только после его прохождения. Не повторять заведомо неуспешный длинный EoMT-run ради завершения плана.
- Провести full-session replay 1× всего профиля DDRNet + RF-DETR + geometry/motion + TGS + fusion/policy с учётом всех наблюдений. Проверить отсутствие растущего отставания, cadence/age/drop/memory budgets каждого слоя и общего выхода до приложения. Отдельно измерить startup, steady state и export; prepared geometry/masks не выдаются за online вычисление.
- Проверить смысловые сценарии: допустимый `hard_surface` без препятствия и с препятствием, static-объект без уточнения имени, человек/животное, движение и неподвижность, отсутствие LiDAR/pose, истёкшая маска и конфликт семантики с геометрией. Тротуар/велодорожка относятся к принятой coarse категории, а не к отрицательным road-only случаям. Это инженерная проверка сельского прототипа, не общая accuracy и не городской автопилот. Новая разметка/обучение не являются условием первого запуска.
- Проверить другую совместимую запись без изменения прикладного кода и отрицательные случаи: отсутствующий канал, неверная калибровка/формат, изменённые веса, другой hardware. Если второй источник отсутствует, соответствующее доказательство остаётся pending. Проверка другой машины/платформы не симулируется заявлением «это Docker».
- Проверить interruption/reconnect, медленный consumer, burst/loss, stop, lease-loss, restart и публикацию. Проверить live-compatible вход с неизвестной заранее длительностью, без чтения будущего, на том же runtime. Физический live через K1/роутер/беспилотник проводится позже при доступности оборудования и согласованного окна; его отсутствие не блокирует replay-прототип. Целевые 300–500 Mbps не являются доказанным каналом: физический gate потребует uplink/jitter/loss/clock alignment/end-to-end age.
- После приёмки нового пути убрать combined EoMT→DDRNet setup из активного real-time каталога, затем ограниченно вывести устаревшие adapters/микроприложения. Исторические записи/результаты и проверенный legacy M4.9 не удалять. Изменять конкретные declarations; для каждой runtime-миграции иметь точный predecessor и восстановление, без массового docker rename/delete.
**Результат и evidence:** один standalone image полного профиля, его manifest и инструкция запуска; таблица проверенных recordings/условий/ошибок; честный статус replay-prototype-qualified либо blocked. Подтверждены sequential exclusivity, переносимость на совместимые записи и регрессии сохранённых результатов. EoMT сохранён отдельно, не потерян и не включён в нагрузку первого профиля.
**Выход:** закрыть scope первого прототипа только при прохождении его replay/standalone gates. Отдельно оставить `physical-live-pending`, `independent-quality-pending`, `vehicle-integration-pending`, `actuation-disabled`. Ни новый известный маршрут, ни прошлый успешный recording, ни хороший FPS не включают автономию. Будущие EoMT/другие профили проходят те же gates отдельно, без расширения текущего этапа до бесконечного поиска моделей.
## Как измеряем и принимаем результат
Для каждого испытания сохраняются точный profile/image/weights/config digest, effective mission policy, equipment/capture/calibration identity, Worker/hardware, драйверы/runtime, source identity, интервалы и численные критерии, cold/warm режим, исходные counters и итоговый verdict. Изменение значимого измерительного контекста требует новой квалификации, а не ручного `ready=true`. FPS измеряется для полного output contract; отсутствие обязательного слоя не считается ускорением профиля.
Считаются source cadence, released/received/selected/inferred/emitted/dropped/expired/failed observations, queue depth, release lag, decode/preprocess/inference/postprocess, online geometry/TGS/fusion/policy и доставка результата до приложения, RSS/VRAM и объём передачи. Все входы должны быть учтены по однозначным терминальным категориям; processed FPS, successful-result cadence и fresh-result cadence — разные величины. У общего scene-result сохраняются возраст и source sequence каждого вложенного слоя, а не только свежий timestamp оболочки.
P95/p99 model-time не заменяет end-to-end age. Для разных машин нельзя вычитать несогласованные часы: clock mapping и его погрешность входят в evidence; внутрипроцессные интервалы измеряются monotonic clock. Startup/warmup не скрывается, но не смешивается с steady-state FPS. Устройство/модель не считается ready до завершения warmup.
Разрешённые пропуски определяются контрактом до запуска. К примеру, 6830/808.779495667/5 ≈1.689 результата/s: прежнее требование EoMT ≥1.8 FPS несовместимо со stride 5 на этом среднем исходном потоке. Требуется согласованная cadence-политика, а не механический перенос прежнего профиля. Quality-check для оптимизированных весов/precision/preprocessing отдельный: этот рефакторинг не создаёт ground truth и не даёт навигационную безопасность.
Проверки идут от дешёвых схем/negative/unit tests к isolated Worker canary, затем integration/full replay и доступному physical live. На Mac запускаются только ограниченные релевантные проверки; никаких model benchmark или full stress suite. После каждого этапа фиксируются выполненный результат, ссылки на evidence, известные ограничения и GO/PAUSE/BLOCKED. Следующий этап не начинается при незакрытом обязательном критерии предыдущего.
## EoMT: проверенные внешние сведения и предел вывода
EoMT — семейство ViT-моделей сегментации изображений; архитектура применяется к semantic, instance и panoptic segmentation. Наш конкретный checkpoint — Cityscapes semantic EoMT-L 1024, а не универсальное обещание качества на любой камере/домене. Это следует из [карточки конкретной модели](https://huggingface.co/tue-mps/cityscapes_semantic_eomt_large_1024) и [исходной статьи](https://arxiv.org/html/2503.19108v1).
В [официальном DINOv2 model zoo](https://github.com/tue-mps/eomt/blob/master/model_zoo/dinov2.md) для Cityscapes EoMT-L 1024×1024 указаны 25 FPS; условия таблицы — NVIDIA H100 с default `torch.compile`, если не оговорено иное. Это не показатель RTX 4090 и не end-to-end скорость нашей системы. Поэтому оснований объявить всё семейство «не real-time» нет, но и оснований квалифицировать наш профиль по этой цифре нет. Сохранение EoMT — отдельный исследовательский/резервный профиль, без обязательства неограниченно его ускорять.
## Progress
- 2026-09-02 22:35 МСК: `6af4c20`, инкремент18. CPU-only TLS A/B/A:96 обменов/192 RPC на case, p95 96.093/93.895/95.145ms; relay не устранил tail.424/424 ciphertext records exact,192 RPC matched,288 clocks reconstructed.13 post-start B tails >50ms локализованы к inter-edge request8/response5; startup mapping unknown сохранён. Прямой TCP SSH22 без forwarding/gRPC/Docker:16/16 успех,7 скачков67.921–91.142ms; Worker loopback после первого0.240–0.299ms. Mac en0 и Worker Realtek8812BU USB — оба Wi-Fi.82 local/82 Worker PASS,142 staged files/132 Python exact,43 artifacts/manifest `abc3ac677b81e0d78f8d5891517022e33ac7fa962166bc399b4009ee48d87d8e`. Нет новых GPU/full-profile запусков или runtime fix; следующий шаг — per-frame timing/10ms reply polling и доступный wired comparison. Временные ресурсы удалены, durable IDs/policies сохранены; два прежних сервиса автономно увеличили restart counts, не объявлены починенными.
- 2026-09-02 22:06 МСК: `59a74a3`, инкремент17. Bounded clock-only probe +10ms scheduling witness, без lease/grant/source/model authority. CPU A/B/A ready48/96 →94/96 →53/96; LAN после warmup непрерывен, ~100ms RPC tails сохраняются. Один полный LAN-canary32/32 exact без WAIT/drop/reconnect,30 результатов до EOF,12 полностью fresh из17 доступных sensor pairs. p95/p99 205.864/213.751ms —125ms FAIL; четыре resident PID/lease1 сохранены. Все338 raw clock exchanges (288 CPU +50 full) независимо пересчитаны.245 local/202 Worker tests PASS,2 Worker-only skip локально;131 source hashes, прежние129 без изменений.67 artifacts/manifest `2645a0faacb89b5af5d3756feea05550a12cc64820be926d32f2103b5f225caf`. Результат не standalone и не изменение постоянного маршрута. Ресурсы освобождены, сервисы/auto clocks восстановлены.
- 2026-09-02 21:12 МСК: `48835a0`, инкремент16. Joint startup PASS; 1× source начинается с0, anchor принят обеими сторонами. Full canary8/32 FAIL: compute discard6, source WAIT7–15, sync16–29, результаты0–5/30–31. Raw receipt exact8/8; до EOF6, полностью fresh5. Clock exchanges55/55 пересчитаны, readiness41/55; running uncertainty5.031–5.667ms, host telemetry33/33 без ошибок. Lease1/4PID пережили WAIT, после End всё освобождено.241 local/198 Worker PASS;129 source hashes.53 artifacts/manifest `9cb369fe7ae185d1a92f6050bc1018a878483f5eec169267f100843245428691`. Следующий gate — CPU-only control/data scheduling attribution, не ещё один слепой GPU retry.
- 2026-09-02 20:37 МСК: `35b6cd9`, инкремент15. Full Mac↔Worker graph использует acknowledged clock bounds, uncertainty для слоёв/ячеек, WAIT и immutable source anchor. Исправлены EOF ordering, repeated clock read и ранний disconnect до OPEN;297 local/173 Worker/12 repeated reconnect PASS. Финальный canary2/32,13 source WAIT+17 sync skips, оба результата после EOF,143.881/155.743ms; real-time FAIL, пороги не ослаблены. Следующий пункт — совместный pre-start clock readiness обеих сторон, затем короткий повтор. Все попытки сохранены;198 artifacts, manifest `7b435cac111b3e0e789aa137ba064651e6d344a5f8fb8fa35b0abbd0e7cb1d74`. Temporary resources удалены, leases released, stock-clock lock снят, четыре сервиса восстановлены,8000/telemetry работают. Этап2 остаётся открытым, stages3–4 не начаты.
- 2026-09-02 19:42 МСК: `d7b8989`, инкремент14. TLS Poll выдаёт pending grant с activation binding без GPU/model authority; four-timestamp mapping с conditional500ppm/50us envelope,16 samples/expiry2s. CPU-only Mac↔Worker:72 probes,60 внутри5ms,8/8 exact echoes, gap2.202s сохраняет PID9/lease1 и22 локальных renewal. Mapping истёк и восстановился; readiness менялась также после warmup.252 local PASS/2 Worker-only skips,65 Worker PASS; Ruff/mypy PASS.81 source hashes сверены; manifest18 artifacts SHA-256 `cd479c577fa3da78b1b01a6a90e03e8c742ecb53561e606d939408120a53a622`. Full-graph mapping/freshness integration остаётся впереди; модели не запускались. Временные ресурсы/секреты удалены, owner released,8000 и телеметрия работают. Старый perception-worker self-restart26→27 зафиксирован, не объявлен исправленным.
- 2026-09-02 15:56 МСК: `70927ea`, full-graph recovery. CPU normal128/gap76 и два повторения full normal128/gap76; 383 tests PASS. Четыре PID сохранены, reset stores 92/16/6/291 →0 и507/42/6/358 →0; stale history/cells отсутствуют. Final normal p95/p99 85.775/89.385ms, gap83.163/89.729ms при SM2610/mem10251. First fresh scene после fault1476.169/3857.475ms, seq30/110,52 intentional skips учтены. Первая GPU попытка прервана до моделей: ошибочный новый idle threshold512MiB был ниже известного baseline534MiB, факт530MiB; сохранена, проверка исправлена, пустой owned lease удалён. 330 artifacts,236 Worker hashes,114 current files verified; `.runtime/perception-stage2-resume-worker-20260902T1545MSK/manifest.json`, SHA-256 `a9449fa4c012e30ce8a06025790c0e110eeebab7c1859fdebf56d5e785961ea3`. Clocks/services восстановлены; legacy restart loops не исправлялись, product/vehicle scope не расширен.
- 2026-09-02 15:30 МСК: `c31c46c`, resumable lifecycle/input epoch, recoverable readiness, decoder/sensor reset. 375 focused tests PASS, Ruff/format 14 files и mypy 7 modules PASS. Worker CPU-only три input epochs, EOF-паузы 150 ms / 2.2 s, 3/44 synthetic source ticks во время пауз, 54 local renewals; PID decoder/sentinel неизменны, BGR exact 3/3. Peak input 5,275,461 bytes, final input/children=0, released lease; temporary container удалён. Один реальный H.264 fixture + synthetic timestamps/sensors/temporal reset, НЕ полный graph/GPU recovery. 73 code hashes сверены, 85 evidence artifacts; `.runtime/perception-stage2-continuity-worker-20260902T1518MSK/manifest.json`, SHA-256 `fbd327d129c88caa13b7183b1ae2c4767e877a6f5472c0f430875cfd7401ed46`. Сервисы/clocks не менялись; прежние legacy restart loops не исправлялись. Старый terminal-readiness эксперимент ниже сохранён как история; новый TARGET по решению владельца — wait/resync для временной недоступности.
- 2026-09-02 14:52 МСК: `92625bf`, readiness/lifecycle increment. 349 focused tests PASS; Worker Linux CPU-only 4/4 scenarios, stop 10.676–63.994 ms, 0 residual bytes, terminal fixture lease generation 4 released. Expired inventory остановил runtime через 1043.960 ms после последнего refresh, несмотря на 13 heartbeat renewals; late refresh/result rejected. Allowed overload сохранил violation после восстановления clocks. 70 measured code files verified, 86 evidence artifacts; `.runtime/perception-stage2-readiness-worker-20260902T1450MSK/manifest.json`, SHA-256 `4588995d763fe82d896b42689e981f1d00feedaf9d6aab29f4579f771d03da3a`. GPU facts synthetic, no model timing or collector qualification. Service IDs/StartedAt/restart counts неизменны до/после; у legacy agents уже были restart counts 21/2/2 — состояние running не объявляется устойчивой готовностью. Clock setters, service mutations, network listeners, registry/UI отсутствуют.
- 2026-09-02 14:22 МСК: `380b6ea` / `f39f1ff`. CPU probe (NumPy 1.26.4, Worker x86) воспроизвёл две ошибки range_m через quaternion address 8 mod 16; aligned copy сохраняет bytes и прежние norm/projection результаты. Удалены четыре full-frame copies на связке decoder→parent→DDRNet; binary framing, source clock, TTL, lease checks не менялись. A–B–B–A, 4×128 без drops: контроль p95/p99 84.702/92.961 и 83.930/91.759 ms; кандидат 85.113/91.219 и 82.425/90.530 ms. Кандидат raw parity 128/128 дважды, available/fresh 76/128; global qualification=false. CPU oracle 128/128 BGR/sensors для обоих вариантов, hung decoder cleanup PASS. 279 focused tests PASS. Manifest 305 artifacts, SHA-256 `9c72a93833a1dda51c4787dca13fb9791a1bf2e31c763b7740e1b21406703707`, `.runtime/perception-stage2-ipc-worker-20260902T1410MSK/manifest.json`. Частоты auto и сервисы восстановлены; registry/UI/image не менялись.
- 2026-09-02 13:55 МСК: `221e429` — supervised decoder RPC (250 ms deadline, 1 MiB input / 1,440,000-byte caller-owned BGR), общий causal sensor cache и atomic reservation → mailbox transfer. CPU oracle 32/32 exact до полного запуска и после review; native-hang injection учтён как failed cameras 0/1/2, child/lease/input освобождены. Полный binary 1×: 384/384 событий, 128/128 сцен без drops, BGR/segmentation/proposals/tracks/threats/material/raw costmap exact; два range_m отличаются на 1.388e-17 m, strict parity=false. p95/p99 161.974/191.806 ms; 76 available sensor pairs, 74 fresh scenes, seq106/107 дополнительно stale. 271 focused tests, Ruff/format/mypy PASS. Evidence `.runtime/perception-stage2-binary-graph-worker-20260902T1335MSK/manifest.json`: 161 artifacts, SHA-256 `e224de4e9f2c5fd419fe0fb0b9be6e01ec267628568e104795ae211ec803bce4`. Один GPU-прогон, без изменений clocks/quotas/models; сервисы восстановлены. Standalone/network/product cutover не заявлены.
- 2026-09-02 10:16 МСК: `097e450` — six-layer shared ABI и read-only Worker operating-envelope checker. Два sequential PyTorch auto-clock runs, 128/128 без drops; финальный C p95/p99 122.11/141.40 ms. 27 свежих полных scenes, 52 missing sensor pairs, 49 stale costmaps; пять дополнительных отказов после receipt aging. Функциональные model/geometry/motion/TGS/material outputs точны к reference 128/128. 142 focused tests + 42 adjacent regressions PASS, 1 исторический evidence-root test FAIL из-за существующих symlinks в соседний checkout; данные и guard не менялись. Этап 1 закрыт как engineering baseline; этап 2 допущен, не реализован. Evidence manifest `3b0a2ad32f6b60d3da48a4bcafdf6e11bc91209abc9d1f65da54b0eb36c52d45`.
- 2026-09-01 18:15 UTC: локальный обзор backend/frontend/Worker и двух документов выполнен в предыдущей итерации; ограничения и отсутствие свежей Worker-проверки перенесены в этот план.
- 2026-09-01 18:15 UTC: прочитаны все пять документов CODEX_V5; уточнение владельца о взаимоисключающих профилях внесено в TARGET. Проверены первичные источники EoMT. Создан план ровно из четырёх этапов.
- 2026-09-01, последующий аудит: прочитаны M4.9T5/E47/M4 и integrated artifacts; через SSH выполнены только read-only Docker inventory/inspect, чтение installed manifests и GPU status. Подтверждено расхождение между viewer composition, reusable graph и portable package; выявлены другие GPU-capable сервисы.
- 2026-09-01 18:48 UTC: по уточнённому сценарию владельца план переработан под один полный standalone Perception-профиль DDRNet + RF-DETR + geometry/motion + TGS + policy-shadow. Два обязательных сегментационных образа больше не являются целью первого прототипа. Road-only/coarse-material разрыв и dependency compatibility включены в этап 1.
- 2026-09-01, следующее решение владельца: fine-grained road-only исключён из первого прототипа; coarse `hard_surface` принят, сельская среда — текущий контекст. Static-obstacle представление переиспользуется без распознавания отдельных видов предметов. Предыдущее требование road-vs-sidewalk gate снято; dependency compatibility остаётся технической задачей.
- 2026-09-01 19:15–19:58 UTC, этап 1: по прямому разрешению владельца остановлены Ollama/Frigate, Docker restart=no закреплён в runtime и Compose, добавлены manual-only profiles. Данные/модели/записи сохранены. После замеров эти два сервиса не восстанавливаются; остальные временно остановленные Mission Core Worker-контейнеры восстановлены.
- 2026-09-01 19:31–19:57 UTC: выполнены последовательные 64-sample baseline DDRNet, RF-DETR, TGS core и synthetic online local-surface. Ограничение BLAS/OMP/MKL до одного потока снизило mean synthetic local-surface с 198.0 до 63.2 ms без изменения алгоритмических порогов. Полный профиль этими цифрами не измерен.
- 2026-09-01 19:49–19:57 UTC: построен локальный диагностический image с Python 3.9 DDRNet environment, TensorRT 11/native Triton base, Python 3.12 geometry и C++ TGS. Все четыре исполнились отдельными последовательными probes одного image ID; DDRNet masks совпали на 64 кадрах. Модели ещё монтируются явно, общего supervisor/IPC нет; standalone/full-profile статус не присвоен.
- 2026-09-01: добавлены ADR 0049, transport-neutral stream/freshness/measurement contracts и pinned candidate manifest без image digest/active registry mutation. 78 focused tests прошли (51 новых contract + 27 существующих live-ingress/synchronizer/shadow); Ruff и mypy новых контрактов прошли. Модельные baseline и нагрузочные проверки на Mac не выполнялись.
- 2026-09-01 20:51 UTC: совместный граф выполнен на исходных camera/point/pose arrivals 1×. Исправлена bounded causal body history для motion/threat и векторизован costmap lookup. До оптимизации 120/128, 8 drops, p95/p99 353.98/401.19 ms; после — 128/128, 0 drops, 174.09/190.62 ms. Fresh input у 75/128 кадров; остальные явно unavailable. 86 focused tests прошли. Все результаты и отрицательные gates сохранены в отчёте этапа 1.
- 2026-09-02 00:20–00:41 МСК: уточнение владельца о долгосрочном экспериментариуме внесено в план, ADR, Desktop status и candidate `experiment_policy`. Тяжёлые профили сохраняются; functional, quality и hardware/source/transport-specific performance статусы независимы.
- 2026-09-02: добавлены CUDA-event/host-IPC timing и экспериментальные варианты DDRNet layout/CUDA Graph; оба не показали устойчивого выигрыша полного графа и не стали defaults. Затем реализован `--schedule overlap-cpu`: один serial GPU worker, один chronological CPU consumer, общий бюджет двух pending кадров, единый bounded учёт входов. Первый fixed-slot вариант потерял один кадр и сохранён как отрицательный результат; dynamic shared-slot вариант дважды дал 128/128 без drops. Маски, proposals, metric observations, tracks, threats, costmap и non-timing TGS output совпали с reference на всех 128 кадрах; age-based policy проверяется отдельно.
- 2026-09-02: 91 focused tests PASS (12 pilot, 52 contract, 27 existing live-ingress/synchronizer/shadow), Ruff PASS, Python 3.9 child lint PASS. Семь последовательных Worker runs и версии кода сохранены в `.runtime/perception-latency-20260902T0020MSK/manifest.json`. Worker-сервисы восстановлены, Triton ready=200, local 8000=200; Frigate/Ollama exited/restart=no. Никаких продуктовых defaults/registry cutover.
- 2026-09-02 01:06 МСК: по запросу владельца сохранены все 122 изменённых/новых исходных файла семью тематическими коммитами; чистая контрольная точка `ffd6be5`. Коммиты: `d655d69` equipment/capture, `a945d66` backend lifecycle, `62d5520` Worker packaging, `5a78c99` portable UI, `4e04d06` real-time contracts, `bfe6ef4` streaming pilot, `ffd6be5` plan/evidence. Push не выполнялся; raw/weights/runtime не включены. Отдельный `05c99ef` исправляет девять legacy test failures: исторические fixtures отделены от новых installed-package pins, старый image явно отвергается; production digest checks не ослаблены.
- 2026-09-02: следующий source-binding increment отделяет первый preroll от текущих LiDAR increments, сохраняет history-only timestamps/points в rolling/TGS и добавляет поэлементные причины unavailable/stale/held. Metadata audit: первый кадр получает 4,787 current points, 7,207 preroll points остаются history-only; остальные 127 admissions и increment identities неизменны. 384 focused Python tests и 62 выбранных frontend architecture/Observatory tests PASS. Нового GPU run, frontend build, deployment или product cutover в этой итерации не было.
- 2026-09-02 01:22–01:33 МСК: preroll fix проверен тремя последовательными 128-frame Worker-прогонами полного графа. Во всех трёх кадр 0 valid и TGS рассчитан; model outputs на 127 общих последовательностях совпали с прежним control. Каждый прогон потерял ровно один кадр из-за `handoff-overflow`: A — seq 84, B/C — seq 38. p95 = 127.18/125.19/120.84 ms, p99 = 143.22/138.02/135.46 ms. Ничего не продвинуто в defaults: zero-drop и 125 ms gate FAIL. Evidence: `.runtime/perception-preroll-worker-20260902T0122MSK/manifest.json`.
- 2026-09-02 01:33 МСК: временно остановленные Triton, perception Worker и два Observatory agent возвращены в исходное состояние; Triton healthy/ready=200, оба agent running, canonical Mac 8000=200, Mac 8765 закрыт. Legacy perception Worker возобновил существовавший до замера HTTP 404 restart loop (running, restart count 4 на контрольном снимке); новый пилот от него не зависел. Ollama/Frigate сохранены exited/restart=no. Модельные запуски были строго последовательными; EoMT и посторонняя GPU-нагрузка не запускались.
- 2026-09-02 01:43–01:50 МСК: `2945859` заменил ложную buffered reservation на synchronous rendezvous, только когда consumer уже ждёт. 109 focused tests и Ruff PASS. Два последовательных Worker-повтора: 128/128, 0 drops, unaccounted=0; p95 = 128.75/122.69 ms, p99 = 151.67/157.10 ms. Повторная функциональная parity всех 128 outputs PASS. Evidence: `.runtime/perception-handoff-worker-20260902T0143MSK/manifest.json`, SHA-256 `d68af383c42eb3686ef199afeebd4625f27d573b77109e7e8ff8c85e811a9003`.
- 2026-09-02 01:50 МСК: Worker-сервисы снова возвращены в исходное состояние; Triton healthy/ready=200, оба Observatory agent running, active pilot containers=0, canonical Mac 8000=200, Mac 8765 закрыт. Legacy perception Worker возвращён в прежнюю HTTP 404 restart-loop конфигурацию; Ollama/Frigate exited/restart=no.
- 2026-09-02 01:57–02:47 МСК: surface reuse `d9ea7c1` сохранил functional parity 128/128, surface p99 19.44/21.87 ms. NVML/no-telemetry и cadence 50/100 ms не дали повторяемого latency PASS. Unified Triton с DDRNet/RF-DETR исполняет модели последовательно; NumPy preprocessing точен 128/128, но TensorRT mask отличается на 0.01195% пикселей и меняет одну ячейку grass→hard_surface / NO_GO→ALLOW_candidate. Backend не продвинут в default. Измерены пять whole-graph Triton variants, p99 137.58–151.37 ms; CUDA Graph/busy-wait проблему не закрыли. 119 focused tests PASS. Evidence: `.runtime/perception-unified-triton-worker-20260902T0227MSK/manifest.json`, SHA-256 `ed81475e71b7d02af03dbc65ef628065baaec80bf4877b83e80cc6b3d682a330`.
- 2026-09-02 02:47 МСК: pilot containers=0, Triton healthy, два Observatory agent running, local 8000=200, 8765 закрыт. Legacy perception Worker восстановлен в прежнюю HTTP404 restart-loop конфигурацию; Ollama/Frigate exited/restart=no. Нет product cutover/deployment, raw/weights/runtime не добавлены в Git.
- Git checkpoint: `d9ea7c1` — surface cache reuse; `34f13ba` — bounded Triton/cadence/NVML/preprocess diagnostics и regression tests. Документы и выводы фиксируются отдельно; push не выполнялся.
- 2026-09-02 09:11–09:40 МСК: численная диагностика frame 115 связала большую часть mismatch с TF32, а critical policy flip — с sigmoid ties и ничьей двух ground votes. `c6c42c2` переводит tie в unknown/NO_GO; 210 cell×frame значений на 41 кадре изменились только консервативно. 122 focused tests PASS. Masks/detector/geometry/motion outputs не изменились от этой правки.
- 2026-09-02 09:31–09:37 МСК: по явному разрешению владельца A/B stock clock lock (requested SM 2610 / memory 10501 MHz; observed CUDA 2610/10251) без изменения 450 W или Docker limits. Auto B: p95/p99 123.14/138.16 ms; locked C/D: 64.08/70.46 и 63.97/67.77 ms; pinned PyTorch E: 71.80/77.02 ms; auto-restored F: 116.71/144.88 ms. Все шесть новых полных проб 128/128, 0 drops; функциональная parity clock A/B 128/128. Это повторяемый bounded latency PASS при указанном envelope, не whole-product qualification. Evidence: `.runtime/perception-parity-pacing-worker-20260902T0911MSK/manifest.json`, SHA-256 `eac303c218ab391ed5dc2cd8d94be6f1eeecec2effc123da7585135a8b63f2dd`.
- 2026-09-02 09:40 МСК: clock resets успешны, GPU снова auto/P8 210/405 MHz, power limit 450 W; pilot containers=0. Четыре Mission Core сервиса восстановлены в прежнюю конфигурацию, Triton healthy/ready=200; прежние сторонние restart-loop дефекты не исправлялись. Ollama/Frigate exited/restart=no, Mac 8000=200, 8765 закрыт. Raw/weights не в Git, push/deployment не выполнялись.
- 2026-09-02 10:16 МСК: этап 1 закрыт как engineering baseline (`097e450`, `adcca7a`), не qualification. Six-layer ABI и Worker envelope закреплены; final auto C: 128/128, 0 drops, p95/p99 122.11/141.40 ms, 27 fresh scenes. Подробности и известный historical baseline test FAIL сохранены в отчёте.
- 2026-09-02 11:00 МСК: этап 2 начат. `bcacb02` переносит queue/GPU scheduler в common perception, ограничивает drop-history и проверяет владение входом при release/cancel. `1f8101e` добавляет адресную свежесть costmap; core tests и полный GPU-пилот используют тот же код. 202 focused tests, Ruff, mypy четырёх изменённых core modules и diff-check PASS.
- Два последовательных Worker runs: whole-scene p95/p99 125.01/130.31 ms, fresh 25/128; per-cell 123.91/136.17 ms, fresh 76/128. В обоих 128/128 без drops; raw functional parity 128/128, missing pairs 52. Per-cell блокирует 2195 cell×frame ground appearances (1994 при publication, ещё 201 при receipt); это не 2195 уникальных физических препятствий. Все 256 derived receipts повторно проверены по payload hashes/ages/policy.
- Продуктовый backend/frontend path и registry не переключены. Этап 2 остаётся незавершённым: общий supervisor/lease fencing, binary live ingress без знания EOF и standalone package ещё не реализованы. Этапы 3–4 не начаты. Следующий bounded шаг — lifecycle/ownership + неизвестная длина входного потока; старый batch materializer не ослаблять.
- Evidence первого инкремента этапа 2: `.runtime/perception-stage2-cells-worker-20260902T1055MSK/manifest.json`, 125 artifacts, SHA-256 `1a951fd9e807099e500fa66f8a40b1076071f96e4c8e0be4ab3094caaa33bf09`. Четыре сервиса восстановлены, Triton ready=200; Ollama/Frigate exited/restart=no, pilots=0, GPU auto P8, Mac 8000=200 и 8765 закрыт. Модели/сырые outputs не попали в Git.
- 2026-09-02 12:00 МСК: `6acf468` / `ac69e3b` — общий subprocess lifecycle и cooperative Worker-local lease/fencing подключены к полному пилоту. 223 focused tests PASS. Отдельные контейнеры подтвердили contention rejection, clean generation succession и crash quarantine. Global product queue не менялась; canonical persistent control root и backend integration не установлены.
- Normal pilot 128/128 без drops, p95/p99 124.98/136.12 ms, fresh 76/128. Intentional expiry A: 52 released = 51 complete + 1 GPU terminal drop; expiry trace B: 50 released = 50 complete. Оба `execution_complete=false`, reason=`lease-lost`, unaccounted=0, все процессы/входы освобождены. Final trace: stop-request deadline +45.74 ms, retirement +4109.82 ms, receipts at/after deadline=0. Все 229 опубликованных raw outputs exact к reference prefix; clocks/quotas/models не менялись.
- Evidence: `.runtime/perception-stage2-lifecycle-worker-20260902T1146MSK/manifest.json`, 259 artifacts, SHA-256 `00bd091a80845e50489a34ce4df2cbf3d2cd60d9ecb13d1d97accd03d44fdba4`. Временные probe containers и два test volumes удалены после сохранения owner records. Четыре сервиса восстановлены; Triton ready=200, Ollama/Frigate exited/restart=no, Mac 8000=200, 8765 закрыт. Этап 2 остаётся в работе; дальше binary ingress, постоянная controller/recovery integration и standalone package.
- 2026-09-02 12:35 МСК: третий инкремент (`da60fef`, `a937400`, `79ce55d`), 276 focused tests PASS. Worker raw IPC: 103/103 exact, каждый data channel до End, peak 553,991 bytes; negative EOF: incomplete=1, delivered=0, release=true. Evidence `.runtime/perception-stage2-binary-ingress-worker-20260902T1232MSK/manifest.json`, 42 artifacts, SHA-256 `2a5492080027a86de9f176f051c9265e29f377d1d9207c86ac93cdff7632505a`. Попытки 1230 (неполный diagnostic code pack) и 1231 (гонка финального timestamp journal) сохранены как rejected; их manifests связаны из принятого. Никакой повторной GPU-квалификации этим probe не выдано.
## Surprises / открытые вопросы
- Инкремент18: «LAN» не означал Ethernet — обе стороны работают по Wi-Fi, Worker через USB Realtek8812BU. Прямой TCP connect воспроизводит большой tail без нашего pipeline; reported866.7Mbps link rate не квалифицирует latency. Это не доказательство вины конкретного адаптера/AWDL/роутера или отсутствия других затрат в коде. Независимое clock/record evidence локализует контрольный tail, но не измеряет ещё крупные camera/scene buffers. First TLS startup без mapping сохранён отдельно; ICMP no-reply не выдан за общую потерю пакетов.
- Инкремент17: clock readiness срывается в A/A2 даже при0 моделях/scene, поэтому общий scene event loop не является единственным достаточным объяснением. LAN улучшил короткий интервал ready, но не все RPC tails: Poll p95 A/B/A2 88.838/92.403/89.950ms, LAN ACK p95 98.266ms. Tailscale preflight показал direct LAN endpoint, а не доказанный DERP. Выбор маршрута относится к connection configuration, не к весам/профилю.32/32 выходов не равны32 свежим scene; локальные Worker accounting gates остаются unknown/false для внешнего источника, отдельный joined verifier доказывает отсутствие потерь. Нельзя складывать/вычитать p95 разных timing boundaries как стоимости независимых стадий.
- Инкремент16: согласованный старт и отсутствие RPC errors не означают непрерывную готовность часов. Running bounds вышли за5ms без GPU/owner failure; это условная uncertainty при500ppm budget, не измеренный физический drift. Offline добавление всех probes в прежних2s всё ещё даёт FAIL на тех же девяти samples. Для следующего изменения нужен timing trace scheduling/control/data, а не автоматическое расширение history или бюджета.
- 2026-09-02 13:55 → 14:22 МСК: два старых range_m расхождения 1.3877787807814457e-17 m воспроизведены и исправлены. Quaternion начинался на byte 24 общего pose buffer; адрес 8 mod 16 менял norm на один ULP в текущем NumPy runtime. Offsets 0/16/32/48 возвращают reference, 8/24/40/56 точно воспроизводят оба отклонения. Теперь quaternion — отдельная immutable 32-byte копия с проверенным 16-byte alignment; формулы и comparator прежние. Полный граф подтвердил 128/128 exact дважды. Это квалификация numeric layout текущего pinned runtime, не универсальное обещание bitwise равенства на любом CPU/NumPy.
- Четыре удалённые full-frame copies дают небольшой измеримый эффект в IPC, не объясняют разницу 192→91 ms. При одинаковых фиксированных частотах контроль тоже проходил 125-ms gate до оптимизации. Новый p95 колеблется внутри межпрогонного разброса; p99 немного ниже в обоих повторах. CPU-only кандидат имел native-decode outlier и худший total p99, хотя IPC-minus-decode снизился; отрицательный результат сохранён. Автоматический runtime не должен обещать fixed-clock qualification без фактического envelope и не должен сам менять host clocks без отдельной authority.
- В том же запуске auto memory clocks переходят 10,251 → 405–810 MHz. По последнему 0.5-s sample перед receipt high/low cohorts имеют p95 88.269/172.079 ms (39/89 кадров). Это корреляция внутри одного прогона, не controlled A/B и не отдельная qualification. Общий p95/p99 161.974/191.806 ms остаётся FAIL. Decoder p95/p99 10.871/11.606 ms, RPC+bundle 18.053/38.991 ms, очередь 44.284/84.492 ms, DDRNet RPC 73.068/77.479 ms; CPU throttled delta=0. Нельзя приписать всю разницу с предыдущими 124.98/136.12 ms новому транспорту или сделать вывод об исчерпании 24 GiB VRAM.
- 2026-09-02 13:09 МСК: decoder-инкремент `350366b` / `f102712` принят отдельно от full graph. Evidence `.runtime/perception-stage2-decoded-ingress-worker-20260902T1310MSK/manifest.json`, 43 artifacts, SHA-256 `612295eabbc8f203ad9b037a69ac9cf97b2df3134b087d9e51a9786738423f2f`; связан с отдельными 128-frame demux/direct/reference manifests. Все 128 BGR совпадают; 32-camera binary canary и negative EOF освобождают весь input budget. Четыре Mission Core сервиса не останавливались; модели и GPU clocks не менялись.
- Повторный `av.open(init + fragment)` не является полнофайловым барьером, но повторяет stream probing на каждом кадре. Принят узкий проверяемый ISO-BMFF layout → один AVCC sample → persistent codec; NumPy импортируется до readiness. p95 первого потокового варианта 19.927 ms → 11.156 ms. Это bounded CPU A/B без модели; не вычитать выигрыш из старого full-graph p99. Codec/parser не импортируют session store или materializer; archive API сохраняет прежние exception contracts. Иной codec/layout, B-frames, multi-sample, пропуск DTS или не-keyframe start требуют нового явно квалифицированного профиля/epoch, а не скрытого fallback.
- Исторический decode-only probe 13:09 не доказывал causal scene/calibration admission. В инкременте 13:55 supervised child, causal cut и full-graph handoff реализованы с прежней реальной калибровкой полного профиля; готовые MP4/masks/geometry не подмешиваются. Multi-recording calibration admission и field quality по-прежнему не проверены.
- 2026-09-02 12:00 МСК: истечение lease и освобождение GPU разделены. После crash OS lock свободен, но durable `active` не допускает новый запуск. Recovery намеренно отсутствует до trusted resource-release proof; нельзя удалять production marker ради продолжения. В пробах удалены только два заведомо временных experiment-owned volume после сохранения записей и остановки всех probe containers.
- Свежая camera/cloud/pose пара не равна свежему полному scene. У TGS last-seen разрешающих ячеек может быть старее camera anchor; новый guard сохраняет этот возраст. В финальном C пять scenes истекли за 1–4 ms между publisher guard и приёмом, хотя раньше выглядели бы свежими. Пока одна такая ячейка запрещает весь scene; per-cell expiry — следующий runtime increment без ослабления 250-ms TTL. 52 source gaps нельзя исправить GPU-ускорением.
- New operating-envelope evaluator — проверяемый контракт readiness, не уже подключённый orchestrator и не host clock manager. Power/P-state/limit остаются измеряемыми фактами; continuous monitoring и авторитетный lease должен предоставить общий runtime. Low GPU utilization не доказывает владение, а snapshot PASS не квалифицирует целый run.
- Малое численное расхождение backend не обязательно семантически безвредно. Critical cell flip устранён fail-closed tie rule, но PyTorch vs TensorRT material по-прежнему совпадает 127/128 кадров. Без ground truth и явной numeric acceptance нельзя заменить reference автоматически.
- Автоматический downclock GPU/VRAM — подтверждённый A/B источник существенной части tail latency. CUDA Graph capture был реальным; CPU quota не троттлила. Server intervals всё ещё не являются CUDA kernel trace, а остаточные IPC/CPU/network затраты не исчезли. Две fixed-clock пробы пройдут 125-ms gate, возвращённый auto — нет; квалификация должна описывать условия Worker, а не только image digest.
- Глобальная блокировка очереди противоречит независимости будущих Worker; нужно перенести ownership scope, а не просто оставить `max_concurrency=1` во всех слоях.
- Старые combined-package manifests сохраняют batch-семантику и не исполняют detector/geometry/TGS/threat. Красивый общий viewer использует отдельные сохранённые результаты; это не готовый профиль.
- FPS исходного recording и старые sampling/min-FPS требования математически расходятся. Численные product budgets должны быть закрыты в этапе 1; пользователь не задал их в этом уточнении.
- Чистое GPU-окно для ограниченных component probes получено: после остановки сторонних и старых Mission Core GPU-процессов 0% utilization и около 529–531 MiB системного/display VRAM. Ollama/Frigate отключены постоянно из автозапуска по явному разрешению владельца. Source uplink, clock alignment и долгий эксклюзивный full-profile run не подтверждены; 300–500 Mbps и будущее hardware остаются гипотезами до проверки.
- Существующий `static_obstacle` достаточен для неподвижных препятствий; точные semantic имена не являются пробелом scope первого прототипа. Динамика определяется temporal evidence, не классом объекта.
- Потеря различия дорога/тротуар/велодорожка в `hard_surface` — осознанно принятый владельцем компромисс сельского прототипа, не блокер. Геометрический запрет остаётся выше material allowance.
- Самостоятельный полный образ ещё не построен. Совместные lifecycle/IPC, resident модели и последовательный GPU schedule проверены в bounded пилоте с mounts, но performance gate FAIL. Экспорт нового образа заблокирован отсутствующим cached Triton blob / NVCR 403. Будущая поставка обязана включить веса и явно задать scratch/cache; рабочий mounted probe не равен standalone. В Triton base отсутствуют Python grpc/protobuf/cv2/TensorRT SDK: native trtexec не означает их наличия. Внутренний RF-DETR HTTP adapter позволил выполнить пилот без них.
- CPU online local-surface — выявленный latency risk даже после ограничения BLAS threads. TGS core около 1 ms на prepared clouds не включает online rolling preparation/costmap; нельзя подставлять эту цифру как стоимость всего геометрического слоя.
## Decision log
- 2026-09-02 16:52 МСК: `61cbdb3`, real Worker host-control increment. 404 tests; normal128/128 exact raw, 50 observations без ошибок; delayed reply2200ms → два expired requests, WAIT, resume seq50 с прежними PID/lease и очищенными stores.102 completed+26 intentional skips; no backlog/unaccounted. Scope `docker-gpu-access` не равен `host-compute`: Windows PID4/Insufficient Permissions не скрывается как доказанная host-wide эксклюзивность. Read-only canary проверил3 последовательных обновления и существующего GPU-capable конкурента. Первый warmup abort из-за PowerShell `$null`/File.Replace и неправильной startup WAIT трактовки сохранён; обе причины исправлены, freshness1s не расширена. Manifest475 artifacts,363 Worker source hashes/115 current files, SHA-256 `aac125bd1b182d164c95d18089793fa54bf9797478bce1afe3de2a1d9093f6ef`, `.runtime/perception-stage2-worker-control-20260902T1630MSK/manifest.json`. Host collector не меняет clocks/limits/services; временные setters применяет только разрешённый measurement harness с восстановлением.
- 2026-09-02, уточнение владельца: временная потеря входной сети/потока не завершает resident AI-профиль. Отдельно сохраняем local Worker lease, ждём свежий поток без backlog и заново синхронизируем temporal state. Unknown/stale GPU telemetry — не доказательство чужого владельца; подтверждённый конфликт/авария остаются terminal. Автоматическое восстановление inference не означает разрешение возобновить физическое движение.
- 2026-09-01, решение владельца: EoMT и DDRNet не считаются вместе на Worker 006. Первоначальная трактовка «никаких двух моделей внутри профиля» отменена последующим уточнением: RF-DETR и DDRNet нужны внутри одного полного профиля; запрет сохраняется для альтернативных профилей и EoMT+DDRNet.
- 2026-09-01, решение владельца: один дрон — один Worker; текущий baseline RTX 4090. Следствие: worker-scoped exclusivity, без GPU-multiplexing и без зависимости от покупки hardware.
- 2026-09-01, решение владельца: EoMT сохранить отдельно, даже если не проходит real-time на текущей карте. Следствие: отрицательный benchmark оформляется как честный статус, а не повод блокировать DDRNet или бесконечно оптимизировать.
- 2026-09-01, рабочее решение плана: сначала контракты и baseline, затем runtime, затем продуктовая интеграция, затем квалификация и ограниченная миграция. Ровно четыре этапа; уточнения ведутся внутри них в этом же документе.
- 2026-09-01, решение владельца: первым нужен самостоятельный Docker с DDRNet, объектной детекцией, LiDAR-расстояниями и TGS для проверки многих записей K1 и последующего полигона. Следствие: цель — полный Perception-профиль, а не голая сегментация; нет зависимости от прежних вычисленных LAB результатов.
- 2026-09-01, решение владельца: беспилотника и motor integration сейчас нет. Следствие: текущий scope заканчивается проверенным perception/policy-shadow прототипом; actuator commands, autonomy acceptance и полный mission configurator — будущие отдельные gates, не скрытая часть этой реализации.
- 2026-09-01, рабочее решение прототипа: в одном контейнере допускаются несколько внутренних runtime-процессов одной задачи для сохранения модельной совместимости; все имеют одного supervisor и одного GPU scheduler. Изолированные среды не превращаются в отдельно устанавливаемые LAB-сервисы.
- 2026-09-01, решение владельца: никаких новых классов для столбиков/подобных предметов; существующего static-object / obstacle достаточно. Следствие: reuse текущего слоя и regression на RAV004, не новая модель/разметка перед первым прототипом.
- 2026-09-01, решение владельца: coarse `hard_surface` достаточен в сельской среде, включая тротуары/велодорожки. Следствие: прежние обязательные road-only/fine-class требования отменены; один segmenter DDRNet, без urban autonomy scope. Геометрия и unknown/freshness сохраняют приоритет.
- 2026-09-01, решение владельца: Ollama и Frigate больше не нужны в постоянной нагрузке. Остановить сейчас, убрать автозапуск после reboot, не восстанавливать после benchmark; сохранить данные для ручного использования. Выполнено и проверено по Docker/Compose, без физического reboot системы.
- 2026-09-01, инженерное решение: стартовый replay stride=1, source clock=1×, ≤16 MiB inflight и два pending camera frames; whole-path p95/p99 ≤125 ms остаются preregistered candidate. Перегрузка должна быть видна и проваливать strict gate; нельзя скрыто замедлить источник/сменить stride/подменить слой старым результатом.
- 2026-09-02, решение владельца: разрешены временный A/B stock clock lock с возвратом и расширение Docker resource limits при необходимости. Clock A/B выполнен, все настройки восстановлены; CPU/RAM quotas и power limit не изменялись. Постоянный автозапуск фиксированных частот этим испытанием не вводится.
## Восстановление и остановки
Для активного профиля transient input/telemetry loss означает waiting/resync с сохранением моделей/lease. Unknown inventory запрещает новый admission, но не доказывает конфликт текущего владельца. Подтверждённая утрата local lease, конфликт, child/decoder failure и cancel остаются terminal. Новый режим включён и измерен в полном binary pilot; прежние one-shot diagnostics сохраняются. Motion resume — отдельный будущий onboard safety gate, не следствие восстановления inference.
При неудаче короткого canary остановить только принадлежащий испытанию профиль, сохранить диагностику и проверить освобождение GPU; не убивать посторонние процессы и не продолжать full-session. Неясный владелец GPU или потеря lease требуют fail-closed остановки нового запуска, а не захвата ресурса поверх старого процесса. Переключение на другой профиль — отдельная последовательная операция, не автоматический GPU fallback.
Новые схемы/декларации вводятся версионно. Возврат к точному предыдущему образу/конфигурации не превращает старый batch-путь в real-time; при откате сохраняется честный статус unavailable/not-qualified. Общую старую очередь нельзя ослаблять без worker-scoped fencing. Опубликованная история и исходники записи не переписываются для прохождения проверок.
Новый scope, изменение аппаратной/операторской границы, необходимость физического действия, отсутствие нужного доступа или существенный выбор latency/quality — повод остановить соответствующую часть и запросить решение владельца. Обычные локальные исправления внутри согласованных границ не требуют переписывания плана. Настоящий прогресс, evidence и решения обновляются здесь, без новых параллельных планов на каждую проверку.
## Outcomes / retrospective
Этап1 завершён; этап2 продолжается. Инкремент18 локализовал один существенный источник длинных задержек ниже Perception-приложения: между TLS edges, с независимым воспроизведением при TCP connect без gRPC/Docker/SSH forwarding. Оба конца на Wi-Fi; конкретный radio/AP/driver/OS механизм остаётся неизвестным, Ethernet comparison возможен при физическом подключении.424 encrypted records/192 RPC exact,288 clocks reconstructed;82 local/82 Worker tests PASS,142 staged files exact,43 artifacts. Модели/GPU/production runtime не запускались и не менялись; full-graph статус по-прежнему из инкремента17:32/32 continuous, p95/p99 205.864/213.751ms FAIL,12 fresh scene. Весь runtime не объявляется невиновным: далее per-frame очереди/сериализация/выдача, отдельный10ms polling и bounded lifecycle. Отсутствие Ethernet не блокирует экспериментальную разработку runtime/standalone. Этапы3–4/registry/UI/actuation не открыты. Временные ресурсы закрыты, IDs/restart policies сохранены; существующие autonomous service restarts отмечены отдельно, не исправлены. Mac8000 PID33360/telemetry работают,8765/18561 отсутствуют; Ollama/Frigate exited/restart=no.
После этапа 4 здесь будут перечислены digest самостоятельного полного образа, измеренные статусы и ошибки по recordings, реально проверенные source/hardware/config комбинации, состояние сохранённого EoMT-варианта и оставшиеся physical-live/quality/vehicle-integration ограничения. Готовый Docker и готовая автономия не отождествляются.
@@ -0,0 +1,112 @@
# ADR 0045: Upstream Rerun as the canonical recorded-LAB pipeline
Date: 2026-08-30
Status: accepted; RAVNOVES004TREE is the first migrated full-route LAB
## Context
The accepted LAB product composition was repeatedly rebuilt over independent
camera, semantic, point-cloud and TGS transports. The resulting implementation
had several clocks, LAB-specific caches, a browser MediaSource decoder and a
separate Three.js spatial renderer. A camera could continue while segmentation
or the cloud stopped; rewind could expose evidence from different source
sequences; switching TGS could block both panes. Low host utilization did not
make that architecture correct: the bottleneck was duplicated admission,
decoding, scheduling and state ownership.
The product owner requires the existing LAB UI and interaction grammar to stay
unchanged. VIDEO/CAMERA, SOURCE POINTS/LOCAL SLAM/TGS COSTMAP/SEMANTICS and
3D/PLAN remain the canonical controls. Models and evidence providers may
change, but a LAB may not create another player, clock, splitter, spatial
renderer, window or status grammar.
The repository state before this migration is retained by the annotated Git
tag `baseline/custom-legacy-before-canonical-rerun-2026-08-30`. Its Russian
stage name is **«Этап перехода от самописного legacy-контура к каноническому
шаблонному Rerun-пайплайну»**.
## Decision
Recorded LAB replay uses the unmodified upstream Rerun SDK and web viewer. The
first accepted dependency is exactly `rerun-sdk==0.36.3` and
`@rerun-io/web-viewer@0.36.3`. Mission Core does not patch the package, vendor a
viewer fork or depend on private viewer source. Product controls are an outer
adapter which requests an ordinary Rerun blueprint.
One native Rerun viewer owns:
- one `session_time` playback clock;
- the recorded camera and semantic image-space evidence;
- `/world/points`, `/world/sensor_pose` and `/world/trajectory`;
- native 3D orbit and top-down plan presentation;
- seek, play/pause and frame synchronization.
The canonical K1 RRD remains the source of pose, source points, bounded Local
SLAM accumulation and trajectory. A LAB may publish one immutable normalized
RRD sidecar containing only derived evidence absent from that recording, such
as camera video, semantic masks and diagnostic 2D boxes. The base recording and
sidecar must have the same application id, recording id and timeline. A sidecar
does not copy, rotate or re-own world geometry.
RAVNOVES004TREE uses a digest-bound sidecar cache. Its sealed fMP4 fragments are
verified, concatenated and transcoded once to an upstream-compatible H.264
`AssetVideo`. Source PTS are preserved. A fragment without a decodable sample
holds the latest preceding frame; decoded samples are never renumbered to a
synthetic fixed-rate clock. Each semantic mask and `VideoFrameReference` is
logged at the exact immutable LAB `session_time`.
Profiles control loading rather than creating different viewers:
- source points use zero accumulation;
- Local SLAM uses a native five-second visible time range;
- TGS COSTMAP and 3D SEMANTICS are enabled only when full-route immutable
artifacts exist and share the recording clock;
- semantic model buttons select an entity path in the same sidecar;
- 3D/PLAN changes native eye controls, never point coordinates;
- layers missing from an immutable result stay visibly disabled and fail
closed; they are not reconstructed from sparse review anchors.
The previous fMP4/Three.js LAB transport remains source-retained only for
explicit legacy comparison. No canonical route selects it, preloads it or lets
it start background work. Removal is allowed after migrated results pass the
same acceptance checks and the rollback tag is no longer operationally needed.
## Acceptance
A migrated recorded LAB is accepted only when:
1. the base RRD identity and sidecar identity match exactly;
2. camera, semantics, point cloud, pose and trajectory follow one Rerun clock;
3. play, pause, forward seek and backward seek do not remount the viewer;
4. SOURCE POINTS and Local SLAM are native views of the same sealed geometry;
5. unavailable TGS or semantic 3D evidence is disabled rather than simulated;
6. first materialization is cached by source/result/renderer digests and a
cache hit performs no decode or inference;
7. the existing LAB page, selectors, report mode, controls and expand behavior
remain unchanged;
8. Data replay and live Rerun profiles continue to use their own load policies;
9. the integrated application remains on `127.0.0.1:8000` and no second Mission
Core service is introduced.
The isolated renderer materialized the first real RAVNOVES004TREE sidecar in
79.5 seconds. Under the live operator service, cold materialization completed
in approximately seven minutes and produced a 393,203,594-byte RRD; this is too
slow to treat as an interactive open and should be moved to publication-time
preparation. With a full SHA-256 recheck on every cache hit, the warm product
endpoint returned headers in 0.89 seconds and streamed the complete local
artifact in 2.48 seconds. These measurements establish the local cache behavior,
not a realtime inference or navigation claim.
## Consequences
- Mission Core keeps its product UI without owning media or spatial playback.
- Rerun can be upgraded through ordinary dependency updates and regression
tests instead of reapplying a local patch.
- New models publish entities and annotations into the same recording contract;
they do not add LAB-specific viewers.
- SLAM clouds and trajectories stay visible through standard Rerun components.
- Useful native boxes/cuboids may be added as ordinary entity layers when their
immutable full-route evidence exists.
- The migration does not improve DDRNet quality, prove terrain traversability
or grant navigation/actuation authority. Those remain separate model and
safety acceptance questions.
@@ -0,0 +1,209 @@
# ADR 0046: Durable Observatory recorded queue and live K1 priority
Date: 2026-08-31
Status: accepted; portable LAB V1 admission and Worker pull foundations implemented;
production executor, claim lease, result publication, Worker deployment and
live-trigger wiring pending
Amended on 2026-09-01 by ADR 0048. Capability-aware claims, renewable leases,
verified result transport and the durable publication lifecycle are now
implemented; physical Worker installation and live-trigger wiring remain outside
that source increment.
## Context
Observatory must let an operator apply an admitted laboratory setup to a saved
session without turning the browser into an execution client. The current compute
host is a shared, finite Worker rather than an elastic pool. Recorded experiments
may wait, but a future live K1 perception process must acquire that compute
immediately and exclusively when its real acquisition lifecycle begins.
Historical LAB results are not automatically executable definitions. In
particular, the existing LAB V1 result proves a recorded EoMT and DDRNet analysis,
but it does not retain one independently versioned, durable executor contract that
can safely be reconstructed from its display metadata. Conversely, M4.9T5 has an
exact accepted RAVNOVES00 source pack and CPU-only TRAVEL TGS release which can be
sealed without inventing learned-model dependencies.
The queue must also survive process restarts and ambiguity around preemption. A
database flag alone cannot prove that a non-checkpointable Worker process released
CPU, memory and staging storage. Starting live K1 work before physical cancellation
is confirmed would permit two owners of the same constrained resource.
## Decision
Mission Core owns a bounded SQLite-backed recorded-job queue. The browser submits
only:
- `source_session_id`;
- `setup_id`;
- one idempotency key.
The request contains no command, script, path, environment variable, image,
model, resource limit or priority. The server resolves an allowlisted source/setup
pair and seals the following identities into the accepted job:
1. the current source-catalog snapshot SHA-256 captured at admission;
2. immutable source-bundle SHA-256;
3. source-capability-manifest SHA-256;
4. source-adapter id, version and SHA-256;
5. setup RunDefinition id, version and SHA-256;
6. executor release and container-image SHA-256;
7. learned-model release list and model-manifest SHA-256;
8. resource-profile SHA-256 and checkpoint policy.
The queue owns idempotency. An exact retry returns the original job and receipt.
The same key with changed identity is a conflict. Jobs advance through `accepted`,
`queued`, `claimed` and `running`, then terminate as `succeeded` or `failed`.
`paused`, `preemption-pending` and `reconciliation-required` expose safety-relevant
branches. Mission Core admits only one active recorded compute owner.
The queue remains the authoritative orchestration and evidence boundary. A queued
job proves durable admission, not remote execution or eventual success. An
authenticated, versioned Worker pull protocol is now implemented as a separate
boundary. The configured Worker contour may claim one server-sealed job and
advance it through start, checkpoint, succeed or fail receipts. The caller cannot
supply commands, paths, environment variables, container images, model identities,
resource policy or priority. This protocol and its tests are foundation only.
The production app hard-disables the Worker router even when a valid private
credential exists. A token must not turn an incomplete transport into a production
execution surface before an expiring claim lease and verified result publisher
are implemented and accepted. The gate remains isolated from K1, Simulation and
legacy LAB startup.
The transport receipt is not result publication. A Worker `succeed` transition
records the returned result id and SHA-256 against the claim, but a separate
publisher must still validate the generic result contract, immutable artifacts,
source/job/definition/model identities and observation-only authority before the
result enters the Observatory catalog.
## Current setup admission
The current executable matrix contains one exact pair:
- source: `RAVNOVES00`;
- setup: `M4.9T5 · TRAVEL TGS · CPU-only, без ML`;
- learned-model releases: `[]`;
- checkpoint policy: `non-checkpointable`.
The empty model list is affirmative provenance: this TGS configuration is a
CPU-only algorithmic pipeline. It is not a placeholder for unidentified weights.
Admission still reads and seals the current source-catalog snapshot and verifies
the pinned source bundle and capability manifest before the job is accepted. The
binding deliberately does not pin a digest of the whole mutable catalog: the
exact compute input is the immutable source pack, while the catalog snapshot is
job-specific provenance.
`LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39` now has a source-independent
portable RunDefinition. Its identity contains the exact EoMT and DDRNet model
artifacts, algorithm/configuration components, resource profile, observation-only
authority and the generic `missioncore.recorded-eomt-ddrnet-review/v2` result
contract. It contains no source session id, source label, host path, command,
environment or caller-selected priority. The old
`missioncore.lab-v1-vegetation-shadow/v1` result is not an exact or existing
result of this generic v2 definition, including for its original RAV004 source.
It remains available only through the immutable legacy LAB catalog; the portable
catalog neither maps nor projects it.
Compatibility is decided by capability admission against the immutable selected
Session, not by `RAVNOVES*` naming. The source must satisfy the admitted K1
plugin/archive, point-cloud/trajectory/video modalities, RIGHT-camera semantic
channel, exact recorded media profile, single seekable media epoch and sealed
calibration identity. Catalog compatibility uses a lightweight `probe` result
containing only the selected Session/catalog identity, source-adapter identity and
camera segment count. It neither prepares/restores a recorded-media sidecar nor
builds a source bundle or capability manifest. A later explicit admission performs
the full immutable read, emits content-addressed path-free documents and rejects
source or definition drift before writing contracts or a queue intent.
This portable definition is intentionally not executable yet. Its executor state
is `not-installed`; no executor release or image identity is sealed. Conversion to
the durable queue definition therefore fails before source persistence or queue
mutation. A compatible recording, including RAV004, has only a successful
source-capability probe, not an existing portable result or runnable LAB V1 job.
The legacy RAV004 result remains independently viewable in the legacy catalog.
The same fail-closed rule applies to every other session/setup combination.
The implemented Worker 006 agent core accepts only path-free queue projections,
validates their sealed identities and resolves execution through a local
four-digest allowlist: executor release, image, model manifest and resource
profile. Network polling cadence, model/runtime installation and deployment are
outside that core and are not inferred from its presence in the repository.
## Priority and preemption protocol
Priority is server-owned:
- live K1 lease: rank `0`;
- recorded replay job: rank `100`.
A pending or active live lease blocks new recorded claims. A running cooperative
job yields at an allowlisted checkpoint and moves to `paused` before live
activation. A non-checkpointable job cannot pretend to pause. Its transition is:
1. persist a stable cancellation intent bound to the job claim, executor release,
image, resource profile and live trigger;
2. invoke the scheduler-owned physical cancellation boundary;
3. persist an identity-bound cancellation receipt proving resource release,
staging discard and restart-from-zero;
4. only then activate the live K1 lease.
The cancellation identity is reused after a crash or retry. A missing callback or
receipt leaves the durable intent pending for retry; a conflicting or
indeterminate receipt moves the record to reconciliation. Both forms block live
activation instead of automatically launching duplicate work. When an
explicit live terminal trigger completes, fails or cancels the lease, paused
recorded jobs return to the queue. A non-checkpointable job starts again from
zero; partial staging never becomes evidence.
## Acceptance boundary
This decision accepts the durable queue core and its identity, lifecycle,
idempotency and preemption contracts; the source-independent LAB V1
RunDefinition; capability-based recorded-source admission; the fail-closed bridge
from portable identities to the durable queue; the authenticated Worker pull
protocol; and the transport-agnostic Worker agent core. It does not claim that the
complete compute loop is deployed. The following remain implementation gates:
- sealing and installing a production LAB V1 executor release and container image
with the exact EoMT/DDRNet artifacts on Worker 006;
- implementing and accepting the generic v2 result assembler, validator and
idempotent Observatory publisher rather than trusting a terminal Worker receipt;
- replacing the current one-shot claim receipt with an expiring, renewable claim
lease whose loss prevents stale execution and terminal acknowledgement;
- deploying, credentialing and physically validating the Worker pull agent and
its local four-digest executor allowlist;
- installation of the exact M4.9T5 executor on the Worker;
- binding real K1 acquisition start/terminal events to live-lease triggers;
- physical acceptance that cancellation releases the required Worker resources
and that recorded work resumes only after the live lease terminates.
Until those gates close, an M4.9T5 submission may remain honestly `queued`. LAB V1
cannot be submitted. The UI must project source compatibility independently from
executor readiness: a compatible recording may show capability `pass` while the
`not-installed` executor keeps preflight blocked. The portable v2 setup always
reports no existing result; its historical vegetation result stays in legacy LAB.
Even a future `ready` executor cannot make this projector expose enqueue until
server-side definition-SHA and check-SHA fenced check/submit endpoints are
implemented and accepted. No current UI state may promise or expose LAB V1
enqueue.
## Consequences
- The UI stays a bounded selector and read-only preflight surface; executable
authority remains on the server, and LAB V1 enqueue stays absent while its
executor is `not-installed`.
- Source compatibility and executor readiness are separate product facts; neither
labels nor an existing result upgrades a compatible Session into an executable
definition.
- Every accepted job is reproducible from sealed source, setup, executor, model
and resource identities rather than mutable host paths.
- Live K1 can receive hard priority without silently losing or concurrently
running recorded work.
- Non-checkpointable replay pays the deliberate cost of discard and
restart-from-zero after preemption.
- K1 acquisition/control principles, the legacy LAB archive and
Simulation/Gaussian remain unchanged; this foundation adds no viewer profile,
data migration, equipment command or lifecycle coupling to those contours.
- Adding a setup or source requires an admitted adapter and RunDefinition; a
display name or historical result is insufficient.
@@ -0,0 +1,126 @@
# ADR 0047: Verified portable Observatory result publication
Date: 2026-08-31
Status: accepted as a backend foundation; production validators, transport and
executor wiring remain blocked
Amended on 2026-09-01 by ADR 0048. Exact built-in validators, package transport,
application publication wiring, durable publication status and a typed portable
result viewer are now implemented. Installed generic package executors and a
physical Worker 006 cutover remain separate acceptance work.
## Context
The durable Observatory queue deliberately treats a Worker `succeed` call as a
transport acknowledgement. Its `result_id` and SHA-256 do not prove that an
artifact exists, that it was computed from the admitted source, that it obeys
the selected RunDefinition, or that it has observation-only authority. Publishing
that acknowledgement directly as a LAB session would let incomplete, corrupt or
mislabelled output enter the same catalog as immutable evidence.
The current canonical LAB V1 result is a separate preserved legacy result. It
must not be reinterpreted as a portable v2 result, rewritten with new provenance,
or used as evidence that the portable executor exists.
## Decision
Portable results cross a new backend-only verification boundary implemented in
`k1link.observatory.portable_result_contract` and
`k1link.observatory.portable_result_publisher`. The boundary does not change the
queue, the source-admission service, Session API, setup projection, UI, K1,
Simulation, or the legacy canonical publisher.
A Worker-side assembler must produce one directory whose basename is the
SHA-256 of its canonical `manifest.json`. The manifest schema is
`missioncore.observatory-portable-result-package/v1`; JSON bytes are canonical
UTF-8 with sorted keys and no insignificant whitespace. Its separately hashed
identity binds:
1. queue `job_id`, request identity, execution identity, submission receipt and
claim generation;
2. source Session, catalog snapshot, source bundle, capability manifest and
source-adapter identities;
3. the complete portable RunDefinition identity, including source requirements,
components, models, resource profile, result contract, executor and authority;
4. result id, result schema, result kind and result-contract SHA-256;
5. a canonical, role-sorted artifact list with confined relative paths, media
types, byte lengths and SHA-256 identities;
6. observation-only authority.
Exactly one non-empty `result-document` JSON artifact is required. Package roots,
the manifest, every path component and every artifact are checked without
following symlinks outside the package. The queue's terminal result SHA must equal
the canonical manifest SHA and the package directory name.
Before catalog publication, the server also:
- resolves the exact `(setup_id, definition_sha256)` in the portable registry and
proves every recorded queue field equals the resulting RunDefinition;
- rejects not-installed executors and the reserved legacy
`lab-v1-vegetation-shadow-<sha256>` namespace;
- rechecks the current SessionStore catalog snapshot;
- reads both source-admission documents from
`observatory-portable-source-contracts/<sha256>.json`, verifies their bytes,
schemas, cross-links, adapter, source and authority;
- invokes a validator registered by exact result-contract SHA-256;
- copies the package manifest and all output artifacts into the central immutable
content-addressed artifact store and records its manifest id;
- publishes one idempotent `LabSessionBinding` through `SessionStore`.
There is intentionally no generic “JSON looks plausible” validator. An unknown
result contract fails before artifact-store or SessionStore mutation. A validator
must understand the exact result schema and determine that the result document
and supporting artifacts are accepted evidence.
## Calculation-profile provenance
The publisher does not read a browser selection or infer a profile from a result
name. It requires a server-owned policy bound to the exact definition id, version
and SHA-256. The resulting immutable provenance contains
`missioncore.observatory-calculation-profile/v1` with:
- `setup_id`;
- full display name;
- origin `archived-definition`;
- definition id, version and SHA-256.
It also stores a SHA-256 of that calculation-profile document. A later Session API
projection can therefore read `calculation_profile` from the result's provenance
instead of reporting whichever setup happens to be selected now.
No replay capability is invented. The portable result package is preserved in
the central artifact store, while a result-schema-specific viewer/replay adapter
must be accepted separately before the catalog binding can claim visual replay.
## Current fail-closed blockers
The publisher and focused contract tests are implemented, but the end-to-end
production loop remains unavailable for concrete reasons:
1. neither `recorded-eomt-ddrnet-review-v2` nor
`m49-tgs-portable-review-v2` has an installed exact result-contract validator;
2. the current Worker protocol returns only `result_id` and manifest SHA-256; it
has no accepted package upload/CAS handoff that gives Mission Core the matching
content-addressed directory;
3. portable executor releases that emit this package contract are not installed
and physically accepted on Worker 006;
4. the application has not registered definition-bound calculation-profile
publication policies or wired the publisher into the terminal Worker flow;
5. no result-schema-specific replay-capability adapter has been accepted.
These are explicit blockers. The backend must not fabricate a package, reuse a
legacy result, trust a Worker success receipt, guess model/profile provenance, or
expose an enqueue/result promise to bypass them.
## Consequences
- Queue success and Observatory publication remain distinct evidence states.
- Exact retry is safe: content-addressed artifact publication and the immutable
SessionStore binding are idempotent; a conflicting result id fails closed.
- Source, definition, model, resource-profile and calculation-profile identities
remain available in one portable provenance document.
- Artifact-store writes may leave harmless immutable unreferenced objects if the
final SessionStore transaction detects a conflict; they cannot overwrite an
existing identity.
- The legacy canonical LAB V1 result and its admission logic remain byte-for-byte
outside this publisher.
@@ -0,0 +1,138 @@
# ADR 0048: Independent installed LAB packages and portable result lifecycle
Date: 2026-09-01
Status: accepted and implemented as source contracts; no Worker deployment or image migration
## Context
The first portable Observatory path still composed M4.9 and LAB V1 through
profile-specific builders and a combined Worker release. A Worker could claim a
job whose exact executor was not installed, execution success could become
terminal before catalog publication, and the UI could open only one hard-coded
legacy replay family. Adding another LAB therefore still risked changes across
the Worker coordinator, backend publication and frontend viewer.
The target remains observation-only. This decision does not authorize a build,
deployment, Docker installation, Worker 006 mutation, K1 command or safety use.
## Decision
### Installed package boundary
A Worker-local `InstalledLabPackageRegistry` binds one independently installed
package to the exact RunDefinition, RuntimeCandidate and four-digest executor
identity. Its manifest contains only reviewed container images, argv, dependency
topology, fixed in-container mount targets and immutable asset IDs. It cannot
contain a host path, secret, environment, Docker socket or job-provided command.
Every package uses one stable container I/O contract:
- read-only source at `/missioncore/input/source`;
- read-only canonical plan at `/missioncore/input/run-plan.json`;
- result package at `/missioncore/output`;
- ephemeral work at `/missioncore/work`.
`single-container` and `fixed-stack` are package properties. Exactly one
container owns the portable result. All images must already be admitted by the
bound RuntimeCandidate, and the package asset inventory must exactly equal that
candidate's reusable asset inventory.
Worker composition now exposes one generic package executor factory. A Worker
may install any non-empty subset of server-ready definitions. It advertises only
the resulting four-digest identities; it is not required to implement every LAB
known by the backend. Existing profile-specific builders remain a compatibility
path until their images adopt the common package I/O contract.
The generic executor runs a deterministic dependency graph of hardened one-shot
containers through the local Docker Engine API. Every container is pinned by
image SHA-256, has no network, a read-only root filesystem, no Linux
capabilities or privilege escalation, and receives only the declared read-only
inputs plus the single shared result output. Memory, CPU, PID, shared-memory,
tmpfs, GPU-count and timeout limits are explicit package fields. Exactly one
`result-writer` step must transitively depend on every compute step, after which
the Worker verifies the declared result manifest, file lengths, digests and the
absence of links or undeclared files before publication can begin.
Worker-local asset bindings translate reviewed controller paths to Docker-host
paths. Those host paths never enter the package manifest or queued job. The
current package contract intentionally admits ordered offline steps only; a
future LAB that genuinely requires simultaneous services needs an explicit new
contract instead of silently weakening the isolation boundary.
### Capability-aware dispatch
Worker claim protocol v2 sends a bounded, canonical snapshot of installed
four-digest executor identities. The queue selects the oldest queued job that
matches one of those identities inside the same transaction that creates the
claim. An empty snapshot claims nothing. The snapshot is included in the claim
request digest, so an idempotency key cannot be replayed with different Worker
capabilities.
Claim v1 remains accepted only as a rolling compatibility path and cannot send a
capability field. New Worker code always uses v2.
### Execution and publication are separate durable lifecycles
Verified upload completion atomically seals execution as `succeeded` and creates
a publication outbox entry. Publication has its own state:
- `not-required` for the compatibility path;
- `pending` after verified execution completion;
- `failed` with bounded error evidence and attempt count;
- `published` with timestamp.
A publisher failure no longer rewrites or loses the successful execution. The
authenticated Worker API returns the durable job with HTTP 202 and exposes an
idempotent publication retry endpoint. Exact retries reuse content-addressed
artifacts and the immutable SessionStore projection; they do not rerun the model.
### Contract-driven result viewing
Portable publications receive an explicit v2 viewer capability
`portable-result-review / portable-result / result-defined`. The public viewer
service resolves that capability, immutable publication provenance and central
artifact manifest, then rechecks the JSON result document's length and SHA-256.
The Control Station selects either the legacy canonical replay adapter or the
portable result adapter from the typed capability. No portable setup ID, LAB ID,
source session ID or result-name family selects the viewer.
The UI polls while execution or publication is pending, refreshes the catalog
after publication, and keeps legacy and portable setup catalogs independently
usable if either endpoint is temporarily unavailable. Catalog merge identity is
`setup_id`, never a display name.
### Validator extension
Result validators are registered and selected by exact result-contract SHA-256.
The built-in LAB V1 and M4.9 functions remain compatibility registrations. The
composition core no longer requires both setup IDs or branches on them, so a
future contract can supply another server-owned registration without changing
the queue or Worker router.
## Compatibility and migration boundary
This source increment does not claim that the currently installed M4.9 or LAB V1
images implement the new common container I/O surface. No synthetic package
manifest is checked in for an image that has not been rebuilt and smoked against
that surface. The old builders continue to work, and M4.9 startup no longer
requires LAB V1 receipt/release environment values.
The next physical migration is deliberately per profile:
1. make one image emit the existing portable result package through the common I/O contract;
2. seal its independent installed package manifest and local asset bindings;
3. run contract composition and a short offline smoke;
4. advertise only that executor identity in claim v2;
5. perform a reversible canary without changing another profile.
## Consequences
- A Worker cannot take an unsupported job merely because it is earlier in the queue.
- Adding a conforming LAB changes its definition, runtime candidate, validator
registration and installed package, not the queue protocol or frontend routing.
- Successful compute and successful catalog publication are both visible and
recoverable facts.
- Portable result review is generic JSON/artifact evidence; richer visual viewers
can be added as new typed capabilities without adding setup-name conditionals.
- The generic package boundary is implemented, while image conformance and
physical Worker 006 acceptance remain explicit, unclaimed work.
@@ -0,0 +1,931 @@
# ADR 0049 — Stream-first full perception profiles
> Product scope/order partially superseded on 2026-09-02 by
> [ADR 0050](0050-recorded-observatory-first.md): recorded calculation and cached
> review come first. Live-stream contracts and historical measurements below
> remain intact; remote realtime is no longer the Observatory release gate.
Date: 2026-09-01; updated 2026-09-02 19:42 MSK. Status: stage-1 engineering
baseline complete; stage-2 has full graph container-network proof plus authenticated
cross-host grant delivery and bounded clock intervals. Continuous clock readiness is
not yet connected to full-graph freshness. Cross-host full-graph qualification,
native-host GPU inventory,
standalone image and product cutover remain open. This is not physical-live acceptance.
Execution order remains the four stages in
[the ExecPlan](../OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
## Decision and owner intent
Observatory LABs test the real-time behavior of a complete perception profile,
not just the quality of an offline export. A recording replaces the physical
source, preserving original release timing at 1×. It must not enable a different
batch implementation. The first profile contains DDRNet-39 GOOSE, RF-DETR native,
online LiDAR geometry/ranges, temporal/motion, CPU TRAVEL TGS/costmap and advisory
policy. One dedicated RTX 4090 owns one active full profile.
The first rural prototype accepts existing coarse `hard_surface`, including
asphalt, sidewalk, bikeway and cobble. Existing `static_obstacle`/`static.unknown`
is sufficient. No new bollard classifier, extra segmenter, road/sidewalk taxonomy
or annotation campaign precedes this prototype. Other material rules remain an
explicit effective mission configuration; rural context alone is not permission
for all vegetation or soil. Geometry/unknown/freshness overrides material allowance.
EoMT is preserved as a separate future profile. It is not loaded with DDRNet.
RF-DETR and DDRNet are two required components of the same first profile: one
supervisor and serialized GPU scheduling, not competing profile jobs.
## CURRENT → TARGET / ownership
| Boundary | Current evidence | Decision / stage |
| --- | --- | --- |
| Portable definitions | `portable_run_definitions.py` owns pins, resources and admission | KEEP identity/sealing; EXTEND stream capabilities and qualification in stage 3 |
| Source requirements | Recorded-only requirements include seekability, one media epoch and init digest | REPLACE as live admission criteria with channel/format/calibration/clock requirements; keep init digest as per-stream integrity |
| Job queue | `recorded_jobs.py` has durable claims but global single-active-job scope | KEEP durable queue; EXTEND worker-scoped fencing, not unbounded GPU concurrency |
| Worker agent | Blocking `execute(job)` and final publication | EXTEND incremental lifecycle and cancellation; no per-LAB agent |
| Runtime | `portable_worker_runtime.py:735` materializes before execution | NEW stream runtime in stage 2; never call this materializer in the real-time path |
| Source transport | Whole camera archive download/hash/extraction before use | REPLACE startup barrier with bounded metadata, chunks and incremental integrity |
| Installed LAB V1 | prepare → complete EoMT → complete DDRNet → assemble | Legacy evidence only; not the new profile and not real-time qualified |
| Camera/source replay | `recorded_source.py` has useful pacing and bounded prefetch, but source-specific timeline and paths | REUSE mechanisms; generic unknown-duration source adapter, no route-sized prefetch |
| Existing live ingress | `LivePerceptionIngress`, `LiveIngressEvent.wire_bytes`, `LiveSensorSynchronizer` already provide raw-first modality queues, hashes, epochs and bounded binding | REUSE, not a second acquisition system; extend worker/profile fencing and aggregate byte limits; map existing envelopes to the selected transport |
| Local surface | `RecordedGeometryStore` reads prepared NPZ | REUSE `K1LocalSurfaceShadowEstimator.process` and `lidar_local_surface_geometry`; feed current points/pose |
| Geometry/ranges | Current point association, exclusive ownership and geometry-only clusters exist | REUSE algorithms; remove recorded-store dependency and expose estimator/frame identity |
| Temporal/motion | `BoundedSpatialTemporalProvider`, `ClassIndependentMotionEstimator` | REUSE with current-run inputs and reset on epoch/lease change |
| TGS | Core algorithm reusable; old runners read a full schedule, some require exactly 4489 frames | REUSE TGS parameters/core; REPLACE schedule/file CLI with incremental input |
| Fusion | M49 TGS can be a neighboring shadow with `tgs_modifies_reference_graph_state=false` | TGS and semantics must influence the current advisory scene, not only viewer composition |
| Publication | Verified immutable result/publication/recovery exist | KEEP asynchronous final sealing; live results do not await it |
| Frontend | Setup/job/result surfaces exist, historical M49 viewer links other results | EXTEND common renderer capabilities with current-run layer references and freshness in stage 3 |
| Recording archive | Source identities and raw evidence must be retained | KEEP; create metadata/index/integrity during acquisition or incrementally, not via preflight full scan |
No changes to the active registry, old queue, source endpoint, UI or canonical
local server are made by this ADR or the stage-1 validators.
## Versioned handshake and observations
### Current host observation adapter (2026-09-02 16:52, `61cbdb3`)
`WorkerControlChannel` / `WorkerControlPump` supply the common readiness monitor
with actual GPU name/driver/clocks and Docker image/configured resource limits /
GPU-access inventory from `collect_worker_host.ps1` on the trusted Windows host.
Only the host collector executes fixed read-only Docker/NVIDIA queries. The AI
container has no Docker socket, host setters or network access. Request and reply
directories are separate; the reply mount must be read-only and cannot also be
reachable through the writable output mount. This local filesystem authority is
NOT authentication for external sensor/GCS traffic.
One pending random nonce, activation digest, sequence and a16KiB reply bound
prevent history/queue growth. The collector reads facts AFTER receiving that
challenge. Observation time is the local Linux request-start monotonic value,
not the reply receipt or Windows timestamp. Delayed replies stay old; missing,
malformed or different-nonce replies cannot refresh readiness. A known matching
reply with changed container/image/owner or competitors fences execution.
Owner, effective config and warmup remain local-controller facts, never accepted
from the sensor stream or inferred from utilization. The pump does not renew the
lease; its I/O runs outside lifecycle locks and the heartbeat thread.
Scope is part of the sealed effective configuration: `docker-gpu-access` means
observed Docker GPU-capable containers, NOT all native host/WSL GPU processes.
It cannot satisfy an envelope requiring `host-compute`. Windows reports PID4 /
Insufficient Permissions; native host coverage stays explicitly unproved.
Incomplete inventory or unavailable GPU telemetry cannot be waived even by
`labelled-experiment`; measured low clocks can be logged as overload conditions.
Bootstrap acquires the local lease and attaches readiness before spawning any
model. Warmup telemetry lag blocks further startup without destroying already
loaded children or invalidating an input epoch that has not started. Active
stream lag pauses input, keeps models and local lease, then requires the existing
fresh-keyframe/sensor resynchronization and temporal reset. The bounded pilot's
outer watchdog remains; this is not yet a durable production controller.
Measured normal graph:128/128 exact raw outputs,50 real observations, p95/p99
91.157/97.946ms. Delaying a host reply2200ms:102 completed,26 intentional omissions,
two expired requests, same four resident PIDs/lease; resume seq50, old temporal
stores/cells absent, first scene fresh. Result receipt gap2.719s is NOT latency
from the Windows fault start (clock domains are not subtracted). Both trials
use the temporary authorized stock-clock envelope, not general qualification.
404 focused tests; evidence SHA-256
`aac125bd1b182d164c95d18089793fa54bf9797478bce1afe3de2a1d9093f6ef`.
The first warmup abort and Windows atomic-replacement correction are retained
in the experiment journal. Historical readiness notes below retain their dates.
`missioncore.perception-stream-start/v1` is represented by `StreamStart` in
`src/k1link/perception/realtime_contract.py`. It contains run/source/worker/epoch,
lease generation, profile/image/effective-config/calibration identities, clock
domain, input mode and channel declarations. The first profile requires camera,
point-cloud and pose. Temporary absence of a sample is an explicit modality
outcome, not removal of a required capability.
It deliberately has no total source bytes, frame count, total duration, whole
archive, command, environment, complete member inventory or required EOF. The
initial metadata budget is 64 KiB. Large constant model assets and FOV masks
belong inside the image; calibration references resolve to bounded metadata.
The stage-2 wire mapping must carry:
- Run/epoch/lease binding on every message; source/channel sequence, capture
timestamp and clock domain; independently verifiable payload length/hash.
- Camera codec initialization and then incremental encoded access units, or
bounded fragments thereof. Native decoded representation is BGR8 800×600 KB4.
Do not send an entire fMP4 epoch as one observation or turn all frames into PNGs.
- Current vendor-registered map point increments with point IDs, coordinate
frame, units, scalar layout and retained raw attributes; map→sensor pose and
calibration identity. The adapter owns vendor decoding; the profile is not a
new K1 protocol implementation and cannot command the scanner.
- Explicit unavailable/gap/end/cancel messages. End is a termination event,
never a prerequisite for first inference. Final recording inventory/hash can
be sealed after consumption; per-chunk integrity is verified before use.
- Integer nanoseconds use protobuf integer fields; any JSON projection uses
decimal strings for 64-bit times rather than lossy JavaScript numbers.
Maximum payload fragment: 1 MiB; total application inflight bytes: 16 MiB;
pending decoded camera frames: 2. Fragment reassembly is included in that budget,
with a deadline and incomplete-chunk accounting. Decoded image/tensor, cloud,
costmap and archive sinks need their own measured bounds inside the RSS budget.
Transport flow control alone is not a memory bound or a freshness policy.
gRPC bidirectional streaming is the first transport candidate, not an already
measured implementation. HTTP control-plane operations remain. The browser does
not connect directly to model RPCs. One authenticated data-plane endpoint feeds
the supervised profile; model processes have no arbitrary external network.
Protocol/codec selection must be measured on actual payload sizes in stage 2;
changing an archive POST to gRPC without changing execution is not acceptance.
Existing `missioncore.live-perception-wire/v1` and result wire v2 are migration
inputs: reuse their semantics and raw-first producer hooks. Their modality count
caps alone permit more than the proposed 16 MiB aggregate inflight budget; an
explicit byte bound is required. No duplicate new scanner feed is introduced.
## Time and lifecycle
Stage-2 full-graph recovery increment, 2026-09-02 15:56 (`70927ea`):
`--recover-input` connects lifecycle to PilotController, RecordingSource,
BinaryGraphBridge, GPU/CPU calls and result binding. Source clock mapping is
fixed before ingress; fault windows never restart/slow it. Bounded diagnostic
`--input-gap sequence:milliseconds` is explicit in effective config. Reconnect
polls quiescence without queuing old events; complete accounting distinguishes
skipped from processed input. Historical one-shot defaults remain available.
The receiver waits for an independently decodable fragment and fresh causal
sensor pair. Decoder-local index resets; original source sequence/timestamps
do not. Actual decoder keyframe validation remains. The graph recreates CPU
surface/geometry/temporal/motion/rolling/threat providers using the same configs
and clears cached segmentation. RF-DETR backend, DDRNet/decoder/TGS children
remain. TGS C++ creates an estimator per request; costmap arrays are per-call,
so no native temporal reset command or restart is needed. Rolling input window
resets independently. GPU/CPU/result gates bind the input epoch, not activation.
Obsolete results release their input rather than terminating the profile;
review removes a lingering collector tuple reference to prior input too.
Two normal/faulted full-profile pairs pass on Worker006. Normal raw parity is
128/128; final p95/p99 85.775/89.385ms. Faulted runs preserve four PIDs and clear
nonempty temporal stores, resuming on seq30/110 after gaps at seq16/72. All
post-resume histories and observed costmap timestamps belong to the new epoch;
first scenes have fresh six-layer evidence and new DDRNet inference. No result
was published during pauses. GPU calls were idle at injection; mid-compute
interruption is covered by focused tests, not new real-GPU cancellation proof.
Recovery availability differs from accepted-frame processing latency: 150ms gap
takes1.476s to next complete scene; 2.2s gap takes3.857s, including waiting for
keyframe/sensor alignment. Faulted output is76 processed +52 skipped cameras,
zero unaccounted; none is replayed. Its p95/p99 83.163/89.729ms describes accepted
frames only. Timings use temporary stock clocks2610/10251MHz; whole-route
availability, auto-clock and network timing remain unqualified. Real collector,
authentication, cross-host clock mapping, standalone and onboard safety are open.
383 tests pass. Evidence330 artifacts,236 Worker snapshot hashes/114 current
files verified; `.runtime/perception-stage2-resume-worker-20260902T1545MSK/manifest.json`,
SHA-256 `a9449fa4c012e30ce8a06025790c0e110eeebab7c1859fdebf56d5e785961ea3`.
Initial preflight abort (512MiB threshold below known534MiB idle baseline) is
preserved; no models/clocks ran in it. Auto clocks/four scoped services restored,
empty/retired fixture volumes and containers removed; legacy restart loops not
fixed. The earlier CPU-only increment below is historical, not latest status.
No full-source transfer, new model, UI or actuator path is introduced.
Owner clarification and stage-2 continuity increment, 2026-09-02 15:30
(`c31c46c`): temporary input/network loss must not unload the resident models.
Unknown/stale GPU telemetry is not proof of a competing owner. The desired
response is waiting and resynchronization; verified owner conflict, actual
local lease loss, child/model/decoder failure and explicit cancellation remain
terminal. This supersedes the blanket terminal-readiness TARGET below; previous
terminal fault probes are retained as historical evidence of their opt-in mode.
Two lifetimes are explicit. The resident activation keeps its original
`StreamStart`, local Worker lease and lease generation. Reconnecting input gets
a new `StreamStart.epoch_id`, bound by existing wire v2 on every packet/result;
acquisition session/generation remains independently checked. A network/GCS
heartbeat is not the local ownership heartbeat. The trusted resident controller
continues renewing its own lease while the input waits. No second profile may
claim the GPU during that wait, and telemetry alone cannot renew/release a lease.
`StreamingLifecycle(recover_input=True, source_clock_ns=...)` requires a trusted
source-to-local-clock mapping and, if monitoring is configured, a
`WorkerReadinessMonitor(recoverable=True)`. Both choices belong in the sealed
effective config. Their defaults preserve the historical one-shot diagnostic;
the current full PilotController/BinaryGraphBridge has not been switched. This
compatibility default is not the final product's desired outage policy.
The input phases are active, waiting and synchronizing. Resident GraphState may
remain RUNNING while input is waiting; that means the processes remain loaded,
not that results or actuation are ready. EOF without End, an IPC read deadline,
connection loss or a declared/source-sequence gap pauses the enabled adapter.
Pending input is discarded; active borrowed buffers remain owned until their
callback drains, and its old result cannot pass the epoch/phase gate. A new
connection waits for the old receiver, active callbacks and mailbox-owned work
to drain. No unbounded queue, replay catch-up, future evidence or new model
process is introduced. Explicit End terminates the source; Cancel remains a
real cancellation. Malformed data and actual decoder failure are not relabelled
as a recoverable transport outage. External network error handling remains an
unqualified adapter boundary, not proved by the IPC socket tests.
Resume requires a new init and a successfully decoded random-access H.264 frame,
fresh pose/points from the new epoch, and a temporal-state reset. The measured
decoder resets codec references/DTS inside the same child process; sensor-window
reset retains its bounded allocation but clears rolling state. `ResumeEvidence`
must be derived by the trusted profile adapter, not accepted as a client Boolean.
Every timestamp is at/after reconnect cutoff and no later than the camera;
camera age ≤250 ms, pose/newest-points age ≤100 ms, oldest-points age ≤250 ms and
pose/points skew ≤100 ms. The lifecycle rechecks evidence after reset and cannot
resume if another pause arrived meanwhile. Full graph tracking/motion/TGS/costmap
reset and CPU-consumer old-result disposal are required next, not yet proved by
the diagnostic callback. Replay source time must keep advancing at 1× through
the outage; the CPU fixture proves this only with a synthetic tick source.
Recoverable readiness maps expired/missing inventory, incomplete warmup and
strict-envelope performance unavailability to waiting, not process destruction.
Fresh facts permit beginning resynchronization, not automatic reuse of the old
epoch. Confirmed wrong owner/generation/worker/image/config, known competing GPU
clients and invalid trusted clock/snapshot ordering still fence the activation.
Labelled performance experiments keep their recorded violations and remain
unqualified. The runtime does not manipulate GPU clocks, other services or a
vehicle. Eventual onboard stop/hold, stale-command rejection and motion-resume
policy are independent future safety gates; restored inference is not permission
to move, and GCS heartbeat is not proof of fresh perception.
375 focused tests pass, including repeated reconnects, stale input/results,
pause during reset, telemetry recovery and retained hard-fault fencing. Worker
CPU-only proof: three epochs, 150-ms and 2.2-s outages, unchanged decoder/sentinel
PIDs, identical BGR 3/3, peak input 5,275,461 bytes and complete cleanup. It uses
one real H.264 fragment and synthetic timestamps/pose/points; the sentinel is not
a GPU model, and temporal reset is a diagnostic fixture. No full-graph recovery,
new GPU latency, real inventory collector, external network or standalone-image
qualification follows. Evidence: 85 artifacts, 73 verified code hashes;
`.runtime/perception-stage2-continuity-worker-20260902T1518MSK/manifest.json`,
SHA-256 `fbd327d129c88caa13b7183b1ae2c4767e877a6f5472c0f430875cfd7401ed46`.
Stage-2 controller-readiness increment, 2026-09-02 14:52 (`92625bf`):
`WorkerReadinessMonitor` consumes existing `WorkerOperatingEnvelope` and
`WorkerSnapshot` under the same lifecycle lock as admission. No host I/O occurs
under this lock. The trusted controller, not a sensor payload, provides the
snapshot in a locally comparable Worker monotonic clock domain and seals the
mode/envelope into its effective configuration. Snapshot arrival is not a lease
renewal, and renewing a lease does not refresh inventory. Identity, exclusive
ownership, known inventory and freshness are mandatory in both modes.
`strict-envelope` rejects post-warmup performance conditions outside the
preregistered envelope. `labelled-experiment` permits these conditions, including
unknown performance facts, but records violations and never grants real-time
qualification. It cannot waive unknown/competing GPU clients, stale inventory,
changed worker/image/config/owner or missing warmup. Low clocks during STARTING
do not prohibit model warmup; transition to RUNNING checks warmup explicitly.
Freshness is checked before spawn/admission/compute/result/heartbeat and in the
50-ms watchdog. A late telemetry refresh cannot resurrect expired authority;
old StreamStart updates are rejected without stopping the current owner.
Readiness loss enters STOPPING and stops only owned child groups. A new owner
still requires verified child/thread/callback/input release and a new generation
and epoch. The monitor cannot clear an active durable lease, force recovery,
start a replacement model, change host clocks/quotas or stop other services.
It retains one snapshot and bounded reason sets, not unlimited telemetry history.
The existing diagnostic PilotController has no continuous real collector yet;
unconfigured runtimes expose `worker_readiness.enabled=false` and cannot claim
monitored readiness. No authentication or host-clock translation is implemented
by this in-process method. External inventory collection, control-channel
delivery, production admission/recovery and standalone packaging remain stage 2.
349 focused tests and four sequential Worker Linux CPU-only scenarios pass.
The latter use synthetic GPU facts, zero GPU devices/models and real child
processes: stop 10.676–63.994 ms, zero residual input, released fixture generation
4. These are not new GPU graph timings. Evidence manifest SHA-256:
`4588995d763fe82d896b42689e981f1d00feedaf9d6aab29f4579f771d03da3a`.
Stage-2 raw IPC increment, 2026-09-02 (`da60fef`, `a937400`, `79ce55d`):
`streaming_wire.py` maps existing `LiveIngressEvent` fields into
`missioncore.live-perception-wire/v2`. The v1 producer hooks, class and legacy
serialization remain unchanged. A bounded length-prefixed JSON header carries
decimal uint64 projections; payloads remain binary. This is a controller-supplied
IPC socket candidate, not a selection/replacement of the intended gRPC network
transport. No listener, arbitrary endpoint, scanner feed or command path is added.
Open compares the entire expected StreamStart and acquisition session binding;
every fragment/control carries a digest of that full binding. A hash is integrity,
not authentication. Fragment size is at most 1 MiB, event caps reuse the existing
1 MiB camera / 2 MiB LiDAR/pose limits. Headers are capped at 64 KiB. One
observation assembles at a time with exact offsets, fragment and whole-event
SHA-256, per-channel source identity/sequence/clock checks. End, Cancel and
gap/unavailable notices are explicit. EOF without End, timeout, malformed,
cross-binding or incomplete input fails the bound stream; an unbound wrong Open
is rejected without stopping the current owner. Camera frames require init;
camera gap invalidates init. Actual codec/keyframe/IDR validation is NOT yet here.
`StreamingSender` owns no queue and fails on its bounded write deadline rather
than stretching replay time. `StreamingIngress` is a lifecycle-tracked thread;
idle reads poll the lease and cannot keep accepting an expired owner. Raw/header
and reassembly reservations charge the SAME StreamMailbox 16 MiB input budget as
queued/active bundles. They survive cancellation until the borrowing callback
returns and buffers are dropped. Reservations are count-bounded too. Trusted
decoder adapters must reserve scratch BEFORE allocation and must not retain raw
callback bytes outside that ownership contract. Python metadata object overhead,
socket/kernel buffers, source adapter memory and model tensors additionally need
RSS/OS resource bounds; this application counter alone is not a total-RSS claim.
Worker evidence uses a separate source process, original camera init/fMP4 segments
and existing normalized point/pose increments paced at original 1x times. All
103 events (32 camera + init + 34 LiDAR + 36 pose) match exact bytes/metadata, each
data modality reaches the hash-only consumer before End, and incomplete EOF
delivers no observation. Normal peak input is 553,991 bytes. This validates raw
IPC, NOT decoder output, the full graph on this ingress, model performance,
authenticated network behavior or a standalone image. The next adapter must
feed incremental decode and causal sensors into the same graph without using
the old concatenated camera file or precomputed geometry. Product source queues,
backend routes and the browser are not switched by this increment.
Stage-2 decoder increment, 2026-09-02 (`350366b`, `f102712`):
`media_fragments.py` now owns the existing bounded ISO-BMFF timing parser.
Archive inspection delegates to it and preserves `SessionIntegrityError` at its
boundary. `perception.streaming_decoder.FragmentDecoder` imports neither the
session store nor its materializer. It receives only bounded init/fragment bytes;
source filenames, recording length and EOF are not decoder inputs.
The measured subset is H.264, one 800×600 frame per fragment, one video track,
explicit moof-relative sample size/offset and no composition offset. The first
fragment must be random-access and the decoded frame must be keyframe; DTS must
remain contiguous. One persistent codec context, one slice thread, no flush or
future packet: each call must yield exactly one frame with the same PTS/raster.
Reordered/B-frame streams, unknown layouts, discontinuities and invalid output
fail closed. Other valid camera formats require explicit qualification, not a
hidden decoder fallback. These are current profile bounds, not a new K1 protocol.
PyAV 18.0.0 is pinned to wheel SHA-256
`ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017`.
It is installed only into a private experiment target and mounted read-only;
standalone packaging must include it. The public
[PyAV codec documentation](https://pyav.basswood.io/docs/stable/api/codec.html)
describes persistent packet decoding and thread modes; compatibility claims here
come from the actual 18.0.0 Worker probe, not from documentation version alone.
Sequential CPU-only 128-frame 1× comparisons produced exact BGR parity against
the previous pinned OpenCV decoder. Direct validated sample extraction replaces
opening/demuxing init+fragment on every frame; NumPy loads before readiness.
Measured new-decoder p95 improves 19.927→11.156 ms, p99 20.920→18.675 ms.
This is one bounded component comparison, not a full-profile speedup claim.
The reference full-MP4 path exists only in the separate pixel-comparison process;
neither new decoder path mounts/reads that MP4.
Combined IPC/decoder probe accepts 103 raw events and yields 32 exact BGR frames.
31 frames arrive before the source End marker; the final frame drains before
the receiver handles End. First decoded callback is 3.071 s before the marker.
Input reservations peak at 5,727,751 bytes including conservative decoder scratch
and init/extradata retention; normal and truncated-input cleanup return to zero.
Native codec/DPB allocations are bounded separately by the CPU-only container;
application payload counters alone do not constrain native allocation or execution
time. Before full-graph integration, native decoding must run in a supervised CPU
child with bounded RPC and explicit decoded-bundle ownership transfer.
Point/pose are preserved raw in this probe, not causally fused into a scene;
calibration identity is explicitly non-scene/synthetic. No full graph, GPU model,
network, physical live feed, vehicle control or product cutover is qualified.
Next: preserve the existing causal sensor cut at camera release, connect decoded
bundles to the same full graph, then repeat full-profile parity/timing. The last
full-profile p95/p99 remains 124.98/136.12 ms, not this decoder's timing.
Stage-2 full binary graph increment, 2026-09-02 13:55 MSK (`221e429`):
the preceding decoder-only boundary is now connected to the existing full graph.
The recording adapter alone reads bounded current fMP4 fragments and normalized
map point/pose rows. It emits original timestamps at 1× through the existing
binary socket ABI; the consumer and decoder receive neither source paths nor
recording length. The diagnostic container still mounts source/code/weights and
a private pinned PyAV target: this is not standalone packaging or a network test.
Native decoding runs in a lifecycle-owned CPU child with a 1-GiB address-space
limit. POSIX pipe RPC reserves header scratch, accepts ≤1 MiB input and writes
exactly 1,440,000 BGR bytes into the caller's pre-reserved buffer. A 250-ms request
deadline, malformed reply or lost lease terminates the stream; no implicit retry
across predictive codec state. Process/native memory remains under container
limits, separately from the 16-MiB application payload budget.
`streaming_sensors` owns the existing causal selection rules. Its rolling and
fresh caches are each bounded by 64,000 points/64 increments; camera arrival
freezes the original-time cut before decode, never selecting future pose/points.
The legacy pilot delegates to the same selection rules. A separate CPU oracle
compares the old producer's complete points/rolling/times/pose/lineage bundles.
`admit_reserved` transfers the decoded allocation atomically to the common
mailbox; rejection retains caller ownership until buffers are discarded.
The global two-pending limit and active-input protection remain unchanged.
Worker evidence: 384/384 input events, 128/128 full graph outputs, no drops,
126 outputs before source End; first result 583 ms. BGR, masks, proposals,
tracks, threats, material and raw TGS costmap match the reference on 128/128.
Two `metric_geometry.range_m` values differ by 1.3877787807814457e-17 m
(seq13/observation3 and seq29/observation2): **strict raw parity remains false**.
No rounding/tolerance was added; the numerical cause remains unreproduced.
The geometry distance-estimator definition and all thresholds are unchanged.
Full p95/p99 161.974/191.806 ms FAIL the unchanged 125-ms target. Available
camera/sensor pairs remain 76/128; 74 scenes are fresh at receipt, with seq106/107
additionally expiring. Auto GPU memory clocks fall from 10,251 to 405–810 MHz;
this correlates with longer DDRNet/queue times, but is not a controlled transport
A/B. Peak input 8,883,895 bytes; remaining input/children/lease zero after stop.
VRAM peak 2,367 MiB is not GPU utilization or a compute-capacity percentage.
Normal 32-frame CPU oracles and a synthetic hung-decoder cleanup probe pass.
The full GPU sample preserves its exact measured code snapshot. Subsequent
review bounds finite-check scratch, closes sockets/cache after constructor or
pre-start failure, and removes CPU-harness heartbeat shutdown noise; those three
files pass a separate Worker CPU oracle, not a second GPU performance sample.
271 focused tests, Ruff/format and five-module mypy pass. Four temporarily stopped
Mission Core services are restored; Ollama/Frigate remain exited/restart=no.
Manifest: 161 artifacts, SHA-256
`e224de4e9f2c5fd419fe0fb0b9be6e01ec267628568e104795ae211ec803bce4`,
`.runtime/perception-stage2-binary-graph-worker-20260902T1335MSK/manifest.json`.
Next: explain numerical reproducibility, measure/tune remaining pipeline tails,
then complete controller/network/standalone boundaries within stage 2.
No quality, physical-live, vehicle-control or real-time qualification is granted.
Stage-2 numeric/IPC increment, 2026-09-02 14:22 MSK (`380b6ea`, `f39f1ff`):
the two preceding raw range differences are resolved, not tolerated. A bounded
CPU-only probe on Worker NumPy 1.26.4 reproduces both exact deviations using
identical quaternion values at addresses 8 mod 16. The wire quaternion starts at
byte 24 of a combined pose buffer; its norm changes by one ULP on that layout.
Offsets 0/16/32/48 restore reference norm/rotation/depth, while 8/24/40/56 reproduce
the deviations. The adapter now owns an immutable 32-byte quaternion copy and
checks 16-byte alignment. Input bytes, projection algorithm and tolerances are
unchanged. Other CPU/NumPy combinations still require numerical qualification.
Synchronous local IPC now borrows contiguous image buffers. Decoder and parent
no longer call BGR `tobytes()` for these handoffs; the sender writes framing and
the borrowed body separately instead of concatenating a full message copy.
Partial writes loop with explicit progress validation; noncontiguous buffers
fail before output. The owner retains the image until send completes. This removes
four application-level BGR copies across decoder→parent→DDRNet, not kernel copies,
network bytes, or all intermediate allocations. Framing and lease/deadline checks
are unchanged; a hung-decoder probe still fails and releases all owned resources.
Preregistered sequential A–B–B–A at authorized temporary stock clock locks:
all samples observed SM2610/memory10251 MHz, unchanged 8 CPU/8 GiB/power limit.
Every run completes 128/128 without drops. Control p95/p99 84.702/92.961 and
83.930/91.759 ms; candidate 85.113/91.219 and 82.425/90.530 ms. Both candidates
match reference BGR, masks, objects, geometry/ranges, tracks, threats, materials,
lineage/binding and raw costmap exactly on 128/128. Baselines still reproduce the
two old float deviations. Median decoder RPC-minus-decode falls 1.642–1.825 to
1.100–1.118 ms; DDRNet RPC-minus-component 2.109–2.217 to 1.558–1.613 ms.
This is a modest IPC improvement; full p95 does not show an unambiguous win.
The previous auto-clock 191.806-ms p99 cannot be compared as a copy-only baseline.
Local 125-ms timing passes at this fixed operating envelope, but only 76/128
scenes are fresh because the same 52 input sensor gaps remain. No overall or
auto-clock/network/physical-live qualification is granted. Auto clocks and four
Mission Core services are restored; Ollama/Frigate remain disabled. 279 focused
tests pass. Manifest: 305 artifacts, SHA-256
`9c72a93833a1dda51c4787dca13fb9791a1bf2e31c763b7740e1b21406703707`,
`.runtime/perception-stage2-ipc-worker-20260902T1410MSK/manifest.json`.
Next implementation boundary is the controller/envelope/network integration and
standalone package. Host clock management is not granted implicitly to that controller.
Open → validate bounded metadata/claim → warm models → Ready → start the replay
clock → observations and incremental scenes → stop/drain bounded state → close
and asynchronously seal the immutable receipt. A live source already producing
data does not queue its entire warmup history: readiness begins at a current
decodable keyframe, with the skipped interval recorded.
Replay release uses original source intervals, not a convenient fixed 12 Hz and
not the processing speed. A slow consumer must not stretch source time to make
inference look real-time. Overload may drop bounded work to preserve liveness,
but the dropped observations remain in the ledger and fail the strict initial
performance target. Initial baseline is every camera frame, stride 1. Choosing
multirate later changes the explicit profile contract; a retained mask is not a
new inference and cannot receive a fresh capture timestamp.
UTC identifies the run; monotonic clocks measure local durations. Cross-host
age requires a clock mapping and its uncertainty. No direct subtraction of
unrelated host monotonic clocks. The common source timeline is separate from
worker wall-clock time. Causality is bounded by the released-observation
watermark at decision time, not by whatever later samples exist in the file.
Each layer retains its own channel/sequence/time; camera anchors and pose/cloud
association times remain separately visible. Future file look-ahead and offline
nearest-pose lookup across unreleased data are forbidden.
Lease expiry, cancellation and epoch changes fence the previous producer. No
new profile may take the GPU until old processes and work have stopped. A
reconnect creates a new epoch, discards stale backlog, resets temporal/rolling
state and reacquires codec initialization/keyframe. It never resends a stale
motor action; this profile has no motor actions at all.
## Outputs and interpretation
Every scene accounts for segmentation, objects, geometry, motion, costmap and
policy exactly once, with current/held/stale/unavailable state and current-run
payload identity. Existing source/object/obstacle/temporal/map/threat contracts
remain the domain vocabulary. `LayerEvidence` adds freshness/completeness checks,
not a second semantic ontology. In stage 2, layer manifests also carry their full
input reference sets; a single anchor does not replace camera/cloud/pose lineage.
Stage-1 implementation update, 2026-09-02 (`097e450`):
`missioncore.perception-scene-freshness/v1` in `realtime_scene.py` extends
`LayerEvidence` with oldest-required-input time and checks the six-layer dependency
graph. A derived result cannot refresh its input age. Decimal int64 JSON strings
preserve timestamps/sequence exactly; transport-neutral Python objects use integers.
The pilot validates payload hashes and assesses freshness again at receipt, and
must reassess at later use. Missing/stale output remains inspectable but cannot
retain permissive advisory policy. This envelope is NOT a replacement for
StreamStart identities, complete input lineage or controller lease fencing.
The stage-1 pilot propagated the oldest last-seen permissive TGS cell into the
costmap/policy age and suppressed the entire scene on expiry. Stage-2 increment
`1f8101e` adds `missioncore.costmap-cell-freshness/v1`: bounded (8192 cells),
index-aligned original support timestamps, decimal int64 strings/null, included
in the costmap payload digest together with the guard mode. Unobserved support
is never assigned a timestamp. The pilot now defaults to `per-cell`; the old
`whole-scene` mode remains an explicit comparison control, not a product cutover.
Both publication and receipt remove permission from expired ground cells and
mark them rejected/NO_GO. Occupied cells retain prohibition. The remaining
permissions propagate their original support age AND the mandatory segmentation,
geometry and motion dependencies. A missing/stale mandatory layer still blocks
the whole policy. A derived consumer view has new costmap/policy hashes while
preserving source identity/timestamps; the original published bytes stay immutable.
Reassessment cannot restore a suppressed action or move the observer clock back.
The enclosing StreamStart must bind the grid/profile/epoch/clock; this descriptor
alone does not establish those identities or authenticate a remote producer.
In the measured 128-frame window, raw model/geometry/motion/TGS/material outputs
remain identical to pinned PyTorch. Effective costmap states and permissions
change deliberately on expiry. A fresh six-layer envelope can still contain
rejected/unknown cells; it is not a claim of full free-space coverage, semantic
correctness, vehicle clearance or physical safety. Recheck again at actual use.
`bcacb02` promotes the proven mailbox and serial GPU stage to common perception
modules, with the pilot importing compatibility aliases rather than owning
another scheduler. Pending ingress + completed GPU outputs share two slots;
active inputs share 16 MiB and are released by their owning stage. Cancellation
discards pending work, a timed-out callback retains ownership, and CPU-owned
input is not freed by GPU shutdown. Diagnostic drop history is capped at 256
entries with exact per-reason totals; it is not the durable terminal ledger.
These primitives are NOT the controller supervisor, a cross-process GPU lease,
StreamStart fencing or the binary data plane. Those stage-2 boundaries remain open.
Stage-2 lifecycle increment, 2026-09-02 (`6acf468`, `ac69e3b`):
`StreamingLifecycle` now supplies the subprocess-backed profile supervisor using
the existing `GraphState` and full `StreamStart` identity. `WorkerLease` uses a
stable POSIX file lock on one controller-selected, private directory per Worker,
plus an atomic/fsynced ownership record. All managed profile containers must use
that SAME directory/volume. Neither the directory nor child commands come from
an incoming source/job. This cooperative local fence supplements, not replaces,
the backend claim and the trusted inventory of unmanaged GPU clients.
Open acquires ownership before process spawn/warmup; Ready enables admission.
Renewal cannot resurrect expired ownership. Admission, each GPU/CPU lane and
result publication/receipt validate run/source/worker/epoch/generation and pinned
image/profile/config/calibration/clock identities. A mismatched client is rejected
without cancelling the current owner. Each compute lane remains single-owner.
The watchdog fences an expired owner even without a new frame and terminates
only its dedicated child process groups. Cleanup keeps ownership while callbacks,
tracked threads, pending/active payloads or child groups remain. Clean retirement
persists `released` before unlocking; a higher generation and new epoch may then
activate. A controller crash leaves `active` even when the kernel unlocks, so
the next owner stays quarantined. No force/unverified recovery API exists.
Cross-container contention/clean succession/crash quarantine and full-profile
lease expiry were exercised on Worker 006. The final timed expiry trace had no
receipt at/after the lease deadline, stop requested after 45.74 ms and retirement
after 4109.82 ms. This is bounded evidence, not a hard-real-time scheduler guarantee.
The holder has a 2 s lease and receives local pilot heartbeats every 250 ms;
those settings and the 50 ms watchdog poll are NOT network/vehicle safety limits.
Current integration remains explicitly diagnostic: `pilot_lifecycle.py` acts as
a local controller, legacy GPU services are quiesced by the authorized launcher,
the named volume is an isolated test volume, and the image still has code/model
mounts. Mounted-code hashes remain in the experiment manifest; an image digest
alone cannot seal those overrides. Backend claims/production-wide canonical root,
trusted post-crash resource-release recovery, continuous GPU inventory/envelope
enforcement, network authentication/heartbeat and binary live ingress are NOT
installed or qualified by this increment. Standalone packaging must remove the
developer mounts and retain the same lifecycle boundaries.
`worker_operating_envelope.py` checks a trusted post-warmup snapshot against
preregistered hardware/driver/resource/clock conditions and StreamStart identities,
ownership and client inventory. Unknown or expired facts fail readiness. The
candidate records the previously measured 4090 fixed-clock envelope; a compatible
auto-clock experiment cannot inherit its latency result. The evaluator performs
no I/O, host clock changes, lease acquisition or qualification; collection and
continuous enforcement belong to the common controller/runtime in stage 2.
DDRNet currently crops the center 600×600 to 512×512; outside that ROI is undefined,
not hard_surface/free. RF-DETR accepts the full 800×600 native raster with its
pinned valid-FOV handling. Their coordinates must be related explicitly.
RF-DETR's existing filter emits person/cat/dog, but minimum box area 64 px,
maximum box fraction 0.5, FOV fraction 0.5 and required valid center create known
small-animal/close-large-object edge cases. They are retained and documented,
not silently retuned or presented as proven field quality.
Detected-object range is the median camera-Z of owned current support points;
geometry-only range is nearest Euclidean distance from the sensor. Neither is
automatically clearance from the physical vehicle body. Missing/ambiguous
support produces unavailable range, not zero/infinity. Generic static obstacles
survive absence of a detector class name.
TGS keeps ground/occupied/rejected/unobserved separate. Ground support alone is
not a traversability or actuation decision. Policy can produce an advisory
allowed-candidate/high-cost/blocked/unknown outcome with explanation. No planner,
physical motor command, autonomous driving acceptance or mission configurator
implementation is introduced.
## Package/dependency decision
The candidate manifest is
`config/perception/k1-perception-ddrnet39-rfdetr-tgs-prototype-v1.json`.
It is not installed into the active portable registry and has no invented image
digest. Model/checkpoint/config identities are pinned; source session IDs and old
derived output paths in reference experiments are not inherited requirements.
Select process/environment isolation inside one future image: Python 3.12
supervisor and geometry, RF-DETR TensorRT 11 runtime, existing Python 3.9 / Torch
1.13.1 cu117 / super-gradients 3.2.0 environment, and CPU C++ TGS. Exchange bounded
shared-memory/IPC payload references; one supervisor serializes GPU work. This
avoids a forced dependency upgrade or checkpoint conversion in stage 1. Image
assembly of the final standalone package and the full schedule pilot remain
explicit implementation evidence. A temporary common-base image passed separate
DDRNet, TensorRT, Python 3.12 geometry and C++ TGS execution probes; DDRNet masks
matched on 64 frames. This proves bounded ABI/execution compatibility in that
image, not jointly resident models, the supervisor/IPC or a complete profile.
The probes still mount pinned assets explicitly; they are not standalone proof.
The temporary Triton base lacks Python grpc/protobuf, cv2 and TensorRT bindings;
native trtexec/server are present. The actual supervisor/transport environment
must explicitly include and validate its dependencies. Numeric-library thread
limits are pinned to one after the bounded synthetic local-surface comparison
(mean 198.0 ms default versus 63.2 ms with limits); algorithm thresholds were not
changed. Real-cloud timing, output parity and whole-graph scheduling remain gates.
The initial DDRNet image-only probe failed before inference: the installed image
does not contain the checkpoint at its logical asset path. The bounded baseline
therefore uses an explicit read-only pinned checkpoint and runner mount. This is
valid component measurement, but fails the target's standalone packaging claim.
Super-gradients also needs a writable log directory; a bounded temporary log
mount was required. The new image must declare scratch areas explicitly without
requiring a writable host home, code checkout or implicit weight cache.
## Preregistered engineering acceptance
Owner clarification, 2026-09-02 MSK: bounded laboratory overload is an admissible
experimental outcome. Keep useful profiles that fail the current 4090 target;
do not block reusable runtime/packaging work solely on that performance result.
Functional execution, experimental availability, quality and real-time
qualification are separate dimensions. Qualification belongs to a measured
profile/config/hardware/source/transport combination, not permanently to a
model name. More powerful Worker/onboard placement is a future retest target,
not a claim that existing latency or internal IPC disappears automatically.
The original budgets and failed measurements below remain unchanged. No slowed
source clock, hidden drops, stale-as-current evidence or actuation is authorized.
The manifest freezes an initial engineering candidate: p95/p99 full output age
≤125 ms, required-layer age ≤250 ms, release lag ≤25 ms, first incremental result
≤1 s after source admission, warmup ≤120 s, stop ≤5 s, VRAM ≤22000 MiB, RSS ≤8192
MiB. First/last-window backlog growth ≤25 ms and no capacity drops, failures,
expired selected frames or unaccounted observations. These are prototype targets,
not physical braking/safety limits or claims of achieved performance.
`ReplayMeasurements`/`realtime_failures` reject full-source preload, EOF-only
results, slowed replay, incomplete accounting, hidden missing layers and budget
violations. Declared source gaps must be verified from the input ledger; they
produce explicit degraded scenes and are not counted as fully fresh perception.
At least one fully evaluable scene is required. A component FPS figure cannot
populate this whole-path receipt. A quality comparison or successful export is
not a substitute for the real-time gate.
Every observation receives a terminal ledger outcome. Track ingress/release,
selection, decode, inference, completion, emission, drop/expiry/failure and source
gaps separately. Store startup separately from steady state; record payload bytes,
queue high-water marks, clock error, RSS/VRAM, image/weights/config/source/equipment
identities. Export and UI encoding cannot block inference through an unlimited
queue. A receiver-side render/receipt timestamp, not just Worker completion, ends
the end-to-end interval.
## Evidence and remaining stage-1 work
### Operator telemetry is not runtime authority (2026-09-02)
Reuse System/Worker and the existing Telegraf → MQTT → Timescale route for
operator observability. A profile may export one bounded 8 KiB current snapshot
at 1 Hz to the host collector, independently from controller heartbeat/model
work. Missing or stale observations are unavailable, never fabricated idle,
loaded-model readiness, per-stage timing or real-time qualification. Agent
restart/reconnection and database retention must not block inference. The faster
Worker-local readiness/inventory channel remains separate; MQTT/Timescale/UI
cannot renew a lease or authorize GPU/vehicle activity. This observability
integration does not constitute the external camera/points binary data plane.
See `experiments/perception/PERCEPTION_STREAM_STAGE1_2026-09-01.md` for measured
component results, bounded validation, Worker maintenance and remaining gates.
Neither this ADR nor passing synthetic contract tests marks the new runtime ready.
## Stage 2 increment 12: external gRPC/TLS candidate (2026-09-02)
`streaming_grpc.py` exposes `/missioncore.perception.v1.BinaryStream/Exchange`.
It is an optional `perception-stream` dependency, locked grpcio1.83.1; it is not
auto-started by the backend or installed into the product LAB path. Both sides
require TLS; the client verifies its supplied trust root and server name. The
local controller alone issues a256-bit random single-use capability with30s
admission expiry, bound to the complete StreamStart and acquisition session.
The endpoint retains only its digest. Invalid/duplicate/stale credentials cannot
reserve a stream, stop its owner, renew a lease, launch a model or create an epoch.
One active stream is admitted; reconnect needs a controller-approved new epoch
and, separately, real decoder/causal-sensor resumption proof. Network disconnect
is not explicit End or Cancel. Production grant delivery is still an integration
gate; the CPU probe transfers ephemeral files only through authenticated SSH.
The generic gRPC method uses identity byte serialization, not generated protobuf
messages. Request messages contain≤64KiB pieces of the unchanged MCI2 wire;
StreamingIngress still validates metadata, fragments, hashes and epoch/source
binding. No archive, duration, file path or executable command is sent. Response
MCR3 is only an envelope over existing domain bytes: magic4, uint64 sequence8,
binding SHA25632, payload SHA25632, then1..1MiB payload. It does not invent a new
scene ontology or claim that full-graph scene/layer serialization is connected.
The actual profile's larger outputs require reviewed fragmentation, not a silent
cap increase. Result age validation and the full result receipt ledger remain
with the graph/application adapter, not the transport envelope.
There are two pending latest replies and one writer/reader per direction; no
background source queue/retry or event-loop callback backlog. Python bridge
storage is reserved inside the existing16MiB mailbox budget. Fixed1MiB HTTP/2
lookahead, disabled BDP growth/retries, bounded metadata/messages/IO are explicit;
native gRPC/TLS/socket memory also needs measured RSS, not a claimed byte-perfect
mapping into mailbox accounting. Public/multi-client DoS hardening is not proved
by this private Worker experiment. A250ms event deadline covers the complete
source event. Even final error-status sending is deadline-bound; otherwise an
unread response can delay cleanup. Late trusted callbacks are quarantined, with
no replacement grant or early byte release until they return. Pending reply
drops are counted; they are not equivalent to end-to-end delivery receipts.
Evidence: initial default64KiB lookahead timed out on1,048,595B. Fixed1MiB passed
two16/16 cross-host samples without changing250ms. Final min/median/max RTT
14.360/23.919/120.815ms on the Mac includes SSH/echo overhead, not clock-subtracted
one-way age.2.202s wait retained the same CPU sentinel PID40 and lease; renewals
32→54. Source clock and resume evidence are explicitly synthetic, model count0.
Peak mailbox6,750,559B; Linux RSS57,244KiB.20 Worker transport tests include1MiB
output, a real slow reader and late-callback cleanup;105 focused Mac tests pass
with the two larger probes intentionally skipped. Final cleanup leaves0bytes,
no temporary listener/container/tunnel/private key/grant. No GPU setters or
durable services changed. Frigate/Ollama remain disabled, Telegraf is Running.
Manifest SHA256 `adf5eaf092feaed6721f66e2adaceded0cdbf55754e1f9953f54623bfb52d331`
at `.runtime/perception-stage2-grpc-20260902T1500Z/manifest.json` (16 artifacts).
Design references: [gRPC flow control](https://grpc.io/docs/guides/flow-control/)
warns that a completed write need not mean network delivery;
[authentication](https://grpc.io/docs/guides/auth/) documents TLS credentials;
[channel options](https://github.com/grpc/grpc/blob/master/include/grpc/impl/channel_arg_names.h)
documents the fixed lookahead window. Source and actual behavior were checked;
these references alone are not a performance qualification.
## Stage 2 increment 13: full graph over container networking (2026-09-02)
The same `BinaryGraphInput` decoder and causal sensor cut now feed the full
graph from `NetworkGraphBridge`. Source and receiver execute in a separate
CPU-only container. No camera/index/sensor recording mount exists in the GPU
container. Resident DDRNet/RF-DETR/decoder/TGS children and the local Worker
lease remain owned by the existing controller; remote ingress cannot restart
models or renew the lease.
The return payload carries the existing scene plus the actual uint8 segmentation
plane. Grid coordinates join the costmap digest. Result1MiB, JSON512KiB and plane
512KiB caps are checked before parsing/retention; total framing also fits1MiB.
The receiver verifies raw mask and all layer hashes and independently derives
freshness/policy. Network arrival, integrity-validation completion and complete
consumer-view readiness are recorded separately.
The pilot verifies a common Linux boot and zero monotonic namespace offsets.
Its grant file is trusted local control, read-only to the source; source status
uses a separate mount. It is **not** the production grant delivery or cross-host
clock mapping implementation. A foreign clock cannot be silently subtracted.
Final normal128b:128/128 byte-exact received scenes, raw model/geometry/motion/
costmap parity to the prior reference, no queue drops, one input epoch, no resets.
Consumer-ready p95/p99/max106.150/115.349/126.910ms. One frame exceeds125ms;76/128
scenes have complete six-layer freshness, with52 original missing sensor pairs.
Gap128b:2.2s disconnect,102 completed +26 omissions24–49, fresh keyframe/pair
resume50, exactly one temporal reset, same four PIDs and lease generation1.
Consumer-ready p95/p99/max107.098/110.817/117.317ms; availability gap2.725s.
Post-gap history-dependent outputs are not asserted equal to uninterrupted
history. All230 mask/scene receipts and their derived views were rechecked.
These runs use temporary stock2610/10251MHz clocks,450W,8CPU/8GiB model and
2CPU/1GiB source envelopes. They prove container-network behavior on one
Worker, **not** Mac↔Worker/radio/live latency. Observed result maximum460248B,
tracked peak13,217,791B, residual0. Full-model slow consumer and cross-host
clock/grant integration precede standalone packaging; product cutover remains
closed. Diagnostic startup retry is not a production recovery service.
Source init originally preceded the delayed start by2s and triggered a spurious
idle timeout. The source now connects100ms before release, without relaxing
the timeout. Final normal/gap have zero/one intended recovery. Missing runner,
read-only mountpoint and launcher ExitCode failures are retained as rejected
attempts.146 focused tests pass;2 larger Worker-only tests skipped on Mac.
Evidence236 artifacts at
`.runtime/perception-stage2-network-graph-20260902T1541Z/manifest.json`, SHA256
`d1bc7e26850a2d9050ee1d8f8a5ece2e6b8c53e2e7dba381cbd4445d68a5ec17`.
See [the detailed report](../../experiments/perception/PERCEPTION_STREAM_STAGE2_NETWORK_2026-09-02.md).
Four prior services and automatic clocks restored; transient containers,
networks, lease volumes, keys and grants removed. Ollama/Frigate stay disabled.
Mac8000 and Worker telemetry remain available; no8765 or external deployment.
## Stage 2 increment 14: cross-host clock/control foundation (2026-09-02)
Application-side TLS Poll may deliver only a pending data grant already issued by the
trusted Worker controller. It cannot acquire/renew the local lease, reset/start models,
choose an epoch or cancel. Data admission remains single-use and independently scoped.
Four timestamps bound remote-minus-local offset without equal-path assumptions; recent
intervals are intersected, aged under an explicit rate/error envelope and expire.
Expired/excess/contradictory evidence means unavailable/WAIT, not a wider hidden budget.
CPU-only Mac↔Worker proof delivers both grants and8/8 exact echoes. A2.202s gap retains
the CPU sentinel and lease1, expires the old mapping and resumes with a new input epoch.
Of72 measured observations,60 meet conditional≤5ms; min/median/max uncertainty are
3.883/4.469/27.118ms and readiness changes after warmup. Consequently this increment
does not claim full-graph cross-host freshness and does not cherry-pick a passing sample.
The next full-graph run must consume continuous mapping state and carry uncertainty to
the receiving freshness decision. [Detailed report](../../experiments/perception/PERCEPTION_STREAM_STAGE2_CONTROL_CLOCK_2026-09-02.md).
## Stage 2 increment 15: acknowledged clocks in the full cross-host graph (2026-09-02)
The Worker validates a one-use echoed Poll challenge before accepting responder-side
clock evidence and the immutable source anchor. Clock validity is checked continuously,
including idle periods, input admission and GPU/CPU/publication boundaries. Unknown or
excess uncertainty enters WAIT without releasing resident models/local ownership.
Upper age bounds govern source cutoff; scene and cell freshness include uncertainty.
Each assessment uses one immutable clock snapshot, and EOF acknowledgement precedes
data-channel retirement. Early transport loss after grant admission but before OPEN
must also enter WAIT; malformed unbound data cannot stop the owner.
The final full-graph Mac source/receiver canary is **not qualified**:13 source WAIT skips,
17 synchronization skips,2/32 receipts (frames30–31), both after source-window EOF.
Latency143.881/155.743ms; no hidden losses, no model reload, sampled VRAM2365MiB,
not a24GiB limit.297 local and173 Worker tests PASS;
early disconnect additionally12/12 across six repeats. Source-first clock readiness
and a fixed2s lead do not establish Worker readiness. Next gate separates two-sided
clock warmup from source-anchor activation before1x starts; after start the source
timeline remains immutable through outages. No gate widening, backlog replay,
standalone or Stage3 promotion. [Evidence and rejected attempts](../../experiments/perception/PERCEPTION_STREAM_STAGE2_CROSSHOST_GRAPH_2026-09-02.md).
## Stage 2 increment 16: joint startup before immutable 1x activation (2026-09-02)
Pre-start clock reports carry a null anchor and warm both observation windows.
Version2 replies bind responder bounds and accepted anchor to the issued challenge.
Source checks both current intervals, ages peer evidence through ACK transit, obtains
explicit anchor acceptance and a data grant before startup. A lost ACK cannot create
a different anchor; missing the agreed start fails rather than silently retiming input.
The existing5ms/2s/500ppm/50us budgets and lease/data authority separation remain intact.
The single32-frame GPU canary now admits frame0, but is not realtime-qualified:
8 results,1 input-gap discard,9 WAIT and14 synchronization skips. All55 six-timestamp
exchanges independently reconstruct; running uncertainty5.031–5.667ms causes a real
clock WAIT without an RPC error, GPU telemetry loss or model restart. More samples
inside the same2s horizon would not remove the measured WAIT.241 local/198 Worker
tests PASS,129 measured source hashes match code48835a0. Next investigate control-loop
scheduling/transport before another full-graph qualification, without budget widening.
[Detailed evidence](../../experiments/perception/PERCEPTION_STREAM_STAGE2_JOINT_START_2026-09-02.md).
## Stage 2 increment 17: transport-specific clock readiness (2026-09-02)
Clock-only A/B/A separates route effects from scene processing: SSH/Tailscale48/96,
strictly host-key-pinned SSH over confirmed LAN94/96 (only initial2 unready), return
to Tailscale53/96. All288 exchanges reconstruct; application handler/loop delays do
not alone explain ~100ms request tails. LAN is an observed connection option, not
a new model dependency, universal speedup or permanent product/default route change.
One full LAN canary returns32/32 byte-exact/reference-exact scenes at1x with no WAIT,
drop or reconnect;50 clock exchanges reconstruct,48 after initial warmup remain ready.
All129 prior runtime source files and the model/effective config remain unchanged.
Latency205.864/213.751ms p95/p99 still fails125ms; only12/32 scenes are fully fresh.
Do not equate complete output accounting with usable geometry/policy or autonomous
readiness. Next instrument data-plane stage boundaries before optimizing remaining
tails; no gate widening.245 local/202 Worker tests PASS,131 source hashes matched.
[Detailed route and full-profile evidence](../../experiments/perception/PERCEPTION_STREAM_STAGE2_LAN_ROUTE_2026-09-02.md).
## Stage 2 increment 18: application-edge transport attribution (2026-09-02)
Optional bounded TLS-record witnesses belong to isolated diagnostic tooling, not
the runtime route or model profile. A/B/A keeps the~100ms tail with and without
the witnesses;424 ciphertext records match between edges and192 serial RPCs
match their handlers.13 post-start long calls localize to request(8)/response(5)
inter-edge transit, not local JSON/handler work. Startup without admitted clock
mapping stays unqualified; timestamps are application receipt/drain, not wire ACKs.
Direct TCP connects to the known Worker SSH port reproduce67.9–91.1ms peaks without
gRPC, Docker or SSH forwarding. Both active links are Wi-Fi; native Worker loopback
after startup is0.240–0.299ms. The specific radio/AP/OS/driver mechanism is unproved.
Do not weaken5ms/125ms gates or replace models to address an external transport
symptom. Conversely, this does not exonerate all application costs or qualify
large scene buffers. Continue per-frame queue/serialization/publication attribution;
an available wired comparison should remove both radio legs but is not a prerequisite
for experimental runtime/standalone work. No product transport or GPU workload changed.
82 local/82 Worker tests PASS;142 staged files exact; previous full-graph latency FAIL
stands. [Detailed evidence](../../experiments/perception/PERCEPTION_STREAM_STAGE2_TLS_ATTRIBUTION_2026-09-02.md).
+132
View File
@@ -0,0 +1,132 @@
# ADR 0050 — Recorded Observatory first; portable profiles then onboard
Date: 2026-09-02. Decision accepted by the owner; implementation in progress.
This supersedes ADR 0049's realtime-first product gate and execution order,
not its live-stream integrity, ownership or safety contracts.
## Decision
The primary operator workflow is a complete calculation of a compatible K1
recording with a selected immutable profile, followed by publication and cached
review. Processing may be slower than acquisition. Network latency is evidence,
not a prerequisite for using the laboratory. After this workflow works, compare
Docker configurations; later qualify the selected configuration on actual
onboard hardware. No vehicle or remote-realtime acceptance is claimed now.
Keep the existing source admission, queue, artifact store, publication recovery,
streaming runtime and common viewer. Do not introduce another per-LAB application.
One Worker executes one active profile; alternative profiles remain sequential.
M4.9T5 currently supplies CPU TGS; LAB V1 supplies sequential EoMT/DDRNet, not the
complete future detector/distance/motion/TGS/policy profile.
## Recorded analysis versus realtime rehearsal
Recorded analysis preserves source timestamps and causal ordering, uses bounded
backpressure, and accounts for every required scheduled input/result. Its elapsed
wall time need not match the source clock. Source sensor gaps remain unknown.
Do not disguise live drop/resync as a complete recorded result. Introducing this
execution mode requires an explicit versioned contract, not silently weakening
the existing 1× live freshness, deadline or ownership gates.
Realtime rehearsal remains an independent qualification. Historical network
canaries retain their original FAIL results and thresholds. Neither cached video
playback FPS nor end-to-end upload/export time is a Worker compute benchmark.
Measure preparation, warmup, processing, delivery and publication separately;
onboard estimates are conditional on matching hardware and effective config.
## Result identity and UI
Only compatible, not-yet-calculated current profile versions belong in the
calculation selector. Published results of every version remain below as evidence.
All calculated means no Calculate action, an empty/disabled selector, and Refresh.
Refresh, source selection and cached review must never start inference.
Reuse requires the exact admitted source fingerprint and immutable definition
(including model, image, configuration, adapter and result-contract identities).
An old LAB name, a legacy overlay, or compute success without publication is not
a matching cache. Missing/corrupt artifacts must not suppress a valid retry.
Publication failure retries publication, not computation, while the sealed package
is recoverable. Changed profile versions become new calculations.
## First implementation increment
Portable queue admission now opts into an atomic duplicate-computation guard.
It uses the existing job identity, not a second cache key ontology, and rejects a
different request while the identical job is active, requires reconciliation, or
has a sealed result pending/failed publication. The check and INSERT use one SQLite
transaction. Exact retries retain the original idempotency semantics. A genuinely
failed compute can be retried with a new request key. Legacy queue callers retain
their existing policy; no schema migration or Worker/image change is introduced.
Published-cache verification, catalog projection, selector changes, progress and
complete recorded-analysis execution are still pending. This increment does not
mark stage 1 or the full workflow complete. Tests cover concurrent admission,
durability, changed source/profile, failure retry and HTTP 409 duplicate handling.
## Second increment: bounded non-binding idle claims
Claim/v3 is active on the backend and both current Worker006 control agents.
Idle polls do not persist receipts; actual grants are durable and share the
request-ID namespace with preserved v2 history. Repeating a non-binding v3 idle
request may later acquire work, unlike an old persisted v2 empty receipt.
The 50,000-row legacy ledger remains intact; a separate 50,000-positive-grant
budget does not change the shared 128 MiB database/WAL/SHM storage bound.
Only the imported gateway's request schema literal changes in two pinned child
images. Compute package identities and model images remain unchanged. The exact
control-agent create declarations, separate from executor package declarations,
are now the durable runtime source; old agents remain stopped for rollback.
126 focused tests and 16 real empty-capability Worker-to-backend probes passed.
All pre-existing queue row hashes are unchanged. This repairs dispatch polling,
not published-cache verification or full recorded-workflow acceptance.
See the [repair evidence and rollback boundary](../../experiments/perception/OBSERVATORY_RECORDED_CLAIM_REPAIR_2026-09-02.md).
## Third increment: exact published-result cache (2026-09-03)
Implemented and activated as a matching backend/frontend release on canonical8000
on 2026-09-03 after an owner-approved Docker VM restart recovered memory headroom.
Full typecheck,714 frontend tests and production build passed sequentially;
API catalogs and in-app UI were checked without creating inference jobs.
All queue row hashes are preserved. This does not claim a completed recorded run.
The portable catalog now joins successful **published** queue receipts to the
current per-source catalog snapshot, immutable definition, Session publication
binding, calculation profile, package contract, central manifest and every
artifact. No matching by LAB name, operator label, or the newest 100-job page.
The queue receives two indexes, with no deletion or rewriting of history.
The existing computation identity remains the only computation key.
An exact usable result projects `existing / open-existing`, its result ID and
path-free source/definition/package identities. It disables new submission but
does not require Worker dispatch or raw-source preparation for cached review.
Missing/corrupt artifacts do not count as coverage; the older evidence remains.
The newest verified exact candidate is returned; other versions are not hidden
from the evidence catalog or mistaken for the current calculation.
Large immutable result objects are hashed in 1 MiB chunks on first use or file
metadata change. A bounded 4096-entry memo rechecks dev/inode/size/mtime/ctime;
normal refresh does not reread unchanged large objects. This is result-integrity
IO, not source upload, source decoding, model inference, or an FPS measurement.
The queue INSERT fence rechecks the published candidate atomically. It never
streams large files while holding the write lock: cold/concurrent/changed
verification returns HTTP409 requesting Refresh. Exact idempotency retries still
return the original job. Verified corruption permits a new request.
The frontend's existing portable decoder now accepts only bound, internally
consistent cached results. No per-LAB page, renderer or selector implementation
was introduced. Full selector/progress/product acceptance remains stage 2.
Browser QA confirms that source/setup-only job selection can still display a
legacy completion status against a current definition. Exact-version status
binding and separating archives from executable choices remain required;
the activated backend cache does not prove those UI changes complete.
See [verification and activation status](../../experiments/perception/OBSERVATORY_PUBLISHED_CACHE_2026-09-03.md).
## Boundaries
No Synology deployment, hardware actuation, motor integration, new capture,
silent model substitutions or deletion of recordings/results. A Docker image's
portability does not promise that a CUDA build runs unchanged on Apple Silicon.
Physical-live and onboard/vehicle acceptance require the actual target hardware.
The only maintained execution plan is
[the four-stage ExecPlan](../OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
@@ -6,7 +6,59 @@ Scope: Mission Core recorded LAB replay, RAVNOVES004TREE, OPS perception state
Excluded: Gaussian/simulation workers and their artifacts
## Outcome
Status: the diagnostic findings and perception conclusions remain evidence, but
the custom fMP4/Three.js implementation described below is superseded by
ADR 0045. It is retained here as the failure audit, not as the current replay
contract.
## Current outcome after ADR 0045
RAVNOVES004TREE now uses one unmodified upstream Rerun 0.36.3 viewer for camera,
semantic masks, diagnostic boxes, source points, bounded Local SLAM, trajectory,
3D/PLAN and playback. The canonical source RRD owns world geometry; a verified
immutable RRD sidecar adds only LAB image-space evidence with the same recording
id and `session_time`. Source PTS are preserved, so missing decodable video
samples hold the previous frame instead of shortening the route or drifting
from masks.
The accepted LAB controls and layout remain unchanged. Full-route TGS and
point-aligned 3D semantics are still absent and therefore remain visible but
disabled. The former fMP4/Three.js route is comparison-only legacy and does not
load on the canonical RAV004 route.
The isolated renderer materialized the sidecar in 79.5 seconds. The canonical
live service cold-path took approximately seven minutes and produced
393,203,594 bytes, so publication-time preparation remains required before this
profile is called immediately openable. With a complete SHA-256 check on every
cache hit, the warm endpoint returned headers in 0.89 seconds and streamed the
local artifact in 2.48 seconds. This is a replay/cache measurement, not a
realtime inference claim.
## Current validation after ADR 0045
- frontend unit suite: 661 passed;
- TypeScript typecheck and production Vite build: passed;
- migration-scoped backend/API suite: 60 passed;
- live canonical blueprint: HTTP 200, 86,343 bytes, 0.185 seconds;
- warm integrity-checked sidecar: HTTP 200, byte-range `RRF2` confirmed;
- canonical service restarted and healthy on `127.0.0.1:8000`; no Mission Core
listener exists on `8765`.
The repository-wide Python suite completed with ten failures in pre-existing K1
camera-recovery/scenario-reset tests. No K1 runtime or test file differs from
the rollback tag in this migration. Nine failures are in the existing active
acquisition camera-restart contract; one is an existing expected-document
mismatch after the runtime added reset timing. These do not invalidate the
Rerun-specific checks, but the repository-wide suite is not represented as
green.
Automated in-app visual QA could not attach to the local address because the
browser surface rejected the localhost URL under its URL policy. No alternate
browser-control bypass was used. The live HTTP/data plane, build and contracts
were accepted; an operator visual pass remains required for the exact layout,
seek and toggle experience.
## Superseded implementation outcome
RAVNOVES004TREE no longer owns a custom LAB viewer. It supplies recording and
model configuration to the same `M4ReplayThreatVisual` and
@@ -72,6 +72,49 @@ waits until `bootout` has fully removed the old label, bootstraps the new
declaration and accepts only the exact Mission Core health document. Any
failure restores the previous plist and repeats the same health acceptance.
### Explicit worktree migration
A plan from a different checkout is rejected by default. To migrate the
canonical service between these two exact worktrees, authorize the installed
predecessor explicitly on both plan and apply:
```bash
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory
uv run python scripts/manage_mission_core_launch_agent.py plan \
--repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory \
--expected-current-repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
```
The plan is admissible only when `current_working_directory` is exactly
`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE`,
`desired_working_directory` is exactly
`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory`
and `changes.repository_migration` is `true`. The same plan must report
`preserved_data_directory` and `changes.preserved_data_directory` as exactly
`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE/.runtime/mission-core`,
with `changes.data_directory_preserved=true`. The desired plist then carries
that exact path as `MISSIONCORE_DATA_DIR`; an already configured nonblank,
private canonical `MISSIONCORE_DATA_DIR` is retained instead. Copy the two
exact hashes from that same output:
```bash
uv run python scripts/manage_mission_core_launch_agent.py apply \
--repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory \
--expected-current-repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE \
--expected-current-sha256 <current-sha256> \
--expected-desired-sha256 <desired-sha256>
```
The migration option is not a general cross-repository override. The planner
resolves it to one exact path and rejects any installed `WorkingDirectory`
that differs. Apply recomputes the same path-bound plan and still requires both
plist hashes, so a changed predecessor or candidate must be planned again.
Do not symlink either worktree's `.runtime/mission-core` to the other. The
checkout-local singleton lock requires a real private directory; durable data
continuity is expressed only by the path-bound `MISSIONCORE_DATA_DIR` in this
migration plan.
Read-only status:
```bash
@@ -0,0 +1,196 @@
# Portable Observatory Worker 006 operational boundary
## Scope and current state
This runbook covers only recorded, observation-only Observatory jobs. It does
not change K1 acquisition/control, Simulation/Gaussian, legacy LAB execution,
navigation or safety authority.
The server queue, renewable claim lease, verified source/result transport and
result publisher exist, but the production Worker API remains deliberately
disabled. All three application gates stay `False` until exact executors are
installed and a complete transport smoke has passed:
- `OBSERVATORY_WORKER_CLAIM_LEASE_READY`;
- `OBSERVATORY_WORKER_VERIFIED_RESULT_PUBLISHER_READY`;
- `OBSERVATORY_WORKER_PRODUCTION_API_ENABLED`.
The canonical central artifact store is already declared in the installed
Mission Core LaunchAgent as:
```text
MISSIONCORE_ARTIFACT_STORE_ROOT=/Volumes/docker/nodedc-mission-core/artifact-store
```
On 2026-08-31 `/Volumes/docker` was not mounted. This is a fail-closed
preflight failure, not permission to create a checkout-local substitute. The
portable Worker server composition now requires `central_status=ready` before
it can be constructed.
The installed `com.nodedc.mission-core.local` LaunchAgent was running from the
M5 Observatory feature worktree while retaining the established Mission Core
data directory in the main checkout. It declares the central artifact-store
root and cache limits, but not the two portable storage roots below. Do not
silently edit or restart this hybrid local service; carry the environment and
working-directory change through one separately reviewed, hash-gated service
transition after the code release is sealed.
Large portable inputs and in-flight results have no Mission Core data-directory
fallback. The server requires both roots through environment-only
configuration:
```text
MISSIONCORE_OBSERVATORY_WORKER_SOURCE_CAS_ROOT=/Volumes/docker/nodedc-mission-core/observatory-worker/source-cas
MISSIONCORE_OBSERVATORY_WORKER_RESULT_STAGING_ROOT=/Volumes/docker/nodedc-mission-core/observatory-worker/result-staging
```
Both directories must already exist, be canonical non-symlink directories,
remain inside `/Volumes/docker/nodedc-mission-core`, and be disjoint from each
other and from `artifact-store`. Mission Core never creates these configured
roots. If `/Volumes/docker` is absent or is only a local directory rather than
a mounted volume, composition fails closed before queue or Worker API exposure.
Provision the directories only through the reviewed server deployment after
the SMB mount preflight succeeds.
Read-only Worker evidence on the same date:
- strict-pinned `ssh -o BatchMode=yes mission-gpu` succeeds;
- Worker host identity remains `DESKTOP-OPJ8J04`;
- Windows OpenSSH `sshd` is running;
- `AllowTcpForwarding` and `GatewayPorts` use OpenSSH defaults: forwarding is
allowed and remote listeners are not exposed beyond loopback;
- Worker loopback port `18080` had no listener.
## Network shape
Mission Core remains the only backend on Mac loopback port `8000`. The Mac
owns one reverse SSH tunnel through the existing strict-pinned `mission-gpu`
alias:
```text
Worker 006 process
-> http://127.0.0.1:18080
-> encrypted SSH reverse forwarding
-> Mac http://127.0.0.1:8000
```
The exact forwarding declaration is:
```text
-R 127.0.0.1:18080:127.0.0.1:8000
```
It neither opens a LAN listener nor sends a credential in process arguments.
`ObservatoryWorkerHttpGateway` independently rejects plaintext HTTP to any
non-loopback host. `worker_tunnel_launchd.py` builds a pure, hashable launchd
plan; `scripts/plan_observatory_worker_tunnel.py` prints that plan and performs
no installation.
## Bearer credential
The server reads one credential from the fixed private data path:
```text
<MISSIONCORE_DATA_DIR>/worker-auth/observatory-worker.token
```
The installed Worker release receives the same secret through its own private,
runner-managed file and passes only its path as
`MISSIONCORE_OBSERVATORY_WORKER_TOKEN_FILE`. The token is ASCII, 32–512 bytes,
has no newline, is never stored in Git, an artifact, a plist, an environment
value, a command line or Ops plaintext, and is read with no-follow semantics.
The admitted service runtime is POSIX and requires mode `0600` or narrower;
native Windows ACL handling is intentionally not guessed.
The Worker service accepts only these non-executable settings:
```text
MISSIONCORE_OBSERVATORY_WORKER_BASE_URL=http://127.0.0.1:18080
MISSIONCORE_OBSERVATORY_WORKER_TOKEN_FILE=<absolute-private-path>
MISSIONCORE_OBSERVATORY_WORKER_WORK_ROOT=<absolute-private-D-backed-path>
MISSIONCORE_OBSERVATORY_WORKER_IDLE_POLL_SECONDS=1
MISSIONCORE_OBSERVATORY_WORKER_TRANSPORT_BACKOFF_SECONDS=5
MISSIONCORE_OBSERVATORY_WORKER_MAX_TRANSPORT_FAILURES=12
```
There is no configurable module, command, image or executable entrypoint.
## Install-time executor seam
`compose_installed_observatory_worker_service` is called only by a reviewed
Worker release. That release injects a constructed
`ObservatoryWorkerExecutorRegistry` directly in memory. Before opening the
HTTP gateway, service composition resolves the four-digest identity of every
portable RunDefinition whose executor state is `ready`:
- executor release SHA-256;
- executor image SHA-256;
- model manifest SHA-256;
- resource profile SHA-256.
No ready definitions, an empty registry, or one missing identity stops the
service before its first claim. Blocked candidates cannot be selected through
configuration and are not silently registered.
## Staged deployment and smoke sequence
No step below was applied by this implementation increment.
1. Seal each executor release and installation receipt. Change a portable
RunDefinition to `ready` only when its exact release/image identities and
local adapter admission agree.
2. Mount the existing canonical SMB artifact store. Require Mission Core
artifact status `central_status=ready`; do not initialize a local surrogate.
Through the reviewed server deployment, provision the two disjoint portable
roots above, set both environment values, and verify they resolve inside the
same mounted `/Volumes/docker/nodedc-mission-core` boundary.
3. Provision one bearer credential through the deployment-owned secret path
on both Mac and the admitted POSIX Worker service runtime. Verify file type,
no-link handling and private permissions without printing the value.
4. Generate the reverse-tunnel launchd plan, review its SHA-256 and exact
arguments, then install it through a separate hash-gated local-service
change. Accept only Worker-side `127.0.0.1:18080/api/health` reaching the
canonical Mac service; no second backend is started.
5. Install the exact Worker release. Its fixed entrypoint loads the sealed
RunDefinition registry and its in-memory executor registry, then calls
`compose_installed_observatory_worker_service`. The process must refuse a
missing token, non-loopback plaintext URL, unsafe work root, absent ready
definition or executor coverage gap.
6. Restart the canonical Mission Core process once with CAS and server token
available. Keep the Worker route disabled and verify that K1,
Simulation/Gaussian and legacy LAB surfaces are unchanged.
7. In a separate reviewed source gate, flip the claim-lease, verified-publisher
and production-API flags together. Restart only the canonical port `8000`
service. An authenticated empty claim must return `204`; missing/wrong bearer
and wrong contour identity must remain `401/403`.
8. Submit one short recorded K1 canary for each profile. Require exact source
materialization, lease heartbeat, result-package digest validation, central
artifact publication, immutable calculation-profile provenance and a
reopenable Observatory result.
9. During a bounded recorded canary, start the existing live K1 priority
transition. Require the recorded job to pause/defer and resume only after
live K1 releases the single Worker resource. This test never grants control
or navigation authority.
## Remaining blockers
- canonical SMB artifact store is currently unmounted on the Mac;
- portable source CAS and result-staging directories/environment values are not
provisioned;
- the installed Mission Core LaunchAgent has not received a sealed
working-directory/environment transition for this release;
- the shared bearer credential has not been provisioned;
- the reverse tunnel plan has not been installed or smoked;
- the Worker polling entrypoint has not been packaged into an admitted POSIX
Worker release;
- both exact executor releases/install receipts still need their own seals;
- production flags and the server route remain off by design;
- no end-to-end result has yet crossed Worker 006 -> central store ->
Observatory under this new path.
The Synology root-owned `nodedc-deploy` registry has no
`mission-core-worker` component. Older Mission Core Worker shadow artifacts
explicitly declare that they are outside that registry. Do not route a Windows
Worker install through an unrelated NAS component or weaken the deploy canon;
the durable Worker release needs its own exact reviewed installation transition
and rollback evidence.
@@ -0,0 +1,153 @@
# Observatory published-cache increment — 2026-09-03
## Status and scope
Implemented, contract-tested and **activated on canonical8000** on 2026-09-03.
Full product acceptance remains open; see the activation evidence below.
Implementation commit: `1e4ddc2` on `codex/m5-1-observatory` (no push).
This is stage1 source/provenance/cache work, not model or recorded-run evidence.
No Worker containers, model profiles, raw recordings, result history, capture,
network settings or physical controls were changed. No Synology deployment.
## Contract
- Exact key: admitted source snapshot + immutable RunDefinition (including
executor image/release, models, adapter, resource and result contracts).
- Candidate: successful queue job with durable `publication_state=published`.
Legacy `succeeded/not-required`, pending or failed publication is not a hit.
- Verify full job/source/definition/profile provenance, Session binding, sealed
package and CAS manifest identity, exact artifact inventory and file hashes.
- Return the newest usable exact result with `existing / open-existing`,
`submission_allowed=false` and path-free evidence identities. Different source,
changed snapshot or profile version remains a distinct computation.
- Existing catalog/preflight/viewer contracts are reused. No per-LAB UI branch.
Full selector filtering and progress are deliberately the next product stage.
- Cached preflight does not prepare raw data or call a Worker; read-only result
review is independent of current source compatibility/dispatch availability.
Source snapshot comparison reads the catalog, not all source media.
- Corrupt/missing/symlinked result objects do not suppress a fresh calculation.
History is retained. Cached review is not a navigation/safety acceptance.
## IO and concurrency
First use or changed metadata streams result objects in 1MiB chunks. Content
checks use a bounded4096-entry fingerprint memo; each lookup rechecks file
device/inode/size/mtime_ns/ctime_ns. Contracts/manifests remain independently
parsed and checked. Warm refresh does not reread unchanged large result objects.
Cold verification can cost result-store IO and is not advertised as constant-time.
Two additive queue indexes support exact published-source and computation lookup.
Publication/INSERT races are fenced in the existing transaction. Its verifier
does not re-enter the queue DB or hash large files under the write lock. A cold,
concurrent or changed verification requests Refresh with HTTP409, never silently
creates another job. Confirmed corruption permits retry with a new key; the
original idempotency key always refers to the original job.
## Verification
- 120 focused backend tests PASS: portable publisher/cache, setup API/projector,
queue binding, recorded queue, Worker API, publication retry/reconciliation.
- 23 focused frontend tests PASS: portable catalog/decoder and application
architecture, sequential Node runner with192MiB heap cap. Peak RSS~108MiB.
- Focused strict TypeScript check of the portable decoder/fetch contract PASS,
192MiB heap cap; peak process RSS~366MiB including compiler/native memory.
- Ruff on all changed Python files PASS; mypy five changed core/API modules PASS.
- Tests use synthetic filesystem/SQLite packages, not historical LAB outputs as
an inference substitute. They cover complete publish→cache→view, missing and
same-size corruption with restored mtime, symlink substitution, changed central
manifest/provenance/source/version, publication race, idempotent retry and no
source preparation/model submission when an exact result is available.
- First fixture run correctly rejected legacy `succeed()` as no publication
outbox. Fixture now exercises `complete_for_publication()` followed by actual
publisher and durable `mark_published()`; the production gate was not relaxed.
- Existing FastAPI/httpx test deprecation warning remains; dependencies unchanged.
- Legacy SQLite migration is covered: indexes are created after publication
columns are added, preserving the pre-existing job and idempotency identity.
Reproduce backend tests with `.venv/bin/pytest -o addopts='' -q` and these files:
`tests/test_observatory_portable_result_publisher.py`,
`tests/test_observatory_portable_setup_api.py`,
`tests/test_observatory_portable_setup_projection.py`,
`tests/test_observatory_portable_queue_binding.py`,
`tests/test_observatory_recorded_jobs.py`, `tests/test_observatory_worker_api.py`,
`tests/test_observatory_publication_retry_api.py`,
`tests/test_observatory_publication_reconciler.py`.
Frontend, from `apps/control-station`:
`NODE_OPTIONS=--max-old-space-size=192 node --test --test-concurrency=1 test/observatoryLaboratorySetups.test.mjs test/applicationArchitecture.test.mjs`.
## Initial activation hold — historical, resolved below
Mac pressure rose from1 to2 during the turn, swap~7.6–7.8GiB; no temporary heavy job
remains. Existing operator Docker services were inspected, not stopped. The
resource gate in `mission-core-product-ui` defers full build and browser QA.
Canonical8000 remains PID57796 on the previous matched release, `/api/health`
HTTP200. No listeners on8765/4173; unrelated launcher5173 is not this project.
Do not claim that the new cache is already live in the operator interface.
After memory pressure normalizes: sequential production typecheck/tests/build,
activate the matching backend/frontend on canonical8000, verify health/catalog
and browser decoder behavior without creating inference jobs, then update this
status. Do not start a second backend or replay process. Stage2 then implements
the selector/progress/full recorded calculation and sequential M4.9T5/LAB V1
acceptance. Neither this report nor a cached viewer FPS proves onboard realtime.
## Resource recovery and activation — 2026-09-03
The earlier memory gate was real, but browser RSS was not a sufficient cause
diagnosis. After the owner stopped unrelated containers, the23-day-old Docker
VM retained a14GB footprint (about13GB accounted swapped/compressed), while
its guest had about12.5GiB available. This was not14GB resident physical RAM
or a model workload. Chrome, Arc and Little Snitch were not touched.
With explicit owner approval, saved all48 container identities/states/policies,
temporarily changed the12 stopped Plane `always` policies to `no`, and restarted
Docker Desktop gracefully. VM PID9403→66297; footprint14GB→1565MB, pressure2→1,
used swap7722.12→2746.19MiB. Only the same3 Mission Core telemetry containers
resumed, all healthy. Restored Plane policies to `always` without starting Plane.
All45 non-Core containers retain their state/StartedAt. Images and mount
identities are unchanged (Docker reordered some Mounts arrays). No containers,
volumes or data deleted; Docker MemoryMiB14336/SwapMiB4096 unchanged. Plane can
start on a future daemon restart: this was only a temporary restart barrier.
Sequential validation under `mission-core-product-ui`:
- Architecture4/4; full typecheck; frontend714/714 unit tests; production build
PASS. Typecheck5.47s/peak RSS902152192bytes, tests18.76s/242204672bytes,
build15.38s/2255650816bytes. Node heap caps1024/512/2048MiB respectively.
Pressure remained1; swap did not grow. Existing large-chunk warnings remain.
- Built in a fresh ignored staging directory; preserved the old dist for
rollback, then replaced the matching frontend/backend via the existing
LaunchAgent. Backend PID67747, UI `/assets/index-DSuuMkq4.js`, index SHA256
`eb8c47d373a2ce1077557d348e097aa354556ddf2a529e3d6c07b4d5fe91922f`.
Health200/reconciler ready. No alternate backend8765 or preview4173.
- Portable catalogs HTTP200:004TREE/00 compatible with both current profiles;
01 remains blocked by missing capture attestation, not a cache failure.
No exact published cache hits in current real data. Both new indexes exist.
- In-app browser: actual new build, source/evidence catalog, portable selection,
Refresh, normal/expanded window, dropdown Escape PASS; console warnings/errors0.
Initial navigation hit the short backend startup gap; a fresh app tab worked.
No heavy replay/model run. Automatic retention of the QA tab was declined by
browser action review; no alternate retention mechanism was used.
- All6 queue-table canonical row hashes unchanged before/after activation and
UI checks:11 jobs,50000 legacy claims,1 reconciliation,0 live leases/v3 grants/
preemptions. The sole succeeded job is still `not-required`, not a new cache
hit. Positive publish→cache→view remains synthetic contract-test evidence.
Private snapshots/logs and previous frontend: `.runtime/docker-vm-recovery-z1636o/`.
No raw recording content was collected into this diagnostic evidence.
## Remaining product acceptance
The cache increment is active, but the complete workflow is not accepted.
Browser QA reproduced the next-stage UI mismatch: archived entries and portable
definitions are mixed in the selector; current M4.9T5 can show an old completion
label next to Calculate. `useObservatoryRecordedJobs` selects by source/setup,
then `jobs[0]`, without an exact definition filter; `ObservatoryWorkspace`
renders `succeeded/not-required` as completed. That is not a current cache hit.
Next: exact-version queue/status binding, selector filtering, publication
progress, complete recorded calculation and sequential M4.9T5/LAB V1 acceptance.
Stage1 user-facing reconciliation and the full stage2 workflow remain open.
Do not repeat the memory recovery or claim/v3 repair, or treat the old realtime
FAIL as a new gate. No per-LAB UI branch or automatic inference on Refresh.
@@ -0,0 +1,147 @@
# Observatory recorded claim repair — 2026-09-02
## Результат и граница
Восстановлен control-путь очереди для обоих текущих Worker006 agents. Backend
и агенты используют versioned claim/v3: пустой опрос не создаёт receipt,
фактическая выдача остаётся долговечной и идемпотентной. Это второй инкремент
нового этапа 1, **не** завершение cache/selector/progress или полного LAB-run.
Модели, compute packages, записи, результаты, их identities и mounts не менялись.
Модельных заданий не создано. Оба агента — диспетчеры; два работающих агента
не означают два параллельных вычислительных профиля. GPU authority не добавлена.
Код: `cabd308` (queue/API/client), `0041e9f` (actual import-path guard),
`43f1cdc` (exact Docker layer). Plan: [актуальные четыре этапа](../../docs/OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
## Причина и контракт
Старый журнал достиг 50 000 receipts: 49 985 пустых опросов и 15 выдач.
Каждый новый idle poll занимал запись; quota проверялась до поиска работы.
Работающие v2-агенты получали 503 и периодически перезапускались после
исчерпания своего transport-failure budget.
- v2 semantics сохранены, включая прежний NULL-result для старого request ID.
- v3 idle — non-binding observation; после idle тот же ID может позднее
получить работу. Явный пустой capability tuple гарантированно не получает job.
- Выданный grant сохраняется в новой `observatory_recorded_claim_grants_v3`.
Повтор возвращает тот же grant либо stale-claim error; другая capability
inventory/claimant с тем же выданным ID отвергается.
- Старый и новый журналы проверяются в одной сериализованной транзакции:
новый протокол не обходится повтором через старый.
- Старый лимит 50 000 не увеличен, строки не удалены. Введён **отдельный**
конечный бюджет 50 000 реальных v3 grants. Общий storage bound SQLite/WAL/SHM
остаётся 128 MiB. Это не бесконечное хранилище: заполнение положительного
журнала требует отдельного архивного lifecycle, а не слепой чистки.
- Authenticated GET `/api/v1/worker/observatory/recorded-jobs/claim-readiness`
показывает оба бюджета, job-state counts и open live leases. Это готовность
очереди, не доказательство GPU/model readiness. UI-проекция ещё не менялась.
## Точный Worker cutover
Обновлён только реально импортируемый файл:
`/opt/nodedc/installed-lab/src/k1link/observatory/worker_http_transport.py`.
Before SHA: `c3d82ffd482a29b17c68d1fb5603e82924766abc0080137d3a0a6130f92ccdce`.
After SHA: `81bdc27f374cd91358f0eb58603e3e1f53e1f3017bf158d269e75affa4567293`.
Содержательное изменение — одна строка schema version v2→v3.
| Контрольный агент | Parent image | Новый transport image |
| --- | --- | --- |
| installed LAB | `5ad7d95baac6…` | `1b1e335916c1c3d77888b7537331e9d775d078957c4ac0f82e91852301725390` |
| M4.9 | `d00274a2a76e…` | `7aa6ccd2ddba4ebb0c07d793e5331539a2e13b07961c269439d5b79762328194` |
Полные parent digests, container IDs, create bodies, mounts, argv, environment,
resource/restart policies и rollback pointers сохранены в release evidence.
Каждый новый image содержит точные прежние RootFS layers плюс один слой с
изменённым transport-файлом и metadata его каталогов. Model/executor images
в package registries остаются прежними; транспортный образ не выдаётся за
новую версию вычислительного профиля.
Два первых apply были остановлены до service cutover:
1. Первоначальный путь указывал на неиспользуемую v1-копию в базовом слое
`/opt/nodedc/mission-core/src`. Хеш совпадал с этой копией, но её protocol
literal — нет. Проверка фактического `module.__file__` подтвердила v2 в
installed-lab overlay. Ошибочная промежуточная гипотеза о работающем v1
опровергнута; реальные контейнеры до финального apply не заменялись.
2. Замена одного байта с сохранением size/mtime не попала в Docker snapshot
diff. После обновления file mtime обнаружен ровно ожидаемый файл.
Добавлен регрессионный тест. Ни пустой diff, ни неверный import path не
принимались как успешный образ.
## Приёмка
- 126 focused tests PASS: legacy/v3 queue, full quota, concurrent retries,
expired grants, capability mismatch, API auth, gateway, Worker service,
recorded submit, portable catalog, publication/retry и migration guards.
- Ruff PASS на изменённом коде/тестах; mypy PASS на трёх source modules.
Единственный warning — существующая Starlette/httpx deprecation.
- 16 настоящих v3 пустых запросов: по 8 из каждого нового агента через его
установленный gateway → локальный proxy → Worker tunnel → canonical8000.
Capabilities пустые; job/model execution невозможны в этих probes.
- Последние100 фоновых Worker polls после cutover:100×204, ошибок0; оба новых
контейнера running, restart count 0, restart=unless-stopped, GPU requests=null.
- Queue snapshot в 23:54 МСК: legacy=50 000, v3=0, jobs=11 (10 failed,
1 succeeded/not-required), live=0. Все строки пяти прежних таблиц
совпали с before snapshot по SHA-256. Ни выдачи, ни jobs, ни reconciliation
history не потеряны.
- Canonical8000 обновлён, PID57796; `/api/health` HTTP200. Начальный health
polling ошибочно обращался к несуществующему `/api/v1/health`; это ошибка
диагностического URL, не отказ сервиса. Правильный endpoint проверен отдельно.
- GPU/model benchmarks, frontend build и полный recorded run не выполнялись.
Ollama/Frigate остаются exited/restart=no. Временные containers и один
диагностический image удалены; остановленные rollback agents сохранены.
## Evidence и воспроизводимость
Worker release:
`D:\NDC_MISSIONCORE\runtime\services\observatory-claim-transport\ec3bf17908c6d0c167156ad93e2edb3d8a3bd914a0b0a66aa5b0de6957bf2496`.
Plan SHA: `ec3bf17908c6d0c167156ad93e2edb3d8a3bd914a0b0a66aa5b0de6957bf2496`.
Migration script SHA: `4b07693fec563ef755002d29345528e501f703974b5c7090470962d51eb460b2`.
Worker receipt file SHA: `c8f72fc5aad9d7aae6a12391304ec268ee8c0d0f80a1100e41e4638a0d56b771`.
Installed-agent declaration SHA:
`9a4832496a7b9de1d079eda31d1d9c67d1543a266808df3d8e69bca1cb1f4a40`.
M49-agent declaration SHA:
`644ed2a4ef6daac0efd9023c77c94d9f2081cd8895fc49019b11922a9c70f1d8`.
Local evidence (не Git): `.runtime/observatory-claims-v3-l9ock9/`.
Before SQLite backup SHA:
`428783a40ba000676da3a2de270cbfb3b2fb0685e7f489303b0e05a6ee4bf9f9`.
After SQLite snapshot SHA:
`934a28e890a318077e25184c5e1f6ff7efc7c1acb8da6a12a9600fea99d490f1`.
Legacy rows SHA:
`a5291155f9c74e552f05a8a856cd120270f5b5132e7ac9a5ed53605f4a743bbe`.
Job rows SHA:
`34205296fe1e335657de9d70890e28ccc16eec5120f6d1ffa1fde15f60d58a0f`.
SQLite file hashes различаются ожидаемо из-за добавленной таблицы; row hashes
прежних таблиц совпадают. Backup содержит реальные operational records и не
должен попадать в Git.
## Durable runtime и откат
Новый источник истины для **контрольных агентов** — точные
`*-declaration.json` в release выше: полный Docker create body и pinned image.
Package manifests остаются источником истины для вычислений. Прежний
`Start-ObservatoryInstalledLabWorker.ps1` с предположением agent image =
package executor image не использовать для замены transport overlay: он
потребует reconciliation. Docker restart policy уже сохраняет новые контейнеры
при обычном перезапуске; реальный reboot в этом инкременте не выполнялся.
Предшественники сохранены:
`ndc-observatory-installed-lab-worker-agent-v2-rollback-810212622de9`,
`ndc-observatory-m49-worker-agent-v2-rollback-b30b41d9e2e2`;
оба stopped/restart=no. Не запускать их рядом с новой версией. Перед откатом
повторно доказать отсутствие активного/queued/reconciliation/live work, остановить
только соответствующий новый агент, проверить точный predecessor ID из declaration,
вернуть имя и прежнюю restart policy. v2 снова упрётся в заполненный legacy ledger:
это аварийный возврат прежнего состояния, не работоспособное решение.
Не восстанавливать SQLite backup поверх новых jobs/grants. Backend rollback
не должен игнорировать появившиеся v3 grants: сначала отдельная reconciliation,
сохранение актуальной БД и контроль владельцев. Автоматического отката со
стиранием поздней работы нет.
Следующий инкремент этапа 1: verified published-cache projection по exact source
и definition, затем селектор/прогресс этапа 2. Реальный полный расчёт профиля и
повторный просмотр без inference остаются обязательной отдельной приёмкой.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
# Stage 2 increment 14 — authenticated grant delivery and cross-host clock bounds
Date:2026-09-02. Result: **the application-side control/clock candidate works;
continuous full-graph cross-host freshness is not yet qualified.** This increment
contains no model/GPU run. It does not change the four-stage plan or open stage3.
## Objective and architecture boundary
The source must receive a short-lived data-stream grant without gaining authority
to acquire/renew the Worker GPU lease, restart models, select an epoch or actuate.
Mac and Worker process monotonic clocks also need an explicit error interval before
cross-host scene age is accepted. A low RTT or one successful sample is not clock
synchronization. A temporary network/clock failure means WAIT and fresh input epoch;
the local lease and resident processes remain owned by the Worker controller.
`StreamControl/Poll` is a small TLS unary method served beside the binary bidirectional
stream. One per-activation bootstrap capability authenticates polling. It returns the
controller's already-issued pending data grant and four clock timestamps. Messages are
8KiB bounded, closed-schema, rate-limited to40/s and limited to one outstanding client
probe. The control endpoint has no lifecycle/model method. Data admission remains
single-use and independently authenticated by `Exchange`.
Clock evidence bounds remote-minus-local offset without assuming equal forward and
return delay. Up to16 recent intervals are intersected, widened by controller-supplied
relative-rate and timestamp-error envelopes, and expire after2s. Contradictory samples
quarantine the clock session. All integer nanoseconds remain exact beyond2^53. This is
a bounded application mapping, not an OS-clock setter or PTP/NTP implementation. The
four-timestamp basis follows the standard exchange model described by
[RFC5905](https://www.rfc-editor.org/rfc/rfc5905.html); the candidate deliberately does
not use the midpoint/equal-path estimate as proof.
During regression, gRPC aio with a native maximum of2 marked the next accepted Poll
over-limit while Exchange and a completing Poll overlapped. Its implementation checks
the next request slot before awaiting that request. The combined server therefore has
three bounded native RPC slots: one active Exchange, one Poll, one accept slot. The
application still enforces exactly one data owner; this is not GPU parallelism. The
behavior is covered by the upstream [gRPC aio server limiter source](https://github.com/grpc/grpc/blob/v1.83.0/src/python/grpcio/grpc/_cython/_cygrpc/aio/server.pyx.pxi#L808-L825).
## Real route method
- Existing authenticated SSH/Tailscale route from Mac to Worker006; TLS gRPC tunneled
through it. No firewall/listener persisted.
- Worker isolated `runc` container:1CPU,512MiB, no NVIDIA runtime, zero models,
pinned diagnostic image
`sha256:664824aa25de1db178f177d67a81b01541a938b479812b9383ddbf03f6b59dbe`.
- Exactly72 sequential real monotonic probes. Conditional envelope:500ppm relative
rate,50us per timestamp,2s maximum age,5ms admission uncertainty.
- Two data phases, four32KiB synthetic events each. Between them:2.2s disconnect;
local lease heartbeat continues. Source clock and resume proof are synthetic and
never presented as sensor freshness.
- Bootstrap ticket/certificate copied once by SSH; no stream grant file copied.
Both stream grants arrived via authenticated Poll. Secrets are absent from evidence.
## Results
| Observation | Result |
| --- | ---: |
| Clock probes independently verified |72/72 |
| Conditional uncertainty min / median / max |3.883 /4.469 /27.118ms |
| Samples inside5ms |60/72 |
| Readiness state transitions |10 |
| Payload/reply SHA-256 exact |8/8 |
| Reply RTT min / median / max |15.796 /22.408 /85.344ms |
| Controlled network gap |2.202s |
| Resident CPU sentinel / lease across gap |same PID9 / generation1 |
| Local lease renewals across gap |136→158 |
| Peak tracked input / final residual |4,718,905B /0B |
Old clock evidence expired during the2.3s client pause. The new epoch used a new data
grant; the control bootstrap remained scoped to the same activation and could not renew
the lease. Mapping recovered after new probes. False→true and true→false clock-ready
transitions also occurred after warmup, so continuous checking is required. It would be
invalid to pick one of the60 passing samples and call the whole run qualified.
No full model run followed: the control/clock foundation is proved, but the current
full-graph adapter does not yet propagate uncertainty into every result freshness
decision or pause/resynchronize on mapping expiry. Running it now would produce a
mislabelled cross-host latency claim. The5ms gate was not widened.
## Validation and retained limits
Local expanded regressions:252PASS,2 intentional Worker-only skips. Final Worker:
65PASS including1MiB result and real slow-reader flow control. Ruff/format, strict mypy
and diff checks pass. A first expanded local command named two nonexistent test files
and ran zero tests; that XML is retained and excluded from acceptance. A first Worker
invocation omitted the Worker-only flag (63PASS/2skips); the explicit final rerun is the
accepted65PASS evidence.
The Worker recorded one pre-existing perception-service self-restart26→27 while the
probe ran; same container ID/config, and this harness issued no service-control command.
It is not hidden and not attributed to the CPU probe. Canonical8000 telemetry remained
reachable/identity-matched; Frigate/Ollama remain exited/restart=no. Final owned
containers/listeners/private keys/bootstrap capabilities are zero. Owner record is
released. No K1, recording, UI, LAB registry, motor/autopilot, Synology or external
deployment was touched.
Evidence:`.runtime/perception-stage2-control-clock-20260902T1635Z/manifest.json`,18
artifacts, SHA-256 `cd479c577fa3da78b1b01a6a90e03e8c742ecb53561e606d939408120a53a622`.
Candidate archive SHA-256
`980c98fc280f593e470a187870f2b1b08d013386db87c70f688336dc1389d7e3`.
## Next stage-2 gate
Wire refreshed clock bounds into the existing Mac source, Worker controller and result
receiver. On expiry/excess uncertainty: stop admitting freshness, WAIT, discard backlog,
obtain a new input epoch and decoder/sensor proof while retaining local models/lease.
Carry uncertainty into consumer-side scene age. Then run one sequential full-profile
Mac↔Worker canary plus bounded slow-consumer case. Standalone packaging remains after
that; product cutover remains stage3.
@@ -0,0 +1,163 @@
# Stage 2 increment 15 — full graph with acknowledged cross-host clocks
2026-09-02, 20:37 MSK. **Implementation/regressions accepted; real-time canary FAIL.**
Stage 2 remains open. No stage 3 cutover, standalone release or actuation is claimed.
Code commit: `35b6cd9`.
## Objective and retained architecture
Connect the existing Mac recording source and real result receiver to the single
Worker006 full graph: DDRNet-39 GOOSE, RF-DETR, online LiDAR geometry/distance,
temporal/motion, TRAVEL TGS/costmap and advisory policy. The source supplies original
camera fragments, normalized point increments and poses incrementally at 1x.
No whole recording upload, predecode, precomputed perception layers or per-recording
application is introduced. Original source timestamps never change during a run.
The Worker model container has no recording/index/camera/sensor-archive mount. This
is still a diagnostic dependency image with explicit code/model mounts, **not** the
portable standalone profile. Mac only reads/releases raw events and validates replies.
Masks and full scene/grid payloads return over gRPC/TLS through the existing authenticated
SSH/Tailscale route. This is not the prospective rover radio or an onboard deployment.
## Implemented contracts
- `StreamControl/ReportClock` echoes the last issued Poll challenge once, including the
source receipt timestamp. The Worker checks nonce, both clock identities and its own
receive/send timestamps; an acknowledgement must arrive within 500ms. The control
capability cannot renew a GPU lease, restart models, select epochs or actuate.
- `ClockReceipt` derives the responder-side offset interval from the original causal
exchange, not a reversed/symmetric-delay shortcut. The source anchor is immutable.
The explicit conditional envelope remains 500ppm relative drift, 50us per timestamp,
2s expiry and uncertainty <=5ms. These assumptions are not hardware clock certification.
- The Worker checks current clock validity at ingress, GPU/CPU work, publication and
while idle. The source checks it during sending and receipt. Unavailable mapping means
WAIT, bounded discard, a new controller-owned input epoch and fresh keyframe/sensors.
Lease renewal/model ownership remains local and independent of the network.
- Source cutoff uses the upper mapped age bound. Scene and per-cell expiry include
uncertainty; the Mac receiver measures elapsed source-to-consumer time entirely on its
own monotonic clock. Worker-local mapped timing is diagnostic, not the acceptance clock.
- One clock snapshot is used throughout each publication/receipt assessment. A concurrent
clock update cannot change the timestamp midway through cell/policy assessment.
- Source completion is acknowledged before data EOF can retire the control endpoint.
Clock warmup precedes initial source-anchor assignment; once assigned, it is never
shifted to recover skipped observations.
- An admitted transport disconnect before the application OPEN now enters WAIT too.
Previously the one-use grant could be consumed while the runtime remained active with
no input, preventing a replacement epoch. An unbound malformed packet still cannot
stop the owner; obsolete peers cannot pause a replacement epoch.
## Method and sequential results
Each model attempt used one profile and the same serialized GPU schedule. Dedicated
RTX4090, 8 CPU/8GiB limit, pids256, shm256MiB; temporary stock-clock reference
2610/10251MHz and unchanged 450W power limit. No EoMT, Frigate or Ollama inference.
Four pre-existing Mission Core services were quiesced and restored per attempt.
All attempts remain separately identified; none is overwritten or omitted:
| Attempt | Outcome | Accepted interpretation |
| --- | --- | --- |
| canary32a | Missing candidate manifest before model startup | Packaging failure, no measurement |
| canary32b | Missing pinned DDRNet runner | Packaging failure, no source processed |
| canary32c, code-v1 | 30 results, 2 compute discards; source EOF acknowledgement timed out | Full graph/receipt evidence, not accepted real-time |
| canary32d, code-v2 | 0 results, clean bounded completion | 3 source WAIT skips +29 synchronization skips |
| canary32e, code-v3 | Source command incorrectly named the index directory, not index.jsonl | Operator-command failure, no source data; explicitly ended via the pilot watchdog handler |
| canary32f, code-v4 | 2 results, clean bounded completion | Final canary FAIL:13 source WAIT skips +17 synchronization skips |
The launcher now checks the manifest, pinned runner hash and all bind sources before
quiescing services. Source command generation checks exact input files before activation.
The pinned runner is unchanged (`b18ad60f277eea69a240a28f290611b94627fb9707faf1bb3e6e22102dad67c1`).
The rejected early source command was stopped by signalling the confirmed diagnostic
main PID7 through its existing SIGALRM handler, allowing ordinary cleanup/lease release.
### First full cross-host observation: canary32c
32 source frames ->32 accepted inputs ->30 byte-exact scene receipts +2 explicit
`input-gap` compute discards (sequences17,27). No reply drops or unaccounted frames.
The discarded calculations coincided with the old repeated-clock-read assessment path;
the run did not retain enough fine-grained tracing to prove that as their sole cause.
All30 received raw model/geometry/temporal/costmap reference comparisons match;28 replies
arrived before source EOF. Twelve scenes are fully fresh; missing/aged source geometry
does not become permissive policy.
Measured source-to-consumer-ready p95/p99:208.197/211.873ms, range90.877–211.873ms.
The125ms latency gate fails even apart from discards/EOF error. Published clock
uncertainty:3.487–4.543ms. Peak reply440373B; tracked input14640679B, below16MiB.
These figures belong to the rejected code-v1 attempt, not the final candidate.
### Final candidate: canary32f
Source released all32 frames at1x. Sequences0–12 were omitted while waiting for
admission. Sequences13–29 were skipped for fresh sensor/keyframe synchronization.
The full graph resumed at30; frames30 and31 returned, byte/hash validated. Both arrived
after the bounded source window ended. Therefore neither complete delivery nor the
result-before-EOF gate passed. This was startup WAIT followed by synchronization,
**not** two successful initial frames followed by a late outage.
The two source-to-consumer-ready delays were143.881 and155.743ms. Two observations
do not establish meaningful p95/p99 or sustainable FPS. Both receipts are fresh under
the explicit250ms freshness envelope; this does not make2/32 availability acceptable.
Segmentation/detector output matches the reference for both frames. History-dependent
geometry/motion/costmap differs after the omitted prefix and reset; full-scene reference
parity is not claimed.
Clock loop:78 observations,28 marked ready; uncertainty min/median/max
3.915/5.312/30.808ms. Published uncertainty4.517–4.562ms. The gate was never widened.
One input epoch, one lease generation and four resident children; no model reload
while awaiting admission. Peak reply403572B, tracked input10479771B, final buffers0.
Final run sampled VRAM peak2365MiB and cgroup peak3313.4MiB; Mac source RSS59.9MB.
This is not evidence of a24GiB capacity limit. Sensor archive rows are read incrementally,
not fully loaded or transferred.
## Validation and retained limitations
- Final local:297PASS,2 intentionally Worker-only skips. Worker:173PASS including
the native large-result/slow-reader tests. Early-open cancellation: six additional
sequential repeats, two cases each,12/12PASS. No timeout was increased.
- The previous intermittent reconnect test was reproduced three times. Diagnostics
found `opened=false`, drained transport and still-active continuity; the correction
covers cancellation both before and after OPEN. An initial overly broad failure
handler was rejected by four existing unbound-peer tests and corrected before GPU use.
- Ruff, format, six-file strict mypy and diff checks pass.128 staged Python files match
measured commit35b6cd9, including the pinned runner mapping and final test-only overlays.
Subsequent formatting in two files is independently AST-identical; no new model result
is attributed to a changed algorithm.
- `verify.py` independently reconciles source/compute/synchronization/receipt counts,
raw scene bytes, mask/layer hashes, Mac receipt ages and clock-bound widening arithmetic.
This campaign retained aggregate clock bounds, not every raw five-timestamp exchange;
raw clock reconstruction remains a next diagnostic, not a claimed proof here.
- No full-session, controlled cross-host full-graph outage or full-graph slow-consumer
acceptance was performed after the negative canary. The earlier same-Worker results
retain their original narrower scope. Product UI/registry was not changed.
## Decision and next Stage 2 gate
The graph works over this real route, but its current start/readiness protocol does
not provide stable availability. The source can first pass its clock bound before
the Worker has accumulated equivalent responder-side evidence; its fixed2s lead does
not prove joint readiness. Missing the initial keyframe/sensor prefix then magnifies
a short admission delay. This is a protocol/startup finding, not a GPU sizing result.
Next: separate two-sided clock warmup from immutable source-anchor activation. Confirm
both peers' current readiness before starting the1x source, then keep the source clock
running unchanged through real faults. Retain raw exchange/ack timing and state-change
ledgers to distinguish route latency, scheduling and protocol overhead. Repeat one short
normal canary; only after that pass, run controlled outage/slow-consumer and longer windows.
Do not weaken the5ms gate, replay backlog or infer an onboard/radio result from SSH.
Standalone packaging remains later within Stage2; Stage3–4 remain unopened.
## Cleanup and evidence
All temporary containers, lease volumes, host collectors and port18561/tunnel are gone.
Owner records released; four service container IDs restored, Triton200, Telegraf Running.
GPU automatic210/405MHz,980MiB,0%,450W at final sample. Existing legacy service defects
are not declared fixed. Ollama/Frigate remain exited/restart=no. Canonical Mac8000 PID33360
and identity-matched agent-MQTT telemetry remain available;8765 absent. No physical K1,
motor/autopilot, recording, Synology or external deployment mutation.
Private TLS keys/bootstrap capabilities were removed on both hosts; public certificates
and raw non-secret diagnostics retained. Session:
`.runtime/perception-stage2-crosshost-20260902T1705Z/`,198 artifacts,
manifest SHA-256 `7b435cac111b3e0e789aa137ba064651e6d344a5f8fb8fa35b0abbd0e7cb1d74`.
Final code-v4 archive SHA-256:
`866d709baa785101d10b32b31a17a1b97b15e6de4de932bfd2e79c00825911ab`.
@@ -0,0 +1,123 @@
# Stage 2 increment 16 — joint source activation and measured clock WAIT
2026-09-02, 21:12 MSK. Code `48835a0`. **Joint startup PASS; full real-time canary FAIL.**
Stage 2 remains open; no Stage 3/4 cutover, standalone release or actuation.
## Change and invariants
Clock warmup no longer starts the recording. The Mac acknowledges every issued probe,
including pre-start probes with a null source anchor. Worker accumulates responder
evidence and returns typed bounds plus its accepted anchor. Both sides must satisfy
the current clock gate before proposing a start; Worker must explicitly accept it.
Mac additionally waits for the pending data grant before returning from startup.
ReportClock uses closed `missioncore.stream-clock-report/v2` and
`missioncore.stream-clock-receipt/v2` documents; legacy/malformed responses fail closed.
Nanoseconds, including signed clock offsets, remain exact canonical decimal strings.
Client checks activation, nonce, clock identities, envelope and accepted anchor.
Peer bounds are aged to the latest possible Worker time at source use, including ACK
transit; a cached readiness boolean cannot authorize a later observation.
The one-second lead is only for bounded grant delivery/setup AFTER joint readiness.
Lost ACK retains the exact same proposal because Worker may already have accepted it.
Explicit nonacceptance permits another pre-start proposal; an accepted anchor never
changes. Missing the accepted start fails startup rather than retiming the recording.
Source input paths are checked before clock activation. Receipt diagnostics retain
bounded raw t1–t6, with one evaluation timestamp per reported readiness snapshot.
Unchanged: 1x original source clock, every-frame candidate, <=16MiB tracked inputs,
two pending cameras, serialized GPU, 125ms p95/p99 whole-path gate, <=5ms conditional
clock uncertainty, 500ppm relative-rate budget, 50us timestamp-error budget, 2s expiry
and 16 recent samples. These are conditional assumptions, not a measurement that the
physical clocks actually drift by 500ppm. WAIT preserves models/local ownership;
resync discards backlog and requires a new epoch/keyframe/current sensors.
## One full-graph canary
Session `.runtime/perception-stage2-joint-start-20260902T1805Z`, run `joint32-worker`.
Actual source start 18:04:32.831227Z; session ID is an identifier, not the run timestamp.
Mac incrementally reads the existing raw recording and receives real scene/mask payloads;
Worker006 runs DDRNet-39 GOOSE, RF-DETR, LiDAR/distance, motion, TRAVEL TGS/costmap/policy.
Model container has no recording mount. One GPU profile, 8 CPUs/8GiB, temporary
2610/10251MHz stock-clock reference, unchanged 450W. No alternate model run or retry.
Pinned dependency image plus developer/model mounts remains **not standalone**.
Transport is gRPC/TLS through SSH/Tailscale, not a measured rover radio/onboard link.
| Original camera sequences | Observed outcome |
| --- | --- |
| 0–5 | Six results; initial camera/keyframe and sensor prefix admitted |
| 6 | Accepted, then explicitly discarded as `input-gap` |
| 7–15 | Nine source WAIT skips |
| 16–29 | Fourteen synchronization skips while waiting for a fresh keyframe/sensor pair |
| 30–31 | Two results after recovery; both arrive after the short source window ends |
Ledger: 32 released =8 results +1 compute discard +9 WAIT skips +14 sync skips.
No unaccounted frame, reply drop, source error or control/data RPC error. Source remains
1x; release-lag p95/p99 3.062/17.286ms, max36.725ms. Joint startup admitted frame0,
unlike increment15's final attempt; this does not establish continuous availability.
Eight Worker/Mac scene payloads are byte-exact; all masks, detections, material,
lineage and sensor bindings match the uninterrupted reference. Geometry/tracks/threats
and raw TGS state match on the six pre-gap results, not on the two post-resync results:
their temporal history was intentionally reset. Do not claim eight uninterrupted
full-graph reference matches. Five received scenes are fully fresh; six arrive before EOF.
Same-Mac source-due → consumer-ready: min101.345ms, median144.951ms,
p95/p99/max215.767ms, n=8. This tiny failing sample is not a stable throughput estimate.
DDRNet GPU-model mean12.895ms, detector mean12.862ms; these are component intervals,
not end-to-end latency or evidence of spare realtime capacity. Peak sampled VRAM2363MiB,
cgroup3341.08MiB, Mac source RSS61,767,680B, tracked inputs12,055,035B, reply419,194B.
The result does not indicate exhaustion of24GiB GPU memory.
## Independently reconstructed clock evidence
All55 exchanges retained t1(source send), t2/t3(Worker receive/send), t4(source receipt),
t5(Worker receipt of report), t6(source ACK return). Independent integer calculations
reproduce BOTH published interval windows and all55 readiness decisions;41 are ready.
The sole accepted source anchor and a ready observation precede initial data connect.
After startup, source samples27–35 are unavailable: uncertainty5.031–5.667ms at the
unchanged5ms limit. Sample36 returns to4.867ms. Worker continuous WAIT lasts864.139ms;
keyframe recovery magnifies the resulting output gap. Source uncertainty min/median/max
4.217/4.620/32.053ms includes initial warmup; the maximum is not running-source drift.
Probe-cycle min/median/max8.420/13.991/101.253ms; report/ACK-cycle7.392/13.269/104.500ms.
These intervals include application scheduling and cannot alone attribute delay to
radio, TCP, SSH, gRPC or event-loop contention.
An offline diagnostic retaining EVERY sample within the same2s horizon also exceeds
5ms throughout these nine unavailable samples. Increasing the16-sample count alone
would not remove this WAIT in the trace. No runtime window/threshold was changed.
Worker host-control observations33/33 accepted, no expiry or envelope violations;
the observed WAIT was clock admission, not missing GPU telemetry or owner loss.
Two input epochs retain lease generation1 and resident PIDs11/40/48/49. Recovery clears
temporal stores with all children alive; final orderly completion releases all resources.
## Validation, evidence and next gate
241 focused local tests PASS,2 Worker-only cases skipped locally;198 Worker CPU tests
PASS, including real TLS joint startup, malformed/legacy receipts, lost accepted-anchor
ACK, one-sided/stale bounds, missing grant, unchanged anchor after recovery and existing
ingress/lifecycle/backpressure cases. Ruff/check-format and typed clock modules mypy PASS.
129 measured Python source files match the local committed code and code-v2 archive.
Code-v1 CPU evidence remains retained; v2 only makes diagnostic readiness use its
recorded timestamp. Only v2 ran the GPU canary.
Archive SHA256 `52e9778cdd54aab33b9e347081ae67ebcc53968773717b9c708174381f2d6eed`.
53 retained artifacts; manifest SHA256
`9cb369fe7ae185d1a92f6050bc1018a878483f5eec169267f100843245428691`.
`verify.py`, `verify_clocks.py`, `clock-reconstruction.json`, `review.json` and
`acceptance.json` reproduce receipt accounting, freshness, timing and closed resources.
Next: CPU-only attribution/A/B of clock/control scheduling versus concurrent scene
transfer/receipt work, preserving the existing route and admission budgets. Determine
what is application scheduling and what is transport; then optimize the proven cause.
Do not add artificial outages or start a long GPU qualification on this failing baseline.
After a stable short full-graph canary: controlled gap/slow-consumer, longer runs and
standalone packaging within Stage2. Product integration stays in later stages.
Temporary model/test containers, collector, lease volume, tunnel18561, key/bootstrap
are gone; owner released, four exact prior service IDs restored, Triton200, Telegraf
Running. Clock-lock reset commands succeeded; subsequent automatic210/405MHz observed,
450W unchanged. Ollama/Frigate remain exited/restart=no. Canonical Mac8000 PID33360 and
identity-matched Worker telemetry work;8765 absent. Existing legacy-service defects
are not declared fixed. No K1, motor, autonomous-driving or external/Synology deployment.

Some files were not shown because too many files have changed in this diff Show More