10 Commits
163 changed files with 21002 additions and 1855 deletions
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Синхронизированная запись</title>
<style>
html, body, #viewer { width: 100%; height: 100%; margin: 0; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<div id="viewer"></div>
<script type="module" src="/src/components/rerun/isolatedRerunEntry.ts"></script>
</body>
</html>
@@ -1,4 +1,11 @@
import { useEffect, useRef, useState } from "react";
import { createIsolatedRerunHost } from "./rerun/isolatedRerunHost";
import { keepRecordedBlueprintSession } from "../core/observation/recordedBlueprintLifecycle";
import {
RECORDED_RERUN_ORBITAL_EYE,
RECORDED_RERUN_PLAN_EYE,
type RecordedRerunCameraEye,
} from "./rerun/recordedRerunCameraJournal";
import type { SceneSettings } from "../sceneSettings";
import {
@@ -138,6 +145,15 @@ export interface RerunViewportProps {
interface RerunBlueprintChannel {
endpointUrl: string;
cameraContract?: string | null;
configureCameraJournal?: (
eye: RecordedRerunCameraEye,
spatialViewportStart: number,
) => void;
getCameraEye?: () => RecordedRerunCameraEye;
setCameraViewportStart?: (spatialViewportStart: number) => void;
getCurrentTimeNs?: () => number | null;
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
channel: {
readonly ready: boolean;
send_rrd: (rrdBytes: Uint8Array) => void;
@@ -166,6 +182,13 @@ interface RecordedRerunIdentity {
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 PORTABLE_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/portable-results\/(?:m49-tgs-portable-review|lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt|rf-detr|object-distance))-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
const COMPOSITION_RECORDED_REPLAY_PATH = /^\/api\/v1\/observatory\/ai-composition-runs\/ai-composition-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.rrd$/;
export function isRecordedRrdSource(path: string): boolean {
return RECORDED_RRD_PATH.test(path) || LAB_RECORDED_REPLAY_PATH.test(path)
|| PORTABLE_RECORDED_REPLAY_PATH.test(path) || COMPOSITION_RECORDED_REPLAY_PATH.test(path);
}
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$/;
@@ -230,8 +253,7 @@ 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)
|| LAB_RECORDED_REPLAY_PATH.test(descriptor.sourceUrl)) ||
!isRecordedRrdSource(descriptor.sourceUrl) ||
descriptor.viewerSourceUrl !== expectedViewerSourceUrl ||
endpoint.origin !== base.origin ||
endpoint.pathname !== descriptor.sourceUrl ||
@@ -262,7 +284,9 @@ export function resolveRecordedBlueprintUrl(
if (explicitSourceUrl !== undefined) {
const explicit = explicitSourceUrl.trim();
if (
!LAB_RECORDED_REPLAY_PATH.test(normalized)
!(LAB_RECORDED_REPLAY_PATH.test(normalized)
|| PORTABLE_RECORDED_REPLAY_PATH.test(normalized)
|| COMPOSITION_RECORDED_REPLAY_PATH.test(normalized))
|| !RECORDED_BLUEPRINT_PATH.test(explicit)
) return null;
const endpoint = new URL(explicit, `${base.origin}/`);
@@ -388,7 +412,11 @@ export async function fetchRecordedBlueprintRrd(
followTrajectory = false,
semanticLayer,
unifiedPerception,
unifiedCameraShare = 0.46,
planView = false,
cameraEye,
currentTimeNs,
onCameraMaxOrbitalRadius,
perceptionLayers = {
enabled: false,
detections2d: false,
@@ -405,7 +433,11 @@ export async function fetchRecordedBlueprintRrd(
followTrajectory?: boolean;
semanticLayer?: "city" | "vegetation";
unifiedPerception?: boolean;
unifiedCameraShare?: number;
planView?: boolean;
cameraEye?: RecordedRerunCameraEye;
currentTimeNs?: number | null;
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
perceptionLayers?: RecordedPerceptionLayers;
fetcher?: typeof globalThis.fetch;
},
@@ -421,10 +453,8 @@ export async function fetchRecordedBlueprintRrd(
!RECORDED_BLUEPRINT_PATH.test(endpoint.pathname) ||
!Number.isFinite(settings.accumulationSeconds) ||
settings.accumulationSeconds < 0 ||
settings.accumulationSeconds > 3600 ||
!Number.isFinite(settings.pointSize) ||
settings.pointSize < 0.1 ||
settings.pointSize > 32 ||
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
@@ -432,12 +462,25 @@ export async function fetchRecordedBlueprintRrd(
![0, 1].includes(viewResetGeneration) ||
(semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) ||
typeof resolvedUnifiedPerception !== "boolean" ||
!Number.isFinite(unifiedCameraShare) ||
unifiedCameraShare < 0.1 ||
unifiedCameraShare > 0.9 ||
typeof planView !== "boolean" ||
(cameraEye !== undefined && [
...cameraEye.position,
...cameraEye.lookTarget,
...cameraEye.eyeUp,
].some((value) => !Number.isFinite(value))) ||
(currentTimeNs !== undefined && currentTimeNs !== null && (
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
)) ||
[
perceptionLayers.enabled,
perceptionLayers.cameraImage ?? true,
perceptionLayers.detections2d,
perceptionLayers.segmentation,
perceptionLayers.cuboids3d,
perceptionLayers.costmap ?? false,
].some((value) => typeof value !== "boolean") ||
identity.applicationId !== "nodedc_mission_core_recorded" ||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId) ||
@@ -468,11 +511,23 @@ export async function fetchRecordedBlueprintRrd(
view_reset_generation: viewResetGeneration,
follow_trajectory: followTrajectory,
unified_perception: resolvedUnifiedPerception,
unified_camera_share: unifiedCameraShare,
semantic_layer: semanticLayer ?? null,
plan_view: planView,
eye_position: cameraEye?.position ?? null,
eye_look_target: cameraEye?.lookTarget ?? null,
eye_up: cameraEye?.eyeUp ?? null,
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
current_time_ns: currentTimeNs,
}),
show_detections_2d: perceptionLayers.detections2d,
show_camera_image: perceptionLayers.cameraImage ?? true,
show_segmentation: perceptionLayers.segmentation,
show_cuboids_3d: perceptionLayers.cuboids3d,
...(perceptionLayers.costmap === undefined ? {} : {
show_costmap: perceptionLayers.costmap,
reactivate_updates: true,
}),
}),
signal,
});
@@ -496,9 +551,34 @@ export async function fetchRecordedBlueprintRrd(
) {
throw new Error("Invalid recorded blueprint RRD");
}
const maxOrbitalRadius = Number(
response.headers.get("X-MissionCore-Camera-Max-Orbital-Radius"),
);
if (Number.isFinite(maxOrbitalRadius) && maxOrbitalRadius >= 0.02) {
onCameraMaxOrbitalRadius?.(maxOrbitalRadius);
}
return payload;
}
export function recordedCameraJournalContract(
{
activeView,
viewResetGeneration,
planView,
}: {
activeView: RecordedRerunView;
viewResetGeneration: 0 | 1;
planView: boolean;
followTrajectory: boolean;
},
): string {
// Following is a tracking property of the current native eye. It must never
// initialize the browser journal again: doing so replaces the operator's
// current pose with the startup preset immediately before the blueprint is
// sent. Plan/3D and explicit reset are the only preset transitions.
return [activeView, viewResetGeneration, planView].join(":");
}
export async function fetchRecordedPerceptionRrd(
endpointUrl: string,
identity: RecordedRerunIdentity,
@@ -707,6 +787,7 @@ export function RerunViewport({
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
const recordedSemanticLayer = recordedProfile?.semanticLayer;
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
const recordedUnifiedCameraShare = recordedProfile?.unifiedCameraShare ?? 0.46;
const recordedPlanView = recordedProfile?.planView ?? false;
const recordedPerceptionRetryGeneration =
recordedProfile?.perceptionRetryGeneration ?? 0;
@@ -752,9 +833,9 @@ export function RerunViewport({
: sourceUrl
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
: null;
const recordedPointColorsUrl = sourceUrl
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin)
: null;
const recordedPointColorsUrl = recordedBlueprintUrl
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
const presentationStatus = rerunPresentationStatus(
status,
presentationGate,
@@ -791,8 +872,7 @@ export function RerunViewport({
return;
}
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource)
|| LAB_RECORDED_REPLAY_PATH.test(normalizedSource);
const isRecordedSource = isRecordedRrdSource(normalizedSource);
let resolvedSource: string;
try {
if (isRecordedSource) {
@@ -827,6 +907,9 @@ export function RerunViewport({
diagnosticLifecycle.verifyBuild();
let disposed = false;
const blueprintOwnerId = crypto.randomUUID().replaceAll("-", "");
blueprintSessionIdRef.current = blueprintOwnerId;
let releaseBlueprintSession = () => {};
let disposeViewer: (() => void) | undefined;
let recordedOpenWatchdog: {
arm: () => void;
@@ -1073,6 +1156,11 @@ export function RerunViewport({
);
}
activeViewerLifecycleRef.current = disposeActiveViewerLifecycle;
const restoreAfterPageCache = (event: PageTransitionEvent) => {
if (event.persisted) setRetryNonce((nonce) => nonce + 1);
};
window.addEventListener("pagehide", disposeActiveViewerLifecycle);
window.addEventListener("pageshow", restoreAfterPageCache);
host.replaceChildren();
appliedPointColorKeyRef.current = null;
@@ -1080,15 +1168,22 @@ export function RerunViewport({
setRecordingBufferProgress(null);
onStatusChange?.("loading");
void import("@rerun-io/web-viewer")
.then(async ({ WebViewer }) => {
const isolatedHost = isRecordedSource ? createIsolatedRerunHost(host) : null;
disposeViewer = () => isolatedHost?.dispose();
const nativeViewer = isolatedHost?.ready ?? import("@rerun-io/web-viewer")
.then(({ WebViewer }) => ({ viewer: new WebViewer(), mount: host }));
void nativeViewer
.then(async ({ viewer, mount }) => {
// React StrictMode intentionally disposes the first effect while the
// dynamic import is still pending. Never start that stale viewer (the
// vendor API treats a missing host as document.body).
if (disposed) return;
const viewer = new WebViewer();
if (disposed) {
viewer.stop();
isolatedHost?.dispose();
return;
}
disposeViewer = createReentrantViewerDisposer(() => {
releaseBlueprintSession();
clearRecordingTimers();
clearPlaybackRangeTimer();
playbackTimeUpdates.cancel();
@@ -1146,6 +1241,7 @@ export function RerunViewport({
// A partially initialized WASM handle can already be gone after a
// startup failure.
}
isolatedHost?.dispose();
host.replaceChildren();
});
if (isRecordedSource && recordedArtifact) {
@@ -1182,6 +1278,13 @@ export function RerunViewport({
event.application_id === "nodedc_mission_core_recorded" &&
/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(event.recording_id)
) {
releaseBlueprintSession = keepRecordedBlueprintSession({
endpointUrl: recordedBlueprintUrl,
origin: window.location.origin,
applicationId: event.application_id,
recordingId: event.recording_id,
ownerId: blueprintOwnerId,
});
const current = recordedIdentityRef.current;
if (
current?.applicationId !== event.application_id ||
@@ -1491,7 +1594,7 @@ export function RerunViewport({
};
await viewer.start(
rerunViewerInitialSource(resolvedSource),
host,
mount,
viewerOptions,
);
if (disposed) {
@@ -1509,7 +1612,34 @@ export function RerunViewport({
if (recordedBlueprintUrl) {
const channel = viewer.open_channel("missioncore/recorded-blueprint");
blueprintChannel = { endpointUrl: recordedBlueprintUrl, channel };
blueprintChannel = {
endpointUrl: recordedBlueprintUrl,
cameraContract: null,
configureCameraJournal: (eye, spatialViewportStart) => {
if ("configure_camera_journal" in viewer) {
viewer.configure_camera_journal(eye, spatialViewportStart);
}
},
getCameraEye: () => "get_camera_eye" in viewer
? viewer.get_camera_eye()
: RECORDED_RERUN_ORBITAL_EYE,
setCameraViewportStart: (spatialViewportStart) => {
if ("set_camera_viewport_start" in viewer) {
viewer.set_camera_viewport_start(spatialViewportStart);
}
},
getCurrentTimeNs: () => {
const currentIdentity = recordedIdentityRef.current;
if (!currentIdentity) return null;
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
},
setCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
if ("set_camera_max_orbital_radius" in viewer) {
viewer.set_camera_max_orbital_radius(maxOrbitalRadius);
}
},
channel,
};
blueprintChannelRef.current = blueprintChannel;
setBlueprintChannelRevision((revision) => revision + 1);
}
@@ -1569,6 +1699,8 @@ export function RerunViewport({
});
return () => {
window.removeEventListener("pagehide", disposeActiveViewerLifecycle);
window.removeEventListener("pageshow", restoreAfterPageCache);
if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) {
activeViewerLifecycleRef.current = null;
}
@@ -1938,6 +2070,9 @@ export function RerunViewport({
useEffect(() => {
if (!recordedBlueprintUrl || !sceneSettings) return;
// Initialize the portable following-eye blueprint after native admission
// and its initial seek, not while the base RRD is still arriving.
if (recordedPerceptionLayers.costmap !== undefined && status !== "ready") return;
const active = blueprintChannelRef.current;
const identity = recordedIdentityRef.current;
if (
@@ -1947,6 +2082,22 @@ export function RerunViewport({
!active.channel.ready
) return;
const abort = new AbortController();
const cameraContract = recordedCameraJournalContract(
{
activeView: recordedView,
viewResetGeneration: recordedViewResetGeneration,
planView: recordedPlanView,
followTrajectory: recordedFollowTrajectory,
},
);
if (active.cameraContract !== cameraContract) {
active.configureCameraJournal?.(
recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE,
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
);
active.cameraContract = cameraContract;
}
const cameraEye = active.getCameraEye?.();
void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
origin: window.location.origin,
blueprintSessionId: blueprintSessionIdRef.current,
@@ -1957,7 +2108,15 @@ export function RerunViewport({
perceptionLayers: recordedPerceptionLayers,
semanticLayer: recordedSemanticLayer,
unifiedPerception: recordedUnifiedPerception,
unifiedCameraShare: recordedUnifiedCameraShare,
planView: recordedPlanView,
cameraEye,
currentTimeNs: active.getCurrentTimeNs?.(),
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
if (blueprintChannelRef.current === active) {
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
}
},
}).then((payload) => {
if (
abort.signal.aborted ||
@@ -1968,6 +2127,9 @@ export function RerunViewport({
return;
}
active.channel.send_rrd(payload);
active.setCameraViewportStart?.(
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
);
}).catch(() => {
// The recording remains usable with its embedded default blueprint.
// A later settings change retries through the same small channel.
@@ -1981,11 +2143,15 @@ export function RerunViewport({
recordedFollowTrajectory,
recordedSemanticLayer,
recordedUnifiedPerception,
recordedUnifiedCameraShare,
recordedPlanView,
recordedPerceptionLayers.enabled,
recordedPerceptionLayers.cameraImage,
recordedPerceptionLayers.detections2d,
recordedPerceptionLayers.segmentation,
recordedPerceptionLayers.cuboids3d,
recordedPerceptionLayers.costmap,
status,
sceneSettings?.accumulationSeconds,
sceneSettings?.customColor,
sceneSettings?.colorMode,
@@ -0,0 +1,33 @@
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveAICompositionReplay } from "../../core/laboratory/canonicalLabReplay";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import type { AICompositionRun } from "../../core/observatory/aiComposition";
function resolveComposition(
runId: string,
launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal },
) {
return resolveAICompositionReplay(runId, launch, options);
}
export function AICompositionReplay({ run }: { run: AICompositionRun }) {
const semantics = run.moduleIds.includes("ddrnet") || run.moduleIds.includes("eomt");
const detections = run.moduleIds.includes("rf-detr")
|| run.moduleIds.includes("object-distance");
const costmap = run.moduleIds.includes("tgs");
return (
<CanonicalResultRerunReplay
key={run.runId}
resultId={run.runId}
sessionId={run.sourceSessionId}
resolveReplay={resolveComposition}
semantics={semantics}
detections={detections}
costmap={costmap}
moduleIds={run.moduleIds}
viewerLayers={run.presentation.viewerLayers}
label={run.configurationLabel}
/>
);
}
@@ -88,6 +88,9 @@ export function CanonicalRecordedLabReplay<
mediaLayerControls,
spatialLayerControls,
spatialLeadingControl,
spatialTrailingControl,
mediaModeControlsVisible = true,
paneToolbarsAlwaysVisible = false,
mediaMultiLayer = false,
mediaContent,
spatialContent,
@@ -116,10 +119,14 @@ export function CanonicalRecordedLabReplay<
mediaLayerControls?: ReactNode;
spatialLayerControls?: ReactNode;
spatialLeadingControl?: ReactNode;
spatialTrailingControl?: ReactNode;
mediaModeControlsVisible?: boolean;
/** Keeps renderer-local controls inside their viewport when only one pane is present. */
paneToolbarsAlwaysVisible?: boolean;
mediaMultiLayer?: boolean;
mediaContent?: ReactNode;
spatialContent?: ReactNode;
/** One upstream Rerun viewer owns both panes and the shared playback clock. */
/** One upstream Rerun viewer owns both panes; this controlled split owns their shared geometry. */
unifiedContent?: ReactNode;
emptyMessage: string;
deckOverlays?: ReactNode;
@@ -162,14 +169,14 @@ export function CanonicalRecordedLabReplay<
aria-label={mediaAriaLabel}
hidden={mediaMode === "none"}
>
{splitView ? (
{splitView || paneToolbarsAlwaysVisible ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="media"
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
>
{mediaLayerControls}
{mediaModeControls}
{mediaModeControlsVisible ? mediaModeControls : null}
</div>
) : null}
{mediaContent}
@@ -181,7 +188,7 @@ export function CanonicalRecordedLabReplay<
data-pane="spatial"
aria-label={spatialAriaLabel}
>
{splitView ? (
{splitView || paneToolbarsAlwaysVisible ? (
<div
className="m4-replay-threat-visual__pane-toolbar"
data-pane-toolbar="spatial"
@@ -190,6 +197,7 @@ export function CanonicalRecordedLabReplay<
<div className="m4-replay-threat-visual__spatial-toolbar-end">
{spatialLayerControls}
{spatialModeControls}
{spatialTrailingControl}
</div>
</div>
) : null}
@@ -216,7 +224,7 @@ export function CanonicalRecordedLabReplay<
expanded={expanded}
onModeChange={onMediaModeChange}
onExpandedChange={onExpandedChange}
modeControlsVisible={!splitView}
modeControlsVisible={!splitView && !paneToolbarsAlwaysVisible}
actions={actions}
overlay={overlay}
transport={transport}
@@ -241,7 +249,7 @@ export function CanonicalRecordedLabReplay<
orientation={splitOrientation}
minPrimarySize={splitView ? 24 : 0}
minSecondarySize={splitView ? 24 : 0}
resizable={splitView && !unifiedContent}
resizable={splitView}
separatorLabel="Изменить размер видео/камеры и 3D/плана"
/>
{mediaMode === "none" && spatialMode === "none" ? (
@@ -0,0 +1,524 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ActivityIndicator,
Button,
Checker,
ControlRow,
Icon,
IconButton,
Inspector,
InspectorSelectField,
RangeControl,
ToastStack,
Window,
type ToastItem,
} 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 type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
import {
fetchLabViewProfile,
saveLabViewProfile,
} from "../../core/observatory/labViewProfile";
import {
defaultSceneSettings,
type PointColorMode,
type PointPalette,
type SceneSettings,
} from "../../sceneSettings";
type MediaMode = "camera";
type SpatialMode = "3d" | "plan";
interface CanonicalReplayLaunch {
base: ObservationSessionReplayLaunch;
replay: CanonicalLabReplayDescriptor;
}
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
const LAB_SCENE_SETTINGS_STORAGE_PREFIX = "missioncore.observatory.lab-scene-settings.v1:";
const LAB_VIEW_PROFILE_ERROR_TOAST = "observatory-lab-view-profile-error";
const COLOR_OPTIONS: readonly { value: PointColorMode; label: string }[] = [
{ value: "intensity", label: "Интенсивность" },
{ value: "height", label: "Высота Z" },
{ value: "distance", label: "Дистанция" },
{ value: "rgb", label: "RGB" },
{ value: "class", label: "Класс" },
];
const PALETTE_OPTIONS: readonly { value: PointPalette; label: string }[] = [
{ value: "turbo", label: "Turbo" },
{ value: "viridis", label: "Viridis" },
{ value: "plasma", label: "Plasma" },
{ value: "grayscale", label: "Серый" },
];
const LAB_SCENE_DEFAULTS: SceneSettings = {
...defaultSceneSettings,
pointSize: 3.8,
accumulationSeconds: 5,
};
export function normalizeLabSceneSettings(settings: SceneSettings): SceneSettings {
const pointSize = Number.isFinite(settings.pointSize) ? settings.pointSize : LAB_SCENE_DEFAULTS.pointSize;
const accumulationSeconds = Number.isFinite(settings.accumulationSeconds)
? settings.accumulationSeconds : LAB_SCENE_DEFAULTS.accumulationSeconds;
return {
...settings,
pointSize: Math.round(Math.max(0.1, pointSize) * 10) / 10,
accumulationSeconds: Math.round(Math.max(0, accumulationSeconds)),
};
}
function loadLabSceneSettings(resultId: string): SceneSettings {
if (typeof window === "undefined") return LAB_SCENE_DEFAULTS;
try {
const raw = window.localStorage.getItem(`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`);
if (raw === null) return LAB_SCENE_DEFAULTS;
const value: unknown = JSON.parse(raw);
if (typeof value !== "object" || value === null || Array.isArray(value)) return LAB_SCENE_DEFAULTS;
const row = value as Record<string, unknown>;
const pointSize = row.pointSize;
const accumulationSeconds = row.accumulationSeconds;
const colorMode = row.colorMode;
const palette = row.palette;
const showGrid = row.showGrid;
const showLabels = row.showLabels;
const showCameraFrustums = row.showCameraFrustums;
if (
typeof pointSize !== "number" || !Number.isFinite(pointSize) || pointSize < 0.1
|| typeof accumulationSeconds !== "number" || !Number.isFinite(accumulationSeconds)
|| accumulationSeconds < 0
|| !COLOR_OPTIONS.some((option) => option.value === colorMode)
|| !PALETTE_OPTIONS.some((option) => option.value === palette)
|| typeof showGrid !== "boolean"
|| typeof showLabels !== "boolean"
|| typeof showCameraFrustums !== "boolean"
) return LAB_SCENE_DEFAULTS;
return {
...LAB_SCENE_DEFAULTS,
pointSize,
accumulationSeconds,
colorMode: colorMode as PointColorMode,
palette: palette as PointPalette,
showGrid,
showLabels,
showCameraFrustums,
};
} catch {
return LAB_SCENE_DEFAULTS;
}
}
export function CanonicalResultRerunReplay({
resultId,
sessionId,
initialPlaybackStartSeconds,
resolveReplay,
semantics = false,
detections = false,
costmap = false,
moduleIds = [],
viewerLayers = [],
label = "Сохранённый результат",
}: {
resultId: string;
sessionId: string;
initialPlaybackStartSeconds?: number;
resolveReplay: (resultId: string, launch: ObservationSessionReplayLaunch, options: { signal: AbortSignal }) => Promise<CanonicalLabReplayDescriptor>;
semantics?: boolean;
detections?: boolean;
costmap?: boolean;
portable?: boolean;
moduleIds?: readonly string[];
viewerLayers?: readonly AIViewerLayer[];
label?: string;
}) {
const {
mediaMode,
spatialMode,
splitPrimarySize,
splitOrientation,
expanded,
onMediaModeChange,
onSpatialModeChange,
onSplitPrimarySizeChange,
onExpandedChange,
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
initialMediaMode: "camera",
initialSpatialMode: "3d",
});
const layerIds = useMemo(
() => new Set(viewerLayers.map((layer) => layer.layerId)),
[viewerLayers],
);
const hasProjectedLayers = viewerLayers.length > 0;
const hasDDRNet = layerIds.has("camera.ddrnet")
|| (!hasProjectedLayers && semantics && moduleIds.includes("ddrnet"));
const hasEoMT = layerIds.has("camera.eomt")
|| (!hasProjectedLayers && semantics && moduleIds.includes("eomt"));
const hasFallbackSemantics = semantics && !hasDDRNet && !hasEoMT;
const hasDetections = layerIds.has("camera.detections") || (!hasProjectedLayers && detections);
const hasTgs = layerIds.has("spatial.tgs") || (!hasProjectedLayers && costmap);
const legacyUnclassified = !hasProjectedLayers
&& moduleIds.length === 0
&& !semantics
&& !detections
&& !costmap;
const hasCameraPane = layerIds.has("camera.source")
|| (!hasProjectedLayers && (legacyUnclassified || semantics || detections));
const hasSourcePoints = layerIds.has("spatial.source-points")
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
const hasLocalSlam = layerIds.has("spatial.local-slam")
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
const hasSpatialPane = hasSourcePoints || hasLocalSlam || hasTgs;
const splitView = hasCameraPane && hasSpatialPane;
const [showCamera, setShowCamera] = useState(true);
const [showDDRNet, setShowDDRNet] = useState(hasDDRNet || hasFallbackSemantics);
const [showEoMT, setShowEoMT] = useState(hasEoMT);
const [showDetections, setShowDetections] = useState(hasDetections);
const [showSourcePoints, setShowSourcePoints] = useState(hasSourcePoints);
const [showLocalSlam, setShowLocalSlam] = useState(hasLocalSlam);
const [showTgs, setShowTgs] = useState(hasTgs);
const [followTarget, setFollowTarget] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [sceneDraft, setSceneDraft] = useState<SceneSettings>(() => loadLabSceneSettings(resultId));
const [blueprintSplitPrimarySize, setBlueprintSplitPrimarySize] = useState(
RERUN_UNIFIED_CAMERA_SHARE_PERCENT,
);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(hasTgs ? 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 sceneDraftRef = useRef(sceneDraft);
const previousSplitViewRef = useRef(splitView);
const trackedLaunchSha256Ref = useRef<string | null>(null);
useEffect(() => {
const launchSha256 = launch?.replay.sha256 ?? null;
if (trackedLaunchSha256Ref.current !== launchSha256 || (splitView && !previousSplitViewRef.current)) {
onSplitPrimarySizeChange(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
setBlueprintSplitPrimarySize(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
}
trackedLaunchSha256Ref.current = launchSha256;
previousSplitViewRef.current = splitView;
}, [launch?.replay.sha256, onSplitPrimarySizeChange, splitView]);
useEffect(() => {
if (!splitView) return;
const timer = window.setTimeout(() => setBlueprintSplitPrimarySize(splitPrimarySize), 75);
return () => window.clearTimeout(timer);
}, [splitPrimarySize, splitView]);
useEffect(() => {
try {
window.localStorage.setItem(
`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`,
JSON.stringify({
pointSize: sceneDraft.pointSize,
accumulationSeconds: sceneDraft.accumulationSeconds,
colorMode: sceneDraft.colorMode,
palette: sceneDraft.palette,
showGrid: sceneDraft.showGrid,
showLabels: sceneDraft.showLabels,
showCameraFrustums: sceneDraft.showCameraFrustums,
}),
);
} catch {
// The visual remains usable when the browser refuses local persistence.
}
}, [resultId, sceneDraft]);
useEffect(() => {
const controller = new AbortController();
void fetchLabViewProfile(resultId, controller.signal).then((settings) => {
if (!controller.signal.aborted && settings) {
sceneDraftRef.current = settings;
setSceneDraft(settings);
}
}).catch(() => {
// The per-browser copy remains a usable fallback while the local service recovers.
});
return () => controller.abort();
}, [resultId]);
useEffect(() => {
const controller = new AbortController();
setLaunch(null);
setLaunchError(null);
setPlayback(null);
setPlaybackController(null);
setViewerStatus("idle");
void resolveObservationSessionReplay(sessionId, {
signal: controller.signal,
maximumWaitMs: 30 * 60 * 1000,
onUpdate: () => undefined,
}).then(async (value) => ({
base: value,
replay: await resolveReplay(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 : "Сохранённая запись недоступна.");
}
});
return () => controller.abort();
}, [resultId, sessionId, resolveReplay]);
const presentationReady = playbackController !== null
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
const presentationState = launchError || viewerStatus === "error"
? "error"
: presentationReady ? "ready" : "loading";
const sceneSettings = useMemo<SceneSettings>(() => ({
...sceneDraft,
projection: spatialMode === "plan" ? "2d" : "3d",
showPoints: spatialMode !== null && (showSourcePoints || showLocalSlam),
showTrajectory: spatialMode !== null && showLocalSlam,
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
const semanticVisible = showDDRNet || showEoMT;
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: initialPlaybackStartSeconds
?? ((launch.base.mediaSources[0]?.timelineStartSeconds ?? launch.base.timelineStartSeconds)
+ (hasTgs ? 0.000001 : 0)),
view: hasCameraPane && !hasSpatialPane ? "perception" : "spatial",
viewResetGeneration,
followTrajectory: followTarget,
semanticLayer: showEoMT ? "city" : "vegetation",
unifiedPerception: splitView,
unifiedCameraShare: blueprintSplitPrimarySize / 100,
planView: spatialMode === "plan",
perceptionLayers: {
enabled: true,
cameraImage: showCamera,
detections2d: hasDetections && showDetections,
segmentation: semantics && semanticVisible,
cuboids3d: false,
costmap: hasTgs && showTgs,
},
perceptionRetryGeneration: 0,
lockPerceptionCameraInteraction: false,
}) : null;
const toggle = (caption: string, active: boolean, onClick: () => void) => (
<Button key={caption} size="dense" shape="pill" variant={active ? "primary" : "secondary"}
aria-pressed={active} onClick={onClick}>
{caption}
</Button>
);
const openSlamSettings = useCallback(() => {
if (expanded) onExpandedChange(false);
setSettingsOpen(true);
}, [expanded, onExpandedChange]);
const patchSceneDraft = useCallback((patch: Partial<SceneSettings>) => {
const next = { ...sceneDraftRef.current, ...patch };
sceneDraftRef.current = next;
setSceneDraft(next);
}, []);
const closeSlamSettings = useCallback(() => {
const next = normalizeLabSceneSettings(sceneDraftRef.current);
sceneDraftRef.current = next;
setSceneDraft(next);
setSettingsOpen(false);
void saveLabViewProfile(resultId, next).then((settings) => {
sceneDraftRef.current = settings;
setSceneDraft(settings);
setToasts((current) => current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST));
}).catch(() => {
setToasts((current) => [
...current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST),
{
id: LAB_VIEW_PROFILE_ERROR_TOAST,
tone: "error",
title: "Настройки LAB не сохранились",
description: "Изменения остались в текущем просмотре. Откройте настройки и повторите.",
},
]);
});
}, [resultId]);
const mediaLayerControls = (
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои камеры">
{toggle("КАМЕРА", showCamera, () => setShowCamera((value) => !value))}
{hasEoMT ? toggle("EOMT", showEoMT, () => setShowEoMT((value) => !value)) : null}
{hasDDRNet || hasFallbackSemantics
? toggle("DDRNET", showDDRNet, () => setShowDDRNet((value) => !value)) : null}
{hasDetections
? toggle("РАМКИ", showDetections, () => setShowDetections((value) => !value)) : null}
</div>
);
const spatialLayerControls = (
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои облака точек">
{hasSourcePoints
? toggle("ИСХ. ТОЧКИ", showSourcePoints, () => setShowSourcePoints((value) => !value)) : null}
{hasLocalSlam ? (
<div className="m4-replay-threat-visual__layer-with-settings">
{toggle("ЛОК. SLAM", showLocalSlam, () => setShowLocalSlam((value) => !value))}
<IconButton label="Настройки облака SLAM" onClick={openSlamSettings}>
<Icon name="settings" size={14} />
</IconButton>
</div>
) : null}
{hasTgs ? toggle("TGS", showTgs, () => setShowTgs((value) => !value)) : null}
</div>
);
const followTargetControl = toggle(
"СЛЕДОВАТЬ",
followTarget,
() => setFollowTarget((value) => !value),
);
const resetSpatialView = (
<IconButton label="Сбросить положение видов"
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}>
<Icon name="refresh" size={16} />
</IconButton>
);
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={label}
mediaMode={hasCameraPane ? mediaMode ?? "camera" : "none"}
mediaModes={[{ value: "camera", label: "КАМЕРА" }]}
spatialMode={hasSpatialPane ? spatialMode ?? "3d" : "none"}
spatialModes={[{ value: "3d", label: "3D" }, { value: "plan", label: "ПЛАН" }]}
expanded={expanded}
splitPrimarySize={splitPrimarySize}
splitOrientation={splitView ? "vertical" : splitOrientation}
mediaAriaLabel="Камера и рассчитанные слои"
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
mediaLayerControls={mediaLayerControls}
spatialLayerControls={spatialLayerControls}
spatialTrailingControl={followTargetControl}
mediaModeControlsVisible={false}
paneToolbarsAlwaysVisible
mediaMultiLayer
actions={resetSpatialView}
unifiedContent={profile ? (
<div className="canonical-vegetation-rerun-replay__viewport-lock"
data-split-view={splitView ? "true" : 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="Слои результата недоступны."
deckOverlays={presentationState === "loading" ? (
<div className="canonical-vegetation-rerun-replay__loading">
<ActivityIndicator label="Загружаем синхронизированную запись" />
</div>
) : undefined}
transport={transport}
onMediaModeChange={onMediaModeChange}
onSpatialModeChange={onSpatialModeChange}
onExpandedChange={onExpandedChange}
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
/>
<Window open={settingsOpen} title="Отображение" subtitle="Параметры облака SLAM"
placement="end" size="sm" onClose={closeSlamSettings}>
<Inspector defaultOpen={["points"]} sections={[
{
id: "points",
label: "Облако точек",
description: "Размер и способ окрашивания",
content: <>
<RangeControl label="Размер точки" min={0.1} max={10} step={0.1}
exactValueBounds={{ min: 0.1 }}
value={sceneDraft.pointSize} formatValue={(value) => `${value.toFixed(1)} пкс`}
onChange={(pointSize) => patchSceneDraft({ pointSize })} />
<InspectorSelectField label="Атрибут цвета" value={sceneDraft.colorMode}
options={[...COLOR_OPTIONS]}
onChange={(colorMode) => patchSceneDraft({ colorMode })} />
<InspectorSelectField label="Палитра" value={sceneDraft.palette}
options={[...PALETTE_OPTIONS]}
onChange={(palette) => patchSceneDraft({ palette })} />
</>,
},
{
id: "history",
label: "Накопление и время",
description: "История облака и траектория",
content: <RangeControl label="Накопление" min={0} max={500} step={1}
exactValueBounds={{ min: 0 }}
value={sceneDraft.accumulationSeconds} formatValue={(value) => `${value} с`}
onChange={(accumulationSeconds) => patchSceneDraft({ accumulationSeconds })} />,
},
{
id: "environment",
label: "Окружение сцены",
description: "Сетка, подписи и камеры",
content: <>
<ControlRow label="Сетка"><Checker checked={sceneDraft.showGrid} label="Показывать"
onChange={(showGrid) => patchSceneDraft({ showGrid })} /></ControlRow>
<ControlRow label="Подписи"><Checker checked={sceneDraft.showLabels} label="Показывать"
onChange={(showLabels) => patchSceneDraft({ showLabels })} /></ControlRow>
<ControlRow label="Камеры"><Checker checked={sceneDraft.showCameraFrustums} label="Показывать"
onChange={(showCameraFrustums) => patchSceneDraft({ showCameraFrustums })} /></ControlRow>
</>,
},
]} />
</Window>
<ToastStack items={toasts}
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
</div>
);
}
@@ -1,383 +1,12 @@
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 { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveCanonicalLabReplay } 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,
}: {
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>
);
return <CanonicalResultRerunReplay resultId={resultId} sessionId={review.sessionId}
initialPlaybackStartSeconds={review.timelineStartSeconds} semantics
resolveReplay={resolveCanonicalLabReplay} label="RAVNOVES004TREE · канонический повтор Rerun" />;
}
@@ -0,0 +1,51 @@
import { GlassSurface } from "@nodedc/ui-react";
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
import type { ObservatoryPortableResultReview } from "../../core/observatory/recordedRun";
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
function resolvePortableTgsReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-tgs" });
}
function resolvePortableSemanticReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-semantic" });
}
function resolvePortableObjectReplay(resultId: string, launch: ObservationSessionReplayLaunch,
options: { signal: AbortSignal }) {
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-objects" });
}
export function PortableResultReplay({
review,
label,
}: {
review: ObservatoryPortableResultReview;
label?: string;
}) {
const schema = review.resultDocument.schema_version;
const tgs = schema === "missioncore.recorded-tgs-costmap-review/v2";
const module = review.resultDocument.module;
const moduleId = module && typeof module === "object" && "module_id" in module
? module.module_id : undefined;
const semantics = schema === "missioncore.recorded-eomt-ddrnet-review/v2"
|| (schema === "missioncore.recorded-ai-layer-review/v1"
&& (moduleId === "ddrnet" || moduleId === "eomt"));
const objects = schema === "missioncore.recorded-ai-layer-review/v1"
&& (moduleId === "rf-detr" || moduleId === "object-distance");
if (!tgs && !semantics && !objects) return (
<GlassSurface className="observatory-replay-state" padding="lg">
<p role="alert">Сохранённый результат этого профиля пока не поддерживает визуальный разбор.</p>
</GlassSurface>
);
return <CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
sessionId={review.sourceSessionId} portable costmap={tgs} semantics={semantics}
detections={objects}
moduleIds={typeof moduleId === "string" ? [moduleId] : []}
label={label}
resolveReplay={tgs ? resolvePortableTgsReplay
: objects ? resolvePortableObjectReplay : resolvePortableSemanticReplay} />;
}
@@ -0,0 +1,261 @@
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Button,
FieldFrame,
Select,
StatusBadge,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
import {
aiModuleIdForSetup,
fetchAIModuleCatalog,
fetchAICompositionRuns,
fetchAICompositionJobs,
saveAIComposition,
type AICompositionReceipt,
type AICompositionRun,
type AIGroupId,
type AIModuleCatalog,
} from "../../core/observatory/aiComposition";
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
const groups: readonly { id: AIGroupId; label: string; description: string }[] = [
{ id: "segmentation", label: "Сегментация", description: "Пиксельные маски сцены" },
{ id: "detection", label: "Объекты", description: "Рамки и классы объектов" },
{ id: "geometry", label: "Облако точек / TGS", description: "Опорная поверхность и карта проходимости" },
{ id: "range", label: "Дистанция", description: "Расстояния до объектов" },
{ id: "motion", label: "Движение", description: "Треки и динамика объектов" },
{ id: "policy", label: "Политика", description: "Теневые правила интерпретации" },
];
export function AIConfigurationWindow({
open, sourceSessionId, sourceLabel, onClose, onCalculated,
}: {
readonly open: boolean;
readonly sourceSessionId: string;
readonly sourceLabel: string;
readonly onClose: () => void;
readonly onCalculated: (receipt: AICompositionReceipt) => void;
}) {
const [catalog, setCatalog] = useState<AIModuleCatalog | null>(null);
const [existingJobs, setExistingJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
const [existingRuns, setExistingRuns] = useState<readonly AICompositionRun[]>([]);
const [selected, setSelected] = useState<Partial<Record<AIGroupId, string>>>({});
const [state, setState] = useState<"idle" | "loading" | "ready" | "saving" | "error">("idle");
const [message, setMessage] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
const request = new AbortController();
setState("loading");
setMessage(null);
void Promise.all([
fetchAIModuleCatalog(request.signal),
fetchAICompositionJobs(sourceSessionId, request.signal),
fetchAICompositionRuns(sourceSessionId, request.signal),
]).then(([value, jobs, runs]) => {
if (request.signal.aborted) return;
setCatalog(value);
setExistingJobs(jobs);
setExistingRuns(runs);
setSelected({});
setState("ready");
}).catch((error: unknown) => {
if (request.signal.aborted) return;
setMessage(error instanceof Error ? error.message : "Каталог AI-модулей недоступен.");
setState("error");
});
return () => request.abort();
}, [open, sourceSessionId]);
const existingByModule = useMemo(() => {
const result = new Map<string, ObservatoryRecordedJob>();
for (const job of existingJobs) {
if (job.state === "failed" || job.publication.state === "failed") continue;
const moduleId = aiModuleIdForSetup(job.setupId);
if (moduleId && !result.has(moduleId)) result.set(moduleId, job);
}
return result;
}, [existingJobs]);
const selections = useMemo(() => groups.flatMap(({ id }) => {
const moduleId = selected[id];
const module = catalog?.groups.find((item) => item.group === id)?.modules.find(
(item) => item.moduleId === moduleId,
);
return module ? [{ group: id, module }] : [];
}), [catalog, selected]);
const selectedModuleIds = useMemo(
() => selections.map(({ module }) => module.moduleId).sort(),
[selections],
);
const matchingRun = useMemo(() => existingRuns.find((run) => (
run.state !== "failed"
&& [...run.moduleIds].sort().join("\0") === selectedModuleIds.join("\0")
)), [existingRuns, selectedModuleIds]);
const configurationAlreadyExists = selections.length > 0 && matchingRun !== undefined;
const configurationIsRunning = matchingRun?.state === "running";
function selectModule(group: AIGroupId, moduleId: string): void {
if (!catalog) return;
setSelected((current) => {
const next = { ...current, [group]: moduleId === "none" ? undefined : moduleId };
const modules = catalog.groups.flatMap((item) => item.modules);
const selectedIds = new Set(Object.values(next).filter((value): value is string => Boolean(value)));
if (moduleId !== "none") {
const module = modules.find((item) => item.moduleId === moduleId);
for (const requirement of module?.requires ?? []) {
if (requirement.startsWith("source.")) continue;
if (modules.some((item) => selectedIds.has(item.moduleId) && item.provides.includes(requirement))) {
continue;
}
const providers = modules.filter((item) => item.provides.includes(requirement));
if (providers.length === 1) {
const providerGroup = catalog.groups.find((item) => item.modules.some(
(candidate) => candidate.moduleId === providers[0]?.moduleId,
))?.group;
if (providerGroup) {
next[providerGroup] = providers[0]?.moduleId;
selectedIds.add(providers[0]?.moduleId ?? "");
}
}
}
} else {
let changed = true;
while (changed) {
changed = false;
const capabilities = new Set(modules.filter((item) => Object.values(next).includes(item.moduleId))
.flatMap((item) => item.provides));
for (const item of modules.filter((candidate) => Object.values(next).includes(candidate.moduleId))) {
if (item.requires.some((requirement) => !requirement.startsWith("source.") && !capabilities.has(requirement))) {
const dependentGroup = catalog.groups.find((candidate) => candidate.modules.some(
(module) => module.moduleId === item.moduleId,
))?.group;
if (dependentGroup) {
next[dependentGroup] = undefined;
changed = true;
}
}
}
}
}
return next;
});
setMessage(null);
if (state === "error") setState("ready");
}
async function calculate(): Promise<void> {
if (selections.length === 0 || state === "saving") return;
if (configurationAlreadyExists) {
setMessage("Эта конфигурация уже рассчитана. Выберите другую конфигурацию.");
return;
}
setState("saving");
setMessage(null);
try {
const receipt = await saveAIComposition(sourceSessionId, selections);
onCalculated(receipt);
onClose();
} catch (error) {
setMessage(error instanceof Error ? error.message : "Не удалось запустить расчёт.");
setState("error");
}
}
return <Window
open={open}
className="observatory-ai-config-window"
title="Сконфигурировать AI-слой"
subtitle={sourceLabel}
size="lg"
closeOnBackdrop={state !== "saving"}
closeOnEscape={state !== "saving"}
onClose={onClose}
footer={<WindowFooterActions>
{configurationAlreadyExists ? (
<span className="observatory-ai-config__duplicate" role="status">
{configurationIsRunning
? "Текущая конфигурация уже рассчитывается."
: "Текущая конфигурация уже рассчитана."}
</span>
) : null}
<Button
variant="primary"
className={state === "saving" ? "observatory-ai-config__calculate--saving" : undefined}
disabled={state !== "ready" || selections.length === 0 || configurationAlreadyExists}
aria-busy={state === "saving"}
onClick={() => void calculate()}
>
{state === "saving" ? "Отправляем на Worker…" : "Рассчитать"}
</Button>
</WindowFooterActions>}
>
<div className="observatory-ai-config" aria-busy={state === "loading" || state === "saving"}>
<p className="observatory-ai-config__lead">
Выберите модуль в каждом нужном слое. Зависимые блоки подключаются явно в этой конфигурации.
</p>
{state === "loading" ? <ActivityIndicator label="Загружаем AI-модули" /> : null}
{catalog ? groups.map(({ id, label, description }) => {
const modules = catalog.groups.find((item) => item.group === id)?.modules ?? [];
const selectedModule = modules.find((item) => item.moduleId === selected[id]);
const selectedExisting = selectedModule
? existingByModule.get(selectedModule.moduleId)
: undefined;
const allCalculated = modules.length > 0 && modules.every(
(module) => existingByModule.has(module.moduleId),
);
return <section className="observatory-ai-module-group" key={id}>
<header>
<div>
<strong>{label}</strong>
<span>{description}</span>
</div>
<StatusBadge tone={selectedModule ? "accent" : "neutral"}>
{selectedExisting
? selectedExisting.state === "succeeded" ? "Уже рассчитано" : "В расчёте"
: selectedModule?.label ?? (allCalculated
? "Результат доступен"
: modules.length ? "Не используется" : "Не установлен")}
</StatusBadge>
</header>
<div className="observatory-ai-module-group__body">
<FieldFrame
label={`Модуль: ${label}`}
description={modules.length
? allCalculated
? "Готовый результат можно включить в новую конфигурацию без повторного расчёта."
: id === "segmentation"
? "DDRNet и EoMT — независимые альтернативы; выберите одну модель."
: id === "range"
? "Дистанция использует рамки детектора и синхронное облако точек."
: id === "geometry"
? "TGS обрабатывает LiDAR независимо от сегментации и детектора."
: "Связи с другими блоками определяются входами выбранного модуля."
: "Для этого слоя на Worker 006 пока нет установленного модуля."}
>
<Select
label={`Выбрать модуль: ${label}`}
value={selected[id] ?? "none"}
options={[{ value: "none", label: "Не использовать" }, ...modules.map((module) => ({
value: module.moduleId,
label: module.label,
description: existingByModule.has(module.moduleId)
? "Готовый результат будет использован без повторного расчёта"
: module.dockerName,
}))]}
disabled={modules.length === 0 || state === "saving"}
onChange={(value) => selectModule(id, value)}
/>
</FieldFrame>
</div>
</section>;
}) : null}
{message ? <p className="observatory-ai-config__error" role="alert">{message}</p> : null}
</div>
</Window>;
}
@@ -0,0 +1,10 @@
import { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameWindow } from "./recordedRerunProtocol";
import { createRecordedRerunOwner } from "./recordedRerunOwner";
// This entry is loaded only in the owned iframe. Upstream code is unmodified;
// destroying the frame also ends its timers, WASM, video and WebGPU lifetime.
const mount = document.getElementById("viewer");
if (!mount) throw new Error("Recorded viewer mount is missing");
(window as RerunFrameWindow).missionCoreRerun = createRecordedRerunOwner(() => new WebViewer(), mount);
@@ -0,0 +1,77 @@
import { createRecordedRerunFacade, type RecordedRerunViewer } from "./recordedRerunFacade";
import type { RerunFrameWindow } from "./recordedRerunProtocol";
import { relayIsolatedRerunInput } from "./isolatedRerunInput";
/** Own the complete recorded-viewer realm, not just its canvas or SDK handle. */
export function createIsolatedRerunHost(host: HTMLElement) {
let frame: HTMLIFrameElement | null = host.ownerDocument.createElement("iframe");
frame.title = "Синхронизированная запись";
frame.className = "rerun-viewport__runtime";
frame.src = "/rerun-runtime.html";
let disposed = false;
let releaseFacade: (() => void) | null = null;
let releaseInput: (() => void) | null = null;
let frameWindow: RerunFrameWindow | null = null;
let rejectReady: ((reason: Error) => void) | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
const dispose = () => {
if (disposed) return;
disposed = true;
clearTimeout(timer);
frame!.onload = null;
frame!.onerror = null;
releaseInput?.();
releaseInput = null;
try {
releaseFacade?.();
} catch {
// Realm teardown is required even if a partial native start failed.
} finally {
releaseFacade = null;
frameWindow = null;
frame!.remove();
frame = null;
rejectReady?.(new Error("Recorded viewer was disposed"));
rejectReady = null;
}
};
const ready = new Promise<{ viewer: RecordedRerunViewer; mount: HTMLElement }>((resolve, reject) => {
rejectReady = reject;
frame!.onload = () => {
if (disposed) return;
try {
frameWindow = frame!.contentWindow as RerunFrameWindow | null;
const api = frameWindow?.missionCoreRerun;
if (!api || api.version !== 2) throw new Error("Recorded viewer host is unavailable");
const owner = createRecordedRerunFacade((request, bytes) => {
const current = frameWindow?.missionCoreRerun;
if (!current) throw new Error("Recorded viewer was disposed");
return current.invoke(request, bytes);
});
releaseFacade = owner.dispose;
api.connect(owner.notify);
releaseInput = relayIsolatedRerunInput(host, frameWindow, frame);
clearTimeout(timer);
rejectReady = null;
frame!.onload = null;
frame!.onerror = null;
resolve({ viewer: owner.facade, mount: host });
} catch (error) {
reject(new Error(String(error)));
dispose();
}
};
frame!.onerror = () => {
reject(new Error("Recorded viewer host failed to load"));
dispose();
};
timer = setTimeout(() => {
reject(new Error("Recorded viewer host timed out"));
dispose();
}, 30_000);
});
host.append(frame);
return { ready, dispose };
}
@@ -0,0 +1,17 @@
/** Relay Escape from the isolated viewer realm to the owning application. */
export function relayIsolatedRerunInput(
host: HTMLElement,
frameWindow: Window | null,
_frame: HTMLIFrameElement | null,
) {
const parent = host.ownerDocument.defaultView;
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented || !parent) return;
host.dispatchEvent(new parent.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
};
frameWindow?.addEventListener("keydown", escape);
return () => {
frameWindow?.removeEventListener("keydown", escape);
frameWindow = null;
};
}
@@ -0,0 +1,244 @@
export type RecordedRerunCameraEye = {
readonly position: readonly [number, number, number];
readonly lookTarget: readonly [number, number, number];
readonly eyeUp: readonly [number, number, number];
};
type Vector3 = [number, number, number];
type DragMode = "rotate" | "pan" | "roll";
const ROTATION_RADIANS_PER_POINT = 0.004;
const RERUN_WEB_LINE_SCROLL_POINTS = 8;
const RERUN_ORBITAL_SCROLL_DIVISOR = 200;
const RERUN_WEB_PINCH_SCROLL_DIVISOR = 100;
const RERUN_MIN_ORBIT_DISTANCE = 0.02;
const DOM_WHEEL_DELTA_LINE = 1;
const DOM_WHEEL_DELTA_PAGE = 2;
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
position: [16, -16, 18],
lookTarget: [0, 0, 0],
eyeUp: [0, 0, 1],
};
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
position: [0, 0, 30],
lookTarget: [0, 0, 0],
eyeUp: [0, 1, 0],
};
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a: Vector3, b: Vector3): Vector3 => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const length = (value: Vector3) => Math.hypot(...value);
const normalize = (value: Vector3, fallback: Vector3): Vector3 => {
const magnitude = length(value);
return magnitude > Number.EPSILON ? scale(value, 1 / magnitude) : fallback;
};
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
const rotateAroundAxis = (value: Vector3, rawAxis: Vector3, angle: number): Vector3 => {
const axis = normalize(rawAxis, [0, 0, 1]);
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
return add(
add(scale(value, cosine), scale(cross(axis, value), sine)),
scale(axis, dot(axis, value) * (1 - cosine)),
);
};
function cloneEye(value: RecordedRerunCameraEye): {
position: Vector3;
lookTarget: Vector3;
eyeUp: Vector3;
} {
return {
position: vector(value.position),
lookTarget: vector(value.lookTarget),
eyeUp: vector(value.eyeUp),
};
}
/**
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
* renderer. Rerun writes operator navigation into its active blueprint clone;
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
* here lets a later layer blueprint activate with the exact operator pose.
*/
export function createRecordedRerunCameraJournal(
canvas: HTMLCanvasElement,
scope: Window & typeof globalThis,
) {
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
let spatialViewportStart = 0;
let pointerId: number | null = null;
let dragMode: DragMode | null = null;
let previousPointer: readonly [number, number] | null = null;
let latestPointer: readonly [number, number] | null = null;
let maxOrbitalRadius = 1.0e17;
const isSpatialPoint = (event: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
};
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
const usableUp = () => {
const fwd = forward();
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
? [1, 0, 0]
: cross(fwd, [0, 0, 1]);
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
const candidate = normalize(eye.eyeUp, fallback);
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
};
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
const rotate = (deltaX: number, deltaY: number) => {
const radius = orbitRadius();
const up = usableUp();
let fwd = forward();
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
fwd = normalize(
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
fwd,
);
const right = normalize(cross(fwd, up), [1, 0, 0]);
const maxPitch = 0.99 * Math.PI / 2;
const nextPitch = clamp(
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
-maxPitch,
maxPitch,
);
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
};
const pan = (deltaX: number, deltaY: number) => {
const fwd = forward();
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
const speed = 0.001 * orbitRadius();
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
eye.position = add(eye.position, translation);
eye.lookTarget = add(eye.lookTarget, translation);
};
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
const rect = canvas.getBoundingClientRect();
const left = rect.left + rect.width * spatialViewportStart;
const centerX = left + (rect.right - left) / 2;
const centerY = rect.top + rect.height / 2;
const relativeX = event.clientX - centerX;
const relativeY = event.clientY - centerY;
const divisor = relativeX * relativeX + relativeY * relativeY;
if (divisor <= Number.EPSILON) return;
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
};
const pointerDown = (event: PointerEvent) => {
latestPointer = [event.clientX, event.clientY];
if (!isSpatialPoint(event)) return;
pointerId = event.pointerId;
previousPointer = [event.clientX, event.clientY];
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
};
const pointerMove = (event: PointerEvent) => {
latestPointer = [event.clientX, event.clientY];
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
const deltaX = event.clientX - previousPointer[0];
const deltaY = event.clientY - previousPointer[1];
previousPointer = [event.clientX, event.clientY];
if (dragMode === "rotate") rotate(deltaX, deltaY);
else if (dragMode === "pan") pan(deltaX, deltaY);
else roll(event, deltaX, deltaY);
};
const pointerEnd = (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
pointerMove(event);
pointerId = null;
previousPointer = null;
dragMode = null;
};
const wheel = (event: WheelEvent) => {
const rect = canvas.getBoundingClientRect();
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
? event.clientX
: latestPointer?.[0];
if (wheelX === undefined || rect.width <= 0) return;
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
? RERUN_WEB_LINE_SCROLL_POINTS
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
? rect.height
: 1;
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
// eframe negates the DOM delta before Rerun applies
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
// exponent products collapse to the raw accumulated DOM delta below.
const divisor = event.ctrlKey
? RERUN_WEB_PINCH_SCROLL_DIVISOR
: RERUN_ORBITAL_SCROLL_DIVISOR;
const radiusFactor = Math.exp(scrollPoints / divisor);
const offset = subtract(eye.position, eye.lookTarget);
const radius = length(offset);
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
// the scene-derived cap is allowed to stay there and is never snapped in.
const nextRadius = clamp(
radius * radiusFactor,
RERUN_MIN_ORBIT_DISTANCE,
Math.max(radius, maxOrbitalRadius),
);
eye.position = add(
eye.lookTarget,
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
);
};
canvas.addEventListener("pointerdown", pointerDown, true);
scope.addEventListener("pointermove", pointerMove, true);
scope.addEventListener("pointerup", pointerEnd, true);
scope.addEventListener("pointercancel", pointerEnd, true);
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
return {
current(): RecordedRerunCameraEye {
return {
position: [...eye.position],
lookTarget: [...eye.lookTarget],
eyeUp: [...eye.eyeUp],
};
},
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
eye = cloneEye(nextEye);
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
pointerId = null;
dragMode = null;
previousPointer = null;
},
setSpatialViewportStart(nextSpatialViewportStart: number) {
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
},
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
if (
Number.isFinite(nextMaxOrbitalRadius) &&
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
) {
maxOrbitalRadius = nextMaxOrbitalRadius;
}
},
dispose() {
canvas.removeEventListener("pointerdown", pointerDown, true);
scope.removeEventListener("pointermove", pointerMove, true);
scope.removeEventListener("pointerup", pointerEnd, true);
scope.removeEventListener("pointercancel", pointerEnd, true);
scope.removeEventListener("wheel", wheel, true);
},
};
}
@@ -0,0 +1,105 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunInvoke } from "./recordedRerunProtocol";
import type { RecordedRerunCameraEye } from "./recordedRerunCameraJournal";
type NativeChannel = ReturnType<WebViewer["open_channel"]>;
type Channel = Pick<NativeChannel, "ready" | "send_rrd" | "close">;
export type RecordedRerunViewer = Pick<WebViewer,
"start" | "stop" | "ready" | "on" | "open" | "close" | "override_panel_state" |
"get_active_recording_id" | "get_active_timeline" | "get_current_time" |
"get_playing" | "get_time_range" | "set_active_timeline" |
"set_current_time" | "set_playing"
> & {
open_channel: (name?: string) => Channel;
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
get_camera_eye: () => RecordedRerunCameraEye;
set_camera_viewport_start: (spatialViewportStart: number) => void;
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
};
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
let nextId = 0;
const callbacks = new Map<number, (...args: unknown[]) => void>();
const starts = new Map<number, { resolve: () => void; reject: (error: Error) => void }>();
const command = (method: string, args: unknown[] = [], id?: number, bytes?: Uint8Array) => {
if (!invoke) throw new Error("Recorded viewer was disposed");
const response = JSON.parse(invoke(JSON.stringify({ method, args, id }), bytes));
if (response.error) throw new Error(response.error);
return response.result;
};
const dispose = () => {
callbacks.clear();
for (const pending of starts.values()) pending.reject(new Error("Recorded viewer was disposed"));
starts.clear();
try { if (invoke) command("stop"); } finally { invoke = null; }
};
const notify = (message: string) => {
if (!invoke) return;
const event = JSON.parse(message);
if (event.type === "event") callbacks.get(event.id)?.(...event.values);
else {
const pending = starts.get(event.id);
starts.delete(event.id);
if (event.type === "started") pending?.resolve();
else pending?.reject(new Error(event.message));
}
};
const facade: RecordedRerunViewer = {
get ready() { return invoke ? command("ready") : false; },
start(source, _parent, options) {
return new Promise<void>((resolve, reject) => {
const id = ++nextId;
starts.set(id, { resolve, reject });
try { command("start", [source, options], id); }
catch (error) { starts.delete(id); reject(error); }
});
},
stop: dispose,
on: ((event: string, callback: (...args: unknown[]) => void) => {
const id = ++nextId;
callbacks.set(id, callback);
try { command("on", [event], id); }
catch (error) { callbacks.delete(id); throw error; }
return () => {
if (callbacks.delete(id) && invoke) command("off", [], id);
};
}) as WebViewer["on"],
open_channel(name) {
const id = ++nextId;
command("channel-open", name === undefined ? [] : [name], id);
let closed = false;
return {
get ready() { return !closed && invoke ? command("channel-ready", [], id) : false; },
send_rrd(bytes) { if (!closed && invoke) command("channel-send", [], id, bytes); },
close() {
if (closed) return;
closed = true;
if (invoke) command("channel-close", [], id);
},
};
},
open: (...args) => command("open", args),
close: (...args) => { if (invoke) command("close", args); },
override_panel_state: (...args) => command("override_panel_state", args),
get_active_recording_id: () => command("get_active_recording_id"),
get_active_timeline: (...args) => command("get_active_timeline", args),
get_current_time: (...args) => command("get_current_time", args),
get_playing: (...args) => command("get_playing", args),
get_time_range: (...args) => command("get_time_range", args),
set_active_timeline: (...args) => command("set_active_timeline", args),
set_current_time: (...args) => command("set_current_time", args),
set_playing: (...args) => command("set_playing", args),
configure_camera_journal: (eye, spatialViewportStart) => command(
"configure-camera-journal", [eye, spatialViewportStart],
),
get_camera_eye: () => command("get-camera-eye"),
set_camera_viewport_start: (spatialViewportStart) => command(
"set-camera-viewport-start", [spatialViewportStart],
),
set_camera_max_orbital_radius: (maxOrbitalRadius) => command(
"set-camera-max-orbital-radius", [maxOrbitalRadius],
),
};
return { facade, dispose, notify };
}
@@ -0,0 +1,115 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
let native: WebViewer | null = null;
let notify: RerunNotify | null = null;
const subscriptions = new Map<number, () => void>();
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
let cameraJournal: ReturnType<typeof createRecordedRerunCameraJournal> | null = null;
const send = (message: object) => notify?.(JSON.stringify(message));
const stop = () => {
notify = null;
for (const unsubscribe of subscriptions.values()) {
try { unsubscribe(); } catch { /* Continue partial-start cleanup. */ }
}
subscriptions.clear();
for (const channel of channels.values()) {
try { channel.close(); } catch { /* Other channels must still close. */ }
}
channels.clear();
cameraJournal?.dispose();
cameraJournal = null;
const current = native;
native = null;
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
};
const required = () => {
if (!native) throw new Error("Recorded viewer was disposed");
return native;
};
const start = async (id: number, args: [string | string[] | null, Parameters<WebViewer["start"]>[2]]) => {
const starting = required();
try {
await starting.start(args[0], mount, args[1]);
if (native === starting && starting.canvas) {
cameraJournal?.dispose();
cameraJournal = createRecordedRerunCameraJournal(
starting.canvas,
window as Window & typeof globalThis,
);
}
if (native === starting) send({ type: "started", id });
} catch (error) {
if (native === starting) send({ type: "start-failed", id, message: String(error) });
} finally {
if (native !== starting) {
try { starting.stop(); } catch { /* Late completion after disposal. */ }
}
}
};
return {
version: 2,
connect(callback) {
if (native) throw new Error("Recorded viewer already has an owner");
native = create();
notify = callback;
},
invoke(request, bytes) {
try {
const { method, args = [], id } = JSON.parse(request);
let result: unknown;
switch (method) {
case "stop": stop(); break;
case "start": required(); void start(id, args); break;
case "ready": result = native?.ready ?? false; break;
case "on": {
const subscribe = required().on as (event: string, callback: (...values: unknown[]) => void) => () => void;
subscriptions.set(id, subscribe.call(required(), args[0], (...values) => send({ type: "event", id, values })));
break;
}
case "off":
try { subscriptions.get(id)?.(); } finally { subscriptions.delete(id); }
break;
case "channel-open": {
const name = args[0] as string | undefined;
channels.set(id, required().open_channel(name));
break;
}
case "channel-ready": result = channels.get(id)?.ready ?? false; break;
case "channel-send": if (bytes) {
channels.get(id)?.send_rrd(bytes);
} break;
case "channel-close":
try { channels.get(id)?.close(); } finally {
channels.delete(id);
}
break;
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
case "get-camera-eye": result = cameraJournal?.current(); break;
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
case "open": required().open(args[0]); break;
case "close": required().close(args[0]); break;
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
case "get_active_recording_id": result = required().get_active_recording_id(); break;
case "get_active_timeline": result = required().get_active_timeline(args[0]); break;
case "get_current_time": result = required().get_current_time(args[0], args[1]); break;
case "get_playing": result = required().get_playing(args[0]); break;
case "get_time_range": result = required().get_time_range(args[0], args[1]); break;
case "set_active_timeline": required().set_active_timeline(args[0], args[1]); break;
case "set_current_time": required().set_current_time(args[0], args[1], args[2]); break;
case "set_playing": required().set_playing(args[0], args[1]); break;
default: throw new Error("Unsupported recorded viewer command");
}
return JSON.stringify({ result });
} catch (error) {
// A native Error also has a foreign prototype. Return text, never throw
// that object into a parent Promise/callback retained after disposal.
return JSON.stringify({ error: String(error) });
}
},
};
}
@@ -0,0 +1,12 @@
/** Only parent-owned inputs and primitive strings cross the recorded realm.
* An SDK instance, Promise, DOM node or native callback never becomes a value
* retained by React. This is not a second playback clock.
*/
export type RerunInvoke = (request: string, bytes?: Uint8Array) => string;
export type RerunNotify = (message: string) => void;
export interface RerunFrameApi {
version: 2;
connect: (notify: RerunNotify) => void;
invoke: RerunInvoke;
}
export type RerunFrameWindow = Window & { missionCoreRerun?: RerunFrameApi };
@@ -23,26 +23,34 @@ export async function resolveCanonicalLabReplay(
origin = window.location.origin,
signal,
fetcher = globalThis.fetch,
sourceKind = "legacy-vegetation",
}: {
origin?: string;
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
sourceKind?: "legacy-vegetation" | "portable-tgs" | "portable-semantic" | "portable-objects";
} = {},
): Promise<CanonicalLabReplayDescriptor> {
const base = new URL(origin);
if (
!SAFE_RESULT_ID.test(resultId)
!(sourceKind === "portable-tgs"
? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId)
: sourceKind === "portable-semantic"
? /^(?:lab-v1-eomt-ddrnet|ai-layer-(?:ddrnet|eomt))-[a-f0-9]{64}$/.test(resultId)
: sourceKind === "portable-objects"
? /^ai-layer-(?:rf-detr|object-distance)-[a-f0-9]{64}$/.test(resultId)
: 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 sourceUrl = sourceKind !== "legacy-vegetation"
? `/api/v1/observatory/portable-results/${resultId}/replays/${launch.sha256}/recording.rrd`
: `/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 (sourceKind === "legacy-vegetation") descriptorUrl.searchParams.set("base_generation", launch.sha256);
if (descriptorUrl.origin !== base.origin) {
throw new Error("Канонический replay LAB должен быть same-origin.");
}
@@ -74,3 +82,53 @@ export async function resolveCanonicalLabReplay(
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
};
}
export async function resolveAICompositionReplay(
runId: 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 (
!/^ai-composition-[a-f0-9]{64}$/.test(runId)
|| !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/observatory/ai-composition-runs/${runId}/replays/${launch.sha256}/recording.rrd`;
const descriptorUrl = new URL(sourceUrl, `${base.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"),
};
}
@@ -0,0 +1,53 @@
/** Small lease for the server's ephemeral blueprint, never for the recording. */
export function keepRecordedBlueprintSession({
endpointUrl,
origin,
applicationId,
recordingId,
ownerId,
fetcher = fetch,
schedule = (callback: () => void) => window.setInterval(callback, 30_000),
cancel = (handle: number) => window.clearInterval(handle),
}: {
endpointUrl: string;
origin: string;
applicationId: string;
recordingId: string;
ownerId: string;
fetcher?: typeof fetch;
schedule?: (callback: () => void) => number;
cancel?: (handle: number) => void;
}): () => void {
const endpoint = new URL(endpointUrl, origin);
if (endpoint.origin !== origin || endpoint.search || endpoint.hash ||
!/^\/api\/v1\/observation-sessions\/[A-Za-z0-9._:-]+\/blueprint\.rrd$/.test(endpoint.pathname)) {
throw new Error("Unsafe recorded blueprint lifecycle endpoint");
}
endpoint.pathname = endpoint.pathname.replace(/blueprint\.rrd$/, "blueprint-lifecycle");
const identity = { application_id: applicationId, recording_id: recordingId,
blueprint_session_id: ownerId };
let closed = false;
let pending: AbortController | null = null;
const renew = () => {
if (closed) return;
pending?.abort();
pending = new AbortController();
void fetcher(endpoint.href, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...identity, action: "renew" }), signal: pending.signal,
}).catch(() => { /* The TTL cleans up after a lost browser/network. */ });
};
const timer = schedule(renew);
return () => {
if (closed) return;
closed = true;
cancel(timer);
pending?.abort();
pending = null;
void fetcher(endpoint.href, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...identity, action: "release" }),
keepalive: true, signal: AbortSignal.timeout(5_000),
}).catch(() => { /* Terminal release is best-effort; the server also has TTL. */ });
};
}
@@ -0,0 +1,72 @@
import {
decodeObservationSessionCatalog,
ObservationSessionApiError,
ObservationSessionContractError,
type ObservationSessionCatalog,
type ObservationSessionFetch,
type ObservationSessionScope,
} from "./sessionArchive";
const PAGE_SCHEMA = "missioncore.observation-session-page/v1";
const CURSOR = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
export interface ObservationSessionCatalogPage extends ObservationSessionCatalog {
readonly nextCursor: string | null;
}
/** Opt-in paging leaves the legacy catalog response and consumers unchanged. */
export async function fetchObservationSessionCatalogPage({
scope,
limit = 100,
cursor = null,
signal,
fetcher = globalThis.fetch,
}: {
scope: ObservationSessionScope;
limit?: number;
cursor?: string | null;
signal?: AbortSignal;
fetcher?: ObservationSessionFetch;
}): Promise<ObservationSessionCatalogPage> {
if (!Number.isInteger(limit) || limit < 1 || limit > 100
|| (cursor !== null && !CURSOR.test(cursor))) {
throw new ObservationSessionContractError("Некорректная страница каталога сессий.");
}
signal?.throwIfAborted();
const query = new URLSearchParams({ limit: String(limit), scope });
if (scope === "laboratory") query.set("lab_contract", "v3");
query.set("pagination", "cursor-v1");
if (cursor !== null) query.set("cursor", cursor);
const response = await fetcher(`/api/v1/observation-sessions?${query}`, {
method: "GET", headers: { Accept: "application/json" }, signal,
});
if (!response.ok) {
throw new ObservationSessionApiError(
`Не удалось загрузить страницу каталога сессий (HTTP ${response.status}).`,
response.status,
);
}
let body: unknown;
try {
body = await response.json();
} catch {
throw new ObservationSessionContractError("Некорректный ответ каталога сессий.");
}
if (typeof body !== "object" || body === null || Array.isArray(body)) {
throw new ObservationSessionContractError("Страница каталога должна быть объектом.");
}
const page = body as Record<string, unknown>;
if (Object.keys(page).length !== 3
|| page.schema_version !== PAGE_SCHEMA
|| !Object.hasOwn(page, "items")
|| !(page.next_cursor === null
|| (typeof page.next_cursor === "string" && CURSOR.test(page.next_cursor)))) {
throw new ObservationSessionContractError("Нарушен контракт страницы каталога сессий.");
}
const catalog = decodeObservationSessionCatalog({ items: page.items });
if (catalog.items.length > limit) {
throw new ObservationSessionContractError("Страница каталога превысила запрошенный размер.");
}
signal?.throwIfAborted();
return { ...catalog, nextCursor: page.next_cursor };
}
@@ -24,9 +24,11 @@ export interface RerunPlaybackController {
export interface RecordedPerceptionLayers {
enabled: boolean;
cameraImage?: boolean;
detections2d: boolean;
segmentation: boolean;
cuboids3d: boolean;
costmap?: boolean;
}
export interface RecordedRrdArtifactDescriptor {
@@ -75,6 +77,8 @@ export interface RecordedSessionRerunProfile {
semanticLayer?: "city" | "vegetation";
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
unifiedPerception?: boolean;
/** Controlled camera-column share for the native horizontal Rerun container. */
unifiedCameraShare?: number;
/** Requests the canonical top-down eye without changing the world coordinate frame. */
planView?: boolean;
perceptionRetryGeneration: number;
@@ -0,0 +1,347 @@
import {
decodeObservatoryRecordedJob,
type ObservatoryRecordedJob,
} from "./recordedJobs";
const COMPOSITION_SCHEMA = "missioncore.observatory-ai-composition/v1";
const CATALOG_SCHEMA = "missioncore.observatory-ai-module-catalog/v1";
const RECEIPT_SCHEMA = "missioncore.observatory-ai-composition-receipt/v3";
const SHA256 = /^[a-f0-9]{64}$/;
export type AIGroupId = "segmentation" | "detection" | "geometry" | "range" | "motion" | "policy";
export interface AIModule {
readonly moduleId: string;
readonly moduleSha256: string;
readonly label: string;
readonly dockerName: string;
readonly requires: readonly string[];
readonly provides: readonly string[];
readonly defaults: Readonly<Record<string, unknown>>;
}
export interface AIModuleGroup {
readonly group: AIGroupId;
readonly modules: readonly AIModule[];
}
export interface AIModuleCatalog {
readonly groups: readonly AIModuleGroup[];
}
export interface AICompositionReceipt {
readonly compositionSha256: string;
readonly created: boolean;
readonly dispatchReady: boolean;
readonly dispatchReason: string;
readonly setupIds: readonly string[];
readonly jobs: readonly ObservatoryRecordedJob[];
readonly run: AICompositionRun;
}
export interface AIViewerLayer {
readonly layerId: string;
readonly paneId: "camera" | "spatial";
readonly label: string;
readonly control: "toggle" | "toggle-with-settings";
readonly order: number;
}
export interface AICompositionPresentation {
readonly configurationLabel: string;
readonly modules: readonly { readonly moduleId: string; readonly label: string }[];
readonly viewerLayers: readonly AIViewerLayer[];
}
export interface AICompositionRun {
readonly runId: string;
readonly sourceSessionId: string;
readonly compositionSha256: string;
readonly moduleIds: readonly string[];
readonly setupIds: readonly string[];
readonly jobIds: readonly string[];
readonly resultIds: readonly string[];
readonly createdAtUtc: string;
readonly state: "running" | "ready" | "failed";
readonly configurationLabel: string;
readonly displayName: string | null;
readonly presentation: AICompositionPresentation;
readonly jobs: readonly ObservatoryRecordedJob[];
}
export const AI_MODULE_SETUP_IDS: Readonly<Record<string, string>> = Object.freeze({
ddrnet: "ai-segmentation-ddrnet-v1",
eomt: "ai-segmentation-eomt-v1",
tgs: "m49-tgs-portable-v2",
"rf-detr": "ai-detection-rf-detr-v1",
"object-distance": "ai-range-object-distance-v1",
});
export function aiModuleIdForSetup(setupId: string): string | null {
return Object.entries(AI_MODULE_SETUP_IDS).find(([, value]) => value === setupId)?.[0] ?? null;
}
export async function fetchAIModuleCatalog(signal?: AbortSignal): Promise<AIModuleCatalog> {
const response = await fetch("/api/v1/observatory/ai-module-catalog", {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "каталог AI-модулей");
exactKeys(root, ["authority", "groups", "schema_version"]);
if (root.schema_version !== CATALOG_SCHEMA) throw new Error("Версия каталога AI-модулей изменилась.");
const known = new Set<AIGroupId>(["segmentation", "detection", "geometry", "range", "motion", "policy"]);
const groups = array(root.groups).map((value): AIModuleGroup => {
const group = record(value, "группа AI-модулей");
exactKeys(group, ["group", "modules"]);
if (typeof group.group !== "string" || !known.has(group.group as AIGroupId)) {
throw new Error("Каталог вернул неизвестную группу AI-модулей.");
}
return { group: group.group as AIGroupId, modules: array(group.modules).map(moduleOf) };
});
if (new Set(groups.map((group) => group.group)).size !== groups.length) {
throw new Error("Каталог AI-модулей содержит повторяющиеся группы.");
}
return { groups };
}
export async function saveAIComposition(
sourceSessionId: string,
selections: readonly { group: AIGroupId; module: AIModule }[],
): Promise<AICompositionReceipt> {
const response = await fetch("/api/v1/observatory/ai-compositions", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: COMPOSITION_SCHEMA,
source_session_id: sourceSessionId,
idempotency_key: createIdempotencyKey(),
selections: selections.map(({ group, module }) => ({
group, module_id: module.moduleId, module_sha256: module.moduleSha256,
parameters: module.defaults,
})),
}),
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "конфигурация AI-слоя");
exactKeys(root, ["composition", "composition_sha256", "created", "dispatch", "run", "schema_version", "source_session_id"]);
if (root.schema_version !== RECEIPT_SCHEMA || root.source_session_id !== sourceSessionId) {
throw new Error("Сервер вернул конфигурацию для другой записи.");
}
const digest = text(root.composition_sha256);
if (!SHA256.test(digest)) throw new Error("Сервер вернул некорректную идентичность композиции.");
const dispatch = record(root.dispatch, "готовность композиции");
exactKeys(dispatch, ["jobs", "ready", "reason", "setup_ids"]);
if (dispatch.ready !== true) {
throw new Error(text(dispatch.reason));
}
const setupIds = array(dispatch.setup_ids).map(text);
const jobs = array(dispatch.jobs).map(decodeObservatoryRecordedJob);
if (!jobs.length || jobs.length !== setupIds.length
|| jobs.some((job, index) => job.setupId !== setupIds[index])) {
throw new Error("Сервер вернул неполную очередь композиции.");
}
const run = compositionRunOf(root.run);
return {
compositionSha256: digest,
created: boolean(dispatch && root.created),
dispatchReady: boolean(dispatch.ready),
dispatchReason: text(dispatch.reason),
setupIds,
jobs,
run,
};
}
export async function fetchAICompositionRuns(
sourceSessionId: string,
signal?: AbortSignal,
): Promise<readonly AICompositionRun[]> {
const query = new URLSearchParams({ source_session_id: sourceSessionId });
const response = await fetch(`/api/v1/observatory/ai-composition-runs?${query}`, {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "запуски композиций");
exactKeys(root, ["items", "schema_version"]);
if (root.schema_version !== "missioncore.observatory-ai-composition-run-list/v1") {
throw new Error("Версия запусков AI-композиций изменилась.");
}
return array(root.items).map(compositionRunOf);
}
export async function renameAICompositionRunProjection(
runId: string,
displayName: string,
): Promise<string> {
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
throw new Error("Некорректная идентичность результата AI inference.");
}
const normalized = displayName.trim();
if (!normalized || normalized.length > 160) {
throw new Error("Название результата должно содержать от 1 до 160 символов.");
}
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
method: "PATCH",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: "missioncore.observatory-ai-composition-run-rename/v1",
display_name: normalized,
}),
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const row = record(body, "результат AI inference");
exactKeys(row, ["display_name", "run_id", "schema_version"]);
if (
row.schema_version !== "missioncore.observatory-ai-composition-run-projection/v1"
|| row.run_id !== runId
|| row.display_name !== normalized
) throw new Error("Сервер не подтвердил переименование результата AI inference.");
return normalized;
}
export async function deleteAICompositionRunProjection(runId: string): Promise<void> {
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
throw new Error("Некорректная идентичность результата AI inference.");
}
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
method: "DELETE",
headers: { Accept: "application/json" },
});
if (!response.ok || response.status !== 204) {
throw apiError(await bodyOf(response), response.status);
}
}
function compositionRunOf(value: unknown): AICompositionRun {
const row = record(value, "запуск AI-композиции");
const expected = [
"composition_sha256", "configuration_label", "created_at_utc", "display_name", "job_ids", "jobs",
"module_ids", "presentation", "result_ids", "run_id", "schema_version", "setup_ids",
"source_session_id", "state",
];
// The receipt embeds the immutable run before its live state projection.
const receiptShape = [
"composition_sha256", "created_at_utc", "job_ids", "module_ids", "presentation",
"run_id", "schema_version", "setup_ids", "source_session_id",
];
const keys = Object.keys(row);
const projected = keys.length === expected.length;
exactKeys(row, projected ? expected : receiptShape);
if (row.schema_version !== "missioncore.observatory-ai-composition-run/v1") {
throw new Error("Версия запуска AI-композиции изменилась.");
}
const sourceSessionId = text(row.source_session_id);
const compositionSha256 = text(row.composition_sha256);
const runId = text(row.run_id);
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId) || !SHA256.test(compositionSha256)) {
throw new Error("Некорректная идентичность запуска AI-композиции.");
}
const presentation = presentationOf(row.presentation);
const state = projected ? text(row.state) : "running";
if (!new Set(["running", "ready", "failed"]).has(state)) {
throw new Error("Неизвестное состояние запуска AI-композиции.");
}
return {
runId,
sourceSessionId,
compositionSha256,
moduleIds: array(row.module_ids).map(text),
setupIds: array(row.setup_ids).map(text),
jobIds: array(row.job_ids).map(text),
resultIds: projected ? array(row.result_ids).map(text) : [],
createdAtUtc: text(row.created_at_utc),
state: state as AICompositionRun["state"],
configurationLabel: projected ? text(row.configuration_label) : presentation.configurationLabel,
displayName: projected
? row.display_name === null ? null : text(row.display_name)
: null,
presentation,
jobs: projected ? array(row.jobs).map(decodeObservatoryRecordedJob) : [],
};
}
function presentationOf(value: unknown): AICompositionPresentation {
const row = record(value, "проекция AI-композиции");
exactKeys(row, ["configuration_label", "modules", "schema_version", "viewer_layers"]);
if (row.schema_version !== "missioncore.observatory-presentation-projection/v1") {
throw new Error("Версия проекции AI-композиции изменилась.");
}
const modules = array(row.modules).map((value) => {
const module = record(value, "модуль проекции");
exactKeys(module, ["label", "module_id"]);
return { moduleId: text(module.module_id), label: text(module.label) };
});
const viewerLayers = array(row.viewer_layers).map((value): AIViewerLayer => {
const layer = record(value, "слой viewer");
exactKeys(layer, ["control", "label", "layer_id", "order", "pane_id"]);
const paneId = text(layer.pane_id);
const control = text(layer.control);
if (!new Set(["camera", "spatial"]).has(paneId)
|| !new Set(["toggle", "toggle-with-settings"]).has(control)
|| typeof layer.order !== "number") {
throw new Error("Некорректная проекция слоя viewer.");
}
return {
layerId: text(layer.layer_id), paneId: paneId as AIViewerLayer["paneId"],
label: text(layer.label), control: control as AIViewerLayer["control"], order: layer.order,
};
});
return { configurationLabel: text(row.configuration_label), modules, viewerLayers };
}
export async function fetchAICompositionJobs(
sourceSessionId: string,
signal?: AbortSignal,
): Promise<readonly ObservatoryRecordedJob[]> {
const query = new URLSearchParams({ source_session_id: sourceSessionId });
const response = await fetch(`/api/v1/observatory/ai-runs?${query}`, {
headers: { Accept: "application/json" }, signal,
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
const root = record(body, "очередь AI-слоёв");
exactKeys(root, ["authority", "items", "schema_version"]);
if (root.schema_version !== "missioncore.observatory-recorded-job-list/v1") {
throw new Error("Версия очереди AI-слоёв изменилась.");
}
return array(root.items).map(decodeObservatoryRecordedJob);
}
function moduleOf(value: unknown): AIModule {
const row = record(value, "AI-модуль");
exactKeys(row, ["defaults", "docker_name", "label", "module_id", "module_sha256", "parameter_choices", "provides", "requires"]);
const digest = text(row.module_sha256);
if (!SHA256.test(digest)) throw new Error("Каталог вернул некорректную версию AI-модуля.");
return {
moduleId: text(row.module_id), moduleSha256: digest, label: text(row.label),
dockerName: text(row.docker_name), requires: array(row.requires).map(text),
provides: array(row.provides).map(text), defaults: record(row.defaults, "параметры AI-модуля"),
};
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Некорректный ${label}.`);
return value as Record<string, unknown>;
}
function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw new Error("Ожидался список."); return value; }
function text(value: unknown): string { if (typeof value !== "string" || !value) throw new Error("Ожидался текст."); return value; }
function boolean(value: unknown): boolean { if (typeof value !== "boolean") throw new Error("Ожидалось логическое значение."); return value; }
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
if (Object.keys(value).sort().join() !== [...expected].sort().join()) throw new Error("Контракт AI-слоя изменился.");
}
async function bodyOf(response: Response): Promise<unknown> { const value = await response.text(); try { return JSON.parse(value); } catch { return value; } }
function apiError(body: unknown, status: number): Error {
const detail = body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>).detail : null;
return new Error(typeof detail === "string" ? detail : `Observatory API вернул HTTP ${status}.`);
}
function createIdempotencyKey(): string {
const entropy = typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `ai-layer:${entropy}`;
}
@@ -1,9 +1,9 @@
import {
fetchObservationSessionCatalog,
type ObservationLabInstance,
type ObservationSessionFetch,
type ObservationSessionSummary,
} from "../observation/sessionArchive";
import { fetchObservationSessionCatalogPage } from "../observation/sessionCatalogPage";
import {
observatoryRecordedRunBinding,
type ObservatoryRecordedRunBinding,
@@ -164,19 +164,23 @@ function boundedLimit(limit: number): number {
function evidenceFromSession(
session: ObservationSessionSummary,
): ObservatoryEvidence {
): ObservatoryEvidence | null {
if (!session.lab) {
throw new ObservatoryCatalogContractError(
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
);
}
// Historical LABs retain their archive and replay adapters. Observatory is
// the portable-profile product; a familiar LAB label is not admission.
if (session.lab.replayCapability?.kind !== "portable-result-review") return null;
const recordedRun = observatoryRecordedRunBinding(session.id, session.lab);
return {
sessionId: session.id,
label: session.label,
status: session.status,
publishedAtUtc: session.lab.publishedAtUtc,
lab: session.lab,
recordedRun: observatoryRecordedRunBinding(session.id, session.lab),
recordedRun,
};
}
@@ -208,11 +212,11 @@ export function buildObservatoryCatalog(
const unresolvedEvidence: ObservatoryEvidence[] = [];
for (const laboratorySession of laboratorySessions) {
const evidence = evidenceFromSession(laboratorySession);
if (!evidence) continue;
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.
// A bounded traversal may stop before this source. Absence alone is
// not proof of a broken relationship or permission to remove data.
unresolvedEvidence.push(evidence);
continue;
}
@@ -236,7 +240,8 @@ export function buildObservatoryCatalog(
window: {
limit: safeLimit,
sourceCount: sourceSessions.length,
laboratoryCount: laboratorySessions.length,
laboratoryCount: [...linked.values()].reduce((count, items) => count + items.length, 0)
+ unresolvedEvidence.length,
sourceLimitReached: sourceSessions.length >= safeLimit,
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
},
@@ -253,9 +258,64 @@ export async function fetchObservatoryCatalog({
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);
const request = new AbortController();
const abort = () => request.abort(signal?.reason);
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, { once: true });
try {
const [sources, laboratories] = await Promise.all([
readCatalogPages("source", safeLimit, fetcher, request.signal),
readCatalogPages("laboratory", safeLimit, fetcher, request.signal),
]);
const catalog = buildObservatoryCatalog(sources.items, laboratories.items, safeLimit);
return {
...catalog,
window: {
...catalog.window,
sourceLimitReached: sources.hasMore,
laboratoryLimitReached: laboratories.hasMore,
},
};
} finally {
request.abort();
signal?.removeEventListener("abort", abort);
}
}
// Metadata only, never recordings or result artifacts. The cap is an explicit
// partial window, not silent completeness or an unbounded background crawler.
const MAX_CATALOG_PAGES = 256;
async function readCatalogPages(
scope: "source" | "laboratory",
limit: number,
fetcher: ObservationSessionFetch,
signal: AbortSignal,
): Promise<{ items: ObservationSessionSummary[]; hasMore: boolean }> {
const items: ObservationSessionSummary[] = [];
const seenIds = new Set<string>();
const cursors = new Set<string>();
let cursor: string | null = null;
for (let index = 0; index < MAX_CATALOG_PAGES; index += 1) {
const page = await fetchObservationSessionCatalogPage({ scope, limit, cursor, signal, fetcher });
for (const item of page.items) {
if (seenIds.has(item.id)) {
throw new ObservatoryCatalogContractError("Каталог повторил сессию между страницами.");
}
seenIds.add(item.id);
// Do not accumulate legacy provenance while walking the shared catalog.
if (scope === "source" || item.lab?.replayCapability?.kind === "portable-result-review") {
items.push(item);
} else if (!item.lab) {
throw new ObservatoryCatalogContractError("LAB-каталог вернул исходную сессию.");
}
}
cursor = page.nextCursor;
if (cursor === null) return { items, hasMore: false };
if (cursors.has(cursor)) {
throw new ObservatoryCatalogContractError("Каталог повторил курсор страницы.");
}
cursors.add(cursor);
}
return { items, hasMore: true };
}
@@ -0,0 +1,104 @@
import type { SceneSettings } from "../../sceneSettings";
const PROFILE_SCHEMA = "missioncore.observatory-lab-view-profile/v1";
export async function fetchLabViewProfile(
resultId: string,
signal?: AbortSignal,
): Promise<SceneSettings | null> {
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
headers: { Accept: "application/json" },
signal,
});
if (response.status === 404) return null;
const body: unknown = await response.json();
if (!response.ok) throw new Error("Профиль отображения LAB не загрузился.");
return decodeProfile(body, resultId);
}
export async function saveLabViewProfile(
resultId: string,
settings: SceneSettings,
signal?: AbortSignal,
): Promise<SceneSettings> {
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
method: "PUT",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: PROFILE_SCHEMA,
result_id: resultId,
scene_settings: {
point_size: settings.pointSize,
accumulation_seconds: settings.accumulationSeconds,
color_mode: settings.colorMode,
palette: settings.palette,
show_grid: settings.showGrid,
show_labels: settings.showLabels,
show_camera_frustums: settings.showCameraFrustums,
},
}),
signal,
});
const body: unknown = await response.json();
if (!response.ok) throw new Error("Настройки LAB не сохранились. Повторите закрытие окна.");
return decodeProfile(body, resultId);
}
function decodeProfile(value: unknown, expectedResultId: string): SceneSettings {
const row = record(value);
exactKeys(row, ["result_id", "scene_settings", "schema_version", "updated_at_utc"]);
if (row.schema_version !== PROFILE_SCHEMA || row.result_id !== expectedResultId) {
throw new Error("Сервер вернул профиль другой LAB.");
}
const settings = record(row.scene_settings);
exactKeys(settings, [
"accumulation_seconds", "color_mode", "palette", "point_size",
"show_camera_frustums", "show_grid", "show_labels",
]);
const pointSize = finite(settings.point_size);
const accumulationSeconds = finite(settings.accumulation_seconds);
const colorMode = settings.color_mode;
const palette = settings.palette;
if (
pointSize < 0.1
|| accumulationSeconds < 0
|| !new Set(["intensity", "height", "distance", "rgb", "class"]).has(String(colorMode))
|| !new Set(["turbo", "viridis", "plasma", "grayscale"]).has(String(palette))
|| typeof settings.show_grid !== "boolean"
|| typeof settings.show_labels !== "boolean"
|| typeof settings.show_camera_frustums !== "boolean"
) throw new Error("Сервер вернул повреждённый профиль LAB.");
return {
projection: "3d",
pointSize,
colorMode: colorMode as SceneSettings["colorMode"],
palette: palette as SceneSettings["palette"],
customColor: "#35d7c1",
accumulationSeconds,
showPoints: true,
showTrajectory: true,
showGrid: settings.show_grid,
showLabels: settings.show_labels,
showCameraFrustums: settings.show_camera_frustums,
};
}
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("Некорректный профиль отображения LAB.");
}
return value as Record<string, unknown>;
}
function finite(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw new Error("Некорректное числовое значение профиля LAB.");
}
return value;
}
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
if (Object.keys(value).sort().join() !== [...expected].sort().join()) {
throw new Error("Контракт профиля отображения LAB изменился.");
}
}
@@ -26,6 +26,7 @@ export interface ObservatoryRecordedJob {
readonly sourceSessionId: string;
readonly setupId: string;
readonly definitionSha256: string;
readonly claimGeneration: number;
readonly state: ObservatoryRecordedJobState;
readonly restartFromZero: boolean;
readonly resultId: string | null;
@@ -85,7 +86,7 @@ export async function fetchObservatoryRecordedJobs(
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) => (
return array(row.items, "items").map(decodeObservatoryRecordedJob).filter((job) => (
job.sourceSessionId === sourceSessionId && job.setupId === setupId
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
));
@@ -124,7 +125,7 @@ export async function submitObservatoryRecordedJob(
});
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
const job = decodeObservatoryRecordedJob(body);
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
throw new ObservatoryRecordedJobContractError(
@@ -155,7 +156,7 @@ export async function retryObservatoryRecordedJobPublication(
);
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
const job = decodeObservatoryRecordedJob(body);
if (job.jobId !== jobId) {
throw new ObservatoryRecordedJobContractError(
"Повтор публикации вернул другой расчёт.",
@@ -164,7 +165,7 @@ export async function retryObservatoryRecordedJobPublication(
return job;
}
function decodeJob(value: unknown): ObservatoryRecordedJob {
export function decodeObservatoryRecordedJob(value: unknown): ObservatoryRecordedJob {
const row = record(value, "расчёт");
exactKeys(row, [
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
@@ -229,6 +230,7 @@ function decodeJob(value: unknown): ObservatoryRecordedJob {
sourceSessionId: text(source.session_id, "source.session_id"),
setupId: text(setup.setup_id, "setup.setup_id"),
definitionSha256: String(setup.definition_sha256),
claimGeneration: nonNegativeInteger(row.claim_generation, "claim_generation"),
state,
restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"),
resultId: result === null ? null : text(result.result_id, "result.result_id"),
@@ -0,0 +1,124 @@
import type { ObservatoryRecordedJob } from "./recordedJobs";
const PHASES = {
"source-transfer": "Передаём входные данные",
"source-preparation": "Подготавливаем вход",
computing: "Обрабатываем запись",
"result-assembly": "Собираем результат",
"result-transfer": "Передаём результат",
} as const;
const UNITS = { frames: "кадров", members: "файлов", steps: "операций" } as const;
type Phase = keyof typeof PHASES;
type Unit = keyof typeof UNITS;
export interface RecordedProgressView {
readonly jobId: string;
readonly claimGeneration: number;
readonly sequence: number;
readonly phase: Phase;
readonly unit: Unit;
readonly completed: number;
readonly total: number | null;
readonly ageSeconds: number;
readonly elapsedSeconds: number;
readonly phaseElapsedSeconds: number;
}
export async function fetchRecordedProgress(
job: ObservatoryRecordedJob,
{ signal, fetcher = globalThis.fetch }: {
signal?: AbortSignal;
fetcher?: typeof globalThis.fetch;
} = {},
): Promise<RecordedProgressView | null> {
const request = new AbortController();
const abort = () => request.abort();
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) request.abort();
const timer = globalThis.setTimeout(abort, 2_500);
try {
const response = await fetcher(
`/api/v1/observatory/runs/${encodeURIComponent(job.jobId)}/progress`,
{ signal: request.signal, headers: { Accept: "application/json" } },
);
if (!response.ok) throw new Error("Прогресс расчёта недоступен.");
return decodeRecordedProgress(await response.json(), job);
} finally {
globalThis.clearTimeout(timer);
signal?.removeEventListener("abort", abort);
}
}
export function decodeRecordedProgress(
value: unknown, job: ObservatoryRecordedJob,
): RecordedProgressView | null {
const row = object(value);
keys(row, ["schema_version", "job_id", "source_session_id", "setup_id",
"definition_sha256", "claim_generation", "state", "received_at_utc", "age_seconds", "progress"]);
if (row.schema_version !== "missioncore.observatory-recorded-progress-view/v1"
|| row.job_id !== job.jobId || row.source_session_id !== job.sourceSessionId
|| row.setup_id !== job.setupId || row.definition_sha256 !== job.definitionSha256
|| row.claim_generation !== job.claimGeneration) throw new Error("Прогресс другой попытки.");
if (row.progress === null || row.state !== job.state) return null;
const progress = object(row.progress);
keys(progress, ["schema_version", "claim_generation", "sequence", "phase_index",
"phase", "unit", "completed", "total", "elapsed_seconds", "phase_elapsed_seconds"]);
if (progress.schema_version !== "missioncore.observatory-recorded-progress/v1"
|| progress.claim_generation !== row.claim_generation
|| !(typeof progress.phase === "string" && Object.hasOwn(PHASES, progress.phase))
|| !(typeof progress.unit === "string" && Object.hasOwn(UNITS, progress.unit))
|| typeof row.received_at_utc !== "string" || !Number.isFinite(Date.parse(row.received_at_utc))) {
throw new Error("Некорректный прогресс.");
}
const completed = integer(progress.completed);
const total = progress.total === null ? null : integer(progress.total, 1);
const elapsed = finite(progress.elapsed_seconds);
const phaseElapsed = finite(progress.phase_elapsed_seconds);
if ((total !== null && completed > total) || phaseElapsed > elapsed) {
throw new Error("Некорректные счётчики прогресса.");
}
integer(progress.phase_index);
return {
jobId: job.jobId, claimGeneration: integer(progress.claim_generation, 1),
sequence: integer(progress.sequence, 1), phase: progress.phase as Phase,
unit: progress.unit as Unit, completed, total, ageSeconds: finite(row.age_seconds),
elapsedSeconds: elapsed, phaseElapsedSeconds: phaseElapsed,
};
}
export function recordedProgressLabel(
job: ObservatoryRecordedJob | null, progress: RecordedProgressView | null,
): string {
if (job?.publication.state === "pending") return "Сохраняем результат";
if (!job || job.state === "accepted" || job.state === "queued") return "Ожидаем расчёт";
if (job.state === "failed") return "Ошибка расчёта";
if (job.state === "succeeded") return "Расчёт завершён";
if (job.state === "paused" || job.state === "preemption-pending") return "Расчёт приостановлен";
if (job.state === "reconciliation-required") return "Проверяем состояние расчёта";
if (!progress || progress.jobId !== job.jobId
|| progress.claimGeneration !== job.claimGeneration) return "Ожидаем данные расчёта";
if (progress.ageSeconds > 15) return "Ожидаем обновление прогресса";
const count = progress.total === null
? (progress.completed > 0 ? ` · ${progress.completed.toLocaleString("ru-RU")} ${UNITS[progress.unit]}` : "")
: ` · ${progress.completed.toLocaleString("ru-RU")} / ${progress.total.toLocaleString("ru-RU")} ${UNITS[progress.unit]}`;
return PHASES[progress.phase] + count;
}
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Прогресс: ожидался объект.");
return value as Record<string, unknown>;
}
function keys(row: Record<string, unknown>, expected: string[]): void {
if (Object.keys(row).length !== expected.length || expected.some((key) => !Object.hasOwn(row, key))) {
throw new Error("Прогресс: неизвестные поля.");
}
}
function finite(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("Прогресс: некорректное число.");
return value;
}
function integer(value: unknown, minimum = 0): number {
const number = finite(value);
if (!Number.isSafeInteger(number) || number < minimum) throw new Error("Прогресс: некорректный счётчик.");
return number;
}
@@ -0,0 +1,98 @@
import { useCallback, useEffect, useState } from "react";
import {
AI_MODULE_SETUP_IDS,
fetchAIModuleCatalog,
fetchAICompositionJobs,
fetchAICompositionRuns,
type AICompositionRun,
} from "./aiComposition";
import { fetchRecordedProgress, type RecordedProgressView } from "./recordedProgress";
import type { ObservatoryRecordedJob } from "./recordedJobs";
const OPEN = new Set(["accepted", "queued", "claimed", "running", "paused",
"preemption-pending", "reconciliation-required"]);
interface Snapshot {
readonly sourceSessionId: string;
readonly jobs: readonly ObservatoryRecordedJob[];
readonly runs: readonly AICompositionRun[];
readonly moduleLabelsBySetup: Readonly<Record<string, string>>;
readonly progress: Readonly<Record<string, RecordedProgressView>>;
readonly error: string | null;
}
export function useAICompositionJobs(sourceSessionId: string) {
const [revision, setRevision] = useState(0);
const [snapshot, setSnapshot] = useState<Snapshot>({
sourceSessionId: "", jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
});
const current = snapshot.sourceSessionId === sourceSessionId ? snapshot : {
sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null,
};
useEffect(() => {
if (!sourceSessionId) {
setSnapshot({ sourceSessionId, jobs: [], runs: [], moduleLabelsBySetup: {}, progress: {}, error: null });
return;
}
const request = new AbortController();
void Promise.all([
fetchAICompositionJobs(sourceSessionId, request.signal),
fetchAICompositionRuns(sourceSessionId, request.signal),
fetchAIModuleCatalog(request.signal),
]).then(async ([jobs, runs, catalog]) => {
const active = jobs.filter((job) => OPEN.has(job.state));
const samples = await Promise.all(active.map(async (job) => [
job.jobId, await fetchRecordedProgress(job, { signal: request.signal }).catch(() => null),
] as const));
if (request.signal.aborted) return;
const catalogLabels = catalog.groups.flatMap(({ modules }) => (
modules.flatMap((module) => {
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
return setupId ? [[setupId, module.label] as const] : [];
})
));
const projectedLabels = runs.flatMap((run) => run.presentation.modules.flatMap((module) => {
const setupId = AI_MODULE_SETUP_IDS[module.moduleId];
return setupId ? [[setupId, module.label] as const] : [];
}));
setSnapshot({
sourceSessionId, jobs, runs,
moduleLabelsBySetup: Object.fromEntries([...catalogLabels, ...projectedLabels]),
progress: Object.fromEntries(samples.filter(
(sample): sample is readonly [string, RecordedProgressView] => sample[1] !== null,
)),
error: null,
});
}).catch((error: unknown) => {
if (request.signal.aborted) return;
setSnapshot((value) => ({
sourceSessionId, jobs: value.sourceSessionId === sourceSessionId ? value.jobs : [],
runs: value.sourceSessionId === sourceSessionId ? value.runs : [],
moduleLabelsBySetup: value.sourceSessionId === sourceSessionId
? value.moduleLabelsBySetup : {},
progress: value.sourceSessionId === sourceSessionId ? value.progress : {},
error: error instanceof Error ? error.message : "Очередь AI-слоёв недоступна.",
}));
});
return () => request.abort();
}, [revision, sourceSessionId]);
const active = current.runs.some((run) => run.state === "running")
|| current.jobs.some((job) => OPEN.has(job.state) || job.publication.state === "pending");
useEffect(() => {
if (!active) return;
const timer = globalThis.setTimeout(() => setRevision((value) => value + 1), 1_500);
return () => globalThis.clearTimeout(timer);
}, [active, revision]);
return {
jobs: current.jobs,
runs: current.runs,
moduleLabelsBySetup: current.moduleLabelsBySetup,
progress: current.progress,
error: current.error,
refresh: useCallback(() => setRevision((value) => value + 1), []),
};
}
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchRecordedProgress, type RecordedProgressView } from "./recordedProgress";
import {
fetchObservatoryRecordedJobs,
@@ -21,6 +22,7 @@ interface JobSnapshot {
readonly jobs: readonly ObservatoryRecordedJob[];
readonly state: RecordedJobsState;
readonly error: string | null;
readonly progress?: RecordedProgressView | null;
}
const EMPTY_JOBS = [] as const;
const OPEN_STATES = new Set([
@@ -63,14 +65,19 @@ export function useObservatoryRecordedJobs(
setSnapshot({
selectionKey, jobs,
state: jobs.length > 0 ? "refreshing" : "loading", error: null,
progress: current?.progress ?? null,
});
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, {
definitionSha256, signal: request.signal,
}).then((next) => {
}).then(async (next) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
const pending = next.find((job) => OPEN_STATES.has(job.state));
const progress = pending
? await fetchRecordedProgress(pending, { signal: request.signal }).catch(() => null)
: null;
if (request.signal.aborted || requestSequence.current !== sequence) return;
if (pending) observedJobId.current = pending.jobId;
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null });
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null, progress });
}).catch((caught: unknown) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setSnapshot({
@@ -95,7 +102,7 @@ export function useObservatoryRecordedJobs(
&& latestJob.jobId === observedJobId.current;
useEffect(() => {
if (state !== "ready" || (!activeJob && !publicationPending)) return;
if ((state !== "ready" && state !== "error") || (!activeJob && !publicationPending)) return;
const timer = globalThis.setTimeout(
() => setRevision((value) => value + 1),
POLL_INTERVAL_MS,
@@ -185,6 +192,7 @@ export function useObservatoryRecordedJobs(
return {
jobs, latestJob, activeJob, publicationPending, computationFailed,
progress: current?.progress ?? null,
state, error, refresh, submit, retryPublication,
};
}
+2 -2
View File
@@ -171,9 +171,9 @@ export const workspaces: WorkspaceDefinition[] = [
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Проверка компьютерного зрения",
title: "AI inference",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
description: "Сессии и квалификация без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
@@ -89,16 +89,6 @@
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;
@@ -155,29 +145,16 @@
display: flex;
min-width: 0;
align-items: center;
align-content: flex-start;
flex-wrap: wrap;
gap: 0.35rem;
pointer-events: none;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
justify-content: flex-end;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"] {
flex-wrap: wrap;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"]
> .m4-replay-threat-visual__pane-layer-controls {
flex: 1 0 100%;
justify-content: flex-start;
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"]
> .m4-replay-threat-visual__pane-mode-controls {
margin-left: auto;
}
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]:has(
.m4-replay-threat-visual__review-controls
) .m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
@@ -185,12 +162,17 @@
}
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
justify-content: space-between;
justify-content: flex-start;
}
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
right: 3.8rem;
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"],
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
.m4-replay-threat-visual__deck:not([data-split="true"])
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
/* The two viewer-wide actions stay at the deck's right edge. Keep their
complete hit area out of this viewport-owned toolbar at every split. */
right: 7rem;
}
.m4-replay-threat-visual__pane-toolbar > *,
@@ -199,18 +181,15 @@
}
.m4-replay-threat-visual__spatial-toolbar-end {
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 0.35rem;
overflow-x: auto;
overscroll-behavior-inline: contain;
scrollbar-width: none;
display: contents;
}
.m4-replay-threat-visual__spatial-toolbar-end::-webkit-scrollbar {
display: none;
.m4-replay-threat-visual__pane-toolbar > .m4-replay-threat-visual__pane-layer-controls,
.m4-replay-threat-visual__spatial-toolbar-end > .m4-replay-threat-visual__pane-layer-controls {
/* A viewport toolbar is one adaptive flex sequence. `display: contents`
keeps the semantic layer group while allowing each control to move to the
next row only when the viewport itself runs out of width. */
display: contents;
}
.m4-replay-threat-visual__pane-mode-controls {
@@ -259,7 +238,8 @@
display: flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--nodedc-glass-outline);
border: 0;
outline: 0;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-floating-surface);
padding: 0.42rem 0.58rem;
@@ -280,6 +260,15 @@
pointer-events: none;
}
.canonical-vegetation-rerun-replay
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
.laboratory-evidence-viewer__controls {
right: 0.6rem;
left: auto;
width: auto;
justify-content: flex-end;
}
.m4-replay-threat-evidence-viewer[data-mode-controls="content"] .laboratory-evidence-viewer__controls > .nodedc-icon-button {
pointer-events: auto;
}
@@ -304,6 +293,30 @@
scrollbar-width: none;
}
.m4-replay-threat-visual__layer-with-settings {
display: inline-flex;
flex: none;
align-items: center;
gap: 0.18rem;
border: 0;
border-radius: 999px;
background: var(--nodedc-glass-control-bg);
padding: 0.12rem;
}
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button {
width: 1.75rem;
height: 1.75rem;
border: 0;
outline: 0;
box-shadow: none;
}
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button:focus-visible {
background: var(--nodedc-focus-surface);
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
}
.m4-replay-threat-visual__review-controls,
.m4-replay-threat-visual__review-controls > *,
.m4-replay-threat-visual__single-pane-controls,
+246 -7
View File
@@ -10,7 +10,6 @@
container-type: inline-size;
}
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice,
@@ -21,18 +20,15 @@
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 {
@@ -47,7 +43,6 @@
line-height: 1.15;
}
.observatory-lead p,
.observatory-state p,
.observatory-evidence-empty p {
max-width: 52rem;
@@ -197,6 +192,50 @@
padding: 0 0.2rem;
}
.observatory-evidence__ordering {
display: flex;
flex: none;
align-items: center;
gap: 0.55rem;
}
.observatory-evidence__sort {
display: grid;
width: 1.7rem;
height: 1.7rem;
place-items: center;
border: 0;
outline: 0;
border-radius: 0;
background: transparent;
padding: 0;
color: var(--nodedc-text-secondary);
cursor: pointer;
}
.observatory-evidence__sort:hover,
.observatory-evidence__sort:focus-visible {
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-primary);
}
.observatory-evidence__sort svg {
width: 1.1rem;
height: 1.1rem;
transition: transform 160ms ease-out;
}
.observatory-evidence__sort path {
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-width: 1.5;
}
.observatory-evidence__sort[data-direction="oldest"] svg {
transform: rotate(180deg);
}
.observatory-evidence-list {
display: grid;
gap: 0.4rem;
@@ -241,6 +280,14 @@
white-space: nowrap;
}
.observatory-evidence-card__copy strong {
overflow: visible;
text-overflow: clip;
white-space: normal;
font-size: var(--nodedc-font-size-sm);
line-height: 1.3;
}
.observatory-evidence-card__copy span {
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
@@ -251,6 +298,28 @@
font-size: var(--nodedc-font-size-xs);
}
.observatory-evidence-card--progress {
grid-template-columns: auto minmax(0, 1fr) auto auto;
border: 0;
outline: 0;
}
.observatory-evidence-card__progress {
height: 0.28rem;
margin: 0.18rem 0 0.08rem;
overflow: hidden;
border-radius: 999px;
background: rgb(var(--nodedc-accent-rgb) / 0.12);
}
.observatory-evidence-card__progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: rgb(var(--nodedc-accent-rgb));
transition: width 180ms ease-out;
}
.observatory-evidence-card__actions,
.observatory-replay__header,
.observatory-replay-state,
@@ -282,6 +351,7 @@
.observatory-replay {
display: grid;
gap: 0.85rem;
font-family: var(--nodedc-font-family);
}
.observatory-replay__header {
@@ -293,10 +363,37 @@
margin: 0.3rem 0 0;
}
.observatory-replay__header h3 {
font-size: var(--nodedc-font-size-sm);
line-height: 1.3;
}
.observatory-replay__header p,
.observatory-replay-state p {
.observatory-replay-state p,
.observatory-replay__description {
margin: 0.35rem 0 0;
color: var(--nodedc-text-muted);
font-family: var(--nodedc-font-family);
font-size: var(--nodedc-font-size-sm);
font-weight: var(--nodedc-font-weight-regular);
line-height: 1.35;
}
.observatory-replay__description {
margin-right: 0.25rem;
margin-left: 0.25rem;
}
.observatory-replay .m4-replay-threat-visual__buffering {
border: 0;
outline: 0;
}
.observatory-replay .laboratory-evidence-viewer {
border: 0;
outline: 0;
border-radius: 0;
box-shadow: none;
}
.observatory-evidence-empty {
@@ -369,7 +466,6 @@
}
@container observatory-workspace (max-width: 920px) {
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice {
@@ -399,3 +495,146 @@
flex-direction: column;
}
}
.observatory-ai-config {
display: grid;
gap: var(--nodedc-space-4);
}
.observatory-ai-config__lead,
.observatory-ai-config__error {
margin: 0;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.45;
}
.observatory-ai-config__error {
color: rgb(var(--nodedc-danger-rgb));
}
.observatory-ai-config__duplicate {
margin-right: auto;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.4;
}
@keyframes observatory-ai-calculate-pulse {
0%,
100% {
background-color: var(--nodedc-primary-action-bg);
}
50% {
background-color: color-mix(
in srgb,
var(--nodedc-primary-action-bg) 88%,
var(--nodedc-canvas-soft)
);
}
}
.observatory-ai-config__calculate--saving:disabled {
animation: observatory-ai-calculate-pulse 1.8s ease-in-out infinite;
cursor: progress;
opacity: 1;
}
.observatory-ai-config-window {
border: 0;
outline: 0;
box-shadow: var(--nodedc-shadow-window);
}
.observatory-ai-config-window::before,
.observatory-ai-config-window::after {
display: none;
}
.observatory-ai-module-group {
overflow: hidden;
border: 0;
outline: 0;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-glass-control-bg);
}
.observatory-ai-module-group > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nodedc-space-3);
padding: var(--nodedc-space-3) var(--nodedc-space-4);
border: 0;
outline: 0;
}
.observatory-ai-module-group > header > div {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.observatory-ai-module-group > header .nodedc-status {
min-width: 0;
max-width: 55%;
overflow: hidden;
text-overflow: ellipsis;
}
.observatory-ai-module-group > header span {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-ai-module-group__body {
padding: var(--nodedc-space-4);
border: 0;
outline: 0;
}
.observatory-ai-module-group__body .nodedc-select-anchor {
width: 100%;
}
.observatory-ai-config-window .nodedc-select-trigger,
.observatory-ai-config-window .nodedc-window__close {
border: 0;
outline: 0;
box-shadow: none;
}
.observatory-ai-config-window .nodedc-select-trigger:focus-visible,
.observatory-ai-config-window .nodedc-window__close:focus-visible {
background-color: var(--nodedc-focus-surface);
box-shadow:
var(--nodedc-glass-control-shadow),
inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
}
@media (max-width: 560px) {
.observatory-ai-config-window .nodedc-window__title {
white-space: normal;
}
.observatory-ai-module-group > header {
align-items: flex-start;
flex-direction: column;
}
.observatory-ai-module-group > header .nodedc-status {
max-width: 100%;
justify-content: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.observatory-ai-config__calculate--saving:disabled {
animation: none;
background-color: color-mix(
in srgb,
var(--nodedc-primary-action-bg) 94%,
var(--nodedc-canvas-soft)
);
}
}
@@ -88,6 +88,13 @@
height: calc(100% + var(--rerun-native-chrome-height));
}
.rerun-viewport__runtime {
display: block;
width: 100%;
height: 100%;
border: 0;
}
.rerun-viewport__canvas canvas {
display: block;
width: 100% !important;
@@ -15,6 +15,9 @@ import {
} from "@nodedc/ui-react";
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
import { AICompositionReplay } from "../../components/laboratory/AICompositionReplay";
import { PortableResultReplay } from "../../components/laboratory/PortableResultReplay";
import { AIConfigurationWindow } from "../../components/observatory/AIConfigurationWindow";
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
import {
@@ -31,8 +34,14 @@ import {
renameObservatoryLabProjection,
} from "../../core/observatory/catalogMutations";
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
import { useAICompositionJobs } from "../../core/observatory/useAICompositionJobs";
import {
deleteAICompositionRunProjection,
renameAICompositionRunProjection,
type AICompositionRun,
} from "../../core/observatory/aiComposition";
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
import type { RecordedProgressView } from "../../core/observatory/recordedProgress";
import type { WorkspaceDefinition } from "../../productModel";
const EMPTY_OBSERVATORY_ITEMS = [] as const;
@@ -51,8 +60,31 @@ type ObservatoryMutationReconciliation =
readonly sessionId: string;
};
type EvidenceSortDirection = "newest" | "oldest";
type ObservatoryEvidenceRow =
| {
readonly kind: "composition";
readonly id: string;
readonly timestamp: string;
readonly run: AICompositionRun;
}
| {
readonly kind: "job";
readonly id: string;
readonly timestamp: string;
readonly job: ObservatoryRecordedJob;
}
| {
readonly kind: "evidence";
readonly id: string;
readonly timestamp: string;
readonly evidence: ObservatoryEvidence;
};
type ObservatoryReplayState =
| { readonly kind: "closed" }
| { readonly kind: "composition"; readonly run: AICompositionRun }
| {
readonly kind: "loading";
readonly binding: ObservatoryRecordedRunBinding;
@@ -112,15 +144,198 @@ function formatDuration(seconds: number): string {
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function compositionEventTimestamp(run: AICompositionRun): string {
if (run.state === "running" || run.jobs.length === 0) return run.createdAtUtc;
return run.jobs.reduce((latest, job) => (
Date.parse(job.updatedAtUtc) > Date.parse(latest) ? job.updatedAtUtc : latest
), run.createdAtUtc);
}
function compositionProducedEvidence(
run: AICompositionRun,
evidence: ObservatoryEvidence,
): boolean {
if (!run.resultIds.includes(evidence.sessionId)) return false;
const publishedAt = Date.parse(evidence.publishedAtUtc);
const compositionCreatedAt = Date.parse(run.createdAtUtc);
return Number.isFinite(publishedAt) && Number.isFinite(compositionCreatedAt)
&& publishedAt >= compositionCreatedAt;
}
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;
function evidenceConfigurationLabel(
evidence: ObservatoryEvidence,
moduleLabelsBySetup: Readonly<Record<string, string>>,
jobs: readonly ObservatoryRecordedJob[],
): string {
const setupId = evidence.lab.calculationProfile?.setupId
?? jobs.find((job) => job.resultId === evidence.sessionId)?.setupId;
const profileName = setupId
? moduleLabelsBySetup[setupId] ?? evidence.lab.calculationProfile?.displayName
: evidence.lab.calculationProfile?.displayName;
return profileName ?? evidence.label;
}
function aiJobTone(job: ObservatoryRecordedJob): "accent" | "success" | "danger" | "warning" {
if (job.state === "failed" || job.publication.state === "failed") return "danger";
if (job.state === "paused" || job.state === "preemption-pending") return "warning";
if (job.state === "succeeded") return "success";
return "accent";
}
const AI_PROGRESS_PHASE_RANGES = {
"source-transfer": [0, 5],
"source-preparation": [5, 25],
computing: [25, 90],
"result-assembly": [90, 95],
"result-transfer": [95, 99],
} as const;
function aiJobProgressPercent(
job: ObservatoryRecordedJob,
progress: RecordedProgressView | undefined,
): number {
if (job.state === "succeeded") return 100;
if (!progress) return 0;
const [start, end] = AI_PROGRESS_PHASE_RANGES[progress.phase];
if (progress.total === null || progress.total === 0) return start;
const phaseFraction = Math.max(0, Math.min(1, progress.completed / progress.total));
return start + ((end - start) * phaseFraction);
}
function aiJobStage(
job: ObservatoryRecordedJob,
progress: RecordedProgressView | undefined,
): string {
if (job.state === "failed" || job.publication.state === "failed") return "Ошибка";
if (job.state === "accepted" || job.state === "queued" || job.state === "claimed" || !progress) {
return "Инициализация";
}
if (progress.phase === "source-transfer" || progress.phase === "source-preparation") {
return "Передача на сервер";
}
return "Просчёт";
}
function computingStageDetail(
job: ObservatoryRecordedJob,
progress: RecordedProgressView,
recordingDurationSeconds: number,
): string {
if (job.setupId === "ai-range-object-distance-v1") {
if (progress.completed === 0) {
return "Подготавливаем камеру и LiDAR";
}
if (progress.completed === 1) {
return "Собираем видеоряд для модели";
}
if (progress.completed === 2) {
return "RF-DETR обрабатывает запись · измеренная скорость Worker 006: 17–18 FPS";
}
if (progress.completed === 3) return "Сопоставляем рамки с LiDAR · осталось несколько минут";
return "Собираем и проверяем результат";
}
if (job.setupId === "ai-segmentation-ddrnet-v1") {
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
if (progress.completed === 1) return "Собираем видеоряд для модели";
if (progress.completed === 2) {
return `DDRNet обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
}
return "Собираем и проверяем результат";
}
if (job.setupId === "ai-segmentation-eomt-v1") {
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
if (progress.completed === 1) return "Собираем видеоряд для модели";
if (progress.completed === 2) {
return `EoMT обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
}
return "Собираем и проверяем результат";
}
if (job.setupId === "ai-detection-rf-detr-v1") {
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
if (progress.completed === 1) return "Собираем видеоряд для модели";
if (progress.completed === 2) {
return `RF-DETR обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
}
return "Собираем и проверяем результат";
}
return "Обрабатываем запись выбранным модулем";
}
function aiJobStageDetail(
job: ObservatoryRecordedJob,
progress: RecordedProgressView | undefined,
recordingDurationSeconds: number,
): string {
if (job.state === "failed") {
return "Не удалось запустить модель. Конфигурацию можно отправить повторно";
}
if (job.publication.state === "failed") {
return "Результат рассчитан, но не сохранён в Обсерватории";
}
if (job.state === "succeeded") {
if (job.publication.state === "pending") return "Завершаем сохранение результата";
if (job.publication.state === "not-required") return "Публикация результата не требуется";
return "Результат сохранён в Обсерватории";
}
if (!progress) {
return "Готовим конфигурацию и место в очереди";
}
if (progress.phase === "source-transfer") {
if (progress.total !== null && progress.total > 0) {
return `${progress.completed.toLocaleString("ru-RU")} / ${progress.total.toLocaleString("ru-RU")} файлов`;
}
return "Копируем исходную запись на Worker 006";
}
if (progress.phase === "source-preparation") return "Проверяем целостность переданных данных";
if (progress.phase === "computing") {
return computingStageDetail(job, progress, recordingDurationSeconds);
}
return "Собираем, передаём и сохраняем результат";
}
function presentAIJob(job: ObservatoryRecordedJob): boolean {
if (job.state === "failed") return true;
if (job.publication.state === "pending" || job.publication.state === "failed") return true;
return job.state !== "succeeded";
}
function presentLatestAIJobs(
jobs: readonly ObservatoryRecordedJob[],
): readonly ObservatoryRecordedJob[] {
const seenSetups = new Set<string>();
return jobs.filter((job) => {
if (seenSetups.has(job.setupId)) return false;
seenSetups.add(job.setupId);
return presentAIJob(job);
});
}
function compositionProgressPercent(
run: AICompositionRun,
progress: Readonly<Record<string, RecordedProgressView>>,
): number {
if (run.state === "ready") return 100;
if (run.jobs.length === 0) return 0;
return run.jobs.reduce(
(total, job) => total + aiJobProgressPercent(job, progress[job.jobId]),
0,
) / run.jobs.length;
}
function compositionStage(
run: AICompositionRun,
progress: Readonly<Record<string, RecordedProgressView>>,
): string {
if (run.state === "ready") return "Расчёт завершён";
if (run.state === "failed") return "Ошибка расчёта";
const current = run.jobs.find((job) => job.state !== "succeeded") ?? run.jobs[0];
return current ? aiJobStage(current, progress[current.jobId]) : "Инициализация";
}
export function ObservatoryWorkspace({
@@ -130,22 +345,21 @@ export function ObservatoryWorkspace({
}) {
const controller = useObservatoryCatalog();
const [selectedSessionId, setSelectedSessionId] = useState("");
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
const recordedJobsController = useObservatoryRecordedJobs(
selectedSessionId,
setupController.selectedSetupId,
setupController.selectedSetup?.runDefinition?.definitionSha256 ?? "",
);
const [aiConfigurationOpen, setAIConfigurationOpen] = useState(false);
const [evidenceSortDirection, setEvidenceSortDirection] = useState<EvidenceSortDirection>("newest");
const aiJobsController = useAICompositionJobs(selectedSessionId);
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
const [compositionRenameTarget, setCompositionRenameTarget] = useState<AICompositionRun | null>(null);
const [renameValue, setRenameValue] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
const [compositionDeleteTarget, setCompositionDeleteTarget] = useState<AICompositionRun | 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 overviewRef = useRef<HTMLDivElement | null>(null);
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
const refreshedPublishedJobRef = useRef<string | null>(null);
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
@@ -163,6 +377,7 @@ export function ObservatoryWorkspace({
useEffect(() => {
if (items.some((item) => item.source.id === selectedSessionId)) return;
closeReplay();
setAIConfigurationOpen(false);
setSelectedSessionId(items[0]?.source.id ?? "");
}, [closeReplay, items, selectedSessionId]);
@@ -171,62 +386,54 @@ export function ObservatoryWorkspace({
const selectedSession = items.find(
(item) => item.source.id === selectedSessionId,
) ?? null;
const presentedEvidence = selectedSession?.evidence ?? [];
const presentedCompositionRuns = aiJobsController.runs;
const presentedEvidence = (selectedSession?.evidence ?? []).filter(
(evidence) => !presentedCompositionRuns.some(
(run) => compositionProducedEvidence(run, 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 compositionJobIds = useMemo(() => new Set(
presentedCompositionRuns.flatMap((run) => run.jobIds),
), [presentedCompositionRuns]);
const presentedAIJobs = presentLatestAIJobs(aiJobsController.jobs).filter(
(job) => !compositionJobIds.has(job.jobId),
);
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 presentedRows = useMemo<readonly ObservatoryEvidenceRow[]>(() => {
const rows: ObservatoryEvidenceRow[] = [
...presentedCompositionRuns.map((run): ObservatoryEvidenceRow => ({
kind: "composition",
id: run.runId,
timestamp: compositionEventTimestamp(run),
run,
})),
...presentedAIJobs.map((job): ObservatoryEvidenceRow => ({
kind: "job",
id: job.jobId,
timestamp: job.updatedAtUtc,
job,
})),
...presentedEvidence.map((evidence): ObservatoryEvidenceRow => ({
kind: "evidence",
id: evidence.sessionId,
timestamp: evidence.publishedAtUtc,
evidence,
})),
];
const direction = evidenceSortDirection === "newest" ? -1 : 1;
return rows.sort((left, right) => (
direction * (Date.parse(left.timestamp) - Date.parse(right.timestamp))
|| left.id.localeCompare(right.id)
));
}, [evidenceSortDirection, presentedAIJobs, presentedCompositionRuns, presentedEvidence]);
const initialLoading = !controller.catalog
&& ["idle", "loading"].includes(controller.state);
const unavailable = !controller.catalog && controller.state === "error";
const replayEvidenceId = replay.kind === "closed"
const replayEvidenceId = replay.kind === "closed" || replay.kind === "composition"
? null
: replay.binding.evidenceSessionId;
const replayEvidence = replayEvidenceId === null
@@ -236,14 +443,13 @@ export function ObservatoryWorkspace({
) ?? null;
useEffect(() => {
const publishedJob = recordedJobsController.jobs.find(
const publishedJob = aiJobsController.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]);
}, [aiJobsController.jobs, controller.refresh]);
useEffect(() => {
if (
@@ -274,8 +480,7 @@ export function ObservatoryWorkspace({
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
if (mutationReconciliation.kind === "delete") setupController.refresh();
}, [controller.catalog, mutationPending, mutationReconciliation, setupController.refresh]);
}, [controller.catalog, mutationPending, mutationReconciliation]);
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
const attempt = replayCoordinatorRef.current.begin();
@@ -305,6 +510,8 @@ export function ObservatoryWorkspace({
const openRename = useCallback((evidence: ObservatoryEvidence) => {
if (!evidence.recordedRun) return;
setCompositionRenameTarget(null);
setCompositionDeleteTarget(null);
setDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
@@ -314,13 +521,52 @@ export function ObservatoryWorkspace({
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
if (!evidence.recordedRun) return;
setCompositionRenameTarget(null);
setCompositionDeleteTarget(null);
setRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setDeleteTarget(evidence);
}, []);
const openCompositionRename = useCallback((run: AICompositionRun) => {
setRenameTarget(null);
setDeleteTarget(null);
setCompositionDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setRenameValue(run.displayName ?? run.presentation.configurationLabel);
setCompositionRenameTarget(run);
}, []);
const openCompositionDelete = useCallback((run: AICompositionRun) => {
setRenameTarget(null);
setDeleteTarget(null);
setCompositionRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
setCompositionDeleteTarget(run);
}, []);
const submitRename = useCallback(async () => {
if (compositionRenameTarget) {
if (mutationPending !== null) return;
setMutationPending("rename");
setMutationError(null);
try {
await renameAICompositionRunProjection(
compositionRenameTarget.runId,
renameValue,
);
setCompositionRenameTarget(null);
aiJobsController.refresh();
} catch (error) {
setMutationError(mutationErrorMessage(error));
} finally {
setMutationPending(null);
}
return;
}
if (!renameTarget?.recordedRun || mutationPending !== null) return;
const requestedDisplayName = renameValue.trim();
const reconciliation: ObservatoryMutationReconciliation = {
@@ -354,9 +600,27 @@ export function ObservatoryWorkspace({
} finally {
setMutationPending(null);
}
}, [controller, mutationPending, renameTarget, renameValue]);
}, [aiJobsController, compositionRenameTarget, controller, mutationPending, renameTarget, renameValue]);
const confirmDelete = useCallback(async () => {
if (compositionDeleteTarget) {
if (mutationPending !== null) return;
setMutationPending("delete");
setMutationError(null);
try {
if (replay.kind === "composition" && replay.run.runId === compositionDeleteTarget.runId) {
flushSync(() => closeReplay());
}
await deleteAICompositionRunProjection(compositionDeleteTarget.runId);
setCompositionDeleteTarget(null);
aiJobsController.refresh();
} catch (error) {
setMutationError(mutationErrorMessage(error));
} finally {
setMutationPending(null);
}
return;
}
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
const reconciliation: ObservatoryMutationReconciliation = {
kind: "delete",
@@ -368,6 +632,7 @@ export function ObservatoryWorkspace({
try {
if (
replay.kind !== "closed"
&& replay.kind !== "composition"
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
) {
flushSync(() => {
@@ -376,7 +641,6 @@ export function ObservatoryWorkspace({
}
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
controller.applyEvidenceDeletion(deleteTarget.sessionId);
setupController.refresh();
setDeleteTarget(null);
setMutationReconciliation(null);
void controller.refresh();
@@ -397,25 +661,16 @@ export function ObservatoryWorkspace({
} finally {
setMutationPending(null);
}
}, [closeReplay, controller, deleteTarget, mutationPending, replay, setupController.refresh]);
}, [aiJobsController, closeReplay, compositionDeleteTarget, controller, deleteTarget, mutationPending, replay]);
return (
<div
ref={overviewRef}
className="observatory-workspace"
data-workspace-id={definition.id}
data-observatory-authority="observation-only"
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
data-observatory-viewer={replay.kind === "ready" || replay.kind === "composition" ? "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>
@@ -434,78 +689,23 @@ export function ObservatoryWorkspace({
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();
}}
variant="primary"
disabled={!selectedSession}
onClick={() => setAIConfigurationOpen(true)}
>
Обновить
Сконфигурировать AI-слой
</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 ? (
{aiJobsController.error ? (
<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}
<span className="observatory-notice__copy">{aiJobsController.error}</span>
<Button size="compact" variant="ghost" onClick={aiJobsController.refresh}>
Повторить
</Button>
</GlassSurface>
) : null}
@@ -517,13 +717,6 @@ export function ObservatoryWorkspace({
</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) ? (
@@ -609,23 +802,150 @@ export function ObservatoryWorkspace({
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
<h3>Лабораторные доказательства</h3>
</div>
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
{selectedSession.evidence.length}
</StatusBadge>
<div className="observatory-evidence__ordering">
<StatusBadge tone={presentedRows.length > 0 ? "accent" : "neutral"}>
{presentedRows.length}
</StatusBadge>
<button
type="button"
className="observatory-evidence__sort"
data-direction={evidenceSortDirection}
aria-label={evidenceSortDirection === "newest"
? "Показать сначала старые результаты"
: "Показать сначала новые результаты"}
title={evidenceSortDirection === "newest"
? "Сначала новые"
: "Сначала старые"}
onClick={() => setEvidenceSortDirection((current) => (
current === "newest" ? "oldest" : "newest"
))}
>
<svg viewBox="0 0 18 18" aria-hidden="true">
<path d="M3 5h12M5 9h10M7 13h8" />
</svg>
</button>
</div>
</header>
{selectedSession.evidence.length > 0 ? (
{presentedRows.length > 0 ? (
<ol className="observatory-evidence-list">
{presentedEvidence.map((evidence) => (
{presentedRows.map((row) => {
if (row.kind === "composition") {
const { run } = row;
const failed = run.state === "failed";
const active = run.state === "running";
const progressPercent = compositionProgressPercent(run, aiJobsController.progress);
return (
<li key={run.runId}>
<div className={`observatory-evidence-card${active
? " observatory-evidence-card--progress" : ""}`}
aria-busy={active}>
<span className="observatory-evidence-card__icon" aria-hidden="true">
{failed ? <Icon name="alert" size={16} />
: run.state === "ready" ? <Icon name="clipboard" size={18} />
: <ActivityIndicator size="compact" />}
</span>
<div className="observatory-evidence-card__copy">
<strong>{run.displayName ?? run.presentation.configurationLabel}</strong>
{active ? (
<div className="observatory-evidence-card__progress" aria-hidden="true">
<span style={{ width: `${progressPercent}%` }} />
</div>
) : null}
<small>{run.state === "ready"
? formatTimestamp(row.timestamp)
: `${compositionStage(run, aiJobsController.progress)} · ${formatTimestamp(row.timestamp)}`}</small>
</div>
{active ? (
<StatusBadge tone="accent">{Math.round(progressPercent)}%</StatusBadge>
) : failed ? <StatusBadge tone="danger">Ошибка</StatusBadge> : null}
<div className="observatory-evidence-card__actions">
{run.state === "ready" ? (
<>
<IconButton
label={`Удалить ${run.displayName ?? run.presentation.configurationLabel} из Обсерватории`}
onClick={() => openCompositionDelete(run)}
>
<Icon name="trash" size={16} />
</IconButton>
<IconButton
label={`Переименовать ${run.displayName ?? run.presentation.configurationLabel}`}
onClick={() => openCompositionRename(run)}
>
<Icon name="edit" size={16} />
</IconButton>
<IconButton label={`Открыть визуальный разбор: ${run.configurationLabel}`}
onClick={() => setReplay({ kind: "composition", run })}>
<Icon name="eye" size={16} />
</IconButton>
</>
) : null}
</div>
</div>
</li>
);
}
if (row.kind === "job") {
const { job } = row;
const progress = aiJobsController.progress[job.jobId];
const progressPercent = aiJobProgressPercent(job, progress);
const moduleLabel = aiJobsController.moduleLabelsBySetup[job.setupId] ?? job.setupId;
const failed = job.state === "failed" || job.publication.state === "failed";
const active = !failed && (
job.state !== "succeeded" || job.publication.state === "pending"
);
return (
<li key={job.jobId}>
<div
className={`observatory-evidence-card${active
? " observatory-evidence-card--progress" : ""}`}
aria-busy={active}
>
<span className="observatory-evidence-card__icon" aria-hidden="true">
{failed
? <Icon name="alert" size={16} />
: job.state === "succeeded"
? <Icon name="check" size={16} />
: <ActivityIndicator size="compact" />}
</span>
<div className="observatory-evidence-card__copy">
<strong>{moduleLabel}</strong>
{active ? (
<div className="observatory-evidence-card__progress" aria-hidden="true">
<span style={{ width: `${progressPercent}%` }} />
</div>
) : null}
<small>
{aiJobStage(job, progress)} · {aiJobStageDetail(
job,
progress,
selectedSession.source.durationSeconds,
)}{progress && !failed && job.state !== "succeeded"
? ` · прошло ${formatDuration(progress.elapsedSeconds)}`
: ""} · {formatTimestamp(row.timestamp)}
</small>
</div>
{active ? (
<StatusBadge tone={aiJobTone(job)}>{Math.round(progressPercent)}%</StatusBadge>
) : failed ? <StatusBadge tone="danger">Ошибка</StatusBadge> : null}
</div>
</li>
);
}
const { evidence } = row;
return (
<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>
<strong>{evidenceConfigurationLabel(
evidence,
aiJobsController.moduleLabelsBySetup,
aiJobsController.jobs,
)}</strong>
<small>
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
{formatTimestamp(row.timestamp)}
</small>
</div>
<div className="observatory-evidence-card__actions">
@@ -661,7 +981,8 @@ export function ObservatoryWorkspace({
</div>
</div>
</li>
))}
);
})}
</ol>
) : (
<div className="observatory-evidence-empty">
@@ -704,21 +1025,34 @@ export function ObservatoryWorkspace({
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
</div>
</GlassSurface>
) : replay.kind === "composition" ? (
<section className="observatory-replay" aria-label="Визуальный разбор конфигурации">
<header className="observatory-replay__header">
<div>
<span className="section-eyebrow">СОХРАНЁННАЯ КОНФИГУРАЦИЯ</span>
<h3>{replay.run.configurationLabel}</h3>
</div>
<Button size="compact" variant="secondary" icon={<Icon name="close" size={14} />}
onClick={returnToOverview}>
Закрыть разбор
</Button>
</header>
<AICompositionReplay run={replay.run} />
</section>
) : 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>
<h3>{replayEvidence && selectedSession
? evidenceConfigurationLabel(
replayEvidence,
aiJobsController.moduleLabelsBySetup,
aiJobsController.jobs,
)
: replay.binding.resultId}</h3>
</div>
<Button
size="compact"
@@ -735,17 +1069,14 @@ export function ObservatoryWorkspace({
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>
<PortableResultReplay review={replay.review}
label={replayEvidence && selectedSession
? evidenceConfigurationLabel(
replayEvidence,
aiJobsController.moduleLabelsBySetup,
aiJobsController.jobs,
)
: replay.binding.resultId} />
)}
</section>
) : null}
@@ -761,7 +1092,7 @@ export function ObservatoryWorkspace({
) : null}
<Window
open={renameTarget !== null}
open={renameTarget !== null || compositionRenameTarget !== null}
title="Переименовать лабораторный результат"
subtitle="Меняется только отображаемое название в Обсерватории"
size="sm"
@@ -770,6 +1101,7 @@ export function ObservatoryWorkspace({
onClose={() => {
if (mutationPending === "rename") return;
setRenameTarget(null);
setCompositionRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
@@ -779,6 +1111,7 @@ export function ObservatoryWorkspace({
disabled={mutationPending === "rename"}
onClick={() => {
setRenameTarget(null);
setCompositionRenameTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
@@ -813,19 +1146,26 @@ export function ObservatoryWorkspace({
disabled={mutationPending === "rename"}
onChange={(event) => setRenameValue(event.currentTarget.value)}
/>
{mutationError && renameTarget ? (
{mutationError && (renameTarget || compositionRenameTarget) ? (
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
) : null}
</form>
</Window>
<AIConfigurationWindow
open={aiConfigurationOpen && selectedSession !== null}
sourceSessionId={selectedSession?.source.id ?? ""}
sourceLabel={selectedSession?.source.label ?? ""}
onClose={() => setAIConfigurationOpen(false)}
onCalculated={() => aiJobsController.refresh()}
/>
<ConfirmationModal
open={deleteTarget !== null}
open={deleteTarget !== null || compositionDeleteTarget !== null}
title="Удалить результат из Обсерватории?"
description={deleteTarget ? (
description={deleteTarget || compositionDeleteTarget ? (
<div className="observatory-delete-confirmation">
<p>
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
Будут удалены только каталожная проекция <strong>{deleteTarget?.label ?? compositionDeleteTarget?.displayName ?? compositionDeleteTarget?.presentation.configurationLabel}</strong>
{" "}и её отображаемое название в Обсерватории.
</p>
<p>
@@ -843,6 +1183,7 @@ export function ObservatoryWorkspace({
onClose={() => {
if (mutationPending === "delete") return;
setDeleteTarget(null);
setCompositionDeleteTarget(null);
setMutationError(null);
setMutationReconciliation(null);
}}
@@ -111,7 +111,7 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
const observatoryCore = await read("core/observatory/catalog.ts");
const recordedRun = await read("core/observatory/recordedRun.ts");
const sharedReplay = await read(
"components/laboratory/CanonicalVegetationRerunReplay.tsx",
"components/laboratory/CanonicalResultRerunReplay.tsx",
);
const workspaceCss = await read("styles/workspaces.css");
const observatoryCss = await read("styles/observatory.css");
@@ -125,7 +125,7 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
assert.match(observatory, /data-observatory-authority="observation-only"/);
assert.match(
observatory,
/data-observatory-viewer={replay\.kind === "ready" \? "attached" : "detached"}/,
/data-observatory-viewer={replay\.kind === "ready" \|\| replay\.kind === "composition" \? "attached" : "detached"}/,
);
assert.doesNotMatch(
`${observatory}\n${observatoryCore}`,
@@ -144,6 +144,13 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
);
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
assert.match(sharedReplay, /recordedSessionRerunProfile/);
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
const source = await read(`components/laboratory/${adapter}.tsx`);
assert.match(source, /<CanonicalResultRerunReplay/);
assert.doesNotMatch(source, /<RerunViewport|<video|setInterval|Canvas|THREE\./);
}
assert.doesNotMatch(workspaceCss, /\.observatory-/);
assert.match(observatoryCss, /\.observatory-workspace/);
});
@@ -132,3 +132,32 @@ test("canonical LAB resolves one generation-bound merged RRD", async () => {
blueprintSourceUrl: "/api/v1/observation-sessions/session-001/blueprint.rrd",
});
});
for (const [prefix, sourceKind] of [
["m49-tgs-portable-review", "portable-tgs"],
["lab-v1-eomt-ddrnet", "portable-semantic"],
]) test(`${sourceKind} resolves Core cache for the exact result and base, never a legacy LAB`, async () => {
const base = "a".repeat(64), generation = "b".repeat(64);
const resultId = `${prefix}-${"c".repeat(64)}`;
const launch = { sourceUrl: "/api/v1/observation-sessions/source/recording.rrd",
viewerSourceUrl: `/api/v1/observation-sessions/source/recording.rrd?generation=${base}`,
sha256: base };
let calls = 0;
const fetcher = async (url, options) => {
calls++;
assert.equal(url, `http://mission-core.test/api/v1/observatory/portable-results/${resultId}/replays/${base}/recording.rrd`);
assert.equal(options.method, "HEAD");
return new Response(null, { headers: { "Content-Type": "application/vnd.rerun.rrd",
"Content-Length": "100", "ETag": `"${generation}"`, "X-Rerun-Format": "RRF2" } });
};
const options = { sourceKind, origin: "http://mission-core.test", fetcher };
const replay = await resolveCanonicalLabReplay(resultId, launch, options);
assert.equal(replay.viewerSourceUrl, `${replay.sourceUrl}?generation=${generation}`);
assert.equal(replay.blueprintSourceUrl, "/api/v1/observation-sessions/source/blueprint.rrd");
await assert.rejects(resolveCanonicalLabReplay(`lab-v1-vegetation-shadow-${"c".repeat(64)}`, launch, options));
await assert.rejects(resolveCanonicalLabReplay(resultId, launch, {
...options, sourceKind: sourceKind === "portable-tgs" ? "portable-semantic" : "portable-tgs",
}));
await assert.rejects(resolveCanonicalLabReplay(resultId, { ...launch, sha256: "d".repeat(64) }, options));
assert.equal(calls, 1);
});
@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server, fetchLabViewProfile, saveLabViewProfile, normalizeLabSceneSettings;
before(async () => {
server = await createServer({ appType: "custom", logLevel: "silent",
server: { middlewareMode: true } });
({ fetchLabViewProfile, saveLabViewProfile } = await server.ssrLoadModule(
"/src/core/observatory/labViewProfile.ts"));
({ normalizeLabSceneSettings } = await server.ssrLoadModule(
"/src/components/laboratory/CanonicalResultRerunReplay.tsx"));
});
after(async () => { await server?.close(); });
const serverProfile = {
schema_version: "missioncore.observatory-lab-view-profile/v1",
result_id: "result-1",
scene_settings: {
point_size: 4.7,
accumulation_seconds: 8,
color_mode: "height",
palette: "viridis",
show_grid: false,
show_labels: true,
show_camera_frustums: false,
},
updated_at_utc: "2026-09-04T09:30:00.000Z",
};
test("LAB view profile round-trips through the result-scoped server endpoint", async () => {
const originalFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, init = {}) => {
requests.push({ url, init });
return new Response(JSON.stringify(serverProfile), {
status: 200, headers: { "Content-Type": "application/json" },
});
};
try {
const settings = await fetchLabViewProfile("result-1");
assert.equal(settings.pointSize, 4.7);
assert.equal(settings.palette, "viridis");
const saved = await saveLabViewProfile("result-1", settings);
assert.equal(saved.accumulationSeconds, 8);
assert.equal(requests[0].url, "/api/v1/observatory/lab-view-profiles/result-1");
assert.equal(requests[1].init.method, "PUT");
assert.deepEqual(JSON.parse(requests[1].init.body).scene_settings, serverProfile.scene_settings);
} finally {
globalThis.fetch = originalFetch;
}
});
test("LAB view profile fails closed on a foreign result identity", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(JSON.stringify({
...serverProfile, result_id: "foreign-result",
}), { status: 200, headers: { "Content-Type": "application/json" } });
try {
await assert.rejects(fetchLabViewProfile("result-1"), /другой LAB/);
} finally {
globalThis.fetch = originalFetch;
}
});
test("LAB scene settings normalize typed values before persistence", () => {
assert.deepEqual(
normalizeLabSceneSettings({
pointSize: 28,
accumulationSeconds: 305,
colorMode: "height",
palette: "viridis",
customColor: "#ffffff",
showGrid: false,
showPoints: true,
showTrajectory: true,
showLabels: true,
showCameraFrustums: false,
projection: "3d",
}),
{
pointSize: 28,
accumulationSeconds: 305,
colorMode: "height",
palette: "viridis",
customColor: "#ffffff",
showGrid: false,
showPoints: true,
showTrajectory: true,
showLabels: true,
showCameraFrustums: false,
projection: "3d",
},
);
});
@@ -886,7 +886,7 @@ 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 && !unifiedContent\}/);
assert.match(canonical, /resizable=\{splitView\}/);
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonical, /secondaryMode=\{\{/);
assert.match(visual, /playback=\{playbackController\.playback\}/);
@@ -40,6 +40,7 @@ let formatAccumulationDuration;
let resolveRerunSourceUrl;
let resolveRecordedBlueprintUrl;
let fetchRecordedBlueprintRrd;
let recordedCameraJournalContract;
let resolveRecordedPerceptionUrl;
let fetchRecordedPerceptionRrd;
let resolveRecordedPerceptionViewerSourceUrl;
@@ -110,6 +111,7 @@ before(async () => {
resolveRerunSourceUrl,
resolveRecordedBlueprintUrl,
fetchRecordedBlueprintRrd,
recordedCameraJournalContract,
resolveRecordedPerceptionUrl,
fetchRecordedPerceptionRrd,
resolveRecordedPerceptionViewerSourceUrl,
@@ -118,7 +120,7 @@ before(async () => {
fetchRecordedPointColorsRrd,
recordedPointColorKey,
isRecordedPlaybackFullyBuffered,
} = await server.ssrLoadModule(
} = await server.ssrLoadModule(
"/src/components/RerunViewport.tsx",
));
({ recordedObservationSources } = await server.ssrLoadModule(
@@ -129,6 +131,25 @@ before(async () => {
));
});
test("follow toggles retain the current recorded camera journal", () => {
const before = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: false,
});
const afterFollowToggle = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: true,
});
const afterPlanToggle = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 0, planView: true, followTrajectory: true,
});
const afterReset = recordedCameraJournalContract({
activeView: "spatial", viewResetGeneration: 1, planView: false, followTrajectory: false,
});
assert.equal(afterFollowToggle, before);
assert.notEqual(afterPlanToggle, before);
assert.notEqual(afterReset, before);
});
test("observation camera windows tile from the bottom-right above the live timeline", () => {
const bounds = { width: 1280, height: 720 };
const left = initialObservationWindowRect(0, 2, bounds);
@@ -444,6 +465,7 @@ test("recorded replay becomes ready only after the complete declared timeline is
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
const calls = [];
const cameraLimits = [];
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
const result = await fetchRecordedBlueprintRrd(
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
@@ -464,6 +486,9 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
activeView: "perception3d",
viewResetGeneration: 1,
followTrajectory: true,
currentTimeNs: 39_215_263_458,
onCameraMaxOrbitalRadius: value => cameraLimits.push(value),
unifiedCameraShare: 0.73,
perceptionLayers: {
enabled: true,
detections2d: true,
@@ -474,7 +499,10 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
return new Response(payload, {
status: 200,
headers: { "Content-Type": "application/vnd.rerun.rrd" },
headers: {
"Content-Type": "application/vnd.rerun.rrd",
"X-MissionCore-Camera-Max-Orbital-Radius": "686.024231",
},
});
},
},
@@ -497,13 +525,20 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
active_view: "perception3d",
view_reset_generation: 1,
follow_trajectory: true,
current_time_ns: 39_215_263_458,
unified_perception: true,
unified_camera_share: 0.73,
semantic_layer: null,
plan_view: false,
eye_position: null,
eye_look_target: null,
eye_up: null,
show_camera_image: true,
show_detections_2d: true,
show_segmentation: false,
show_cuboids_3d: true,
});
assert.deepEqual(cameraLimits, [686.024231]);
await fetchRecordedBlueprintRrd(
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
@@ -537,6 +572,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
},
);
assert.equal(calls[1].body.unified_perception, false);
assert.equal(calls[1].body.unified_camera_share, 0.46);
assert.equal(calls[1].body.show_cuboids_3d, true);
await assert.rejects(
@@ -63,14 +63,31 @@ function evidence(id, sourceSessionId, publishedAtUtc) {
labId: `LAB-${id}`,
sourceSessionId,
resultKind: "recorded-evidence",
resultId: `result-${id}`,
resultId: id,
sourceResultId: null,
configSha256: "a".repeat(64),
runCreatedAtUtc: publishedAtUtc,
publishedAtUtc,
replayCapability: null,
replayCapability: {
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review", viewerProfile: "portable-result",
timeline: "result-defined", activation: "explicit", commandsEnabled: false,
},
calculationProfile: null,
provenance: { verdict: "must-not-be-inferred" },
provenance: {
schema_version: "missioncore.observatory-portable-result-publication/v1",
authority: { commands_enabled: false },
calculation_profile: null, calculation_profile_sha256: "1".repeat(64),
job: {}, method: {}, storage: {},
source: { session_id: sourceSessionId },
run_definition: { definition_sha256: "a".repeat(64) },
result_package: { manifest_sha256: "b".repeat(64), artifact_manifest_id: "c".repeat(64) },
replay_capability: {
schema_version: "missioncore.observation-lab-replay-capability/v2",
kind: "portable-result-review", viewer_profile: "portable-result",
timeline: "result-defined", activation: "explicit", commands_enabled: false,
},
},
},
};
}
@@ -154,7 +171,9 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
const calls = [];
const fetcher = async (input, init) => {
calls.push({ input: String(input), method: init?.method });
return new Response(JSON.stringify({ items: [] }), {
return new Response(JSON.stringify({
schema_version: "missioncore.observation-session-page/v1", items: [], next_cursor: null,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
@@ -175,8 +194,8 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
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",
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3&pagination=cursor-v1",
"/api/v1/observation-sessions?limit=50&scope=source&pagination=cursor-v1",
],
);
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
@@ -203,7 +222,7 @@ test("Observatory exposes bounded-window uncertainty without inventing a broken
);
});
test("Observatory projects a typed canonical run only through its exact sourceSessionId", () => {
test("historical canonical replay stays out of Observatory without modifying its archive record", () => {
const canonicalResultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
const canonical = evidence(
canonicalResultId,
@@ -227,20 +246,144 @@ test("Observatory projects a typed canonical run only through its exact sourceSe
commandsEnabled: false,
},
};
const before = structuredClone(canonical);
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",
assert.deepEqual(catalog.items[0].evidence, []);
assert.equal(catalog.window.laboratoryCount, 0);
assert.deepEqual(canonical, before);
});
test("only admitted portable evidence is visible regardless of LAB label, age or installed version", () => {
const legacy = evidence("legacy", "source", "2026-09-03T13:00:00Z");
legacy.lab.labId = "LAB M4.9T5";
legacy.lab.replayCapability = null;
const historical = evidence("old-portable", "source", "2026-07-20T10:00:00Z");
historical.lab.labId = "LAB E24"; // Labels must never become an allow/deny list.
const current = evidence("new-portable", "source", "2026-09-03T12:00:00Z");
const catalog = buildObservatoryCatalog(
[source("source", "2026-07-19T10:00:00Z")], [legacy, historical, current],
);
assert.deepEqual(catalog.items[0].evidence.map((item) => item.sessionId), ["new-portable", "old-portable"]);
assert.equal(catalog.window.laboratoryCount, 2);
assert.equal(findObservatoryEvidence(catalog, "legacy"), null);
});
test("a malformed portable binding errors instead of silently disappearing as legacy", () => {
const broken = evidence("broken", "source", "2026-09-03T12:00:00Z");
broken.lab.provenance.source.session_id = "another-source";
assert.throws(() => buildObservatoryCatalog(
[source("source", "2026-07-19T10:00:00Z")], [broken],
), /identity/);
});
function wire(item) {
const lab = item.lab;
return {
id: item.id, label: item.label, started_at_utc: item.startedAtUtc,
completed_at_utc: item.completedAtUtc, status: item.status,
modalities: item.modalities, duration_seconds: item.durationSeconds, replayable: item.replayable,
...(lab ? { lab: {
lab_id: "LAB M4.9T5", source_session_id: lab.sourceSessionId, result_kind: lab.resultKind,
result_id: lab.resultId, source_result_id: lab.sourceResultId, config_sha256: lab.configSha256,
run_created_at_utc: lab.runCreatedAtUtc, published_at_utc: lab.publishedAtUtc,
provenance: lab.provenance, replay_capability: lab.replayCapability ? lab.provenance.replay_capability : null,
calculation_profile: null,
} } : {}),
};
}
function pageResponse(items, nextCursor = null) {
return new Response(JSON.stringify({
schema_version: "missioncore.observation-session-page/v1",
items: items.map(wire), next_cursor: nextCursor,
}), { headers: { "Content-Type": "application/json" } });
}
test("500 sources and later-page results are searchable metadata, with no archive or viewer requests", async () => {
const sources = Array.from({ length: 500 }, (_, index) => source(`source-${index}`, "2026-08-29T10:00:00Z"));
const labs = Array.from({ length: 101 }, (_, index) => {
const item = evidence(`legacy-${index}`, sources[0].id, "2026-08-29T11:00:00Z");
item.lab.replayCapability = null;
return item;
});
labs.push(evidence("portable-after-legacy", sources[499].id, "2026-08-29T12:00:00Z"));
const calls = [];
const catalog = await fetchObservatoryCatalog({ fetcher: async (input, init) => {
const url = new URL(String(input), "http://localhost");
calls.push(url);
assert.equal(init.method, "GET");
assert.equal(url.pathname, "/api/v1/observation-sessions");
const rows = url.searchParams.get("scope") === "source" ? sources : labs;
const cursor = url.searchParams.get("cursor");
const start = cursor === null ? 0 : rows.findIndex((item) => item.id === cursor) + 1;
const page = rows.slice(start, start + 100);
return pageResponse(page, start + page.length < rows.length ? page.at(-1).id : null);
} });
assert.equal(calls.length, 7);
assert.equal(catalog.items.length, 500);
assert.equal(catalog.window.laboratoryCount, 1);
assert.equal(catalog.window.sourceLimitReached, false);
assert.equal(catalog.window.laboratoryLimitReached, false);
assert.equal(catalog.items.find((item) => item.source.id === "source-499").evidence[0].sessionId, "portable-after-legacy");
assert.deepEqual(catalog.unresolvedEvidence, []);
});
test("exactly one full page with no cursor is complete, not a guessed truncated window", async () => {
const catalog = await fetchObservatoryCatalog({ limit: 1, fetcher: async (url) =>
String(url).includes("scope=source")
? pageResponse([source("source", "2026-08-29T10:00:00Z")])
: pageResponse([evidence("result", "source", "2026-08-29T11:00:00Z")]),
});
assert.equal(catalog.window.sourceLimitReached, false);
assert.equal(catalog.window.laboratoryLimitReached, false);
});
test("paging rejects repeated records, cursor cycles, unsafe cursors and unversioned responses", async () => {
for (const failure of ["duplicate", "cycle", "unsafe", "unversioned", "too-large"]) {
let index = 0;
await assert.rejects(fetchObservatoryCatalog({ limit: 1, fetcher: async (url) => {
if (String(url).includes("scope=laboratory")) return pageResponse([]);
index += 1;
if (failure === "unversioned") return new Response(JSON.stringify({ items: [] }));
if (failure === "unsafe") return pageResponse([], "../../private");
const id = failure === "duplicate" ? "same" : `source-${index}`;
const rows = [source(id, "2026-08-29T10:00:00Z")];
if (failure === "too-large") rows.push(source("extra", "2026-08-29T10:00:00Z"));
return pageResponse(rows, failure === "cycle" ? "cycle" : id);
} }));
assert.ok(index <= 2);
}
});
test("the traversal cap is explicit and cannot confirm absence of a result", async () => {
let count = 0;
const catalog = await fetchObservatoryCatalog({ limit: 1, fetcher: async (url) => {
if (String(url).includes("scope=source")) return pageResponse([]);
count += 1;
return pageResponse([], `cursor-${count}`);
} });
assert.equal(count, 256);
assert.equal(catalog.window.laboratoryLimitReached, true);
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(catalog, "missing"), false);
});
test("failure aborts the sibling traversal and an already aborted caller starts no requests", async () => {
const signals = [];
await assert.rejects(fetchObservatoryCatalog({ fetcher: async (url, init) => {
signals.push(init.signal);
if (String(url).includes("scope=source")) return new Response("unavailable", { status: 503 });
return pageResponse([]);
} }), /503/);
assert.ok(signals.every((signal) => signal.aborted));
const request = new AbortController();
request.abort();
await assert.rejects(fetchObservatoryCatalog({ signal: request.signal, fetcher: async () => {
assert.fail("aborted catalog cannot start a request");
} }), { name: "AbortError" });
});
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let decode;
let label;
let fetchProgress;
const job = {
jobId: "observatory-run-" + "1".repeat(32),
sourceSessionId: "source-a", setupId: "m49-tgs",
definitionSha256: "a".repeat(64), claimGeneration: 2, state: "running",
publication: { state: "not-required" },
};
function view() {
return {
schema_version: "missioncore.observatory-recorded-progress-view/v1",
job_id: job.jobId, source_session_id: job.sourceSessionId, setup_id: job.setupId,
definition_sha256: job.definitionSha256, claim_generation: 2, state: "running",
received_at_utc: "2026-09-03T12:00:00Z", age_seconds: 0.5,
progress: {
schema_version: "missioncore.observatory-recorded-progress/v1",
claim_generation: 2, sequence: 4, phase_index: 2,
phase: "computing", unit: "frames", completed: 3, total: 10,
elapsed_seconds: 8, phase_elapsed_seconds: 5,
},
};
}
before(async () => {
server = await createServer({ configFile: false, logLevel: "silent", server: { middlewareMode: true } });
const module = await server.ssrLoadModule("/src/core/observatory/recordedProgress.ts");
decode = module.decodeRecordedProgress;
label = module.recordedProgressLabel;
fetchProgress = module.fetchRecordedProgress;
});
after(async () => { await server?.close(); });
test("actual frame count is separate from completion and publication", () => {
const progress = decode(view(), job);
assert.equal(label(job, progress), "Обрабатываем запись · 3 / 10 кадров");
assert.equal(label({ ...job, publication: { state: "pending" } }, progress), "Сохраняем результат");
assert.doesNotMatch(label(job, progress), /%|завершён|FPS|ETA/);
});
test("unknown total never becomes a synthetic percentage", () => {
const value = view();
value.progress.total = null;
assert.equal(label(job, decode(value, job)), "Обрабатываем запись · 3 кадров");
value.progress.completed = 0;
assert.equal(label(job, decode(value, job)), "Обрабатываем запись");
});
test("old source definition or attempt cannot flash as current progress", () => {
for (const delta of [
{ job_id: "other" }, { source_session_id: "other" }, { setup_id: "other" },
{ definition_sha256: "b".repeat(64) }, { claim_generation: 1 },
]) assert.throws(() => decode({ ...view(), ...delta }, job));
assert.equal(decode({ ...view(), state: "succeeded" }, job), null);
assert.equal(label({ ...job, claimGeneration: 3 }, decode(view(), job)), "Ожидаем данные расчёта");
});
test("invalid counters, invented phase and extra fields fail closed", () => {
for (const delta of [
{ completed: true }, { completed: 11 }, { completed: -1 }, { total: 0 },
{ completed: Number.MAX_SAFE_INTEGER + 1 }, { phase: "done" }, { path: "/private" },
{ phase_elapsed_seconds: 9 }, { claim_generation: 3 },
]) assert.throws(() => decode({ ...view(), progress: { ...view().progress, ...delta } }, job));
});
test("missing and stale telemetry show no fabricated advancement", () => {
assert.equal(decode({ ...view(), progress: null, age_seconds: null, received_at_utc: null }, job), null);
assert.equal(label(job, null), "Ожидаем данные расчёта");
assert.equal(label(job, decode({ ...view(), age_seconds: 30 }, job)), "Ожидаем обновление прогресса");
});
test("terminal jobs keep their real state when progress telemetry is absent", () => {
assert.equal(label({ ...job, state: "failed" }, null), "Ошибка расчёта");
assert.equal(label({ ...job, state: "succeeded" }, null), "Расчёт завершён");
});
test("progress is a read-only request bound to the active job", async () => {
const calls = [];
const progress = await fetchProgress(job, { fetcher: async (path, init) => {
calls.push([path, init.method ?? "GET"]);
return new Response(JSON.stringify(view()));
} });
assert.deepEqual(calls, [[`/api/v1/observatory/runs/${job.jobId}/progress`, "GET"]]);
assert.equal(progress.completed, 3);
});
@@ -35,9 +35,9 @@ test("Observatory is the third independent Polygon workspace", () => {
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Проверка компьютерного зрения",
title: "AI inference",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
description: "Сессии и квалификация без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
@@ -63,7 +63,7 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("core/observatory/useObservatoryCatalog.ts"),
read("core/observatory/recordedRun.ts"),
read("components/laboratory/CanonicalVegetationRerunReplay.tsx"),
read("components/laboratory/CanonicalResultRerunReplay.tsx"),
read("workspaces/laboratory/CanonicalVegetationRerunReplay.tsx"),
read("core/observation/viewerProfile.ts"),
read("styles/observatory.css"),
@@ -73,9 +73,15 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
assert.match(workspace, /useObservatoryCatalog/);
assert.doesNotMatch(workspace, /observatory-lead/);
assert.doesNotMatch(workspace, /Расчёт записанных маршрутов выбранными профилями/);
assert.match(workspace, /Связанных результатов нет/);
assert.match(workspace, /не является выводом о качестве/);
assert.match(workspace, /presentedEvidence = selectedSession\?\.evidence \?\? \[\]/);
assert.match(workspace, /function compositionProducedEvidence\(/);
assert.match(workspace, /publishedAt >= compositionCreatedAt/);
assert.match(workspace, /const presentedCompositionRuns = aiJobsController\.runs/);
assert.match(workspace, /presentedEvidence = \(selectedSession\?\.evidence \?\? \[\]\)\.filter/);
assert.match(workspace, /!presentedCompositionRuns\.some\([\s\S]*compositionProducedEvidence\(run, evidence\)/);
assert.doesNotMatch(workspace, /MAX_PRESENTED_EVIDENCE|\.evidence\.slice\(/);
assert.match(workspace, /Полнота исторических/);
assert.match(workspace, /вне текущего загруженного среза/);
@@ -86,12 +92,13 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
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/,
/function evidenceConfigurationLabel[\s\S]*calculationProfile\?\.displayName[\s\S]*return profileName \?\? evidence\.label/,
);
assert.match(
workspace,
/<span>\{evidenceResultSubtitle\(evidence\)\}<\/span>/,
/<strong>\{evidenceConfigurationLabel\([\s\S]*evidence,[\s\S]*aiJobsController\.moduleLabelsBySetup,[\s\S]*aiJobsController\.jobs,[\s\S]*\)\}<\/strong>/,
);
assert.doesNotMatch(workspace, /evidenceConfigurationLabel\([^)]*,\s*selectedSession\.source\.label/);
assert.match(
workspace,
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
@@ -99,10 +106,17 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
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.match(workspace, /<PortableResultReplay review=\{replay\.review\}/);
const portable = await read("components/laboratory/PortableResultReplay.tsx");
assert.doesNotMatch(portable, /Документ результата|JSON.stringify|StatusBadge/);
assert.match(portable, /missioncore.recorded-eomt-ddrnet-review\/v2/);
assert.match(portable, /sourceKind: "portable-semantic"/);
assert.match(portable, /sourceKind: "portable-objects"/);
assert.match(portable, /costmap=\{tgs\} semantics=\{semantics\}/);
assert.match(portable, /detections=\{objects\}/);
assert.match(portable, /<CanonicalResultRerunReplay/);
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
assert.doesNotMatch(workspace, /evidence\.lab\.resultKind|без запуска тяжёлого/);
assert.match(workspace, /Проверяем точную связь результата/);
assert.match(workspace, /role="alert"/);
assert.match(workspace, /Повторить/);
@@ -111,7 +125,10 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
workspace,
/const returnToOverview[\s\S]*closeReplay\(\);[\s\S]*scrollIntoView\(\{ block: "start" \}\)/,
);
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
assert.match(
workspace,
/<h3>\{replayEvidence && selectedSession[\s\S]*evidenceConfigurationLabel\([\s\S]*replayEvidence,[\s\S]*aiJobsController\.moduleLabelsBySetup,[\s\S]*aiJobsController\.jobs,[\s\S]*\)[\s\S]*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"/);
@@ -185,6 +202,10 @@ test("Observatory rename and delete use admitted projection mutations and canoni
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
assert.match(workspace, /renameAICompositionRunProjection/);
assert.match(workspace, /deleteAICompositionRunProjection/);
assert.match(workspace, /openCompositionRename\(run\)/);
assert.match(workspace, /openCompositionDelete\(run\)/);
assert.match(
workspace,
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
@@ -195,9 +216,12 @@ test("Observatory rename and delete use admitted projection mutations and canoni
);
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
const deleteFlowEnd = workspace.indexOf("}, [closeReplay, controller", deleteFlowStart);
const deleteFlowEnd = workspace.indexOf("}, [aiJobsController, closeReplay", deleteFlowStart);
assert.ok(deleteFlowStart >= 0 && deleteFlowEnd > deleteFlowStart);
const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
const wholeDeleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
const evidenceFlowStart = wholeDeleteFlow.indexOf("if (!deleteTarget?.recordedRun");
assert.ok(evidenceFlowStart >= 0);
const deleteFlow = wholeDeleteFlow.slice(evidenceFlowStart);
const teardown = deleteFlow.indexOf("closeReplay();");
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
@@ -218,77 +242,128 @@ test("Observatory rename and delete use admitted projection mutations and canoni
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([
test("Observatory configures independent AI modules and shows queued evidence progress", async () => {
const [workspace, styles, configWindow, jobsHook, sharedReplay] = await Promise.all([
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("styles/observatory.css"),
read("core/observatory/useObservatoryLaboratorySetups.ts"),
read("core/observatory/useObservatoryRecordedJobs.ts"),
read("components/observatory/AIConfigurationWindow.tsx"),
read("core/observatory/useAICompositionJobs.ts"),
read("components/laboratory/CanonicalResultRerunReplay.tsx"),
]);
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
assert.match(workspace, /setupController\.error \? \(/);
assert.match(workspace, />\s*Сконфигурировать AI-слой\s*<\/Button>/);
assert.doesNotMatch(workspace, /label="Выбрать сетап лаборатории"/);
assert.doesNotMatch(workspace, />\s*Обновить\s*<\/Button>/);
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
assert.match(workspace, /useObservatoryRecordedJobs/);
assert.doesNotMatch(workspace, /useObservatoryRecordedJobs|useObservatoryLaboratorySetups/);
assert.match(configWindow, /title="Сконфигурировать AI-слой"/);
assert.match(configWindow, /label: "Сегментация"/);
assert.match(configWindow, /Зависимые блоки подключаются явно/);
assert.match(configWindow, /label: "Облако точек \/ TGS"/);
assert.match(configWindow, /label: "Дистанция"/);
assert.match(configWindow, /Дистанция использует рамки детектора и синхронное облако точек/);
assert.match(configWindow, /TGS обрабатывает LiDAR независимо от сегментации и детектора/);
assert.match(configWindow, /providers\.length === 1/);
assert.match(
workspace,
/runPreflight\?\.outcome === "queueable"[\s\S]*runPreflight\.submissionAllowed/,
configWindow,
/<WindowFooterActions>[\s\S]*"Отправляем на Worker…" : "Рассчитать"/,
);
assert.match(configWindow, /className=\{state === "saving" \? "observatory-ai-config__calculate--saving"/);
assert.match(configWindow, /aria-busy=\{state === "saving"\}/);
assert.match(styles, /@keyframes observatory-ai-calculate-pulse/);
assert.match(
workspace,
/showCalculate \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
styles,
/\.observatory-ai-config__calculate--saving:disabled \{[\s\S]*animation: observatory-ai-calculate-pulse 1\.8s ease-in-out infinite;[\s\S]*opacity: 1;/,
);
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, /useAICompositionJobs\(selectedSessionId\)/);
assert.match(workspace, /observatory-evidence-card--progress/);
assert.match(workspace, /const active = run\.state === "running"/);
assert.match(workspace, /\{active \? \([\s\S]*observatory-evidence-card__progress/);
assert.match(workspace, /run\.state === "ready" \? <Icon name="clipboard"/);
assert.match(workspace, /function aiJobStage\(/);
assert.match(workspace, /return "Инициализация"/);
assert.match(workspace, /return "Передача на сервер"/);
assert.match(workspace, /return "Просчёт"/);
assert.match(workspace, /Собираем видеоряд для модели/);
assert.match(workspace, /измеренная скорость Worker 006: 17–18 FPS/);
assert.doesNotMatch(workspace, /phaseElapsedSeconds \/ progress\.completed/);
assert.match(workspace, /AI_PROGRESS_PHASE_RANGES/);
assert.match(workspace, /"source-preparation": \[5, 25\]/);
assert.match(workspace, /computing: \[25, 90\]/);
assert.match(workspace, /Подготавливаем камеру и LiDAR/);
assert.match(workspace, /прошло.*formatDuration\(progress\.elapsedSeconds\)/s);
assert.match(workspace, /Не удалось запустить модель/);
assert.doesNotMatch(workspace, /job\.terminalMessage|job\.publication\.error/);
assert.doesNotMatch(workspace, /function aiModuleLabel\(/);
assert.match(workspace, /aiJobsController\.moduleLabelsBySetup\[job\.setupId\] \?\? job\.setupId/);
assert.match(jobsHook, /fetchAIModuleCatalog\(request\.signal\)/);
assert.match(jobsHook, /AI_MODULE_SETUP_IDS\[module\.moduleId\]/);
assert.match(jobsHook, /run\.presentation\.modules\.flatMap/);
assert.match(jobsHook, /Object\.fromEntries\(\[\.\.\.catalogLabels, \.\.\.projectedLabels\]\)/);
assert.match(workspace, /<strong>\{moduleLabel\}<\/strong>/);
assert.match(workspace, /<strong>\{run\.displayName \?\? run\.presentation\.configurationLabel\}<\/strong>/);
assert.doesNotMatch(workspace, /<strong>\{selectedSession\.source\.label\} ·/);
assert.match(workspace, /type EvidenceSortDirection = "newest" \| "oldest"/);
assert.match(workspace, /const presentedRows = useMemo<readonly ObservatoryEvidenceRow\[]>/);
assert.match(workspace, /direction \* \(Date\.parse\(left\.timestamp\) - Date\.parse\(right\.timestamp\)\)/);
assert.match(workspace, /className="observatory-evidence__sort"/);
assert.match(workspace, /data-direction=\{evidenceSortDirection\}/);
assert.match(workspace, /formatTimestamp\(row\.timestamp\)/);
assert.match(styles, /\.observatory-evidence-card\s*\{[\s\S]*grid-template-columns:\s*auto minmax\(0, 1fr\) auto auto/);
assert.match(styles, /\.observatory-evidence__sort\s*\{[\s\S]*border:\s*0;[\s\S]*outline:\s*0/);
assert.match(
workspace,
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
styles,
/\.observatory-evidence-card__copy strong \{[\s\S]*font-size: var\(--nodedc-font-size-sm\);/,
);
assert.match(sharedReplay, /LAB_SCENE_SETTINGS_STORAGE_PREFIX/);
assert.match(sharedReplay, /window\.localStorage\.getItem/);
assert.match(sharedReplay, /window\.localStorage\.setItem/);
assert.match(sharedReplay, /unifiedCameraShare: blueprintSplitPrimarySize \/ 100/);
assert.match(sharedReplay, /setSettingsOpen\(false\);[\s\S]*void saveLabViewProfile/);
assert.match(
jobsHook,
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
sharedReplay,
/showPoints: spatialMode !== null && \(showSourcePoints \|\| showLocalSlam\)/,
);
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(sharedReplay, /showTrajectory: spatialMode !== null && showLocalSlam/);
assert.match(
sharedReplay,
/accumulationSeconds: showLocalSlam \? sceneDraft\.accumulationSeconds : 0/,
);
assert.match(workspace, /<AICompositionReplay run=\{replay\.run\} \/>/);
assert.match(workspace, /function presentAIJob\(job: ObservatoryRecordedJob\)/);
assert.match(workspace, /if \(job\.state === "failed"\) return true/);
assert.match(workspace, /return job\.state !== "succeeded"/);
assert.match(workspace, /function presentLatestAIJobs/);
assert.match(workspace, /seenSetups\.has\(job\.setupId\)/);
assert.match(configWindow, /fetchAICompositionJobs\(sourceSessionId, request\.signal\)/);
assert.match(
configWindow,
/if \(job\.state === "failed" \|\| job\.publication\.state === "failed"\) continue;/,
);
assert.match(configWindow, /Текущая конфигурация уже рассчитана/);
assert.match(configWindow, /configurationAlreadyExists/);
assert.match(configWindow, /Готовый результат будет использован без повторного расчёта/);
assert.doesNotMatch(configWindow, /disabled: existingByModule\.has\(module\.moduleId\)/);
assert.doesNotMatch(configWindow, /selections\.some\(\(\{ module \}\) => existingByModule/);
assert.match(jobsHook, /const OPEN = new Set/);
assert.match(jobsHook, /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(workspace, /onCalculated=\{\(\) => aiJobsController\.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, /\.observatory-evidence-card__progress \{/);
assert.match(styles, /\.observatory-ai-module-group > header \{/);
assert.match(styles, /\.observatory-ai-module-group \{[\s\S]*border: 0;[\s\S]*outline: 0;/);
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\(\)/,
);
});
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server, createRecordedRerunCameraJournal, initialEye;
before(async () => {
server = await createServer({ appType: "custom", logLevel: "silent",
server: { middlewareMode: true } });
const module = await server.ssrLoadModule(
"/src/components/rerun/recordedRerunCameraJournal.ts");
createRecordedRerunCameraJournal = module.createRecordedRerunCameraJournal;
initialEye = module.RECORDED_RERUN_ORBITAL_EYE;
});
after(async () => { await server?.close(); });
class FakeEvent {
constructor(type, init = {}) { this.type = type; Object.assign(this, init); }
}
class FakeTarget {
listeners = new Map();
emitted = [];
addEventListener(type, listener) {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener); this.listeners.set(type, listeners);
}
removeEventListener(type, listener) {
this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener));
}
dispatchEvent(event) {
this.emitted.push(event);
for (const listener of this.listeners.get(event.type) ?? []) listener(event);
return true;
}
}
const radius = (eye) => Math.hypot(
eye.position[0] - eye.lookTarget[0],
eye.position[1] - eye.lookTarget[1],
eye.position[2] - eye.lookTarget[2],
);
test("recorded orbital eye tracks only 3D viewport navigation", () => {
const scope = new FakeTarget();
const timers = [];
Object.assign(scope, {
WheelEvent: FakeEvent,
requestAnimationFrame(callback) { callback(); return 1; },
setTimeout(callback) { timers.push(callback); return timers.length; },
});
const canvas = new FakeTarget();
Object.assign(canvas, {
clientHeight: 200,
isConnected: true,
getBoundingClientRect: () => ({
left: 100, right: 500, top: 50, bottom: 250, width: 400, height: 200,
}),
});
const journal = createRecordedRerunCameraJournal(canvas, scope);
journal.configure(initialEye, 0.46);
const pointer = (type, x, y, buttons) => new FakeEvent(type, {
clientX: x, clientY: y, button: 0, buttons, pointerId: 7, pointerType: "mouse",
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
});
canvas.dispatchEvent(pointer("pointerdown", 200, 100, 1));
scope.dispatchEvent(pointer("pointermove", 240, 120, 1));
scope.dispatchEvent(pointer("pointerup", 240, 120, 0));
assert.deepEqual(journal.current(), initialEye);
canvas.dispatchEvent(pointer("pointerdown", 380, 100, 1));
scope.dispatchEvent(pointer("pointermove", 420, 120, 1));
scope.dispatchEvent(pointer("pointerup", 420, 120, 0));
const rotated = journal.current();
assert.notDeepEqual(rotated.position, initialEye.position);
assert.ok(Math.abs(radius(rotated) - radius(initialEye)) < 1e-9);
journal.setMaxOrbitalRadius(40);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 380, clientY: 130, deltaX: 0, deltaY: 2_000, deltaMode: 0,
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
}));
assert.ok(Math.abs(radius(journal.current()) - 40) < 1e-9);
journal.configure(rotated, 0.46);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 0, clientY: 0, deltaX: 0, deltaY: -20, deltaMode: 0,
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
}));
const zoomed = journal.current();
assert.ok(Math.abs(radius(zoomed) - radius(rotated) * Math.exp(-20 / 200)) < 1e-9);
const snapshot = journal.current();
journal.setSpatialViewportStart(0.8);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 380, clientY: 130, deltaX: 0, deltaY: -20, deltaMode: 0,
}));
assert.deepEqual(journal.current(), snapshot);
journal.dispose();
});
@@ -0,0 +1,261 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server, createIsolatedRerunHost, keepRecordedBlueprintSession, createRecordedRerunFacade, createRecordedRerunOwner, relayIsolatedRerunInput;
before(async () => {
server = await createServer({ appType: "custom", logLevel: "silent",
server: { middlewareMode: true } });
({ createIsolatedRerunHost } = await server.ssrLoadModule(
"/src/components/rerun/isolatedRerunHost.ts"));
({ keepRecordedBlueprintSession } = await server.ssrLoadModule(
"/src/core/observation/recordedBlueprintLifecycle.ts"));
({ createRecordedRerunFacade } = await server.ssrLoadModule(
"/src/components/rerun/recordedRerunFacade.ts"));
({ createRecordedRerunOwner } = await server.ssrLoadModule(
"/src/components/rerun/recordedRerunOwner.ts"));
({ relayIsolatedRerunInput } = await server.ssrLoadModule(
"/src/components/rerun/isolatedRerunInput.ts"));
});
after(async () => { await server?.close(); });
function fixture(t, { stopFails = false } = {}) {
const timers = new Map();
let timerId = 0, creates = 0, stops = 0, removals = 0;
t.mock.method(globalThis, "setTimeout", (fn) => {
timers.set(++timerId, fn); return timerId;
});
t.mock.method(globalThis, "clearTimeout", (id) => timers.delete(id));
const listeners = new Map();
const viewer = { stop() { stops++; if (stopFails) throw new Error("partial start"); } };
const mount = {};
const frame = {
contentWindow: {
missionCoreRerun: createRecordedRerunOwner(() => { creates++; return viewer; }, mount),
addEventListener: (name, fn) => listeners.set(name, fn),
removeEventListener: (name) => listeners.delete(name),
},
remove() { removals++; },
};
const host = { dataset: {}, ownerDocument: { createElement(name) {
assert.equal(name, "iframe"); return frame;
} }, append(child) { assert.equal(child, frame); } };
return { host, frame, viewer, mount, timers, listeners,
counts: () => ({ creates, stops, removals }) };
}
function bridge(native, mount = {}) {
const child = createRecordedRerunOwner(() => native, mount);
const parent = createRecordedRerunFacade((request, bytes) => {
assert.equal(typeof request, "string");
const result = child.invoke(request, bytes);
assert.equal(typeof result, "string");
return result;
});
child.connect(message => {
assert.equal(typeof message, "string");
parent.notify(message);
});
return parent;
}
test("recorded realm terminates on close even if upstream stop throws", async (t) => {
const f = fixture(t, { stopFails: true });
const scope = createIsolatedRerunHost(f.host);
assert.equal(f.frame.src, "/rerun-runtime.html");
f.frame.onload();
const ready = await scope.ready;
assert.notEqual(ready.viewer, f.viewer);
assert.equal(ready.mount, f.host);
assert.equal(f.timers.size, 0);
assert.equal(f.listeners.size, 1);
scope.dispose(); scope.dispose();
assert.deepEqual(f.counts(), { creates: 1, stops: 1, removals: 1 });
assert.equal(f.listeners.size, 0);
assert.equal(f.frame.onload, null);
assert.equal(ready.viewer.ready, false);
});
test("native frame input relays Escape and leaves pointer ownership inside Rerun", () => {
const listeners = new Map(), events = [];
class ParentEvent { constructor(type, init) { this.type = type; Object.assign(this, init); } }
const host = { dataset: {}, ownerDocument: { defaultView: {
KeyboardEvent: ParentEvent, PointerEvent: ParentEvent,
} }, dispatchEvent: event => events.push(event) };
const child = {
addEventListener: (name, fn) => listeners.set(name, fn),
removeEventListener: name => listeners.delete(name),
};
const release = relayIsolatedRerunInput(host, child, null);
assert.equal(listeners.has("pointerdown"), false);
listeners.get("keydown")({ key: "Escape", defaultPrevented: false });
assert.equal(events[0].key, "Escape");
release(); release();
assert.equal(listeners.size, 0); assert.equal(events.length, 1);
});
test("disposed facade, channel and unsubscribe reject native use and copy event values", async () => {
let eventCallback, unsubscribed = 0, sends = 0, starts = 0;
const range = { min: 1, max: 2 }, mount = {};
const native = {
ready: true, stop() {},
async start(source, parent) { assert.equal(parent, mount); starts++; },
get_time_range: () => range,
on(event, callback) { eventCallback = callback; return () => { unsubscribed++; }; },
open_channel: () => ({ ready: true, send_rrd() { sends++; }, close() {} }),
};
const { facade, dispose } = bridge(native, mount);
await facade.start(null, {}, null);
assert.equal(starts, 1);
let event;
const unsubscribe = facade.on("time_update", value => { event = value; });
eventCallback(range);
assert.deepEqual(event, range); assert.notEqual(event, range);
assert.notEqual(facade.get_time_range(), range);
const channel = facade.open_channel();
channel.send_rrd(new Uint8Array());
dispose(); dispose(); unsubscribe();
assert.equal(unsubscribed, 1);
assert.equal(channel.ready, false);
channel.send_rrd(new Uint8Array());
eventCallback({ min: 3, max: 4 });
assert.deepEqual(event, range);
assert.equal(sends, 1);
await assert.rejects(facade.start(null, {}, null), /disposed/);
});
test("close before frame load cancels startup and cannot reopen", async (t) => {
const f = fixture(t);
const scope = createIsolatedRerunHost(f.host);
const lateLoad = f.frame.onload;
const rejected = assert.rejects(scope.ready, /disposed/);
scope.dispose(); lateLoad();
await rejected;
assert.deepEqual(f.counts(), { creates: 0, stops: 0, removals: 1 });
assert.equal(f.timers.size, 0);
});
test("closing during SDK startup also releases a late successful start", async () => {
let finish, stops = 0;
const owner = bridge({
start: () => new Promise(resolve => { finish = resolve; }),
stop() { stops++; },
}, {});
const pending = owner.facade.start(null, {}, null);
const rejected = assert.rejects(pending, /disposed/);
owner.dispose();
assert.equal(stops, 1);
finish();
await rejected;
await Promise.resolve();
assert.equal(stops, 2);
assert.equal(owner.facade.ready, false);
});
test("late failed startup cannot revive a disposed parent and still finishes cleanup", async () => {
let fail, stops = 0;
const owner = bridge({
start: () => new Promise((_resolve, reject) => { fail = reject; }),
stop() { stops++; if (stops > 1) throw new Error("already gone"); },
}, {});
const pending = owner.facade.start(null, {}, null);
const rejected = assert.rejects(pending, /disposed/);
owner.dispose();
fail(new Error("startup failed"));
await rejected;
await Promise.resolve();
assert.equal(stops, 2);
assert.equal(owner.facade.ready, false);
});
test("primitive bridge preserves all public clock/control arguments and errors", async () => {
const calls = [];
const methods = {
open: [["one.rrd", "two.rrd"]], close: ["one.rrd"],
override_panel_state: ["time", "hidden"],
get_active_recording_id: [], get_active_timeline: ["recording"],
get_current_time: ["recording", "session_time"], get_playing: ["recording"],
get_time_range: ["recording", "session_time"],
set_active_timeline: ["recording", "session_time"],
set_current_time: ["recording", "session_time", 123.456],
set_playing: ["recording", false],
};
const native = { stop() {}, start: async () => { throw new Error("native startup failure"); } };
for (const name of Object.keys(methods)) native[name] = (...args) => { calls.push([name, args]); return 1; };
const { facade, dispose } = bridge(native);
for (const [name, args] of Object.entries(methods)) facade[name](...args);
assert.deepEqual(calls, Object.entries(methods));
await assert.rejects(facade.start(null, {}, null), /native startup failure/);
dispose();
});
test("sync native failures become parent-owned errors and optional channel names stay undefined", () => {
const nativeError = new Error("native failure");
let channelName = "unobserved";
const { facade, dispose } = bridge({
stop() {},
open() { throw nativeError; },
open_channel(name) { channelName = name; return { close() {} }; },
});
assert.throws(() => facade.open("rrd"), error => error !== nativeError && /native failure/.test(error.message));
facade.open_channel();
assert.equal(channelName, undefined);
dispose();
});
test("dispose closes every auxiliary channel even if one channel fails", () => {
const closed = [];
let stops = 0;
const owner = bridge({
stop() { stops++; },
open_channel: name => ({ close() {
closed.push(name);
if (name === "first") throw new Error("already gone");
} }),
}, {});
const first = owner.facade.open_channel("first");
const second = owner.facade.open_channel("second");
owner.dispose(); owner.dispose(); first.close(); second.close();
assert.deepEqual(closed, ["first", "second"]);
assert.equal(stops, 1);
});
test("host load timeout removes the frame and clears its timer", async (t) => {
const f = fixture(t);
const scope = createIsolatedRerunHost(f.host);
const rejected = assert.rejects(scope.ready, /timed out/);
[...f.timers.values()][0]();
await rejected;
assert.equal(f.counts().removals, 1);
assert.equal(f.timers.size, 0);
});
test("blueprint termination cancels renewal and sends one bounded keepalive release", async () => {
let tick, canceled = 0;
const requests = [];
const release = keepRecordedBlueprintSession({
endpointUrl: "/api/v1/observation-sessions/source/blueprint.rrd",
origin: "http://core.test", applicationId: "nodedc_mission_core_recorded",
recordingId: "recording", ownerId: "a".repeat(32),
fetcher: async (url, init) => { requests.push({ url, ...init }); return {}; },
schedule(fn) { tick = fn; return 1; }, cancel() { canceled++; },
});
tick();
const renewal = requests[0];
release(); release(); tick();
assert.equal(canceled, 1);
assert.equal(requests.length, 2);
assert.equal(renewal.signal.aborted, true);
assert.equal(requests[1].keepalive, true);
assert.equal(requests[1].url, "http://core.test/api/v1/observation-sessions/source/blueprint-lifecycle");
assert.deepEqual(JSON.parse(requests[1].body), {
action: "release", application_id: "nodedc_mission_core_recorded",
recording_id: "recording", blueprint_session_id: "a".repeat(32),
});
});
test("blueprint lease refuses a foreign endpoint", () => {
assert.throws(() => keepRecordedBlueprintSession({ endpointUrl: "https://other.test/blueprint.rrd",
origin: "http://core.test", applicationId: "app", recordingId: "recording", ownerId: "owner",
}), /Unsafe/);
});
@@ -14,6 +14,8 @@ let liveRerunReceiverBindingIdentity;
let recordedOpenWatchdogTimeoutMs;
let rerunViewerInitialSource;
let resolveRecordedViewerSourceUrl;
let isRecordedRrdSource;
let resolveRecordedBlueprintUrl;
before(async () => {
server = await createServer({
@@ -31,6 +33,8 @@ before(async () => {
recordedOpenWatchdogTimeoutMs,
rerunViewerInitialSource,
resolveRecordedViewerSourceUrl,
isRecordedRrdSource,
resolveRecordedBlueprintUrl,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
});
@@ -86,6 +90,25 @@ test("one canonical LAB replay generation reaches the same native receiver", ()
);
});
for (const prefix of [
"m49-tgs-portable-review",
"lab-v1-eomt-ddrnet",
"ai-layer-ddrnet",
"ai-layer-eomt",
"ai-layer-rf-detr",
"ai-layer-object-distance",
]) test(`${prefix} replay uses recorded admission and the shared source blueprint`, () => {
const source = `/api/v1/observatory/portable-results/${prefix}-${"c".repeat(64)}/replays/${"d".repeat(64)}/recording.rrd`;
const blueprint = "/api/v1/observation-sessions/source/blueprint.rrd";
assert.equal(isRecordedRrdSource(source), true);
assert.equal(isRecordedRrdSource(source.replace("/replays/", "/untrusted/")), false);
assert.equal(resolveRecordedViewerSourceUrl({ sourceUrl: source,
viewerSourceUrl: `${source}?generation=${sha256}`, sha256, byteLength: 100 },
"http://mission-core.test"), `http://mission-core.test${source}?generation=${sha256}`);
assert.equal(resolveRecordedBlueprintUrl(source, "http://mission-core.test", blueprint),
`http://mission-core.test${blueprint}`);
});
test("live presentation waits for the exact receiver to expose a usable range", () => {
assert.equal(isLiveRerunPresentationReady(false, { min: 1, max: 2 }, 1), false);
assert.equal(isLiveRerunPresentationReady(true, null, 1), false);
@@ -225,6 +248,9 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
assert.doesNotMatch(source, /streamVerifiedRecordedRrd/);
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
assert.doesNotMatch(source, /recordedChannel/);
assert.match(source, /recordedPerceptionLayers\.costmap,/);
assert.match(source, /recordedPerceptionLayers\.costmap !== undefined && status !== "ready"/);
assert.match(source, /perceptionLayers\.costmap === undefined \? \{\} : \{\s*show_costmap: perceptionLayers\.costmap,\s*reactivate_updates: true/s);
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
assert.match(
@@ -97,7 +97,7 @@ test("M4 keeps independent semantic controls in media and spatial panes", async
assert.match(source, /aria-label="Слои 3D и плана"/);
assert.match(canonical, /data-pane-mode="media"/);
assert.match(canonical, /data-pane-mode="spatial"/);
assert.match(canonical, /modeControlsVisible=\{!splitView\}/);
assert.match(canonical, /modeControlsVisible=\{!splitView && !paneToolbarsAlwaysVisible\}/);
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
});
@@ -470,7 +470,7 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
"utf8",
),
readFile(
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
new URL("../src/components/laboratory/CanonicalResultRerunReplay.tsx", import.meta.url),
"utf8",
),
readFile(
@@ -494,17 +494,20 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
assert.match(rerunSource, /<RerunViewport/);
assert.match(rerunSource, /ИСХ\. ТОЧКИ/);
assert.match(rerunSource, /ЛОК\. SLAM/);
assert.match(rerunSource, /resolveCanonicalLabReplay/);
assert.match(rerunSource, /resolveReplay\(resultId, value/);
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, /lockPerceptionCameraInteraction: false/);
assert.match(rerunSource, /&& !semantics[\s\S]*&& !detections[\s\S]*&& !costmap/);
assert.doesNotMatch(rerunSource, /trackNativeSplit|rerunNativeCursor|rerunNativeInput/);
assert.match(canonicalSource, /resizable=\{splitView\}/);
assert.match(rerunSource, /unifiedCameraShare: blueprintSplitPrimarySize \/ 100/);
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, /max=\{500\}/);
assert.match(rerunSource, /exactValueBounds=\{\{ min: 0 \}\}/);
assert.match(replayStyles, /m4-replay-threat-visual__pane-toolbar \{[\s\S]*flex-wrap: wrap;/);
assert.match(replayStyles, /m4-replay-threat-visual__spatial-toolbar-end \{[\s\S]*display: contents;/);
assert.match(rerunSource, /<ToastStack items=\{toasts\}/);
assert.match(rerunSource, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
@@ -517,22 +520,14 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
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(rerunSource, /value: "tgs", label: "TGS", disabled: true/);
assert.match(rerunSource, /hasTgs \? toggle\("TGS", showTgs/);
assert.match(canonicalSource, /primary=\{mediaPane\}/);
assert.match(canonicalSource, /secondary=\{spatialPane/);
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
assert.match(canonicalSource, /resizable=\{splitView && !unifiedContent\}/);
assert.match(canonicalSource, /resizable=\{splitView\}/);
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
assert.match(resultSource, /linkedTgsResultId/);
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
+6
View File
@@ -91,6 +91,12 @@ export default defineConfig(({ mode }) => {
},
build: {
target: "esnext",
rollupOptions: {
input: {
app: fileURLToPath(new URL("./index.html", import.meta.url)),
rerun: fileURLToPath(new URL("./rerun-runtime.html", import.meta.url)),
},
},
},
server: {
host: "127.0.0.1",
+161
View File
@@ -0,0 +1,161 @@
{
"schema_version": "missioncore.observatory-ai-module-registry/v1",
"modules": [
{
"module_id": "camera-source",
"label": "Подготовка камеры K1",
"group": "preparation",
"image_sha256": "da926459aee0a841bbdfaf80a0eb5fbead354c56794d1f3384eeba66d0a49e00",
"implementation_sha256": "a0b1a74887e5f1cf78e867f81f246bae08e872fed320a2939986c17913418524",
"model_sha256": null,
"contract_sha256": "87c9e8864f9469719e69bfd750c418e077944a6260ffc6fd3ce12daf4a1da7d5",
"requires": [
"source.camera"
],
"provides": [
"camera.frames"
],
"optional_inputs": [],
"parameter_choices": {
"cadence": [
1
]
},
"defaults": {
"cadence": 1
},
"state_policy": "stateless"
},
{
"module_id": "ddrnet",
"label": "DDRNet-39 · GOOSE",
"group": "segmentation",
"image_sha256": "489fc7d1157fd0f1cd1d82e06a15737b7b2aaaf72b2ddb2aca2992b91a97488e",
"implementation_sha256": "9a3fceb65374ccc3dccdbe02691c2b62376ff3a3a6d16439cb9f6266e73c78b0",
"model_sha256": "b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6",
"contract_sha256": "197c9d7dd0f55f79992215a57e450c5442f3d1510d74083bcf123577f85da199",
"requires": [
"camera.frames"
],
"provides": [
"segmentation.mask"
],
"optional_inputs": [],
"parameter_choices": {
"cadence": [
1
]
},
"defaults": {
"cadence": 1
},
"state_policy": "stateless"
},
{
"module_id": "eomt",
"label": "EoMT Large · Cityscapes",
"group": "segmentation",
"image_sha256": "5b770178e4a5c8fbe8f8ddab3b83a598973dbe103b669851110b11a0918ab846",
"implementation_sha256": "17ac471d5c51f70b3af6fb6046e1880c731691253c338b1626702ce4ca375d2b",
"model_sha256": "c265da9a74f58f5c3f4826d23ca4ca78beac0b106cca5842beca61580de5b782",
"contract_sha256": "09ca841400b08af0607d19d346f78ab768567256c8c58789a6ade4ede8f425a0",
"requires": [
"camera.frames"
],
"provides": [
"segmentation.mask"
],
"optional_inputs": [],
"parameter_choices": {
"cadence": [
1
]
},
"defaults": {
"cadence": 1
},
"state_policy": "stateless"
},
{
"module_id": "tgs",
"label": "TRAVEL TGS · облако и карта проходимости",
"group": "geometry",
"image_sha256": "f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3",
"implementation_sha256": "52813392aabd02efc5c2b8f7c22ed88e3ef4cc8ad3aafeba2792efe503e29fe9",
"model_sha256": null,
"contract_sha256": "9dd80c8e2504559d2156fca933de6eb27901e35305e6853aeb84707e1cb13892",
"requires": [
"source.calibration",
"source.lidar",
"source.pose"
],
"provides": [
"geometry.costmap",
"geometry.ground"
],
"optional_inputs": [],
"parameter_choices": {
"history-seconds": [
1
]
},
"defaults": {
"history-seconds": 1
},
"state_policy": "causal-reset-at-source-start"
},
{
"module_id": "rf-detr",
"label": "RF-DETR Large · рамки объектов",
"group": "detection",
"image_sha256": "2b8b44be8e9ee4060aa6997fc4c025ad7120f37ecd720a9e59b5e02ac6c90f66",
"implementation_sha256": "4eb580eb938164bf0624e94fdc3b046aacf32fb8022c93c67fa26bab7c3d19d0",
"model_sha256": "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695",
"contract_sha256": "815d0afd24355da3a462e0423dab8284165c89cebd136d7c8584a32602178b96",
"requires": [
"camera.frames"
],
"provides": [
"detections.2d"
],
"optional_inputs": [],
"parameter_choices": {
"cadence": [
1
]
},
"defaults": {
"cadence": 1
},
"state_policy": "stateless"
},
{
"module_id": "object-distance",
"label": "Дистанция до объектов · K1 LiDAR",
"group": "range",
"image_sha256": "69d68f64981b41e5bcce1e642433e3d466b88c51942a5ba3aec9180d8ed04263",
"implementation_sha256": "0e3e426e2f768bc019a3715a50fc77a88626869abd9fa919e1aef5763d0c53da",
"model_sha256": null,
"contract_sha256": "5a6e446f009420352587f83388331b368d00950ab2fb896c6350aaa9b31c2e2e",
"requires": [
"detections.2d",
"source.calibration",
"source.lidar",
"source.pose"
],
"provides": [
"objects.ranged"
],
"optional_inputs": [],
"parameter_choices": {
"cadence": [
1
]
},
"defaults": {
"cadence": 1
},
"state_policy": "stateless"
}
]
}
+336
View File
@@ -0,0 +1,336 @@
{
"schema_version": "missioncore.observatory-domain-ontology/v1",
"ontology_id": "mission-core.observatory",
"version": "1.3.0",
"owner": "NODE.DC Mission Core",
"lifecycle": "local-runtime-canonical",
"entities": [
{
"id": "mission.transport-unit",
"identity": "transport_id",
"owner": "transport registry",
"lifecycle": "registered-to-retired"
},
{
"id": "mission.equipment-unit",
"identity": "equipment_id",
"owner": "equipment registry",
"lifecycle": "registered-to-retired"
},
{
"id": "observatory.equipment-mount",
"identity": "mount_id",
"owner": "equipment capture registry",
"lifecycle": "time-bounded"
},
{
"id": "observatory.capture-profile",
"identity": "capture_profile_id",
"owner": "equipment capture registry",
"lifecycle": "versioned"
},
{
"id": "observatory.recorded-session",
"identity": "source_session_id",
"owner": "session archive",
"lifecycle": "recording-to-immutable"
},
{
"id": "observatory.module-version",
"identity": "module_sha256",
"owner": "module registry",
"lifecycle": "installed-or-retired"
},
{
"id": "observatory.container-image",
"identity": "image_sha256",
"owner": "module registry",
"lifecycle": "built-to-retired"
},
{
"id": "observatory.worker-node",
"identity": "worker_node_id",
"owner": "worker registry",
"lifecycle": "registered-to-retired"
},
{
"id": "observatory.composition",
"identity": "composition_sha256",
"owner": "composition store",
"lifecycle": "immutable"
},
{
"id": "observatory.composition-run",
"identity": "run_id",
"owner": "composition run store",
"lifecycle": "append-only"
},
{
"id": "observatory.recorded-job",
"identity": "job_id",
"owner": "recorded job queue",
"lifecycle": "queued-to-terminal"
},
{
"id": "observatory.portable-result",
"identity": "result_id",
"owner": "portable result publisher",
"lifecycle": "immutable"
},
{
"id": "observatory.lab-projection",
"identity": "result_id",
"owner": "session catalog",
"lifecycle": "published"
},
{
"id": "observatory.lab-view-profile",
"identity": "result_id",
"owner": "replay presentation",
"lifecycle": "mutable-per-result"
},
{
"id": "observatory.viewer-pane",
"identity": "pane_id",
"owner": "replay presentation",
"lifecycle": "versioned-contract"
},
{
"id": "observatory.viewer-layer",
"identity": "layer_id",
"owner": "replay presentation",
"lifecycle": "versioned-contract"
}
],
"relations": [
{
"id": "observatory.equipment-mount.mounts_equipment",
"from": "observatory.equipment-mount",
"to": "mission.equipment-unit",
"cardinality": "many-to-one"
},
{
"id": "observatory.equipment-mount.attaches_to_transport",
"from": "observatory.equipment-mount",
"to": "mission.transport-unit",
"cardinality": "many-to-one"
},
{
"id": "observatory.recorded-session.captured_on_transport",
"from": "observatory.recorded-session",
"to": "mission.transport-unit",
"cardinality": "many-to-zero-or-one"
},
{
"id": "observatory.recorded-session.captured_with_equipment",
"from": "observatory.recorded-session",
"to": "mission.equipment-unit",
"cardinality": "many-to-many"
},
{
"id": "observatory.recorded-session.uses_equipment_mount",
"from": "observatory.recorded-session",
"to": "observatory.equipment-mount",
"cardinality": "many-to-many"
},
{
"id": "observatory.recorded-session.uses_capture_profile",
"from": "observatory.recorded-session",
"to": "observatory.capture-profile",
"cardinality": "many-to-one"
},
{
"id": "observatory.composition-run.uses_recorded_session",
"from": "observatory.composition-run",
"to": "observatory.recorded-session",
"cardinality": "many-to-one"
},
{
"id": "observatory.composition.contains_module",
"from": "observatory.composition",
"to": "observatory.module-version",
"cardinality": "one-to-many"
},
{
"id": "observatory.module-version.implemented_by_image",
"from": "observatory.module-version",
"to": "observatory.container-image",
"cardinality": "many-to-one"
},
{
"id": "observatory.container-image.installed_on_worker",
"from": "observatory.container-image",
"to": "observatory.worker-node",
"cardinality": "many-to-many"
},
{
"id": "observatory.composition-run.applies_composition",
"from": "observatory.composition-run",
"to": "observatory.composition",
"cardinality": "many-to-one"
},
{
"id": "observatory.composition-run.uses_job",
"from": "observatory.composition-run",
"to": "observatory.recorded-job",
"cardinality": "one-to-many"
},
{
"id": "observatory.recorded-job.executes_on_worker",
"from": "observatory.recorded-job",
"to": "observatory.worker-node",
"cardinality": "many-to-one"
},
{
"id": "observatory.recorded-job.publishes_result",
"from": "observatory.recorded-job",
"to": "observatory.portable-result",
"cardinality": "one-to-zero-or-one"
},
{
"id": "observatory.composition-run.projects_lab",
"from": "observatory.composition-run",
"to": "observatory.lab-projection",
"cardinality": "one-to-zero-or-one"
},
{
"id": "observatory.lab-projection.has_view_profile",
"from": "observatory.lab-projection",
"to": "observatory.lab-view-profile",
"cardinality": "one-to-zero-or-one"
},
{
"id": "observatory.module-version.exposes_layer",
"from": "observatory.module-version",
"to": "observatory.viewer-layer",
"cardinality": "many-to-many"
},
{
"id": "observatory.viewer-layer.belongs_to_pane",
"from": "observatory.viewer-layer",
"to": "observatory.viewer-pane",
"cardinality": "many-to-one"
}
],
"panes": [
{
"pane_id": "camera",
"label": "Камера",
"order": 10
},
{
"pane_id": "spatial",
"label": "Облако точек",
"order": 20
}
],
"layers": [
{
"layer_id": "camera.source",
"pane_id": "camera",
"label": "КАМЕРА",
"control": "toggle",
"order": 10
},
{
"layer_id": "camera.ddrnet",
"pane_id": "camera",
"label": "DDRNET",
"control": "toggle",
"order": 20
},
{
"layer_id": "camera.eomt",
"pane_id": "camera",
"label": "EOMT",
"control": "toggle",
"order": 30
},
{
"layer_id": "camera.detections",
"pane_id": "camera",
"label": "РАМКИ",
"control": "toggle",
"order": 40
},
{
"layer_id": "spatial.source-points",
"pane_id": "spatial",
"label": "ИСХ. ТОЧКИ",
"control": "toggle",
"order": 10
},
{
"layer_id": "spatial.local-slam",
"pane_id": "spatial",
"label": "ЛОК. SLAM",
"control": "toggle-with-settings",
"order": 20
},
{
"layer_id": "spatial.tgs",
"pane_id": "spatial",
"label": "TGS",
"control": "toggle",
"order": 30
}
],
"module_projections": [
{
"module_id": "ddrnet",
"configuration_label": "DDRNet-39 · GOOSE",
"layers": [
"camera.source",
"camera.ddrnet"
]
},
{
"module_id": "eomt",
"configuration_label": "EoMT Large · Cityscapes",
"layers": [
"camera.source",
"camera.eomt"
]
},
{
"module_id": "rf-detr",
"configuration_label": "RF-DETR Large",
"layers": [
"camera.source",
"camera.detections"
]
},
{
"module_id": "tgs",
"configuration_label": "TRAVEL TGS",
"layers": [
"spatial.source-points",
"spatial.local-slam",
"spatial.tgs"
]
},
{
"module_id": "object-distance",
"configuration_label": "Дистанция до объектов · K1 LiDAR",
"layers": [
"camera.source",
"camera.detections",
"spatial.source-points",
"spatial.local-slam"
]
}
],
"named_queries": [
"recording.capture-context",
"composition.configuration-label",
"composition.member-results",
"composition.viewer-layers",
"composition.ready-state",
"lab.view-profile"
],
"platform_sync": {
"mode": "none",
"promotion_gate": "stable cross-product meaning with an agreed Platform Ontology migration",
"platform_repository_is_runtime_dependency": false
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11 -5
View File
@@ -227,9 +227,13 @@ Mission Core already has three semantic mechanisms:
3. `docs/domain-model/mission-core-experimental-vocabulary-v0alpha2.md` for
meanings that are not yet stable enough for Platform Ontology.
These mechanisms are sufficient before A3. A new RDF/graph store, ontology
service, or parallel entity catalog would introduce duplicated identity and
migration work without a current query or integration consumer.
These mechanisms were sufficient before A3. Observatory modular compositions
crossed the gate on 2026-09-04: planning, Worker dispatch, LAB publication and
replay presentation require the same module/composition/result/layer relations,
and typed single-result traversal lost the TGS member of a composed run. The
bounded local package is defined by
`docs/domain-model/observatory-domain-ontology-v1.md`. It keeps identity in the
existing stores and has no Platform Ontology runtime dependency.
A formal local runtime ontology is introduced only when:
@@ -239,8 +243,10 @@ A formal local runtime ontology is introduced only when:
- the graph answers named queries used by the product or automation;
- promotion or synchronization with NODE.DC Platform Ontology is defined.
Until those conditions hold, new stable meanings extend the versioned local
vocabulary and executable contracts. They do not create a second runtime model.
Other domains continue to extend the versioned local vocabulary and executable
contracts until they independently satisfy these conditions. The admitted
Observatory package projects existing store identities and cannot become a
second authority for them.
## Automated boundary gate
+568 -78
View File
@@ -1,23 +1,555 @@
# Observatory: модульные Docker-композиции → записанные LAB → CUDA-борт
## ЕДИНСТВЕННЫЙ ТЕКУЩИЙ МАРШРУТ — 2026-09-03, модульное решение
Этот раздел заменяет прежний маршрут ниже. Исторические `CURRENT`, «следующий
шаг», номера этапов и требования «один профиль = один полный Docker» в архивной
части не являются актуальными заданиями. Замеры и выполненная работа сохранены.
Решение владельца: [ADR 0051](adr/0051-modular-observatory-profiles.md).
Фактическая сверка и очистка:
[handoff report](../experiments/perception/OBSERVATORY_MODULAR_HANDOFF_2026-09-03.md).
### Цель и термины
Запись K1 → настройка AI-слоя по функциональным группам → расчёт выбранной
композиции → неизменяемая LAB и кэш на Core → повторный просмотр без Worker.
Один модуль используется разными композициями, одна композиция — разными
совместимыми записями. Удачная композиция позднее переносится на CUDA-борт.
- **Модуль:** отдельный версионированный Docker-образ модели или связанной
функции, например DDRNet, EoMT, RF-DETR, LiDAR/TGS geometry. Не отдельный
микросервис для каждого арифметического шага.
- **Профиль:** проверенная immutable композиция модулей, параметров, связей и
политики исполнения. Новая комбинация не требует сборки монолитного образа.
- **LAB:** применение точной композиции к точному снимку записи, с доказательствами,
границами покрытия и измерениями. Сохранённый результат не меняется задним числом.
- **Борт:** будущий совместимый CUDA-компьютер, не Mac Mini. Конкретные CPU/GPU,
драйверы и архитектура контейнеров квалифицируются после выбора оборудования.
Работа recorded-first. Расчёт медленнее записи допустим; достижение remote
realtime через Wi-Fi/LTE не блокирует лаборатории. Runtime всё равно сохраняет
потоковые контракты и оптимизированную подготовку данных. FPS проигрывания кэша
и скорость сборки из кэшированных узлов не выдаются за вычислительный FPS борта.
### CURRENT: установленное и доказанное
- Ветка `codex/m5-1-observatory`, HEAD `eff60e4`, поверх него есть незакоммиченные
изменения предыдущих инкрементов. Не сбрасывать и не считать всё новым diff
этой архитектурной правки. Полный список даёт `git status`.
- Core на8000: admission, очередь/claim v3, exact-cache/idempotency, publication
outbox/recovery, metadata pagination, отделение portable LAB от Legacy,
общий сохранённый viewer. На Worker установлен source CAS двух агентов и
bounded heartbeat retry. Это основа миграции, не повод написать всё заново.
- M4.9T5: CPU TGS, отдельный специализированный агент. Job
`observatory-run-b230216709dc4c59bc56c98c7e329bf1` ×004TREE опубликована:
6830 camera anchors,6811 LiDAR,19 UNOBSERVED; цикл1416.763s. Расписание
39.215–757.160s внутри808.779s записи: не заявлять полное покрытие всей записи
или полный AI-граф. Сохранённую публикацию и рабочий release сохранить.
- LAB V1: установлен фиксированный стек prepare → EoMT → DDRNet → assemble.
Это старый сравнительный состав, не новый выбор одного segmenter. Последняя
job `observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` завершена failed после
lease loss на EoMT; нового опубликованного LAB V1 нет. Исправления heartbeat
установлены, но успешный полный повтор ещё не доказан.
- Есть прототип DDRNet/RF-DETR/geometry/distance/motion/TGS/costmap/policy-shadow.
Переиспользовать алгоритмы, не называть прототип принятым автономным профилем.
- Реестр пользовательских композиций, per-node result-cache, групповые настройки
и независимо упакованные модули нового формата **ещё не реализованы**.
- Реальная полная visual/memory-lifecycle приёмка saved replay открыта. Прежние
749 frontend/166 backend checks — проверки предыдущего инкремента, не новой
модульной архитектуры. Исторические тесты не заменяют новые приёмочные сценарии.
- Audit:24 контейнера →11 после удаления13 проверенных остановленных
предшественников/debug.80 образов и9 volumes оставлены. Сохранены два текущих
агента, последняя rollback-пара, рабочие результаты и зависимости. Активные
Gaussian/Triton/perception требуют отдельной проверки потребителей.
- EoMT floor250GiB + working set изменён в source и узких тестах; установленный
образ пока350GiB. Нужен новый sealed release, не правка digest существующего.
### Продуктовые правила нового AI-конфигуратора
В существующей Обсерватории у выбранной записи — «Настройка AI-слоя» и компактное
окно с функциональными группами. Использовать существующие `Window`, `FieldFrame`,
`Select`, `WindowFooterActions` и канонические кнопки; не создавать новый workspace
или локальную дизайн-систему. Обычная checkbox-матрица Docker не подходит.
Группа сегментации: один выбор DDRNet / EoMT / «Не использовать», если частичный
состав допустим. Два segmenter в одной новой LAB запрещены сервером, не только UI.
Другие кандидаты групп: детекция, LiDAR/TGS/costmap, связь объектов с расстоянием,
motion, policy-shadow. Их окончательные границы определяются кодом/контрактами.
Один модуль с несколькими capabilities не запускается несколько раз.
Сервер проверяет зависимости: object-distance требует детекцию, облако и
калибровку; геометрические препятствия могут вычисляться без семантического
детектора. Не требовать необязательный модуль для независимого результата.
Для недоступного сочетания — понятная причина, не молчаливое включение другой ML.
Точная конфигурация уже опубликована → открыть существующую LAB, «Рассчитать» нет.
Нет результата → «Рассчитать», при активной job — существующий реальный progress
без дубликата. Нет кнопки/плашки «Расчёт завершён». Поставщики остаются в dropdown:
рассчитанность относится ко всей композиции, а не к отдельной модели. Это заменяет
старое правило удаления готового полного профиля из единственной выпадашки.
### Четыре этапа — действуют только эти
#### 1. Контракты композиции и безопасная граница миграции
- Сверка Desktop/кода/installed runtime, retention inventory и первая очистка
остановленных экземпляров выполнены в этом handoff. Спецификация ADR принята.
- Следующее: реализовать версионированные Module/Composition contracts поверх
installed-package boundary; определить producer/consumer схемы и capability
группы по фактическим алгоритмам. Не считать описательный ADR готовым API.
- Зафиксировать content identity: source, image/code/weights, параметры,
preprocessing/calibration, cadence/precision, temporal state и graph edges.
Порядок щелчков в UI не меняет identity; любые значимые входы — меняют.
- Retention manifest должен охватить pinned packages, текущий M49, старый V1,
прототип полного графа, модельные assets и один rollback. Затем отдельно
согласованно вывести активные устаревшие сервисы; только после проверки
зависимостей удалять недостижимые images/build caches/temp directories.
Приёмка: контрактные тесты несовместимости и exact identity; доказанный список
сохраняемых артефактов; никакой утраты записей/Legacy/публикаций. Этап целиком открыт.
#### 2. Переиспользуемые Docker-модули и общий Worker runtime
- Упаковать DDRNet и EoMT отдельно с закреплёнными зависимостями/весами;
LiDAR/TGS и остальные функции выделять по согласованным границам. Сохранить
общие базовые слои/проверенные assets без копирования всего набора в каждый image.
Веса входят в переносимый дистрибутив как image layers либо явные immutable
model assets с проверкой digest; случайный host checkout/conda environment
не является допустимой скрытой зависимостью модуля.
- Расширить generic launcher, не создавать агент под каждую модель. Одна аренда
на композицию Worker006, тяжёлые GPU шаги последовательно. Несколько активных
CPU/служебных контейнеров не означают разрешённый параллельный ML inference.
- Один source/preparation path, локальные межмодульные данные на Worker; не
отправлять промежуточные кадры/облака через Core туда-обратно. Сохранить bounded
потоковый I/O; целиковый cold input barrier не превращать в вечную архитектуру.
- Добавить exact per-node cache и зависимое invalidation, stateful history binding,
прогресс computed/reused/failed. Worker source CAS сам по себе это не реализует.
- Результат публикуется и просматривается с Core. Повтор publication не запускает
inference заново. Окончание/cancel освобождают временные процессы/RAM/VRAM,
не удаляют постоянные результаты. Ресурсную политику250GiB активировать новым release.
Приёмка: standalone cold start без developer checkout и необъявленных mount;
последовательные реальные прогоны двух допустимых композиций; exact cache hit,
частичный reuse, смена зависимости и безопасный failure/recovery.
#### 3. Конфигуратор, сохранённый просмотр и продуктовая приёмка
- Реализовать групповые Select и server admission в существующей Обсерватории.
Legacy не переносить назад; старый dual-segmentation V1 только совместимость/история.
- Доказать source × composition: новая совместимая запись без LAB, расчёт,
открытие сохранённой LAB, новая конфигурация, отсутствие дубликата точного повтора.
Минимум две совместимые записи и две допустимые композиции, последовательно.
- Проверить общую временную/пространственную привязку camera/segmentation/objects/
range/TGS по фактически выбранным outputs, full configured coverage и явные gaps.
- Завершить реальную normal/expanded/Escape/close/reopen приёмку и освобождение
viewer RAM/GPU после закрытия; не ограничиваться контрактными тестами.
- Проверить большие каталоги, рестарты Core/Worker, publication retry, отмену,
полную наблюдаемость результата без работающих моделей.
Приёмка: оператор выполняет весь цикл без инженерных команд; просмотр берётся
с Core, рабочий Worker не нужен. Этапы2–3 могут иметь согласованные инкременты,
но непройденный сквозной сценарий не считается закрытым.
#### 4. Перенос принятой композиции на CUDA-борт — позже
Те же логические модули и версии; аппаратно-совместимая упаковка, локальный
транспорт, долгоживущие процессы/модели вместо старта Docker на каждый кадр.
Квалифицировать полный граф и совместное потребление памяти, не сумму независимых
FPS и не cached replay. Горячие узлы можно позднее объединять по измерениям без
потери логической модульности. Mac Mini не является целевым бортом.
Моторы, автономное движение, ArduRover и safety acceptance — отдельная будущая
работа, сейчас observation-only/policy-shadow.
### Инструкция следующему чату
Прочитать актуальную верхушку Desktop `_MISSING_CORE_…FINAL_STATUS…md`, ADR0051,
этот раздел и handoff report. Проверить `git status`, Core8000 и точные installed
identities read-only; начать с незакрытых контрактов этапа1. Старые installer
scripts с зашитыми predecessor IDs не запускать повторно. Не начинать с новой
полной сборки старого dual-segment LAB V1 или глобального Docker prune.
Не менять Synology/деплой21, Little Snitch, чужие сервисы, Docker Desktop limits.
Не коммитить существующий общий dirty diff как собственную новую работу.
---
## АРХИВ ПРЕЖНЕГО МАРШРУТА — до модульного решения 2026-09-03
Весь следующий текст — история. Слова «актуальный», CURRENT и следующие шаги
внутри него описывают состояние своего инкремента, не текущий план.
# Observatory: четыре этапа — записанные лаборатории → переносимые профили → борт
## Актуальное решение владельца — 2026-09-02
## Актуальный маршрут — 2026-09-03
**Этот раздел заменяет прежний порядок и GO-зависимости плана ниже.** Сначала
доводим лабораторный продукт на записях, затем экспериментируем с составом
Docker-профилей, затем переносим выбранный профиль на подходящий бортовой
компьютер. Сетевые 125 ms, Ethernet, LTE и наличие беспилотника больше не
являются условиями готовности Observatory. Старый этап 1 и инкременты 1–18
сохраняются как выполненная инженерная работа; отрицательные realtime-замеры
не переименовываются в PASS.
Сверено с кодом `bee8552`, установкой `54c8d82`, исправлением reindex `bd947b4`
и последними решениями владельца. Проверки и сборка текущего инкремента поверх
`eff60e4` завершены; возврат к основному маршруту зафиксирован ниже. M4.9T5 ×004TREE рассчитан через UI, опубликован
и повторно открыт из кэша после перезапуска. Общий camera/TGS replay реализован;
подробная визуальная и memory-lifecycle приёмка ещё открыта. Разделы до журнала инкрементов — текущий
маршрут; исторические «следующий шаг» и «этап открыт/закрыт» ниже не команды.
Основной путь: **запись → совместимый ещё не рассчитанный профиль → Рассчитать
→ фактический прогресс → проверенная публикация → сохранённый просмотр**.
Просмотр, Refresh и выбор записи не запускают модели. Готовые результаты всех
версий остаются в «Лабораторных доказательствах». Если рассчитаны все текущие
совместимые профили, выбор пуст/недоступен, кнопки «Рассчитать» нет, «Обновить»
остаётся. Старое имя LAB не доказывает совпадение версии конфигурации.
### Цель и результат
### Этап 1 — Идентичность расчёта и переиспользование результата (в работе)
Оператор записывает маршруты K1, один раз рассчитывает совместимую запись
выбранным профилем и затем изучает сохранённый результат без повторного inference.
Один профиль применяется к разным записям, разные профили сравниваются на одной.
Удачный полный профиль впоследствии переносится на фактический бортовой компьютер.
Лаборатория допускает расчёт медленнее записи. Требуются полнота предусмотренного
профилем анализа, качество, воспроизводимость и честные измерения. Remote realtime
PASS через Wi-Fi/LTE не является условием готовности лабораторий. Потоковые
контракты, bounded очереди, scheduler, ownership/recovery и телеметрия сохраняются.
### CURRENT: что есть и чего ещё нет
- **Реализовано и локально проверено:** source/profile admission, durable queue,
защита от дубликатов, восстановленный claim/v3, точный опубликованный кэш,
выбор только совместимых нерассчитанных portable profiles и общие viewer contracts.
- **UI:** готовность видна только по результату внизу. Все текущие профили
рассчитаны → выбор пуст, «Рассчитать» нет, «Обновить» остаётся. Плашек завершения
нет; во время работы общий индикатор показывает настоящую фазу и доступные счётчики.
Внутри первичного decode/передачи архива детальные счётчики ещё отсутствуют.
Обсерватория теперь показывает только identity-bound portable результаты;
исторические LAB остаются в Legacy. Каталог дочитывает cursor pages по100
metadata entries (до256 страниц на scope с явным partial flag), поиск видит
все загруженные источники. Fixture500 sources и real20-page proof PASS.
- **Предыдущая функциональная приёмка:** 726 frontend tests, 128 focused progress/queue/API/
transport tests, отдельно40 runtime/wiring и3 installer tests, typecheck/build,
Ruff/mypy и browser QA. Для найденного при рестарте cache-дефекта — ещё89
session/publication и72 admission/cache/queue/API tests. Эти проходы пересекаются,
их суммы не являются числом уникальных тестов.
В очереди12 jobs: прежние10 failed,1 succeeded/not-required и1 новый published.
Все11 прежних jobs сохранены. Новый exact cache/document повторно открыт
после исправления no-op reindex и перезапуска; видео/TGS-view ещё не принят.
- **Реальный M49-результат:** 6830 camera-fragment anchors, 6811 доступных LiDAR
и19 UNOBSERVED; учёт149733073 rolling-point contributions сходится,0 unaccounted.
Расписание39.215–757.160s не покрывает все808.779s исходной сессии. Полный цикл
23:36.763; TGS p95 2.6075ms и runner-stage p95 29.99172ms — только эти стадии,
не full-profile/onboard FPS и не качество сегментации.
- **Текущие составы:** M4.9T5 — CPU TGS; LAB V1 — последовательные EoMT и DDRNet.
Ни один не равен полному будущему AI-профилю рига. Архивные overlays в одном
viewer не доказывают, что один Docker вычислил все слои.
- **Последний viewer-инкремент:** primitive-only owner v2 и передача Escape/pointer
из iframe для существующих внешних controls. 740 frontend/64 focused backend
tests, полный typecheck/build PASS, новая сборка обслуживается на8000.
Разделитель native viewer снова передаёт координаты внешним панелям; проверено
контрактным тестом, реальная visual acceptance этой версии ещё не выполнена.
- **Входы Worker, новый инкремент2A:** общий source CAS двух агентов установлен;
другая job/generation/конфигурация переиспользует exact cached bytes, частичная
camera-cache требует только отсутствующие members. M49 проверяет готовый
LiDAR pack до raw decode; прежний producer/identity сохранён.111 focused tests,
Ruff/mypy и реальный cross-agent synthetic proof PASS, моделей не запускали.
Это не кэш viewer (он остаётся на Core) и не устранение cold whole-input barrier.
- **Последний продуктовый инкремент2D:** Legacy separation и pagination активны
на8000 (`app-9nK8VtbK.js`).747 frontend/41 backend tests, typecheck/build,
Ruff/mypy PASS. Browser catalog/search/normal/expanded/Refresh/dropdown Escape
принят; тяжёлый replay не открывался. Legacy API до/после побайтно неизменён.
- **Освобождение памяти:** найден остаток записанного viewer после закрытия.
Добавлены disposable upstream realm, размыкающий ссылки facade и backend
release/TTL. Предыдущий browser proof подтвердил освобождение GPU, но прежний
facade оставил829 MB renderer после закрытия (112 MB до). Для v2 реальный
open/close ещё не проверен: browser auto-review отклонил тяжёлое открытие при
pressure2; запрошено отдельное разрешение. Это не блокирует код/контракты
оставшихся частей этапа2 и не считается доказанным исправлением memory issue.
Активные данные/качество не урезаются, дисковый cache не удаляется.
- **Крупный проход saved review/recovery:** общий viewer подключён к сохранённым
EoMT/DDRNet masks LAB V1 через новый строгий adapter; M49 cache identity сохранена.
Outbox больше не застревает за префиксом exhausted/backoff rows. Краткие
heartbeat transport failures повторяются в пределах аренды с тем же sequence;
новый control layer установлен в оба агента.749 frontend/166 backend tests,
typecheck/build/Ruff/mypy PASS; Core `app-w6onjKPq.js` healthy на8000.
- **Реальный LAB V1 ×004TREE пока FAIL:** новая job
`observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` потеряла lease на шаге EoMT.
После независимой проверки освобождения ресурсов штатно reconciled→failed;
история сохранена, очередь11 failed/2 succeeded,0 live leases. Новый LAB не
опубликован. Отдельно подтверждён устаревший EoMT disk floor350GiB сверх
рабочего набора: при текущих397.47GB free admission не пройдёт. Перед повтором
нужна новая sealed resource policy, не очистка данных/не покупка памяти.
[Полный отчёт и точные границы доказательств](../experiments/perception/OBSERVATORY_SAVED_REVIEW_RECOVERY_2026-09-03.md).
- **Есть инженерный прототип полного графа:** DDRNet/RF-DETR/geometry/distance/
motion/TGS/costmap/policy-shadow и короткие потоковые proofs. Не пишем заново.
Самостоятельный продуктовый образ и полный recorded-run этого состава не приняты.
- **Не закрыто:** полный recorded-analysis текущими
профилями, синхронный сохранённый visual replay, матрица двух профилей/записей,
оставшиеся recovery cases, standalone и фактический перенос на борт.
Источники: [ADR 0050](adr/0050-recorded-observatory-first.md),
[cache/UI evidence](../experiments/perception/OBSERVATORY_PUBLISHED_CACHE_2026-09-03.md),
[claim/v3](../experiments/perception/OBSERVATORY_RECORDED_CLAIM_REPAIR_2026-09-02.md).
Desktop final-status — операторская сводка и история; подробный маршрут ведётся
только здесь. Из CODEX_V5 взяты разделение CURRENT/TARGET, критерии готовности и
сохранение выполненной работы, не неподтверждённые сведения о runtime.
Текущее исполнение и точные declarations:
[progress / первый запуск](../experiments/perception/OBSERVATORY_RECORDED_PROGRESS_2026-09-03.md).
Последняя сверка и проверки:
[возврат к основному сценарию](../experiments/perception/OBSERVATORY_MAINLINE_RECONCILIATION_2026-09-03.md).
## Границы исполнения
- Один Worker 006/RTX 4090 — один активный профиль; альтернативы и тяжёлые проверки
последовательны. DDRNet и RF-DETR допустимы внутри одного полного профиля с
последовательным GPU inference. EoMT сохраняется отдельно; существующий
сравнительный LAB V1 не превращается в параллельный запуск двух segmenters.
- Только Mission Core 8000 и Worker 006, без Synology/deploy-canon21, новых
LAB-микроприложений и изменений чужих сервисов. На Mac 18GB — resource gate,
последовательные проверки, без нагрузочных тестов и остановки чужих приложений.
- Записи, история, артефакты и отрицательные замеры сохраняются. Не выдумывать
capture attestation RAVNOVES01; новая конфигурация/семантика получает новую
версию, старые результаты сохраняют свою идентичность.
- Полнота recorded-analysis не ослабляет live freshness/ownership. Временные
сетевые/телеметрические сбои восстанавливаются по существующему контракту,
без бесконтрольных рестартов моделей и второго владельца Worker.
- Пока observation-only/policy-shadow. Моторы, автономная навигация и controller/
safety acceptance не включаются скрыто. Борт и более мощная GPU не блокируют этапы 1–3.
## Этап 1 — Основа, идентичность и выбор профиля
**Состояние:** основа реализована и локально проверена. Это не утверждение,
что весь пользовательский цикл уже принят: сквозная приёмка явно в этапе 2.
Повторно строить queue/cache/selector не требуется.
**Результат:** exact source snapshot + immutable profile + проверенный пакет
определяют reuse. Повторный клик не создаёт конкурирующий расчёт, новая версия
не скрывается старым названием LAB, завершение не дублируется кнопкой/плашкой.
**Доказательства:** queue/cache/decoder tests, first-render selection fences,
normal/expanded UI; последний код `e436fb5` и отчёты выше. Остаток — исправлять
только дефекты, обнаруженные сквозным циклом 2, а не повторять выполненный ремонт.
## Этап 2 — Полный цикл записанной лаборатории — следующий активный этап
**Цель:** полный расчёт записи, фактический прогресс, пригодный для просмотра
опубликованный результат и повторное открытие после перезапуска без inference.
**2A. Полный анализ и прогресс.** Сверить текущие executor inputs/outputs с
наработанным runtime. Явно оформить версионированный `recorded-analysis` отдельно
от `realtime-rehearsal`; сначала малые контрактные fixtures, не долгий GPU-run.
**Частично выполнено:** bounded attempt-scoped progress реализован и активирован.
Новый M49-run использовал прежний профиль и полностью выполнил его расписание.
Это не закрывает2A: целиковый input barrier ещё сохранён. Новый preparation adapter
проверяет exact LiDAR-cache до вызова прежнего producer, общий source CAS уже
установлен в оба агента. На warm input не повторяются raw decode и передача
готовых members, но cold materialization по-прежнему ждёт весь вход. Progress
observation и warm reuse не являются новым режимом исполнения.
Evidence: [source reuse](../experiments/perception/OBSERVATORY_SOURCE_REUSE_2026-09-03.md).
- Сохранить source timestamps и causal ordering. Медленный Worker притормаживает
подачу, а не выбрасывает обязательные кадры ради 1×. Учитывается каждый вход/
выход по расписанию профиля; stride, sensor gaps и overload drops различаются.
- Ограничить чтение, decode, буферы, транспорт и запись outputs. Полное
предварительное накопление/пересылка исходной сессии не становится постоянным
условием старта. Sealing выходного пакета после EOF — не input barrier.
- Передать реальные счётчики Worker через backend в общий UI: обработанные
единицы, доступный знаменатель, текущая фаза. Не выдумывать проценты/ETA;
различать попытки. Хранить ограниченный актуальный снимок, не бесконечный
journal на каждый кадр. После публикации прогресс исчезает, остаётся результат.
- Различать preparation, warmup, compute, transport и publication. При исправимой
ошибке публикации повторять публикацию сохранённого пакета, не inference.
**2B. Первый вертикальный proof: M4.9T5 × RAVNOVES004TREE.** Пройти полный путь
через обычный UI и Worker: вход → расчёт → реальный прогресс → sealed package →
публикация → маркер конфигурации внизу → общий viewer → повторное открытие.
Из выбора исчезает именно рассчитанная версия. Проверить полноту TGS-выходов и
timeline. Это приёмка CPU TGS, не полного detector/segmentation профиля.
Историческая M4.9 projection без review binding не заменяет этот proof.
**Доказано на новом run:** compute → seal → publication с первой попытки,
точный cache hit, исчезновение M49 из выбора и открытие документа после рестарта.
Найден и исправлен no-op reindex: служебная переиндексация больше не меняет
source identity; реальное изменение по-прежнему требует нового расчёта.
Общий `CanonicalRecordedLabReplay` уже получает сохранённый camera/source/TGS
RRD с Core; JSON не является единственным review. **Осталось в2B:** сохранение
ручного ракурса при blueprint activation, подробная визуальная оценка и
подтверждённое освобождение памяти закрытого viewer. Использовать уже sealed
пакет, не считать заново и не подмешивать архивные E47/M49 semantics. Название
«полный маршрут» не доказывает покрытия всех исходных timestamps.
**2C. LAB V1 и другая запись.** Выполнить LAB V1 × 004TREE, затем оба профиля
× RAVNOVES00, строго последовательно. Медленная EoMT не возвращает нас к гонке
за сетевым FPS. Не менять незаметно состав, cadence или разрешение ради PASS.
RAVNOVES01 — отрицательный случай до подтверждённой аттестации.
**Попытка2026-09-03:** LAB V1 ×004TREE запущен штатно, но после transfer и prepare
не завершил EoMT: lease expired, результата нет. Heartbeat retry исправлен и
установлен, exact-job resources освобождены, failed receipt сохранён. Перед
следующим расчётом изменить350GiB disk floor через новую sealed версию с
проверяемым бюджетом рабочего набора; предусмотреть дешёвый preflight до долгих
asset checks и bounded diagnostics до cleanup. Не считать этот canary успешным.
**2D. Повторное использование, сбои и большой каталог.**
- Refresh, повторный вход и перезапуск открывают тот же результат без новой job.
Все текущие профили рассчитаны → выбор пуст, «Рассчитать» нет; новая версия → новый расчёт,
старая остаётся ниже. Проверить transient disconnect, compute failure,
publication retry, missing/corrupt artifacts и отсутствие дубликатов.
- **Реализовано:** Обсерватория не показывает Legacy/canonical архивные LAB;
они остаются в существующем архиве без миграции/удаления. Portable результаты
прошлых версий сохраняются при точном binding, независимо от имён и даты.
- **Реализовано и проверено:** постраничный каталог и поиск по загруженным
источникам. Нельзя спрятать профиль
из-за cache hit, соответствующий результат которого нельзя найти/открыть
из-за окна каталога. Сотни metadata entries проверяются функциональными
fixtures, не нагрузкой на Mac и не загрузкой всех видео в RAM.
[Evidence и границы traversal](../experiments/perception/OBSERVATORY_CATALOG_BOUNDARY_2026-09-03.md).
- **Новый recovery proof:** keyset outbox проходит мимо exhausted/backoff rows;
interruption до publish/после publish до ACK восстанавливает тот же результат
без compute. Transient heartbeat/lost ACK повторяет тот же sequence, истёкшая
аренда/чужая identity/HTTP401/403/409 не принимаются. Это contract tests и
установленный control layer, не реальная long-outage qualification.
**Приёмка:** матрица 2 профиля × 2 совместимые записи, полные обязательные выходы,
честный прогресс, published package, сохранённый просмотр без compute после
перезапуска, проверенные recovery/negative cases и сохранность истории.
Первый M4.9 run **с принятым визуальным replay** закрывает 2B, не весь этап 2.
Зависимости: основа 1, текущие записи и Worker; не новый полный профиль или борт.
## Этап 3 — Самостоятельные Docker-профили и лабораторные эксперименты
**Цель:** воспроизводимый полный профиль для разных маршрутов и честное сравнение
конфигураций через уже принятый лабораторный цикл 2.
1. Включить готовый граф/runtime в самостоятельный профиль: DDRNet + RF-DETR +
LiDAR association/distance + static obstacles + temporal/motion + TGS/costmap +
policy-shadow. Не подмешивать обязательные выходы из чужих старых overlays.
2. Веса, модельные runtime, конфигурация и graph входят в переносимую поставку;
холодный старт без developer checkout/скрытых model caches. Записи, результаты
и секреты внешние; backend/broker/БД не внутри perception-контейнера.
Понятные profile/image имена с `ndc-`, версией и digest, без слепых переименований.
3. Для сельского прототипа оставить coarse hard_surface и общий static-obstacle.
Людей/животных/динамику/расстояния проверять на выбранных сценах; не обещать
качество по FPS. Второй segmenter, городской автопилот и обязательная новая
разметка не нужны. EoMT — отдельная сохраняемая альтернатива.
4. До каждого последовательного эксперимента фиксировать effective config и
Worker/GPU baseline; измерять отдельно preparation/warmup, compute, queues,
transport, publication и качество. Оптимизировать подтверждённые узкие места,
не возвращаться к бесконечной погоне за Wi-Fi latency.
**Приёмка:** cold start переносимого пакета, весь заявленный граф в одном run,
воспроизводимые результаты на нескольких маршрутах, сравнение конфигураций и
явные слабые места. Перегрузка GPU — допустимый исход эксперимента; безопасные
пределы памяти/очередей остаются. Worker-only throughput и full-graph latency —
условный baseline на этом железе, не обещание onboard FPS. Viewer FPS и вычитание
несвязанных p95 не заменяют измерение. Наработки старого плана не переписываются.
## Этап 4 — Отбор кандидата и перенос на фактический борт
По уточнению владельца 2026-09-03 это будущий пункт: сейчас никакой перенос на
борт, изменение onboard runtime или подключение контроллеров не выполняется.
**Цель:** выбранная конфигурация воспроизводится на целевом компьютере, её
границы известны до подключения автономного управления.
**Работа/доказательства:** зафиксировать образ/config, оборудование, калибровки,
матрицу качества/нагрузки и ограничения. После появления борта проверить местный
вход, те же timestamps/выходные контракты, весь граф, latency, ресурсы и recovery.
CUDA-образ не объявлять автоматически переносимым на Apple Silicon: другая
архитектура требует подходящей сборки и повторной квалификации.
**Закрытие:** пакет кандидата — готовность лабораторной части; реальный перенос
pending до испытания фактического оборудования. Flight controller/ArduRover,
watchdog, stop/slow при пропаже данных, моторы и автономное прохождение —
отдельный согласованный допуск. Красивый replay его не заменяет.
## Progress / точка продолжения
- [x] 2026-09-03 — этап 1: queue/cache/identity/selector реализованы и локально проверены.
- [x] 2026-09-03 — история сохранена, completed-status UI удалён.
- [x] Этап 2A, часть progress — реальные bounded Worker → backend → UI snapshots.
- [x] Этап 2A, warm source reuse — shared CAS и LiDAR early lookup установлены на Worker;111 tests и cross-agent synthetic proof.
- [ ] Этап 2A, остаток — versioned recorded-analysis и устранение полного input barrier.
- [x] Этап 2B, compute/publish/cache — M4.9 ×004TREE завершён; документ повторно открыт после рестарта.
- [x] Найденный reindex-дефект exact cache исправлен; исходная связь восстановлена по точному SHA proof.
- [x] Этап 2B, saved replay — camera/source cloud/TGS в одном native Rerun, выдача с Core после рестарта без compute.
- [ ] Этап 2B, visual acceptance — сохранение ручного ракурса и подробная визуальная оценка; не подменять работающим проигрывателем.
- [x] Primitive-only owner v2 и мост native input:16 focused/740 frontend/64 backend tests, typecheck/build; сборка на8000.
- [ ] Этап 2B/2D, memory lifecycle — GPU/server cleanup проверены в предыдущих циклах; прежний renderer удерживается нестабильно. Browser acceptance v2 отдельно не разрешена auto-review; остальные части этапа2 продолжаются.
- [x] Этап 2D, каталог — Legacy separation, cursor pagination, fixture500 и real20-page proof; UI на8000 проверен.
- [ ] Этап 2C–2D — LAB V1/вторая запись и оставшиеся cache/recovery cases.
- [ ] Этап 3 — самостоятельный полный профиль и сравнение конфигураций.
- [ ] Этап 4 — кандидат, затем фактическая бортовая квалификация.
- [x] 2026-09-03 — план и Desktop-сводка синхронизируются с фактическим исполнением.
**Сделано 2026-09-03:** общий сохранённый camera/TGS-viewer для уже опубликованного
job `observatory-run-b230216709dc4c59bc56c98c7e329bf1`, без нового compute.
Evidence: [saved replay](../experiments/perception/OBSERVATORY_SAVED_TGS_REPLAY_2026-09-03.md).
Core хранит/выдаёт 193,622,178-byte replay; Worker не нужен для просмотра.
**Остаток2B:** ручной ракурс сбрасывается при смене portable-слоя из-за upstream
blueprint clone activation. Не объявлять stable source ID сохранением eye;
выбирать только поддержанный native-механизм либо отдельно согласованный upgrade,
не патчить WASM и не создавать второй renderer. Подробная визуальная приёмка открыта.
Начальный cursor +1мкс показал camera/TGS в промежуточной проверке, но в финальном
expanded first-frame камера была пустой до playback; устойчивость не принята.
Прежний facade функционально работает, GPU715→375 MB освобождается,
но renderer112→829 MB после закрытия. Follow-up с `vmmap`: один цикл834→215 MB
за35с с исчезновением крупных VM областей, повторный812→824 MB за91с.
Это не принято как стабильное исправление memory issue; retained root не доказан.
В коде введён primitive-only owner v2: SDK handles/Promise/errors остаются в iframe,
parent получает JSON-строки, late start и все каналы завершаются независимо.
После решения владельца вернуться к основной работе завершены последовательно
полный typecheck,740 frontend tests,64 focused backend tests и build. Дополнительно
устранена потеря pointer/Escape при iframe boundary для существующих controls;
16 focused tests включают перевод координат native divider и cleanup listeners.
На8000 `app-DHVEqf5x.js`, backend89747 не перезапускался. Build15.14с,
max RSS1994817536 bytes; OOM нет. Swap за интервал финальной сборки вырос
1934.69→2173.69MiB: это не доказательство отсутствия resource pressure.
Browser auto-review отдельно отклонил тяжёлый M49 viewer при pressure2; запрос
владельцу отправлен, обхода не было, проверочная вкладка закрыта. Этот конкретный
visual gate открыт, но он не останавливает работу над input/catalog/contracts.
Подробности: [memory lifecycle](../experiments/perception/OBSERVATORY_MEMORY_LIFECYCLE_2026-09-03.md).
**Непосредственно дальше — основной этап2:** перед повтором LAB V1 исправить
ресурсную политику диска новой sealed версией и сохранение диагностики отказа;
не повторять известный350GiB admission FAIL. Затем закончить2C. Также открыты
versioned recorded-analysis и
incremental input2A, завершение saved-viewer2B, затем последовательная матрица
LAB V1/вторая запись2C и recovery/постраничный каталог2D. Подготовку2A и metadata
fixtures2D можно продолжать независимо от разрешения на тяжёлую browser-проверку.
Прежний `_capture_arrays()` остаётся только в cold producer path нового adapter;
early warm lookup и общий Worker source CAS проверены и установлены. Не называть
их новым incremental execution. Producer SHA pack неизменен; новый input path
должен сохранять старые exact-cache результаты. Memory lifecycle остаётся
критерием завершения операции, не отдельной
бесконечной оптимизацией или условием переписать план. Не повторять claim/v3,
ремонт селектора, Docker VM или сетевой canary. Лабы в целом ещё не закрыты.
## Карта продолжения, проверка и восстановление
- `src/k1link/observatory/recorded_jobs.py`, `worker_agent.py`,
`installed_lab_package_runner.py` — job, ownership и исполнение.
- `src/k1link/observatory/m49_portable_executor.py`,
`portable_lab_v1_executor.py`, `m49_portable_source.py` — текущие adapters.
- `portable_result_publisher.py`, `portable_publication_reconciler.py`,
`portable_result_cache.py` в том же каталоге — publish/recovery/cache.
- `apps/control-station/src/core/observatory/` и
`apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx` —
проекции и общий экран, не новая модельная orchestration.
- Runtime/graph paths и численные proofs — в прежнем плане и связанных отчётах
ниже. Перед конкретным изменением сверять владельца модуля.
Каждый инкремент проверяется по своему результату: contract tests, необходимый
Worker proof, затем UI/evidence. UI-изменения следуют product-ui canon; новые
visual entities требуют отдельного решения. Проверка идентичности не доказывает
качество. При сбое сохранять последнее проверенное состояние, не очищать очередь
и не повторять закрытые этапы; новую hardware/authority потребность выделять явно.
## Решения и расхождения документации
- 2026-09-02, владелец: recorded-first вместо сетевого realtime-first; старые
FAIL сохраняются, лаборатории не зависят от LTE/борта.
- 2026-09-03, владелец: результат внизу вместо completion button/status — реализовано.
- 2026-09-03, владелец: активной операции разрешён необходимый расход памяти;
завершённая обязана освободить временные ресурсы. Не заменять cleanup снижением
качества/новыми жёсткими RAM caps. Little Snitch не трогать; файлы результатов сохранить.
- 2026-09-03, актуализация: этап 1 — реализованная основа, сквозной proof явно
в этапе 2. Это уточнение границы приёмки, не приписывание успешного end-to-end run.
- Desktop17/20, утверждение об отсутствии online prototype и безусловный1×
устарели. Исправлены по решениям владельца; прошлые цифры/попытки сохранены.
## Журнал recorded-first инкрементов — история, не текущие TODO
Промежуточные «ещё не реализовано», «следующий шаг» и stopping points ниже
относятся к соответствующей итерации; текущий статус и маршрут — выше.
Первый инкремент: portable submit защищён атомарной проверкой существующей
job identity. Другой ключ запроса не создаёт дубликат active/reconciliation
@@ -86,7 +618,7 @@ succeeded/not-required не принят за новый результат. В
сохранены. Worker/model jobs не создавались. Browser: каталог, выбор, Refresh,
normal/expanded и Escape проверены, console warnings/errors0.
Этап1 пользовательски ещё не закрыт: текущий селектор смешивает архивные entries
На момент активации cache этап1 пользовательски ещё не был закрыт: селектор смешивал архивные entries
и portable definitions, а source/setup-only выбор последней job показывает
старое «Расчёт завершён» рядом с новым «Рассчитать». Это не текущий cache hit.
Следующий шаг на стыке этапов1–2: exact-definition queue/status binding,
@@ -95,68 +627,26 @@ normal/expanded и Escape проверены, console warnings/errors0.
Подробности/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.
**Четвёртый инкремент, 2026-09-03: правило выбора исправлено.** Селектор получает
только portable definitions, оставляет совместимые без проверенного exact cache
hit; архивные LAB и рассчитанные версии остаются в доказательствах ниже.
Кнопки/плашки завершения и дублирующее «Записанный разбор» убраны. Все загруженные
результаты показаны без прежнего лимита6. «Рассчитать» отсутствует при пустом
выборе и во время уже принятого расчёта; до успешной проверки запуска кнопка
неактивна. Во время работы есть только индикатор ожидания, без вымышленных
процентов. Ошибки и retry публикации сохранены вне строки выбора.
Queue GET фильтрует точную definition SHA до LIMIT; при смене source/setup/SHA
предыдущие jobs/errors не показываются даже до React effects. Публикация
обновляет оба каталога; удаление projection также обновляет выбор.
720 frontend tests,52 focused queue/API tests, architecture4/4, typecheck,
Ruff/mypy и production build PASS. Подробная финальная runtime/UI-проверка —
в [отчёте](../experiments/perception/OBSERVATORY_PUBLISHED_CACHE_2026-09-03.md).
Визуальная проверка также выявила сжатие селекторов в узком обычном окне:
существующая адаптивная раскладка теперь зависит от ширины окна Observatory,
а не всего viewport. Старые terminal logs не выдаются за ошибку нового выбора.
Полный новый LAB-run, реальные счётчики прогресса и positive cache/view на
операторских данных остаются следующим acceptance этапа2, а не объявляются
закрытыми этой правкой интерфейса.
## История прежнего realtime-first плана (не текущие команды и зависимости)
@@ -110,3 +110,48 @@ not a realtime inference or navigation claim.
- The migration does not improve DDRNet quality, prove terrain traversability
or grant navigation/actuation authority. Those remain separate model and
safety acceptance questions.
## Resource ownership addendum — 2026-09-03
An active calculation/view may allocate what its admitted workload needs. On
termination its ephemeral resources must become releasable; lowering evidence
quality or deleting durable results is not a substitute for lifecycle cleanup.
Recorded viewers run the same unmodified upstream SDK in a disposable,
same-origin iframe inside the existing viewer surface. A small synchronous
facade exposes only the public methods already used by the product adapter.
The revision-2 boundary returns primitive JSON strings, including events and
errors; the parent parses them in its own realm. Native handles, DOM nodes,
channels, unsubscribe closures and SDK startup Promises stay inside the iframe.
Parent-owned RRD bytes enter a synchronous channel call, without transfer or
changing the payload/quality. Parent startup Promises are settled by primitive
notifications and rejected on disposal. Every channel is closed independently;
a late SDK startup completion is stopped again. Teardown severs references and
removes the iframe. There is still exactly one native renderer and clock,
no new playback UI, SDK patch or second backend. The live source path stays
direct upstream. Page-cache restoration starts a fresh viewer lifecycle.
The iframe relays only Escape and primitive pointer fields to parent-owned
events for the existing outer controls. Coordinates are translated by the
iframe bounds, including the native-chrome crop. Native Rerun still owns its
canvas gestures and divider; nothing is cancelled or sent back to the canvas.
The parent tracks the same divider for control alignment, not a second layout
or renderer. Teardown removes every relay listener and foreign frame reference.
The backend's ephemeral blueprint store has an exact viewport owner, explicit
release, idle expiry (300 seconds; reaped every 30 seconds), and shutdown cleanup.
An open owner renews every 30 seconds. Release is serialized with rendering;
late updates for that released identity are rejected. Expiry never stops an
active render, a recording, a Worker profile or a vehicle. Durable files are not
removed. The pre-existing 32-entry cache bound is unchanged; no new active data
or image-quality cap is introduced.
DOM disappearance is not memory acceptance. Compare process footprints before,
during and after ordinary sequential open/close cycles without forced GC or
page reload. See the dated memory-lifecycle report for measurements and limits.
Revision 2 and the input relay passed full typecheck, 740 frontend tests,
64 focused backend tests and a production build, served on canonical 8000.
Real browser acceptance of this revision remains pending: auto-review rejected
opening the heavy saved viewer under elevated pressure; explicit owner approval
was requested. This does not gate unrelated input/catalog contract work, nor
does a successful build prove that the recorded memory retention is fixed.
@@ -1,5 +1,10 @@
# ADR 0050 — Recorded Observatory first; portable profiles then onboard
> 2026-09-03: [ADR 0051](0051-modular-observatory-profiles.md) now defines
> modular Docker composition and grouped single-provider settings. Its UI and
> packaging rules supersede the finite whole-profile selector/one-image target
> below. Recorded-first, exact cache, preserved evidence and safety remain.
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.
@@ -48,6 +53,17 @@ 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.
## Implementation checkpoints
The checkpoints below are chronological evidence, not a current TODO list.
As of 2026-09-03, the queue/claim/cache/selector foundation is implemented and
locally checked. Attempt-bound progress is implemented and active (`bee8552`,
control-agent installation `54c8d82`). One real M49 run succeeded and published;
exact cached document reopening survives restart after the no-op reindex fix
`bd947b4`. Full recorded-analysis and synchronized saved visual replay remain
unaccepted; the current four-stage
route and next increment are maintained only in the linked ExecPlan.
## First implementation increment
Portable queue admission now opts into an atomic duplicate-computation guard.
@@ -121,8 +137,79 @@ 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).
## Selector contract clarification — 2026-09-03
The owner explicitly excludes completion buttons and status badges. A completed
current profile is represented by its reviewable, profile-marked evidence below,
not by a “calculation complete” control. The selector now contains compatible
portable definitions with no verified exact published cache hit. An archived LAB
or old definition never hides a new version. An empty selector has no Calculate.
An in-flight computation has only an activity indicator; failed publication
keeps a separate recovery action and never starts inference again.
Removed the six-card presentation cap; the existing catalog window limit remains
explicit. Queue reads now filter definition SHA before pagination. Hook snapshots
are fenced by source/setup/definition so previous jobs cannot flash in the next
selection. New synthetic tests exercise none/all/partially cached choices,
unavailable executors, legacy/new-version separation and pre-effect selection
changes. Sequential validation:720 frontend tests,52 queue/API tests, architecture,
typecheck, Ruff/mypy and production build PASS. Full recorded-run/product
acceptance and measured progress counters remain stage2.
Historical terminal logs do not become a new selection's error. The existing
responsive selector layout follows workspace width, including restored narrow
windows, rather than only the browser viewport.
## Real progress, publication and restart proof — 2026-09-03
Worker-to-backend-to-UI progress is claim/generation/sequence-bound and stored as
one bounded current row per job. It does not renew ownership or change live
semantics. One UI-submitted M49 × RAVNOVES004TREE run published on its first
attempt after 1416.763 s. All 6830 fragment-schedule rows and result artifacts
were verified; 19 missing-LiDAR positions remain UNOBSERVED, point accounting
has zero unaccounted contributions. The schedule spans 39.215–757.160 s, not
the whole 808.779 s source session. This CPU-only result is not a full AI profile.
An actual canonical restart exposed a missed case: unchanged archive discovery
advanced the source row's update clock, changing its admitted catalog hash and
hiding the exact cache hit. Reconciliation now preserves the **entire existing
snapshot** when only that incidental clock differs. Real changes still alter
identity; the fingerprint, package and computation schemas are not weakened.
The affected source's one clock field was restored only after reconstructing
its exact admitted hash, with backup and transactional before/after fences.
No source bytes, result provenance or job identity were rewritten.
Following activation/restart, the same catalog, job and cached result document
reopened; all previous jobs and claim history remain. However, the portable
review currently renders a checked JSON document/artifact inventory, not video
and TGS. Therefore stage 2B stays open for a common schema-bound saved visual
adapter, using this sealed result without recomputation or legacy overlays.
See [real-run and restart evidence](../../experiments/perception/OBSERVATORY_RECORDED_PROGRESS_2026-09-03.md).
## Boundaries
Owner clarification, 2026-09-03: Observatory's result list includes only admitted
portable-profile publications, not every historical LAB linked to a source.
Legacy contours, canonical archive projectors and stored evidence remain
unchanged. Filter by executable replay/publication binding, never by LAB label,
date, model name or equality to the currently installed definition. Older valid
portable versions remain reviewable. Versioned opt-in cursor pages feed the
existing source search; legacy catalog consumers retain their previous response.
See [catalog boundary evidence](../../experiments/perception/OBSERVATORY_CATALOG_BOUNDARY_2026-09-03.md).
Source-reuse increment (2026-09-03): both current control agents now share a
Worker-local source CAS. Every generation still obtains its own authenticated,
claim-bound manifest and checks exact digest/length before reusing bytes. Missing
camera members are fetched individually instead of retransferring a partly cached
epoch. Job/output/profile identities and model packages are unchanged. Cached
visual results remain served by Core, not this Worker input cache.
M49 now selects an exact current-producer LiDAR v2 pack before raw decoding;
the legacy producer file and its identity hash remain unchanged. Full source
hash and strict NPZ integrity validation still occur on reuse. Cold whole-source
materialization, versioned recorded-analysis and full workflow acceptance remain
open. Shared-cache synthetic Worker proof is not a model run or a throughput
measurement. See [source-reuse evidence](../../experiments/perception/OBSERVATORY_SOURCE_REUSE_2026-09-03.md).
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.
@@ -0,0 +1,181 @@
# ADR 0051 — Modular AI containers and composed Observatory profiles
Date: 2026-09-03. Owner-approved target; the first local modular vertical slice is installed.
## Decision and supersession
A reusable module is an independently packaged Docker image for a model or a
cohesive algorithmic function. A profile is an immutable, validated composition
of module versions, parameters, input/output connections and execution policy.
A LAB run applies that composition to an exact recording snapshot. A new
combination does not require building another image containing all models.
This replaces the earlier **one full profile = one image/container** packaging
requirement in the historical plan. It keeps ADR 0050's recorded-first purpose,
ADR 0048's generic installed-package boundary and the stream contracts from
ADR 0049. Several containers are not intrinsically a defect. Per-LAB application
forks, hidden host dependencies and duplicated data preparation remain defects.
The future onboard host is CUDA-capable, architecture/runtime compatible
hardware. It is explicitly **not Mac Mini**. Exact CPU architecture, GPU,
driver and inference-runtime compatibility still need target qualification;
CUDA availability alone is not a promise that every existing image will run.
New owned module images/containers use the `ndc-` namespace, readable model or
function names, explicit versions and ownership labels. A composition has its
own readable LAB label; module/composition/job identities are linked in runtime
metadata. A reused module need not be renamed to match every LAB that uses it.
## Operator composition
Each recording exposes AI-layer settings inside the existing Observatory.
Use functional groups with a single-choice Select, not a checklist of arbitrary
containers. Segmentation admits **at most one** provider: DDRNet or EoMT, never
both in the same new LAB. Optional groups may offer None if the resulting graph
still satisfies its declared outputs. A geometry-only or segmentation-only LAB
is valid and must not claim the omitted capabilities.
Candidate groups are segmentation, object detection, LiDAR/TGS geometry and
costmap, object/range association, motion and policy-shadow. Their final physical
module boundaries follow existing code ownership and measured data exchange,
not a mandatory container per mathematical operation. Multiple capabilities
provided by one module do not instantiate that module multiple times.
The server validates provider cardinality, dependencies, source capabilities,
calibration/time frames, output contracts and installed Worker versions. A client
cannot supply executable argv, image names, host paths or arbitrary resource
settings. Invalid dependencies are explained, never silently supplemented by
old LAB overlays or an unselected second segmenter.
An exact existing published composition is opened, not recomputed. The settings
remain editable so the operator can create another composition. There is no
Calculate action for a complete exact cache hit, no “calculation complete”
button/badge, and no duplicate LAB. The grouped selectors keep already-used
providers: completion belongs to the whole composition, not to an individual
dropdown option. This supersedes the old finite-profile selector grammar, not
its exact-cache and idempotency guarantees.
## Execution, identity and caches
- Keep one recorded owner on Worker006 and serialize heavy GPU execution.
Selecting several modules does not authorize concurrent GPU model jobs.
- Share source delivery/preparation locally on Worker. Intermediate image/point
buffers do not round-trip through Core between modules. Do not reintroduce
whole-recording upload/decode as a permanent streaming-start prerequisite.
- Keep a common timestamped, bounded I/O contract with explicit sensor gaps,
coordinate frames, unavailable outputs and causal temporal state. Recorded
analysis may be slower than acquisition; live has separate freshness gates.
- Freeze graph topology, module image/weights/code/config identities, input
identities, preprocessing, calibration, cadence, precision and state policy.
Canonical ordering of the selection UI is not part of the semantic identity.
- Reuse a node only for exact validated inputs and producer identity. A changed
detector invalidates its dependent range/motion results, not an independent
segmentation result. Stateful reuse also binds initialization and history.
- Final LAB/cache publication remains on Core. Worker source/preparation caches
are not the final viewer store. Failed publication retries the sealed result,
not inference. A partially cached graph is not a complete ready LAB.
- Record reused versus computed nodes and provenance. Cache playback or cached
composition assembly is never reported as measured onboard processing FPS.
Onboard modules are long-lived within a mission; do not start a container or
reload weights per frame. Resident memory, local transport, bounded queues and
end-to-end latency require a joint benchmark. An optional later co-location of
hot nodes is a measured deployment optimization, not a reason to remove logical
modularity. No automatic actuation or navigation acceptance is introduced.
## Current implementation and migration
Current M49 runs CPU TGS in its specialized agent. Current LAB V1 is an installed
fixed stack: prepare → EoMT → DDRNet → assemble, with host-bound model/runtime
assets. Neither is the new modular composition system. Keep the successful M49
publication and exact existing releases; retain old dual-segmentation results
as historical comparisons, without admitting that combination in new profiles.
Reuse `InstalledLabPackage`, its generic launcher, queue/claim/recovery, source
CAS, verified publication and common viewer. Extend the versioned contracts for
compositions and per-node reuse. Do not redesign the queue or create another
agent per model. Source CAS alone does not implement intermediate-result reuse.
The owner authorized Worker cleanup. Classify every exact container/image/cache
against running services, pinned packages, model assets, rollback and retained
evidence before deleting. Stopped instances can be retired independently of
images and volumes. Preserve raw recordings, published LABs, Legacy views,
required source/model caches and one usable rollback; no global Docker prune.
The accepted EoMT disk floor is now **250 GiB plus its working-set estimate**.
Source and narrow tests have changed; installed EoMT still has 350 GiB until a
new sealed image/release is activated. Never patch a historical digest in place.
## Acceptance and route
Done means: select one provider per group; compute two distinct compositions on
compatible recordings with exact cache reuse; view their complete bound outputs
from Core; recover publication without compute; prove exclusive GPU scheduling
and cleanup; cold-start the selected module distribution without developer
checkout or unlisted caches. Onboard qualification remains future work.
The historical combined route remains documented in
[the four-stage ExecPlan](../OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
## Local implementation record — 2026-09-03
The first implemented slice replaces the Observatory's fixed profile selector
and separate refresh/calculate controls with one always-available
`Сконфигурировать AI-слой` action for the selected source. Its canonical Window
contains functional groups, one provider Select per group, the close action and
one `Рассчитать` action. The Window uses background, spacing and typography for
grouping; it has no group outlines, header divider lines or modal rim.
DDRNet and EoMT are independent alternatives in the Segmentation group. They
share only the model-neutral `camera-source` preparation step and neither model
consumes the other model's result. The installed module repositories and exact
local image identities are:
- `ndc/mission-core-ai-module-camera-source` —
`da926459aee0a841bbdfaf80a0eb5fbead354c56794d1f3384eeba66d0a49e00`;
- `ndc/mission-core-ai-module-ddrnet` —
`489fc7d1157fd0f1cd1d82e06a15737b7b2aaaf72b2ddb2aca2992b91a97488e`;
- `ndc/mission-core-ai-module-eomt` —
`5b770178e4a5c8fbe8f8ddab3b83a598973dbe103b669851110b11a0918ab846`;
- `ndc/mission-core-ai-module-rf-detr` —
`2b8b44be8e9ee4060aa6997fc4c025ad7120f37ecd720a9e59b5e02ac6c90f66`;
- `ndc/mission-core-ai-module-object-distance` —
`69d68f64981b41e5bcce1e642433e3d466b88c51942a5ba3aec9180d8ed04263`;
- `ndc/mission-core-ai-module-tgs` —
`f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3`.
The executable graph also contains `ai-detection-rf-detr-v1` for image-space
boxes and `ai-range-object-distance-v1` for box-to-LiDAR association. The range
package executes RF-DETR as its explicit dependency, then uses synchronized
LiDAR, pose and calibration to estimate range. TGS consumes LiDAR, pose and its
own history independently. Selecting TGS and range therefore produces two
independent durable jobs; segmentation does not feed either of them. The common
Worker agent launches package steps with job-specific container names under
`ndc-mission-core-ai-module-*`, serializes GPU work, publishes a
`missioncore.recorded-ai-layer-review/v1` result and leaves the historical
combined LAB V1 and Legacy contour intact.
Submission creates a durable Observatory job immediately. The source-bound
evidence area shows its selected configuration, current phase, measured
progress and an estimated remaining time. A published result moves into the
existing evidence list and uses the same open, rename and delete actions as
the other portable LAB results.
The active Worker 006 directory release is `20260903-v5`, release SHA-256
`a87c82adfa1058f14f850f488ace38e5c7c9d9e2145d4678a81b52f2a1aad573`.
Its common agent image SHA-256 is
`d6f2ef1a3f38ebc8503b62296ca9def10472a60a41ffc5edd123df565520dec8`.
The DDRNet, EoMT, RF-DETR and object-distance definition SHA-256 values are
respectively
`b04206a8472588fee22e0282228e51b7817c80f9739180e834b704733d7aaf76`,
`6d13402883e8fa79a8ea97b15e81e704d3c0f9dac5d94445e5e53c18dcebe98f`,
`a89f51a66a070deb50f4904f596545cdcd7289df7e42b8511aec9547dedd52e8`
and `cc56e72fdfb38565a0b402c90a91917821463d980f5855e4cdf4d8b9f262a94c`.
The standalone detector and composite range package have distinct resource
profile identities, so their otherwise shared RF-DETR model manifest cannot
collapse into one executor registration. All four package executor identities
are unique. DDRNet, EoMT, RF-DETR and object-distance pass live source,
executor, sealed-definition and durable-queue preflight for
`20260828T130511Z_viewer_live`; the published M49 TGS result is an exact cache
hit. No new full-recording RF-DETR or object-distance inference was run as part
of this installation acceptance.
@@ -0,0 +1,68 @@
# Missing Core Observatory domain ontology v1
Status: local runtime canonical. Owner: NODE.DC Mission Core.
The Observatory now has four independent consumers of the same relationships:
composition validation, Worker job dispatch, LAB/result projection and replay
controls. The previous typed-contract-only approach lost the relation between a
multi-module submission and its member results. This satisfies the local
ontology admission gate in `docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`.
The executable source is `config/observatory-domain-ontology.json`. It defines
transport and equipment units, time-bounded equipment mounts, capture profiles,
recorded sessions, module versions, immutable compositions, append-only
composition runs, container images, worker nodes, jobs, portable results, LAB
projections, viewer panes and viewer layers. These are peer subjects with independent identities and
lifecycles. A per-result LAB view profile owns the operator's mutable SLAM
display choices. The entities follow the NODE.DC Platform ontology architecture without making
Platform a dependency. Its named queries are recording capture context,
configuration label, member results, viewer layers and ready state.
A recorded session binds to the transport, equipment units, equipment mounts
and exact capture profile that produced it. A composition run points to that
session and never substitutes its own identity for transport or equipment. This
keeps future vehicle and sensor changes separate from model configuration and
from the published evidence.
A module version is implemented by an immutable container image. Installation
of that image on a worker and execution of a recorded job on a worker are
separate relationships, so Worker 006 is not encoded inside the module identity.
The replay layer remains a capability of the module version and can therefore be
derived through the module-to-image relationship without coupling UI controls to
a Docker runtime instance.
One saved composition is opened by one Rerun viewer and one shared recording
clock. Camera, LiDAR, depth and future sensor views are panes inside that viewer's
blueprint. Every pane owns its layer controls; the overlay follows the pane bounds
when a separator moves. A second Rerun viewer is reserved for a genuinely
independent recording or clock, because duplicating a viewer per pane would also
duplicate the recording transport, memory and synchronization work.
Pane construction is projected from module capabilities. DDRNet and EoMT create
only the camera pane with their independent segmentation layer. RF-DETR creates
the camera pane with object frames. TRAVEL TGS creates only the spatial pane with
source points, local SLAM and TGS. Object distance composes the camera detections
and spatial LiDAR panes it needs. Combining modules takes the ordered union of
these pane layers, so a configuration cannot invent an unrelated camera or point
cloud viewport.
Operator SLAM display settings belong to the published LAB result identity. They
are mutable presentation state, stored atomically in the Mission Core runtime,
and are loaded again after browser storage is cleared. They do not modify the
immutable calculation result, module configuration or recorded evidence.
Identity and lifecycle remain owned by the existing stores. The ontology does
not replace their contracts and does not contain executable commands, mutable
container instances, paths or resource grants. Image digests and worker
identities remain facts supplied by their owning registries. The ontology
projects their exact identities into one shared relationship model.
Version 1 uses additive migration for existing single-module results. A recorded
composition run may reference an already published exact module result; this is
recorded as reuse, not a new inference. Failed attempts remain audit evidence and
do not satisfy the ready-state query.
There is no runtime dependency or write path to NODE.DC Platform Ontology. The
Platform repository supplied the package architecture only. Synchronization is
`none` until a meaning becomes stable across products and receives a separate
owner-approved migration.
@@ -0,0 +1,113 @@
# Observatory: граница с Legacy и постраничный каталог
Session: `observatory-catalog-cleanup-tpit2O`, 2026-09-03.
Изменение поверх `eff60e4`; прежние незакоммиченные viewer/source-reuse правки сохранены.
## Решение и результат
По уточнению владельца Обсерватория показывает результаты переносимых профилей,
а не весь архив исследований, связанный с выбранной исходной записью.
Legacy «Лабораторные контуры» и их данные остаются нетронутыми. Разделение —
типизированная проекция, не удаление LAB, не миграция исходников и не список
запрещённых имён E24/E25/E26.
В текущем каталоге RAVNOVES004TREE теперь показывает один опубликованный M4.9T5
от3 сентября. Старый M49 без replay capability и исторический canonical LAB V1
не попадают в Обсерваторию. У RAVNOVES00 вместо14 исторических карточек —0.
Нерассчитанные portable profiles по-прежнему доступны для расчёта. Существующие
версии полноценных portable результатов не скрываются по возрасту или несовпадению
с текущей установленной версией профиля.
## Реализация
- `core/observatory/catalog.ts`: принимает только `portable-result-review` через
существующий строгий `observatoryRecordedRunBinding`: source/result/definition
identity и publication provenance. Неверная portable identity вызывает ошибку,
не переименовывается в Legacy и не скрывается как успешная очистка.
- Старые canonical/experimental adapters и их endpoints не удалены. Их наличие
в коде не означает включение в новый продуктовый список. Общий session catalog
продолжает возвращать исторические записи своему прежнему потребителю.
- Backend opt-in `pagination=cursor-v1` добавляет versioned page schema и
`next_cursor` из существующей keyset pagination `SessionStore`. Без opt-in
форма ответа остаётся ровно прежней: `items`. `scope`, `lab_contract` и
правила публикации/скрытия проекций не менялись.
- `core/observation/sessionCatalogPage.ts`: отдельный строгий page adapter.
Observatory дочитывает страницы по100 metadata entries, проверяет уникальность
записей, повторные/небезопасные cursors, версию/размер ответа, HTTP errors и
отмену. При ошибке отменяется второй обход; существующий hook сохраняет
последний успешный каталог и показывает ошибку обновления.
- Предел обхода —256 страниц на scope (по умолчанию до25600 строк). Достижение
предела оставляет явный partial-window флаг; отсутствие записи в таком срезе
не подтверждает удаление. Исторические provenance не накапливаются при обходе.
Список хранит только метаданные, не видео, облака или result artifacts.
- Текущий поиск `Select` работает по всем загруженным источникам. Отдельного
server-side текстового поиска нет. Это keyset traversal, не транзакционный
снимок всего каталога: новая запись во время обхода появляется при следующем
Refresh; удалённый cursor вызывает ошибку/повтор, не выдуманную полноту.
- Из карточек убран внутренний `result_kind`, пояснение над списком описывает
операторскую задачу. Новых controls, визуальных паттернов, CSS или per-LAB
branches нет; скилл Mission Core UI сохранил канонические controls и разделение
`core → workspace`.
## Проверки
-24 focused frontend/architecture tests PASS; полный последовательный frontend
suite747 PASS; full typecheck PASS. Backend41 tests PASS; Ruff/mypy PASS.
- Fixture500 источников и102 LAB entries проверяет связь portable результата
со source499 после первой сотни legacy rows. Отдельно: точная полная страница
без ложного limit flag, дубли/cursor cycle, abort, небезопасный cursor,
неизвестная версия, превышение страницы и явный предел обхода.
- Backend102 sources +102 LABs: старый ответ неизменён; страницы100+2, без
потери/дубликатов; scope-invalid cursor404, неизвестная pagination422.
- На действующем8000 production adapter с `limit=1` сделал20 GET:3 источника,
17 исходных LAB rows,1 admitted result,0 unresolved, оба has-more false.
UTC13:24:52.491–13:24:52.921; monotonic `performance.now()`
130.567750–559.498167ms в одном Node-процессе. Это metadata proof, не FPS.
- Browser: normal/expanded composition, поиск и выбор RAVNOVES00/004TREE,
обе нерассчитанные версии у00, один текущий M49 у004TREE, Refresh и Escape
dropdown проверены. Вне dropdown Escape не изменил modeless окно; закрытие
полного viewer и memory lifecycle в этом проходе не проверялись.
- Legacy API до и после установки совпал побайтно. Worker queue сохранила
10failed/2succeeded,0 live leases и1 v3 grant. Новых model jobs —0.
## Установка и эксплуатация
Сборка `app-9nK8VtbK.js` обслуживается единственным Core на8000, PID1425 после
контролируемого перезапуска idle-сервиса;8765 пуст. Предыдущая frontend-сборка
сохранена в private evidence `previous-dist`, а не удалена. Backend declarations,
порты и LaunchAgent не изменены.
Первый build с искусственным V8 heap limit1024MiB завершился heap-limit OOM
до установки; прежний UI оставался рабочим. Последовательный build с2048MiB
прошёл за10.84с, maximum RSS2253422592 bytes. Это ограничение build-процесса,
не доказательство нехватки всей RAM Mac. Swap2133.69→2301.06MiB после сборки,
pressure2; временные build/test процессы завершены. Heavy replay не запускался,
прежний browser gate не обходился. Little Snitch, чужие приложения, Docker
limits, GPU и модельные контейнеры не менялись. Оставлена одна лёгкая вкладка
Обсерватории для владельца, без viewer/inference.
## Остаток маршрута
Выполнена часть2D: product/Legacy separation и постраничный metadata catalog.
Это не закрытие всего этапа2. Далее остаются2A — versioned recorded-analysis и
cold incremental input;2B — синхронный replay/ручной ракурс/ресурсный lifecycle;
2C — последовательные LAB V1×004TREE и оба профиля×RAVNOVES00;2D — оставшиеся
recovery/negative cases. Standalone полного графа3 и реальный борт4 отдельно.
Ops-карточка в этом инкременте не обновлялась.
## Evidence
Private root: `.runtime/observatory-catalog-cleanup-tpit2O`. API metadata,
screenshots и runtime output не добавлены в Git.
| Артефакт | SHA-256 |
| --- | --- |
| `legacy-before.json`, `legacy-after.json` | `4b5a19d83569c598e9936f1a65ef445d65eccc4bdc2731823bbbdebbf746cabc` |
| `live-pagination-proof.json` | `1a5c65b04b34f2f797c927f3e29094b399f9c51c717786a1975344e6cc975dcf` |
| `frontend-tests.log` | `674745c5a008cdb39a5d5a0b360ddff304da855dca2ba899ca2bd341a415dee0` |
| `backend-tests.log` | `d8b26fab3621b44e79ae5555ccd2de5447fb1efac9c7c2737b97bf8563e7e2e1` |
| `build-2g.log` | `f1b7d14266cee959bc69b271cffa17a4d1483c2af7330301fbc7261a01519a7f` |
| `worker-readiness-after.json` | `26db0da5261bae8b1b85cb06c93f633dcaa9d732a543c6fca24326a6831a7758` |
| `normal.png` | `c45fe93e56a1edaf6580bcbef5848d09a34e5bdc84903103567a58e47191d5db` |
| `rav00-results.png` | `63cfbbbc6e89b2bb17ddd68c83dddb89a235e8d9eb54709955a70139dd2c76f2` |
| `rav004-results.png` | `28520a65a459b6736697cfd82527df9ba2eb3ebacfd1888a70f914b1e4f43809` |
@@ -0,0 +1,118 @@
# Observatory: возврат к основному лабораторному сценарию
Дата: 2026-09-03. Session: `observatory-mainline-FZrkOa`.
Checkout: `codex/m5-1-observatory`, инкремент поверх `eff60e4`.
Handoff snapshot: UTC `2026-09-03T12:22:38.948Z`, Node `process.hrtime.bigint()`
`2222283739491791` ns. Catalog snapshot имеет отдельные UTC/monotonic в evidence;
часы разных механизмов не вычитаются друг из друга. Для тестов/build сохранено
измерение elapsed через `/usr/bin/time -l`, не per-stage trace приложения.
## Решение и граница
Владелец потребовал вернуться к основному плану и не превращать общую занятость
RAM Mac в бессрочный блокер. Активные процессы могут потреблять нужную память;
после завершения временные ресурсы освобождаются. Это правило не отменяет
зафиксированный дефект закрытого viewer и не означает, что лабы уже приняты.
Только локальный Core8000, без Worker/model runs, внешнего deployment, изменений
Little Snitch/пользовательских служб/Docker limits, удаления записей/cache.
UI skill сохранил один renderer/clock, прежние controls и последовательные gates.
## Объективный статус четырёх этапов
| Этап | Что доказано | Что остаётся |
| --- | --- | --- |
| 1. Основа | Admission, очередь/claim, защита от дублей, exact cache/version/selector | Не повторять уже выполненный ремонт |
| 2. Полные лабы | M49×004TREE рассчитан, sealed/published, документ/cache пережили restart; общий saved replay реализован | Versioned recorded-analysis/input, visual acceptance, LAB V1 и вторая запись, recovery и большой каталог |
| 3. Полный Docker-профиль | Существует инженерный граф DDRNet/RF-DETR/LiDAR/distance/motion/TGS/costmap/policy-shadow | Самостоятельный image/cold start, полный run и сравнение конфигураций |
| 4. Борт | Цель определена | Реальное целевое оборудование и отдельная квалификация; сейчас не переносим |
**Лабы целиком не закрыты.** M4.9T5 сейчас CPU TGS; LAB V1 — последовательная
EoMT+DDRNet segmentation, а не готовый полный профиль рига. Старые FPS и
красивые overlays не доказывают целый вычислительный граф в одном Docker.
## Сверка незакрытого кода
- `core/observatory/catalog.ts:fetchObservatoryCatalog` запрашивает два окна
по100 metadata entries (source/laboratory), не продолжает cursor. API
`/api/v1/observation-sessions` cursor уже принимает. Нужно довести постраничные
source/result связи и поиск, не грузить записи целиком и не скрывать результаты.
- `compute/lidar_replay.py:build_lidar_replay_pack_v2` вызывает `_capture_arrays()`
до cache lookup. Это не инкрементальный вход. Source/logical/producer hashes
участвуют в identity, поэтому новый путь требует версионирования и сохранения
старых exact-cache результатов, не механической перестановки строк.
- `viewer/recorded.py:_RecordedBlueprintStream` активирует clone при обновлении
слоёв; сохранение ручного eye не решено. Нужен поддержанный upstream-механизм,
не локальный патч SDK/WASM и не второй renderer. Initial camera frame и
полная visual coverage тоже ещё не приняты.
## Выполненный инкремент
Накопленный primitive-only owner v2 проверен и собран. Дополнительно исправлена
потеря input между native iframe и существующими внешними controls: Escape и
pointer fields копируются в parent-owned events с переводом координат через
bounds iframe. Слежение за native divider снова получает события. Исходный
canvas не получает синтетических действий; native gestures не отменяются.
Все5 listeners удаляются при dispose. Это минимальная поддержка прежней
композиции, а не новый UI control или новый clock.
## Итоговая последовательная проверка
| Проверка | Результат | Elapsed | Maximum RSS bytes |
| --- | --- | ---: | ---: |
| Lifecycle/input + architecture | 16 PASS | — | — |
| Полный TypeScript | PASS | 4.87с | 904871936 |
| Полный frontend suite | 740 PASS | 21.73с | 264798208 |
| Lifecycle/session API/RRD backend | 64 PASS | 3.81с | 190988288 |
| Production build | PASS | 15.14с | 1994817536 |
Первые739 frontend tests и первая сборка сохранены отдельно; после input relay
повторены итоговые gates740. Число16 входит в740, суммы не уникальные тесты.
RSS относится к измерению команды, не всей физической памяти Mac. В backend
сохранилось deprecation warning Starlette/httpx; в build — warning больших chunks.
Serving: `app-DHVEqf5x.js`, runtime `rerun-Dy4Vuq0s.js`, upstream SDK0.36.3 без
патча; WASM `re_viewer_bg-BO4B44yr.wasm` 50428810 bytes. `/`, `/rerun-runtime.html`
и `/api/health` отвечают успешно, PID89747 на127.0.0.1:8000;8765 пуст.
Нового backend restart не было.
## Browser и ресурсы: точная граница доказательства
Каталог нового v2 был открыт обычным путём LAB→Обсерватория до input relay patch;
renderer92266=136MB, shared GPU72641=408MB, Core89747=164MB. Это не heavy replay.
Auto-review отклонил открытие готового M49 при pressure2 из-за риска прежнего
остаточного потребления. Запрошено явное разрешение на короткий просмотр;
нет ответа на момент этой сводки. Обхода не было. Следовательно, normal/expanded/
Escape/divider и open/close именно последней сборки **не приняты в браузере**.
Временная проверочная вкладка закрыта; canonical Core оставлен работающим.
OOM не произошло. До финальной сборки swap1934.69MiB, после2173.69MiB,
pressure2. Нельзя назвать RAM неограниченной или заявить memory-fix PASS по
успешному build. Docker — только три durable telemetry containers примерно
56/292/13MiB. Посторонние приложения и VM limits не менялись.
## Продолжение и приёмка
Продолжать оставшийся2A — versioned полный анализ/incremental input; при
разрешённом просмотре завершить2B. Далее LAB V1×004TREE, оба профиля×RAVNOVES00,
затем recovery/negative cases и пагинация. Подготовка metadata fixtures2D не
зависит от heavy browser gate. Старые данные/результаты остаются immutable.
Отдельный memory gate не подменяет эти задачи. Этап3 следует за принятым
лабораторным циклом; борт, моторы и сетевой realtime PASS сейчас не требуются.
## Evidence manifest
Private directory: `.runtime/observatory-mainline-FZrkOa`.
Исходные записи/скриншоты/секреты в Git не добавлялись.
| Файл | SHA-256 |
| --- | --- |
| `input-bridge-tests.log` | `b007e29a4b828b0982347dee267c0498424048abd7441efa0a1abc6ccdc54592` |
| `input-bridge-typecheck.log` | `7c66ca38e202d5c34eaf2509723281e558aadb8fd8716ca38095d31cac877098` |
| `input-bridge-full-tests.log` | `e8a103bb84f7412706f29661ca32a63c3b9f8a34b4e9d83a1a1bf468a009242e` |
| `input-bridge-build.log` | `a437b38a1d6873707193e742c8719942b0842034adbe6168e5e507771b13226c` |
| `backend-tests.log` | `3ccd6d31b1de48f0506557ab5cb83324121fdd80ca143a2d72a5dc7080bfed56` |
| `01-catalog.txt` | `f226d322bd9749245a6d436806829b015038fa829ccb96979544ccb7d0518066` |
Ops ранее не ответил на direct read; карточка в этом инкременте не менялась.
Актуализированы локальный ExecPlan и Desktop final-status.
@@ -0,0 +1,216 @@
# Mission Core: освобождение ресурсов после просмотра — 2026-09-03
## Решение и граница
По уточнению владельца активная операция использует необходимую ей память.
После завершения/закрытия/отмены/ошибки освобождаются её временные буферы,
визуализатор, каналы, таймеры и серверные сессии. Новые ограничения качества,
разрешения, числа точек или размера активной записи не вводятся. Дисковые
записи, результаты и replay-cache сохраняются. Little Snitch, пользовательские
приложения и настройки Docker VM не менялись.
Это исправление найденного lifecycle записанного просмотра, не приёмка памяти
всех операций Core/Worker. Холодную подготовку, расчёт полного профиля,
публикацию и live receiver нужно проверять отдельно по стадиям и PID.
## Доказанная исходная проблема
На `eff60e4` сохранённый M49 RRD размером 193622178 bytes открывался без compute.
Renderer: 111 MB до → 1104 MB открыто → 567 MB спустя 74 секунды после закрытия.
Reload снижал его до 133 MB. GPU-процесс: 372 → 838 → 825 → 363 MB.
Core оставался около 168–170 MB; Docker VM — 2613 MB.
В upstream 0.36.3 `stop()` уже вызывает native destroy/free/deinit, но цикл
`check_for_panic` продолжает планировать таймер. Наличие этого дефекта установлено
по коду; он не доказан как единственный владелец всего остатка памяти. По ADR0045
SDK не патчится. В backend также отсутствовало освобождение blueprint-сессии
при закрытии UI: ограничение 32 владельцами само по себе не завершало lifecycle.
## Реализация
- `components/rerun/`: disposable iframe и фасад публичного upstream API.
Уничтожение контекста с очисткой ссылок, включая подписки и каналы, даже при
ошибке SDK stop. Ни родительская Promise, ни сохранённый callback не должны
держать foreign DOM/SDK object после dispose. Один native clock сохраняется.
- `RerunViewport`: cleanup на unmount/pagehide; новое owner-id при повторном
запуске; восстановление из browser page cache через новый lifecycle.
Активный live path не переведён в iframe.
- `recordedBlueprintLifecycle`: renewal раз в 30с и bounded keepalive release.
- `recorded_blueprint_lifecycle.py`: explicit release, idle TTL 300с,
render/release exclusion, временный запрет запоздалого повторного открытия
released owner, очистка failed render и shutdown. Reaper раз в 30с.
Потеря renewal удаляет только восстанавливаемый ephemeral blueprint.
- `session_api`: точный owner lifecycle endpoint; released update получает 410.
Освобождение работает и после удаления исходника, не требует materialization.
## Проверки кода
- 64 focused backend tests (lifecycle, session API, RRD), Ruff PASS; mypy новых
registry/recorded модулей PASS.
- Финальный typecheck, 734 frontend tests и production build PASS; тяжёлые
проверки строго последовательны. Сохранилось обычное предупреждение Vite о
больших chunks; SDK не обновлялся.
- Fixtures: stop throws, close before load, late load, timeout, повторный dispose,
сохранённый facade/channel/unsubscribe после dispose, копирование event/range,
отмена renewal и keepalive release; expired owner, reset, LRU, shutdown,
release во время render и fence позднего запроса.
- Канонический backend перезапущен один раз, PID 89747, только 8000. После
холодного старта `/api/health`, `/` и `/rerun-runtime.html` отвечают 200.
## Промежуточная browser-проверка: одного iframe недостаточно
До добавления фасада два штатных открытия того же сохранённого M49 дали:
| Фаза | Renderer 89791, MB | GPU 72641, MB | Core 89747, MB |
| --- | ---: | ---: | ---: |
| Каталог до открытия | 124 | 423 | 152 |
| Первый просмотр, playback | 993 | 743 | 164 |
| Сразу после закрытия | 805 | 401 | 164 |
| Через 89 секунд | 810 | 369 | 163 |
| Повторное открытие | 1016 | 719 | 166 |
| Повторное закрытие | 790 | 410 | 166 |
GPU освобождён; устойчивый возврат renderer к baseline **не доказан**.
Нельзя выдать отсутствие удвоения расхода за полное исправление. Поэтому после
этой проверки добавлено размыкание всех ссылок через facade.
В этой промежуточной версии проверены камера/TGS на начальном cursor, play/pause
(39.215 → 49.899с), переключение SOURCE, normal/expanded и Escape из iframe.
После закрытия iframe/review = 0, lifecycle POST = 204. Самая ранняя +1мкс
граница теперь показала изображение камеры и TGS, но исходный неудобный ракурс
и его сброс при blueprint activation не исправлялись этим инкрементом.
Mac pressure = 1 и swap = 2013.75 MiB без роста в обоих циклах. Docker VM
оставалась 2616 MB. GPU-процесс общий; footprint процессов нельзя складывать
как точную оценку физической RAM приложения. Значения MB округляет `footprint`.
## Финальный facade: функциональный PASS, memory acceptance FAIL
После нормализации pressure до1 выполнен один контрольный цикл финальной сборки
`app-C54IKeub.js`, тот же immutable RRD, без вычисления профиля:
| Фаза | Renderer 90898, MB | GPU 72641, MB | Core 89747, MB |
| --- | ---: | ---: | ---: |
| Каталог до открытия | 112 | 365 | 162 |
| Открыто, пауза | 943 | 680 | 164 |
| После короткого playback | 1014 | 715 | 164 |
| После закрытия | 819 | 411 | 164 |
| Через 62с после предыдущего замера | 829 | 375 | 164 |
Facade **не устранил остаточный footprint renderer**. Браузерный memory issue
не закрыт и итог не выдаётся за возврат RAM к baseline. Явные GPU/server cleanup
и прекращение подписок реализованы; какой native/allocator/GC ресурс держит
оставшуюся память, этим опытом не доказано. Не называть весь остаток живой
утечкой без retained-object/native evidence, но и не называть его безвредным кэшем.
Функционально проверены normal/expanded, Escape из iframe, play/pause
39.215→56.757с, изображения камеры и TGS после playback, iframe/review0 после
закрытия, server lifecycle204; browser error logs пусты. На начальном expanded
кадре камера оказалась пустой, после play появилась: устойчивость initial frame
остаётся визуальным ограничением, несмотря на успешный промежуточный first-open.
Проверки bfcache и аварийного старта — fixtures, не реальные fault injection.
В финальном closed-idle snapshot pressure стал2; swap не вырос (2013.75→2005.75
MiB), Docker VM2616 MB. Проверочная вкладка закрыта. Новые тяжёлые циклы
остановлены resource gate `mission-core-product-ui`. Следующий шаг — локализация
удерживаемой native/WASM памяти либо подтверждённый механизм завершения её
владельца; не ещё один такой же open/close и не принудительный GC/reload как
продуктовое «лечение». Холодный compute и остальные операции остаются отдельно.
## Follow-up: карта памяти и primitive-only owner, 2026-09-03
До изменения boundary повторно проверена прежняя сборка `app-C54IKeub.js`
на том же immutable M49, с `vmmap -w` в каждой фазе. Это новая диагностика
типов/адресов памяти, а не доказательство исправления по исчезновению canvas.
| Фаза, UTC | Renderer91040, MB | GPU72641, MB | Core89747, MB |
| --- | ---: | ---: | ---: |
| Каталог, 11:41:10 | 119 | 381 | 161 |
| Открыто, 11:41:24 | 914 | — | — |
| Закрыто, 11:41:58 | 834 | 420 | 162 |
| Через35с, 11:42:33 | 215 | 379 | 162 |
| Повторно открыто | 941 | 717 | 165 |
| Повторно закрыто, 11:44:10 | 812 | 415 | 165 |
| Через91с, 11:45:41 | 824 | 376 | 163 |
В первом цикле крупные writable области `1f160…/1f161…` исчезли: вместо них
`vmmap` показывает невыделенный 8-GiB адресный резерв с0 resident/dirty/swap.
То есть память действительно возвращалась, не просто уходила в swap. Но
повторный цикл не воспроизвёл этот результат: оставалось543MB untagged memory,
в основном writable/swapped, а не executable code. Это исключает объяснение
всего остатка исключительно скомпилированным WASM-кодом, но **не определяет
конкретный retained-object root**. Общий memory gate остаётся FAIL.
`vmmap` предупреждает, что не может разобрать внутреннюю PartitionAlloc zone;
карта VM не заменяет анализ живых объектов. В этой серии сохранён UTC, но
per-phase monotonic timestamp не был записан; интервал35/91с — разница UTC.
Новая реализация boundary v2 оставляет native SDK, каналы, подписки, Error
объекты и startup Promise целиком внутри iframe. Родитель получает JSON-строки
и создаёт собственные plain objects/Promise; getter/clock остаётся native,
дополнительного polling/clock/renderer нет. Это устраняет сам путь передачи
foreign SDK objects, не выдавая его за доказанную единственную причину остатка.
Межоконные ссылки и prototype chains действительно могут удерживать удалённое
окно: [разбор Chrome](https://web.dev/articles/detached-window-memory-leaks).
Исходящие native ошибки также превращаются в текст. Каждый auxiliary channel
закрывается даже при ошибке соседнего. Dispose немедленно отклоняет pending
parent start; позднее завершение SDK start повторно вызывает публичный stop.
Проверено на v2:15 focused tests (включая architecture), focused strict TypeScript
для пяти runtime-модулей PASS. Проверены все используемые аргументы native
clock/control API, copied events/ranges, primitive errors, close-before-load,
late success/failure и независимая очистка каналов. Это synthetic lifecycle
coverage, **не heap/footprint acceptance**.
На завершении предыдущего прохода production build, полный frontend suite/typecheck и новый browser A/B **не
запускались**: после закрытия тестовой вкладки pressure устойчиво2, swap
1941.75→1917.75MiB не растёт. Temporary viewer закрыт, других наших временных
тяжёлых процессов нет. По `mission-core-product-ui` heavy QA приостановлена;
чужие приложения, Little Snitch и Docker VM не менялись. На8000 остаётся
предыдущая рабочая сборка, backend89747 healthy. Следующий шаг после pressure1:
полный typecheck/tests/build последовательно, затем real normal/expanded/Escape
и bounded memory check v2 с повторным открытием, без forced GC/reload.
При повторном FAIL нельзя объявлять boundary исправлением всей memory issue.
Ops instructions read завершился60s timeout; карточка не изменялась.
## Возврат к основному сценарию: v2 собран, browser gate отдельно открыт
По прямому решению владельца продолжены необходимые последовательные проверки,
без повторного расследования всей памяти Mac. Обнаружен и исправлен ещё один
эффект iframe: canvas pointer events не доходили до родительского слежения за
native divider, поэтому внешние controls теряли выравнивание при его перетаскивании.
Новый relay передаёт только primitive input fields, переводит координаты через
iframe bounds, сохраняет Escape и удаляет все5 listeners при dispose. Native
canvas/gestures остаются у upstream; качество, слои и clock не меняются.
Итоговые проверки:16 focused tests,740 frontend tests,64 focused backend tests,
полный typecheck и production build PASS. Build15.14с, maximum RSS1994817536 bytes
(около1.86GiB), а не требование выделить приложению столько памяти постоянно.
Новая сборка `app-DHVEqf5x.js` обслуживается на8000 без нового backend restart.
На первом проходе каталога renderer136MB, shared GPU408MB, Core164MB.
**Browser acceptance v2 не выполнена.** Auto-review отдельно запретил тяжёлое
открытие готового M49 при pressure2 с учётом прежнего остаточного footprint.
Запрошено явное разрешение на один короткий просмотр; действие не обходилось,
проверочная вкладка закрыта. Сохранённый viewer/Worker inference не запускались.
Все сборки/тесты завершились без OOM, однако swap в интервале финальной сборки
вырос1934.69→2173.69MiB. Нельзя заявить отсутствие memory pressure или приписать
этот системный прирост исключительно Core. Никакие чужие сервисы не остановлены.
Memory issue остаётся открытым критерием завершения операции. Он не блокирует
оставшиеся input/catalog/contracts этапа2 и не заменяет основной маршрут:
полный расчёт → сохранённый просмотр → матрица профилей/записей → recovery.
Проверки и план: [mainline reconciliation](OBSERVATORY_MAINLINE_RECONCILIATION_2026-09-03.md).
## Evidence
Private numeric evidence: `.runtime/observatory-memory-9Xq3IH` (до правки),
`.runtime/observatory-memory-fix-V6D9D8` (текущая проверка и test/build logs).
Follow-up: `.runtime/observatory-memory-owner-UGy0AS` (VM maps, footprints,
focused v2 tests/types; старый browser build отмечен отдельно).
Текущий проход: `.runtime/observatory-mainline-FZrkOa`; hashes и версии сборки
зафиксированы в mainline reconciliation. Полный browser memory A/B v2 там отсутствует.
В Git нет содержимого записи, heap dump или скриншотов исходных данных.
Ops direct MCP не ответил на чтение instructions/projects; запись отчёта в
MISSIONCOR-72 не выполнена и не заявляется успешной. Локальные документы —
фактический handoff до восстановления Ops.
@@ -0,0 +1,245 @@
# Observatory modular handoff — 2026-09-03
## 1. Objective and architecture stage
Owner approved the transition from monolithic full-profile packaging to reusable
Docker modules and immutable compositions. This pass audits CURRENT, records
TARGET, retires inspected obsolete stopped instances and prepares a self-contained
Desktop handoff. It does not implement the new composer or build/install modules.
Decision: [ADR0051](../../docs/adr/0051-modular-observatory-profiles.md).
Sole current route: the new upper section of
[ExecPlan](../../docs/OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
Earlier routes and numerical evidence remain historical, not current commands.
Ops report created through direct Tasker MCP, MISSION CORE card73,
`a6e5bea8-5441-44ca-957f-1c117b1decf3`, at `2026-09-03T16:07:07.955933+00:00`.
Its12 titled blocks separate completed audit from open modular implementation;
the pre-existing saved-replay card72 was not overwritten.
## 2. Decision question and hypothesis
Can Observatory expose functional provider groups, reuse model/function images
across compositions, keep exact cached LABs and later move the selected composition
to a compatible CUDA onboard host? This is the accepted architecture direction,
not yet an experimentally qualified performance claim. Mac Mini is explicitly
not the onboard target. Avoid per-LAB micro-apps and one image containing every
possible model combination.
The UI uses one canonical Select per group. DDRNet and EoMT are alternatives in
new LABs, never two selected segmenters. A module may provide several capabilities
without being instantiated several times. Existing dual-segment LAB V1 remains
legacy compatibility. Exact completed compositions open their existing LAB;
there is no Calculate action or completion badge for that exact configuration.
## 3. Immutable evidence and bounds
Session: `observatory-modular-handoff-mHGULT`.
Repository: `NODEDC_MISSION_CORE_m5_observatory`, branch
`codex/m5-1-observatory`, HEAD `eff60e4`, with pre-existing dirty changes retained.
Private evidence directory:
`.runtime/observatory-modular-handoff-mHGULT` in that checkout; excluded from Git.
No recording contents, credentials, raw logs or model weights enter this report.
Read-only Worker inventory before: UTC `2026-09-03T15:45:51.756951+00:00`, local
monotonic start1116.805708333, duration2.502617583s. After: UTC
`2026-09-03T15:57:28.620618+00:00`, local monotonic start1815.378697625,
duration0.801071041s. Monotonic values are within the local process/host timeline,
not synchronized Worker timestamps.
| Artifact | SHA256 |
| --- | --- |
| worker-inventory-before.json | a7f91a21797c9814d441629b59b5eab1307b2976ac84ecde7c23daef8632ba20 |
| worker-inventory-after.json | 5463f35a3c2b5134d499c495d93d77f7350438038a7a3010031c6842d417fbef |
| stopped-container-archive.json | b1c6d3fbc6ae3fcd9a5a34d1d24616433f7f643e7c8233eab287e88747ed5188 |
| cleanup-receipt.json | 58af029462c7fc73472ab1aae29be89f90c1412b2d4e35bfe23e3fb37c6b1030 |
| Canonical ordered job identity tuples before/after | 69cae43ed43fac04d6443350e39a5f83f0e64f9c7ed58e6889f7320d2b80cc9f |
| Original Desktop document before handoff prepend/rename | 7a0e57ba24eb95c95cd86aae55e24ae933de73746ecb299ce9542f6e8d2228c9 |
## 4. Method, models, algorithms and identities
Read source package/runner, worker queue/transport/cache, publication/replay and
current Observatory selector; compare installed Docker declarations via the
current agent's Docker socket over the existing SSH target `mission-gpu`.
Inventory excludes Env/credentials. Inspect stopped writable-layer diffs and
mounts before deletion; archive logs privately, not in the normal Git tree.
Current installed LAB V1 package:
`1bc84be07634ff69ac7459a2c39dc8fc9bcee33f72985dfa2c6088bb78976d8e`.
Definition:`269d71a24b4e63cff54e01273f9d4b35fc6cdd72bc6fadec206169ae0777e6ac`.
Release:`667858623962cd6d9849a8985b803f59e429916b5c56f76a6fc6c80c0c54526b`.
| Fixed step | Pinned image SHA256 | GPU request | Memory limit |
| --- | --- | --- | --- |
| prepare | 5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373 | none | 4GiB |
| EoMT | adba3dc8c97b161ba261ec44fca9ebe1680f117d1bcb1481440172cc3331a174 | one | 24GiB |
| DDRNet | e6c986100613ec804f0e0076ca8695abf43ff88ef9d5d85f6857e0b41db74051 | one | 16GiB |
| assemble | 5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373 | none | 8GiB |
Memory limits in this table are container host-RAM limits, not GPU VRAM quotas.
The fixed sequence uses mounted code/model/runtime assets. EoMT and DDRNet have
different Python/Torch environments; retain their separation during packaging.
This is not yet an operator-editable graph or standalone module distribution.
M49 CPU TGS runs inside its specialized agent with source preparation and a
compiled executor subprocess. Preserve executor image
`f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3` and current assets.
Also retain full-graph engineering prototype image `986dbe712699…`
(`ndc-k1-perception-ddrnet39-rfdetr-tgs:stage1-joint-20260901`). Its presence is
not proof of a complete accepted recorded LAB or autonomy.
## 5. Worker/runtime topology and resource policy
Current agents, preserved and running before/after:
- `ndc-observatory-installed-lab-worker-agent`, ID
`be85dc40147af3eda18803b913598d733c31f07ff587ededae5a58a0ad76f21e`, image
`bfdc94ae51906cf327901951f3e03e5f9d3a2d9692593f93e16170235adbcf7a`.
- `ndc-observatory-m49-worker-agent`, ID
`d58b3872d2ab399ca3566ab88080da1000988eee22c9c7bab7ccc8f2eac63796`, image
`e545a47a7ea2318fe946d0c3170f9b53b9a640ba7b0b98f226ad73a2e50f7650`.
Latest stopped rollback pair retained:
`a01b0fc675a01f52f7312d669d8b0e4147ab175baeca6396ec58f7cea4bd6029` and
`c51bd8d95a85e218acc417d8972945656a31cb2dba8dc947842152a225f4d538`.
Control declaration directory:
`D:\NDC_MISSIONCORE\runtime\services\observatory-heartbeat-recovery-20260903-OjYeg7\release`.
Control agent image identities and compute-step image identities differ.
After cleanup:11 containers,7 running/4 stopped,80 images,9 named volumes.
Other five running services retained pending consumer/dependency retirement:
Gaussian gateway, Gaussian pipeline, Gaussian terrain executor, perception worker,
Triton. Gateway/pipeline health is unhealthy; perception has recurring restarts.
No causal connection to the new cleanup was inferred from those pre-existing states.
Frigate and Ollama remain stopped, restart=no, as previously requested.
One Worker006/RTX4090 retains exclusive composition ownership and sequential heavy
GPU execution. No model inference or performance benchmark was launched this pass.
The owner-approved EoMT disk floor is250GiB plus estimated working set, instead
of350GiB. Source/tests changed earlier and were checked again here; installed
EoMT remains350GiB until a new sealed image/release. No GPU/RAM limits or clocks
were changed in this pass.
## 6. Implementation and bounded cleanup
Two-phase archive then delete; recheck all exact IDs/names/images/stopped states,
restart policies and layer diffs. Check both current agents running, queue fully
terminal, no active lease or running LAB compute container. Deletions use exact
container IDs, `force=false`, `v=false`. No volume, image, bind directory or
recording deletion; no global prune. The archive-only first attempt failed on
unordered Docker diff results; sorting corrected the comparison before any delete.
Removed13 stopped instances:
| Short ID | Name |
| --- | --- |
| 05222a8e343e | ndc-observatory-installed-lab-worker-agent-pre-source-reuse-05222a8e343e |
| 51027773cd1b | ndc-observatory-m49-worker-agent-pre-source-reuse-51027773cd1b |
| 7a3e07929c82 | ndc-observatory-m49-worker-agent-pre-progress-7a3e07929c82 |
| a8e8353d4eb9 | ndc-observatory-installed-lab-worker-agent-pre-progress-a8e8353d4eb9 |
| 810212622de9 | ndc-observatory-installed-lab-worker-agent-v2-rollback-810212622de9 |
| b30b41d9e2e2 | ndc-observatory-m49-worker-agent-v2-rollback-b30b41d9e2e2 |
| 7921ab34712f | ndc-observatory-m49-worker-agent-legacy-v1-20260901 |
| 623c651a5a71 | ndc-observatory-m49-worker-agent-892d008-retired |
| 1898416f461e | ndc-mission-core-perception-worker-pre-e19-20260731 |
| b61e5b237f80 | mission-core-perception-worker-e21-debug3 |
| 432cae7d87f6 | mission-core-perception-worker-e21-debug2 |
| d928b067f9b9 | mission-core-perception-worker-e21-debug1 |
| b33b06d1061b | mission-core-perception-worker-e16-backup-20260723 |
Private archive contains2078504 log bytes plus redacted instance configuration.
It is not a rootfs/volume backup. Removed Docker IDs cannot be restored; services
can be recreated from retained images/declarations/data if necessary. Removed
writable-layer sizes sum1175552bytes, not tens of GB. No claimed physical disk
recovery was measured from sparse backing-store compaction.
Files changed by this architecture/document pass: ADR0051 added, ADR0050 amended,
current ExecPlan upper route replaced by modular route (old text archived), this
report added, Desktop status renamed with `_` and self-contained0.46 prepended.
Private audit/cleanup scripts are evidence tooling, not a new product runtime.
Pre-existing UI/runtime changes were neither reverted nor treated as newly built.
## 7. Validation and reproduced evidence
- Exact before/after container set difference is the13 approved stopped IDs;
no new containers appeared. Images and volume identity sets unchanged80/9.
- Installed release file SHA256 mappings unchanged; both agents running.
- Queue13 job tuples `(job_id,state,result_id,claim_generation)` unchanged:
11failed/2succeeded,0 live leases.
- Core `/api/health` operational=true at `2026-09-03T15:59:16.426016+00:00`;
verification local monotonic1923.95940075, duration0.026172833s.
PID2421 serves8000; no8765 Mission Core listener. Build `app-w6onjKPq.js`.
Recording cache20 entries/653266743bytes; artifact cache9 pinned objects.
- `pytest -q tests/test_observatory_portable_lab_v1_component_adapters.py -k
'disk or legacy_python39'`:7PASS. Ruff on component and its tests:PASS.
No complete frontend suite/build, heavy viewer QA or model benchmark this pass.
- The complete former Desktop body is preserved below the historical divider;
its SHA is recorded above. Existing architecture and before-full-profile
Desktop documents remain untouched.
- `git diff --check`:PASS. Desktop historical-body SHA reproduced exactly;
both preserved sibling-document SHAs unchanged; old unprefixed current filename
absent and new underscore-prefixed file present.
## 8. Results retained, not newly computed
M49×RAVNOVES004TREE published job:
`observatory-run-b230216709dc4c59bc56c98c7e329bf1`.
Result:`m49-tgs-portable-review-a09d2b4a07d103e4f3693ba746a51be2197214768774eabfded4f109ae80dce4`.
6830 camera anchors,6811 LiDAR,19 UNOBSERVED, cycle1416.763s, schedule39.215–757.160s
of808.779s source. This is CPU TGS, not a full ML perception profile or complete
capture coverage. Detailed saved camera/TGS visual and lifecycle acceptance is open.
Latest LAB V1×004TREE job
`observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` failed on lease loss at EoMT.
It did not complete DDRNet/publication. Reconciliation preserved failure history
after resource-release verification. Current heartbeat fixes are installed;
there is no newly proved successful full LAB V1 run. Disk-floor admission is a
separately discovered issue; its causality for the lease failure is not proved.
## 9. Limitations and rejected approaches
- New Module/Composition contracts, grouped UI and per-node result reuse remain
unimplemented. Source CAS is not node-result cache; current cold input barrier
remains. Existing fixed-stack support is useful but not complete modularity.
- 749frontend/166backend checks belong to the previous increment, not this design.
Historical replay overlays or cached FPS do not establish full-graph inference.
- Images, build caches and input/model caches were not pruned. Before deletion,
enumerate reachable pinned packages, prototype, models, current consumers and
rollback. Image5ad7… is still required by prepare/assemble although one old
agent instance using it was retired. Logical image sizes share layers.
- Active legacy/Gaussian/Triton retirement needs a separate dependency decision;
unhealthy does not itself authorize removing another consumer's data.
- No blanket cache deletion, extra agent per model, per-frame Docker startup,
GPU concurrency, MacMini onboard target, or hidden motor authority.
## 10. Decision
Adopt modular composition as TARGET; preserve the recorded-first product and
verified queue/source/publication/viewer foundation. Retain successful M49 and
legacy releases during migration. Current audit and bounded cleanup are complete;
the new four-stage architecture implementation and broad cleanup are not complete.
## 11. Next stage and forbidden authority
Next chat begins with current Desktop0.46, ADR0051, ExecPlan upper section and this
report; read `git status` and installed identities before changes. Start executable
composition/dependency/identity contracts and retention closure, then reusable
module releases/common runtime, grouped UI/end-to-end saved review, finally CUDA
onboard qualification. Do not rerun old installers with stale predecessor IDs.
No Synology/deploy-canon21, device mutation, autonomy, motor controls, Little
Snitch changes or Docker Desktop resource-limit changes. Core8000 is durable and
remains running. Existing worktree changes, raw records and historical evidence
are user-owned and preserved.
## 12. Acceptance checker
- [x] Accepted architecture distinguished from installed code.
- [x] Single-provider groups; no dual segmentation in new profiles; CUDA not MacMini.
- [x] Worker exact inventory,13-target archive/delete, identity/queue/health checks.
- [x] Desktop rename/context, ADR and current four-stage route prepared.
- [ ] Remaining cache/image/active-legacy retirement after retention closure.
- [ ] New executable composition/module contracts and module releases.
- [ ] Operator composer and exact node-cache implementation.
- [ ] Full saved visual/lifecycle and source×composition acceptance.
- [ ] CUDA onboard qualification and separate control/safety acceptance.
@@ -137,7 +137,7 @@ Sequential validation under `mission-core-product-ui`:
Private snapshots/logs and previous frontend: `.runtime/docker-vm-recovery-z1636o/`.
No raw recording content was collected into this diagnostic evidence.
## Remaining product acceptance
## Product acceptance remaining at initial cache activation
The cache increment is active, but the complete workflow is not accepted.
Browser QA reproduced the next-stage UI mismatch: archived entries and portable
@@ -151,3 +151,70 @@ 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.
## Selector correction and acceptance — 2026-09-03
The owner clarified that a finished calculation is represented only by its
profile-marked evidence below, never by a completion button/status. Implemented
in the shared Observatory surface using existing Design Guideline components:
- Only compatible, uncalculated portable definitions are offered. The verified
catalog cache decision remains authoritative; names, archived definitions,
failed or unpublished jobs cannot hide a current profile. No Calculate when
the selection is empty. During an accepted computation only an activity
indicator appears; no invented percentage. Before admission the button is
disabled. Failed publication retains recovery without new inference.
- Removed completion badges, the duplicate recorded-review badge and the
six-result presentation cap. All results in the loaded catalog are shown.
The existing catalog-window bound remains explicit; this is not complete
pagination for500 recordings.
- Queue reads filter source/setup/definition before LIMIT. UI snapshots are
fenced by the same selection key before React effects, so previous jobs and
request errors cannot flash against a new selection. Historical terminal logs
are not displayed as new operator failures; observed/current failures remain
actionable.
- Publication refreshes both catalogs; projection deletion refreshes selection.
Browser QA found selector compression in a restored narrow window; moved the
existing responsive layout from viewport width to workspace container width.
No new component, per-LAB page, model configuration or Worker deployment.
Sequential checks: architecture4/4, full typecheck, frontend720/720,
focused queue/API52/52, Ruff and mypy PASS. A synthetic test proves definition
filtering happens before pagination; API accepts a valid digest and rejects a
malformed one with422. Tests cover zero/all/partly cached choices, old versions,
incompatible/archived entries, missing projections and stale first-render data.
Final production build14.56s, peak RSS2108293120bytes; unchanged large-chunk
warnings. Pressure remained1 and swap fell2666.19→2618.19MiB. No concurrent heavy
tests/builds/browser runs; Docker limits/containers and operator apps untouched.
Active canonical8000: backend PID69976, health200/reconciler ready, frontend
`/assets/index-BHcT8phv.js`, CSS `/assets/index-pPpyKjkx.css`,
index SHA256 `97da43251ef0d0635eb078ee1a102fa820f1716bb9ec5879dffb284aa3628ea6`.
No alternate backend8765/preview4173. The existing LaunchAgent restarted only
the canonical backend for the API change; final frontend-only corrections did
not restart it again. Original frontend backup:
`.runtime/observatory-selector-fQUwPT/frontend-dist-previous`.
For rollback, restore that build and the matching pre-change code on the same
8000 endpoint. Do not replace or truncate the queue DB.
In-app browser acceptance:
- 004TREE offers exactly the two current portable profiles, not four mixed
archive/current entries. Both select correctly; Calculate becomes available
after preflight. Completion labels and historical error alerts are absent.
- 01 has an empty disabled selector and no Calculate because its capture
attestation is missing. This is incompatibility, not a fake cache hit.
- 00 renders all14 loaded evidence entries. 004TREE retains both old entries
and its existing admitted LAB V1 review action; no archive projection was
deleted or falsely promoted into a current verified result.
- Refresh, dropdown Escape, normal/expanded windows and readable selector text
verified. Final browser console warnings/errors0; completion labels0,
alerts0. No Calculate, publication retry, delete, rename or replay was invoked.
All six queue-table row hashes are identical before/after this correction and
browser checks:11 jobs,50000 legacy receipts,1 reconciliation,0 live leases,
v3 grants and preemptions. No inference or publication jobs were created.
Current real data still contains no exact successful/published cache hit;
all-cached/partial-cache behavior is synthetic-test evidence, not a new completed
LAB run. The remaining stage2 work is measured progress, full sequential
M4.9T5/LAB V1 calculation, publication, and cached viewing after restart.
@@ -0,0 +1,194 @@
# Recorded Observatory — attempt-bound progress and first real run
Date: 2026-09-03. Progress: `bee8552`; control layers: `54c8d82`;
unchanged-source reindex fix: `bd947b4`.
This is an observation/control-plane increment of stage 2, not onboard or
full recorded-analysis acceptance. No Synology release, physical acquisition,
actuation, model replacement or GPU benchmark is involved.
## Implemented and activated
- Worker counters are sampled independently of execution and sent through the
authenticated Worker gateway. Claim owner, generation, sequence and monotonic
counters are checked transactionally. Observation neither renews a lease nor
changes job identity, terminal state, artifact integrity or publication.
- One bounded snapshot per job is stored in a three-column SQLite table;
snapshots are at most 2048 bytes. Existing job JSON and six previous tables
are unchanged. The existing 128 MiB queue quota is not raised.
- A source/profile/definition/attempt-bound read endpoint supplies the existing
Observatory activity indicator and visible domain copy. No new per-LAB app,
generic visual control, completion badge or completion button was introduced.
- Phases: input delivery, input preparation, computation, result assembly and
output delivery; publication uses the existing queue state. M49 computation
counts complete ordered rows from the real TGS timing output. LAB V1 exposes
completed package operations, not invented per-frame inference counts.
- The two existing control agents receive an eight-file offline child layer.
Compute package declarations, compiled TGS runner, models, profile definition,
GPU allocation and resource limits are preserved. Previous agents remain
stopped with `restart=no`; no old and new owner are intentionally run together.
## Validation before the real run
- Frontend: architecture tests, typecheck, **726 unit tests**, production build.
Tests and build ran sequentially; memory pressure remained level 1 and swap
was approximately 2594 MiB. The existing large-chunk Vite warning remains.
- Backend/control tests: **128 focused tests** including payload admission,
owner/generation fencing, duplicate/regressive snapshots, sender connection
failure, queue persistence, HTTP authentication, old/new transport compatibility,
actual subprocess invocation and timeout child cleanup. A separate **40 tests**
cover portable runtime, M49 entrypoint/queue/LAB V1 wiring and minimal imports.
Ruff and focused mypy pass. The existing TestClient deprecation warning remains.
- Exact offline installer: **3 tests** covering file/hash inventory and declaration
fences. These are separate from the 128 + 40 backend checks above.
- Canonical8000 was replaced only while the queue was idle. Previous UI output
and a SQLite backup were retained. All rows in the six pre-existing tables
matched their pre-update hashes; the new progress table was empty.
- Browser: actual source/profile selected, normal and expanded window checked,
dropdown Escape checked, no Calculate action while that profile runs. Reload
and reopening/selecting M49 recover the same job and generation, not a new job.
The workspace maximize action itself is not an Escape-to-restore contract.
## Exact control-agent installation
Plan SHA-256:
`108897767de320562b4f75297e1c49d0ae5c44770f9c820d92d127f0fdad9216`.
Installer SHA-256:
`13edd1dd4e2579de788721de5ff397f57a5e1af0e4c59593c4af5a29f1fc2a77`.
Started `2026-09-03T08:06:19.107635Z`, finished
`2026-09-03T08:06:48.126401Z`. Receipt retains UTC and monotonic timestamps.
| Control agent | New immutable image | Retained predecessor |
|---|---|---|
| `ndc-observatory-m49-worker-agent` | `927c3c4f5b00ae6c084d1f5a8bc77f7b262cfc5e83cf4be1c48f615c12c06e80` | `ndc-observatory-m49-worker-agent-pre-progress-7a3e07929c82` |
| `ndc-observatory-installed-lab-worker-agent` | `052af3ccd10e11b162c163943f5427af2dc95b09b93481dd85e954e494ba1107` | `ndc-observatory-installed-lab-worker-agent-pre-progress-a8e8353d4eb9` |
Durable release:
`D:\NDC_MISSIONCORE\runtime\services\observatory-progress-20260903-DbxaPA\release`.
Its exact `*-declaration.json` files supersede the previous claim-v3 control-agent
declarations. Package manifests remain the compute source of truth. The installer
rechecks the exact container and complete Config/HostConfig hash immediately
before cutover. The loaded Python file hashes and running state are verified
after replacement. Only temporary offline layer/helper containers are removed.
Rollback is explicit, idle-only and per agent: prove no open recorded/live work,
retain current declarations/evidence, stop the exact replacement, then restore
the corresponding predecessor's name and previous restart policy. Do not start
predecessors alongside replacements, use the old package launcher to overwrite
the control layer, or restore an old SQLite backup over newer work. No reboot
was performed; installed restart declarations, not reboot recovery, were checked.
## Real run — succeeded and published
One ordinary UI Calculate action submitted:
- Source: RAVNOVES004TREE / `20260828T130511Z_viewer_live`, source duration 13:29.
- Setup: `m49-tgs-portable-v2`, definition
`f56d6321bd794ccdfb7d2e3b05d044b11f616ffb81ee29517386cc253046d4eb`.
- Job: `observatory-run-b230216709dc4c59bc56c98c7e329bf1`, generation 1.
- Input inventory: 6837 members. Delivery completed after approximately 298 s.
Initial LiDAR decode and source-stage construction completed before TGS; this
remains a whole-input executor, not the target incremental runtime.
- Submitted `2026-09-03T08:08:03.013Z`; published on the first attempt at
`2026-09-03T08:31:39.776Z`: **1416.763 s / 23 min 36.763 s** end to end.
- Result: `m49-tgs-portable-review-a09d2b4a07d103e4f3693ba746a51be2197214768774eabfded4f109ae80dce4`.
Package SHA-256: `52e8afd6aaa38b615008463b873b94feae160946a69ce67aba12efc5f12b1589`.
Central manifest: `0297a3502e7510e3d126ddf33e1afc2ef495638255351cd37a6dbed5b42ab832`.
- All **10 central artifacts**, including the package manifest, independently
passed byte-length and streaming SHA-256 verification on the Mac. The package
contains 9 result members plus that manifest; sampled transfer progress ending
at 8/9 is not the publication/completeness authority.
- **6830 ordered schedule rows**, **2244 costmap cells** per row. There are
6811 available LiDAR associations and 19 missing ones; the corresponding
costmap states are all UNOBSERVED and their height bounds remain NaN.
- Point accounting: 149733073 eligible = 36777326 ground + 112748049 nonground +
207698 rejected; **0 unaccounted**. Counts cover rolling-window contributions,
not unique physical points across the entire route. Rejected is not free space.
- Source-session duration is 808.779495667 s. This profile schedules one anchor
per admitted camera fragment: first 39.215263458 s, last 757.160263458 s;
the camera epoch ends at 757.260263458 s. Its 717.945 s anchor span is not
proof of coverage of every second of the 13:29 source or every decoded image.
- Exact runner trace: TGS-only p50/p95 **1.204 / 2.6075 ms**, stage-wall p50/p95
**17.676102 / 29.991720 ms**. Sums are 9.122023 s TGS and 131.038739079 s
stage wall. These exclude source preparation, result assembly and transfer.
JSON `effective_fps=9.51187` is schedule cadence, not processing/onboard FPS.
- Final queue: 10 retained failures, one retained historical success/not-required,
one new success/published, one v3 claim grant and one progress row. All 11 old
jobs, the 50000 legacy claim receipts and previous reconciliation remain intact.
No second profile, LAB V1, ML/GPU model or onboard execution was started.
An early diagnosis based on a stationary 556912640-byte temporary camera archive
did **not** prove a network stall. That equals the sealed archive's complete
length; the job subsequently advanced normally. Do not report it as a proven
transport outage or benchmark.
## Restart found and fixed a real cache defect
The first idle-only canonical restart recovered health in 20.985 s and preserved
all 12 job rows, but **failed cache acceptance**. The result document still opened;
the profile reappeared in the calculation selector. Archive reconciliation
unconditionally replaced `observation_sessions.updated_at_utc`; that field is
part of the exact source catalog hash, so unchanged data looked like a new source.
`bd947b4` keeps the existing fingerprint algorithm and all source/artifact fields.
Inside the candidate transaction it compares the complete before/after snapshot
with the previous update clock. If everything else is identical, it rolls back
the no-op reindex. Real session/source/artifact differences still commit with
the new timestamp. This does not introduce matching by LAB name or weaken source
or result integrity.
The already-published job needed one bounded metadata repair: changing only its
source row's clock from `2026-09-03T08:36:25.809Z` back to
`2026-09-03T08:06:12.724Z` reconstructs **exactly** the admitted full-catalog hash
`2db6b8f109f5cc31bb32733ca633e03becca83d1f140a68ecc9ec9522010c24e`.
The old clock was recovered by a bounded, read-only millisecond candidate check
in this turn's known startup/admission interval; no other field was varied.
A SQLite backup was retained. The transaction rechecked the current hash, then
the reconstructed admitted hash, and changed only that one field in one row.
Raw evidence, package/provenance and job receipts were not rewritten or recomputed.
Do not generalize this repair to another source without the same exact hash proof.
Validation: **89 session-store/publication tests** (including 7 new regressions),
**72 admission/LAB-cache/queue/API tests**, Ruff and focused mypy pass. A new store,
reconciliation with a later clock and cold result cache preserve reuse; actual
session/source/artifact changes still invalidate it. No frontend rebuild was
needed for this backend-only repair; the previously verified progress UI remains.
After activation/restart, canonical8000 recovered in 20.650 s with PID77997.
Catalog, complete job projection and result view equal their pre-restart values.
Browser Refresh/reopen keeps M49 below, only LAB V1 remains in the selector,
and the exact result document opens with no new job. The 12 job rows remain
unchanged. This proves saved **document/cache** persistence, not visual replay.
The first failed acceptance is retained in the evidence, not overwritten as PASS.
## Remaining boundaries — not hidden by the progress feature
1. This does not introduce `recorded-analysis` into the retained live runtime.
No live freshness, latest-wins, ownership or recovery rule is weakened.
2. Existing M49 still delivers the whole admitted input before TGS. The current
LiDAR builder performs full capture passes **before** its replay-pack cache
lookup (`compute/lidar_replay.py`, `build_lidar_replay_pack_v2`). Eliminating
this barrier needs its own versioned input/runtime change and equivalence proof.
3. Camera archive download/verification and initial LiDAR decode have no internal
byte/message counter yet. File/frame counts may stay unchanged within those
operations. Unknown totals remain unknown; no made-up percentage or ETA.
4. Phase snapshots are not a complete performance trace. Warmup/stage timings,
throughput, all required-frame accounting and onboard estimates need separate
evidence. These UI counts are not FPS or real-time qualification.
5. **Stage 2B is not closed.** Publication and cached document reopening after
restart are proved, but `portable-result-review` currently renders JSON and
the artifact inventory, not synchronized camera/TGS playback. The existing
title “полный маршрут и воспроизведение” is not an accepted capability proof.
Add a schema-bound, bounded saved-data adapter to the common replay pattern;
admit only this result's arrays and the exact source camera timeline. Do not
reuse the legacy 4489-frame M49/E47 overlays or invent semantic/detector layers.
Reuse this sealed result to validate the viewer without another inference run.
6. LAB V1 and the other recording follow sequentially after the first visual
cycle. Full time coverage/source synchronization must be qualified separately
from counts on the current fragment schedule.
Private evidence, not Git: `.runtime/observatory-progress-DbxaPA/` contains the
SQLite backup and row hashes, payload/plan/receipt/declarations, build/test logs,
submitted/completed jobs, result/artifact checks, restart failure, exact clock
repair proof/backup and cache/history acceptance, plus bounded read-only progress
observations. Model assets and
recorded camera/LiDAR payloads are not copied into this report.
@@ -0,0 +1,160 @@
# Observatory: сохранённые маски, восстановление и реальный LAB V1 canary
Session: `observatory-full-pass-OjYeg7`, 2026-09-03. Поверх `eff60e4`;
предыдущие незакоммиченные изменения сохранены. Этап2 **не закрыт**.
## Цель и архитектурный этап
Один крупный проход по сохранённому просмотру и recovery2D, с реальным запуском
LAB V1 × RAVNOVES004TREE из2C. Не новый LAB-микроинтерфейс, не remote realtime
qualification, не перенос на борт. Legacy остаётся отдельным архивом.
## Вопрос и гипотеза
Можно ли получить опубликованный LAB V1, смотреть его маски в общем viewer и
пережить краткую потерю связи/подтверждения без повторного вычисления? Контрактные
проверки выполнены, но реальный canary до публикации не дошёл. Не переносить
локальный PASS тестов на качество или работоспособность всего профиля.
## Исходные доказательства
Обычная кнопка «Рассчитать» создала одну job
`observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` в
`2026-09-03T13:40:35.163Z`, generation1. Источник:
`20260828T130511Z_viewer_live`, RAVNOVES004TREE. Definition
`269d71a24b4e63cff54e01273f9d4b35fc6cdd72bc6fadec206169ae0777e6ac`.
Source bundle: `2412ee2374590a3b0bca8849a2410188325397b1651b7ae2916c48ead664c417`.
Состав: последовательные prepare → EoMT → DDRNet → assemble; не полный граф рига.
## Метод и идентичность
Новый `portable_semantic_replay.py` принимает только sealed
`missioncore.recorded-eomt-ddrnet-review/v2` и связывает source/bundle/clock,
component documents, число кадров, SHA/размеры архивов и taxonomy. EoMT target
labels принадлежат точному preprocessing profile
`ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875`, не сырому
порядку Cityscapes. DDRNet использует64 класса опубликованной taxonomy.
Маски800×600 читаются по одной: EoMT tar/gzip streaming, DDRNet ZIP. Имена,
дубликаты, неполнота, raster/class bounds и синхронизация проверяются. PNG остаются
lossless; ни разрешение, ни cadence ради приёмки не изменены. Производитель и
архивные LAB adapters не переписаны. Отдельная renderer identity сохраняет старый
M49 derivative cache. Источник и результат объединяются общим native Rerun
adapter; повторный просмотр идёт с Core, не с Worker, без inference.
## Worker/runtime и ресурсы
Один активный профиль на4090. При установке control layer очередь idle,
11 failed/2 succeeded,0 live leases; два успешных результата не изменялись.
Модели, package/config digests, limits и source-cache mounts сохранены.
Новая установка control-agent layer завершена `2026-09-03T14:23:22.507723Z`.
Plan: `8b16f20e4b7efa14a70344120d063e298e2eda1fd95288904c636806038cab41`.
| Агент | Новый image SHA-256 | Сохранённый predecessor |
|---|---|---|
| M49 | `e545a47a7ea2318fe946d0c3170f9b53b9a640ba7b0b98f226ad73a2e50f7650` | `ndc-observatory-m49-worker-agent-pre-heartbeat-c51bd8d95a85` |
| Installed LAB | `bfdc94ae51906cf327901951f3e03e5f9d3a2d9692593f93e16170235adbcf7a` | `ndc-observatory-installed-lab-worker-agent-pre-heartbeat-a01b0fc675a0` |
Durable declarations и rollback:
`D:\NDC_MISSIONCORE\runtime\services\observatory-heartbeat-recovery-20260903-OjYeg7\release`.
Predecessors stopped/restart=no. Откат только после проверки отсутствия новых
jobs/live owners; никогда не запускать старого и нового владельца одновременно.
Установка — два проверенных Python-файла поверх exact parent image, без скачиваний
или compute package rebuild. Временные offline helpers удалены, данные сохранены.
## Реализация recovery
- Outbox раньше ограничивал первые строки до проверки backoff/max attempts.
Несколько старых failed rows могли навсегда скрыть поздний готовый результат.
Теперь keyset pages по32, курсор по immutable creation/job identity, limit
применяется к реальным попыткам публикации. История не удаляется.
- Worker heartbeat теперь повторяет только типизированные transport failures и
HTTP408/429/500/502/503/504, с тем же generation/token/sequence. HTTP401/403/409,
неверный JSON и изменённая identity не становятся временным успехом.
I/O heartbeat ограничен5s; повторы укладываются в монотонный бюджет последней
аренды. Потерянный ACK не продлевает локальный срок сам по себе.
- При истечении бюджета, позднем ACK или завершении работы во время
неопределённого подтверждения результат не объявляется успешным. Новый владелец
не запускается вместо старого. Бесконечный сетевой outage пока не превращён в
полноценную pause/resume модель — это отдельный незакрытый recovery case.
- Добавлены безопасные heartbeat retry/recovered/lost и executor-failure logs:
job/generation/sequence и классы ошибок, без exception text/credentials.
## Проверки
- **749 frontend tests**, полный typecheck, production build PASS.
- Финальный единый backend проход: **166 tests,0 failures/errors/skips**,
XML в evidence. Outbox/publisher/SQLite recovery, replay/API/masks, heartbeat,
транспорт и installer. Остальные ранние проходы пересекаются с этим набором.
- Ruff, mypy7 изменённых backend modules, `git diff --check` PASS.
- Lost ACK и краткий transport failure: тот же heartbeat sequence, один start,
один результат, без повторного executor. Publication interruption до publish или
после publish до ACK: повторная публикация того же package, не новая job.
- Build12.68s; maximum RSS2071019520 bytes. Тяжёлые локальные проверки шли
последовательно. Docker limits/Little Snitch/посторонние приложения не менялись.
- Core healthy на8000, новый `app-w6onjKPq.js`;8765 пуст. Предыдущий dist сохранён.
Browser: fresh reload, обычное/развёрнутое окно, Refresh, profile selection и
Escape. M49 снизу, LAB V1 в выборе, кнопка только «Рассчитать»; Legacy не вернулся.
**Тяжёлый replay не открывался**: ранее отклонённый browser action не обходили,
отдельное разрешение запрошено. Это не visual/lifecycle PASS.
## Реальный результат и выявленные ограничения
Input transfer завершился примерно за388s; последнее наблюдение — steps1/4,
EoMT ещё не завершён, elapsed1318.28s. В `14:02:39.666Z` job перешла в
`reconciliation-required / claim-lease-expired`. Новый LAB V1 не опубликован.
Последний heartbeat Core принял, после чего renew прекратились; progress некоторое
время продолжал поступать. Точный transport exception прежний агент не сохранил.
Нельзя объявлять доказанной ни GPU-перегрузку, ни конкретную причину разрыва.
Прежний runner удалил временный attempt и одноразовый EoMT container; отдельная
executor error была замаскирована heartbeat-lost. Подтверждены отсутствие exact
job containers/attempt children и idle agent. Через штатный `reconcile_failed`,
без ручного SQL update, job переведена в failed; исходная причина и generation
сохранены в immutable receipt:
`a76131ce20f5def2fe13101d86b93b70911585784dfa839557e82288573cf821`.
SQLite backup и proof сохранены до изменения. Повторный расчёт не запускался.
**Независимо подтверждён disk admission blocker.** В реально установленном EoMT
image `adba3dc8c97b161ba261ec44fca9ebe1680f117d1bcb1481440172cc3331a174`
действует `350 * 1024**3` bytes floor плюс
`frame_count * 800 * 600 * 7 + input_byte_length`. Source-файл image и репозитория
совпал: `938bbb4d98802e470b2baee0fa3ec3baaa3781199415ec0fa116aaaf8f235661`.
После cleanup свободно397474037760 bytes. Для6830 кадров нужно минимум
398758438400 bytes **ещё без входного видео**. Повтор при этих условиях не пройдёт.
Это проверка текущего условия, не восстановленный stderr предыдущего контейнера.
Floor в350GiB не означает, что модели реально нужно столько рабочего места.
## Решение
Сохранённый semantic adapter и outbox fix установлены на Core, recovery layer —
на Worker. Канонический viewer переиспользован по product-ui skill; отдельного
микроприложения и новых controls нет. Этап2 не закрываем. Существующие partial
full-input подготовка, cache и source identities сохранены;2A не подменяется
этой работой. Физическое управление не включено.
## Непосредственно дальше
Перед повтором LAB V1: пересмотреть disk policy в **новой sealed версии**,
сохранив расчёт рабочего набора и разумный запас; перенести её дешёвую проверку
до долгой проверки asset trees/подготовки. Не менять старый image/config под тем
же digest и не чистить пользовательские данные ради искусственного350GiB floor.
Сохранять bounded failure diagnostics до cleanup. Затем LAB V1 ×004TREE и оба
профиля ×RAVNOVES00 последовательно. Параллельно по смыслу плана остаются2A
incremental cold input и2B saved visual/open-close; локальные тяжёлые процессы
по-прежнему выполняются только последовательно. Standalone3 и будущий борт4 отдельно.
## Acceptance checker и evidence
Закрыто: outbox starvation regression, bounded transient heartbeat protocol,
локальная установка/rollback identities, shared semantic replay contracts.
Не закрыто: реальный успешный LAB V1,2×2 matrix, полная visual/lifecycle
приёмка, long outage recovery и самостоятельный полный Docker.
Private evidence:
`.runtime/observatory-full-pass-OjYeg7` — test logs/XML, old dist, screenshot,
job/reconciliation documents, SQLite backup, installed EoMT policy и agent
plan/receipt с UTC/monotonic. Raw evidence не добавляется в Git.
Direct Ops `tasker_get_agent_instructions` завершился60s timeout; карточка не
изменялась, отчёт не объявляется опубликованным в Ops.
@@ -0,0 +1,138 @@
# Observatory — saved camera/TGS replay on Core
Date: 2026-09-03. Core packaging: `2797985`; shared viewer: `9db29bc`.
Stage 2B, saved-data projection increment; full visual
acceptance remains open. No new Worker computation, model change, acquisition,
Synology release or onboard/actuation work.
## Source and result
- Session: `20260828T130511Z_viewer_live`, RAVNOVES004TREE.
- Existing job: `observatory-run-b230216709dc4c59bc56c98c7e329bf1`, generation 1.
- Result: `m49-tgs-portable-review-a09d2b4a07d103e4f3693ba746a51be2197214768774eabfded4f109ae80dce4`.
- Package: `52e8afd6aaa38b615008463b873b94feae160946a69ce67aba12efc5f12b1589`.
- Base RRD: `a65196a55fc1a59d391329777608f512a3930b1287f38971da91b9bf379e1468`.
- New merged replay: `012e9c2bdc20a06c5e2c748b8e3d149230fd1a3cf13ac15570c39a3555573a87`,
**193,622,178 bytes**. Original recording and sealed result are unchanged.
The current profile is CPU TRAVEL TGS, not the planned full
DDRNet/RF-DETR/distance/motion/policy graph. No historical E47 or LAB overlay is
substituted for an absent layer.
## Implementation
`portable_tgs_replay.py` verifies artifact digests, source-stage identity,
frame/source clock equality, shapes, dtypes, state codes and measured z bounds.
Each map-gravity-local costmap is translated by its exact selected source pose;
no rotation or estimated pose is introduced. The base RRD remains sole owner
of points, pose and trajectory. Native Boxes3D carry TGS; missing/unobserved
cells have no invented physical volume. Per-state clears and an epoch-end clear
prevent stale obstacles surviving missing evidence or a seek.
`portable_replay.py` packages native AssetVideo/VideoFrameReference and TGS into
one source-clock RRD through the existing canonical merger. Video uses source
PTS, H.264, two encoder threads and a separately sealed proxy. Packaging is
serialized on Core; it has no Worker/model calls. Bounds include 768 MiB camera
input, 192 MiB video proxy, 1 GiB served RRD and a 3 GiB free-space start gate.
These are current admission bounds, not support for arbitrary recording sizes.
The same-origin API binds result ID, base generation and replay generation.
HEAD prepares a missing derivative; GET never prepares or computes. GET requires
the exact generation and supports Range; stale generations return 412.
Warm cache admission verifies publication, metadata, size and SHA, memoizing
the file identity at nanosecond precision in a bounded 32-entry map.
The common `CanonicalResultRerunReplay` is extracted from the existing LAB V1
viewer. Both thin profile adapters use one unmodified Rerun 0.36.3 viewer,
one native clock and existing Design Guideline controls. Source points use
zero accumulation; Local SLAM uses five seconds. No second media/Three.js player
or profile-specific page pattern is introduced. Existing selector semantics
remain: calculated current M49 below; uncalculated current LAB V1 in the selector.
## Video timing evidence
The first proxy exposed an encoder time-base defect: passthrough alone rounded
timestamps to 100 ms and produced **526 duplicate PTS**. It was rejected, not
admitted by weakening the check. The corrected proxy explicitly uses the demuxer
time base (`-enc_time_base -1`) together with `-fps_mode passthrough`.
Real source: 6,830 video packets; proxy: **6,748 decoded frames**, zero duplicate
or backwards PTS, maximum distance to the corresponding source packet PTS
**222 ns**. The last relative PTS is 717.945 s. This is not a claim that every
fragment produced a decodable frame. No synthetic fixed-rate renumbering occurs.
TGS has 6,830 anchors × 2,244 cells: 6,811 available LiDAR associations and
19 missing associations represented as UNOBSERVED. Anchors span
39.215263458–757.160263458 s; the camera epoch ends at 757.260263458 s.
The base recording spans 0–808.779495667 s. Outside the epoch the derived camera
and TGS are cleared; missing evidence does not imply free space.
## Validation and measurements
- **114 backend tests**: portable arrays/integrity/cache/API/video-timebase,
shared legacy merger, session API, publication and RRD blueprint tests.
- **728 frontend tests**, architecture checks, typecheck and production build.
Existing Vite chunk-size and TestClient deprecation warnings remain.
- Ruff on changed replay modules/tests and focused mypy on the three new
backend modules pass. `git diff --check` passes.
- Successful cold packaging HEAD: **116.950 s**; warm first SHA check:
**0.292 s**; warm HEAD after canonical restart/reopen: **0.046 s**.
Header time is not browser-ready time or inference latency. The browser
still loads/decodes a 194 MB RRD; opening is not instantaneous.
- Exact-generation Range request returns 206 and `RRF2`. Wrong generation,
missing preparation and same-size cache corruption have regression tests.
- Browser: saved M49 opened after canonical restarts, play/pause, forward and
backward seek (39 s, 77 s, 406.630 s, end and before coverage), normal/expanded
composition, Escape, Source/Local SLAM/TGS, camera modes and 3D/PLAN checked.
Unavailable semantic controls stay disabled. Coverage-end camera/TGS clearing
is visually confirmed. No Calculate action was used during this increment.
- Queue readback retains the same current M49 job, created 08:08:03.013Z;
no new M49 run was created by opening the viewer.
- Heavy local operations ran sequentially. Transient memory pressure 2 after
full frontend work recovered to 1 before the next heavy stage; no host load
test or Docker capacity increase was performed. Only three durable telemetry
containers remain; no model container was started by this work.
Private command evidence is in `.runtime/observatory-replay-Vt0m2o/`; raw
recordings, images, caches and logs stay out of Git.
Ops report **MISSIONCOR-72**, “Observatory — сохранённый M49 replay с Core”,
issue `4d8079b2-6a3e-4822-9e12-dcd4a4f20017`, was created through direct MCP.
An earlier transport-failed attempt was followed by a successful empty search
before creation was retried; no duplicate was found.
## Open visual boundary — do not mark full 2B complete
Rerun 0.36.3 activates a **clone** of the SDK blueprint. Appending to the SDK
source without activation does not update the visible layer. Reactivation
updates layers but discards the active clone's manually edited eye. A stable
source-store ID does **not** prove operator-camera preservation.
This is confirmed by browser orbit/toggle QA and
[upstream activation implementation](https://github.com/rerun-io/rerun/blob/0.36.3/crates/viewer/re_viewer_context/src/store_hub.rs#L910).
Portable replay explicitly opts into reactivation. Legacy/Data/live behavior
is not changed by this opt-in. Distinct native 3D and plan presets fix the
plan-to-3D return. Initial following-eye activation waits until source-clock
admission; initial/reset framing remains native, not a fixed world-origin eye.
QA also isolated initial latest-at boundary admission: at the exact first
timestamp derived layers were absent, while a small forward seek revealed them.
The portable initial cursor is placed 1 microsecond inside the first sample;
logged camera/TGS/source timestamps remain unchanged. This is not a per-frame
offset or evidence interpolation.
The final browser recheck of this one-microsecond initial-cursor change is
pending: after the final successful build macOS memory pressure stayed at 2
with all temporary test/build/viewer processes already stopped. The local
resource gate prevents another heavy replay until pressure returns to normal.
**Changing a portable layer still resets a manually changed
viewpoint to the preset.** Preserving that eye needs a supported native mechanism
or an explicitly reviewed dependency decision. No WASM patch/private API/fork,
DOM emulation of viewer controls or second camera engine is added.
Other remaining gates: detailed visual quality/coverage acceptance, versioned
incremental recorded-analysis input (2A), LAB V1 and second source (2C), bounded
disk eviction/recovery/catalog at scale (2D). The new derivative cache currently
has per-artifact bounds/free-space admission but no total LRU quota or health
aggregation. Packaging is still lazy on first open, not publication-time.
The old LAB V1 camera-proxy timing path is not repaired by this new adapter.
Replay success is neither realtime full-graph qualification nor traversability,
navigation, motor or safety authority.
@@ -0,0 +1,129 @@
# Observatory: повторное использование входов на Worker
Session: `observatory-source-reuse-AsldTM`, 2026-09-03.
Установлено 12:53:45.460616–12:54:11.038263 UTC; monotonic
25614923660636–25640502446818 ns (Worker). Изменения поверх `eff60e4`.
## Решение и результат
Продолжен основной этап2A без повторной диагностики общей памяти Mac. Убраны
два вида повторной работы: скачивание уже закэшированных source members в новую
job/generation и декодирование исходной LiDAR-записи перед обнаружением готового
v2 input pack. Изменение установлено в оба существующих агента Worker006.
Это **кэш исходников для вычисления на Worker**, не перенос пользовательского
кэша результатов: сохранённые визуальные результаты по-прежнему выдаёт Core.
Холодная передача/подготовка новой записи остаётся целиковой; этап2A целиком
не закрыт и recorded-analysis не объявлен реализованным новым режимом.
## Реализация и сохранённые границы
- `worker_source_cache.py`: общий content-addressed кэш по SHA-256 и длине.
Gateway сначала получает и проверяет актуальный claim-bound manifest; кэш
не заменяет claim/generation/source admission. Каждая job сохраняет отдельный
fixed layout и свой manifest. Никаких URL/команд/путей от кэша не принимается.
- `worker_http_transport.py`: ready members восстанавливаются из локального
кэша. Полностью готовая камера не запрашивает архив; частично готовая запрашивает
только недостающие members. Cold epoch сохраняет существующий packed transport.
В progress учитываются реальные готовые members, не вымышленные inference frames.
- Разные агенты имеют разные `/work`; введён явный service config
`MISSIONCORE_OBSERVATORY_WORKER_SOURCE_CACHE_ROOT=/source-cache` и общий named
volume `ndc-observatory-source-cas-v1`. Все четыре ownership labels заданы.
- На одной filesystem — read-only hardlinks после проверки bytes; между mount
points — проверяемая disk copy с буфером до1MiB. Для необязательной cache copy
оставляется2GiB свободного диска; нехватка места означает cache miss, не потерю
качества/кадров. Прежние записи и результаты не удаляются. Повреждённый cache
object не используется и не перезаписывается; свежая загрузка остаётся в job.
Cache не удерживает массивы/сессии в RAM. Все temporary links/copies закрываются.
- `compute/lidar_preparation.py`: перед неизменным producer ищется exact source /
session / current-producer pack. Проверяются raw, metadata и optional clock
origin hashes; strict v2 reader проверяет артефакты, arrays, logical content и
equivalence. Он закрывается в `finally`. На warm hit исходник не декодируется;
потоковое SHA-чтение исходника и полная проверка NPZ остаются. Cold directory
не добавляет отдельного предварительного hash-pass перед прежним builder.
- `compute/lidar_replay.py` не изменён: SHA
`543a1d63889ad513e6307603cf477f937645c1e4df9a63a169424afd9d2471b8`.
Этот producer digest входит в исторические pack identities. Прежние packs
сохраняют свои IDs/manifest bytes; новый adapter не подделывает producer version.
Коллизия report threshold, который v2 не включал в identity, теперь явно
отклоняется при reuse, не возвращает другой отчёт и не перезаписывает данные.
- M49 source adapter использует новый preparation path; release source inventory
включает новый модуль. Алгоритмы, model/config/profile/package identities,
исходные timestamps и правила unknown/obstacle не менялись.
Кэш наполняется при обычной подготовке. Старые рабочие каталоги не обходились
массово и все ранее скачанные записи автоматически не импортированы.
## Проверки
111 focused backend tests PASS за3.87с, command maximum RSS115867648 bytes.
Ruff PASS; mypy пяти изменённых runtime-модулей PASS. UI в этом инкременте не
менялся: повторного frontend build/heavy browser QA не было.
Проверены warm/cold legacy pack IDs, изменённые raw/metadata/origin/session,
повреждённые артефакты, отсутствие metadata, symlink, смена файла во время
validation, threshold mismatch и cleanup. Gateway fixtures используют новый
процесс-клиент и другой work root с общей CAS: новая generation/другой профиль
делают только GET актуального manifest; changed/missing camera member добавляет
ровно один member GET, без полного epoch archive. Старый manifest/отсутствующий
claim отвергаются. FIFO, directory, unsafe digest, partial copy и corrupt CAS
не попадают в вычислительный вход. Admission installer проверен отдельно.
На реальном Worker выполнен небольшой synthetic proof, **не benchmark профиля**:
167936 bytes, SHA `aca7c6f9cce176db34410eeb0cd3e6e5d4ede34f0d0c8da5c97f94e6d3b5ced7`.
M49 agent сохранил bytes, installed-LAB agent восстановил их через общий volume
в свой другой `/work`, exact сравнение прошло. Третий шаг удалил только этот
проверенный synthetic cache object. Temporary work directories удалены каждым
шагом. Model jobs=0. UTC/monotonic каждого шага сохранены отдельно.
## Установка, состояние и восстановление
Read-only plan SHA `f7d1552161970d074a87aa3e9baa75f080d5f835c0373326c873ae481b5c26a7`.
Пять code files установлены offline child layers, исходный producer закреплён
отдельным неизменным SHA. До каждого cutover проверены exact container/create
hashes и свободная queue; настройки CPU/RAM/GPU/network не менялись. Добавлены
только source-cache mount/env и соответствующая code layer.
| Агент | Новый image SHA-256 | Сохранённый predecessor |
| --- | --- | --- |
| `ndc-observatory-m49-worker-agent` | `d252326dba36a1d4e4194f2862078a00090e93439c03f4d6cef97bf8ef43a607` | `ndc-observatory-m49-worker-agent-pre-source-reuse-51027773cd1b` |
| `ndc-observatory-installed-lab-worker-agent` | `b9131e995b14a8e42fbf0e5bf0e017c93ce1af33c0910927ae5e4514d900e683` | `ndc-observatory-installed-lab-worker-agent-pre-source-reuse-05222a8e343e` |
Новые агенты Running, RestartCount0, restart=`unless-stopped`. Предшественники
Stopped/restart=`no`. Durable declarations с полным create body и receipt:
`D:\NDC_MISSIONCORE\runtime\services\observatory-source-reuse-AsldTM\release`.
При rollback сначала проверить idle/reconciliation, не запускать predecessor
одновременно с replacement. Общий source volume не удалять; старый агент просто
не использует его. Прежние package launchers не должны перезаписывать новые
declarations. Фактическая перезагрузка Worker не выполнялась.
Queue после установки:10 failed/2 succeeded,1 v3 grant,0 open live leases —
существующий результат/история сохранены, новых jobs не создавалось. Core PID89747
healthy на8000,8765 пуст. Временные installer containers удалены штатным `--rm`.
Посторонние контейнеры Worker/Mac, Little Snitch и Docker limits не менялись.
## Остаток основного плана
Теперь следует убирать **cold whole-input barrier** версионированным execution
contract: полный recorded-analysis отдельно от realtime-rehearsal, bounded
incremental input и учёт всех предусмотренных результатов без drops ради1×.
Переиспользование cache само по себе этого не доказывает. Затем visual2B,
матрица LAB V1/другая запись2C и recovery/пагинация2D. Полный standalone3 и борт4
остаются отдельными этапами. Нет новых FPS, модельной квалификации или actuation.
## Evidence manifest
Private root: `.runtime/observatory-source-reuse-AsldTM`; raw recordings и secrets
в Git не добавлены. Полные declarations сохранены private, не в продуктовом UI.
| Файл | SHA-256 |
| --- | --- |
| `backend-tests.log` | `39086fa4ec312b3b8d509fda7b211aa37a14673743c9047c574efd3bde9d36df` |
| `worker-plan.json` | `0f7cb148dc8d01d7d68157daf9f8f6187c1fac6e72729107cd9b6930d568689c` |
| `worker-receipt.json` | `d030763b0d3450d5fbc14990530a80b6588d4e76943ba2d3eb190e3f909e2c3e` |
| `worker-cache-retain.json` | `99ec71c328820126664aaf4cef667030c288cf3da2e4e2eacf5dc30100b570f1` |
| `worker-cache-restore.json` | `bad2efd604351151a8451d5ae4785b68e6bb9b0d9400cfefb1d2b73ef784de1a` |
| `worker-cache-cleanup.json` | `22bd551108acc0711f5ea83c994999fbaf9f3ae5dbd68492aaee4d6552bf04e0` |
Ops card в этом инкременте не обновлялась; локальный отчёт/ExecPlan/Desktop
сводка являются текущим handoff, не утверждением об успешной записи в Ops.
@@ -0,0 +1,29 @@
FROM ndc/mission-core-installed-lab-v1-eomt-step:ee0efdd9af72
ARG NODEDC_SHARED_SHA256
ARG NODEDC_EOMT_SHA256
ARG NODEDC_MODULE_SHA256
ARG NODEDC_REVISION
COPY portable_lab_v1_component_adapter.py /opt/nodedc/adapter/portable_lab_v1_component_adapter.py
COPY run_portable_lab_v1_eomt_component.py /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py
COPY run_portable_camera_source_component.py /opt/nodedc/adapter/run_portable_camera_source_component.py
RUN test "$(sha256sum /opt/nodedc/adapter/portable_lab_v1_component_adapter.py | cut -d' ' -f1)" = "${NODEDC_SHARED_SHA256}" \
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py | cut -d' ' -f1)" = "${NODEDC_EOMT_SHA256}" \
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_camera_source_component.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
&& chmod 0444 /opt/nodedc/adapter/*.py \
&& cd /opt/nodedc/adapter \
&& python3 -B -m py_compile portable_lab_v1_component_adapter.py \
run_portable_lab_v1_eomt_component.py run_portable_camera_source_component.py \
&& rm -rf /opt/nodedc/adapter/__pycache__
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
com.nodedc.role="ai-module" \
com.nodedc.module-id="camera-source" \
com.nodedc.managed-by="mission-core-worker"
ENTRYPOINT ["python3"]
CMD ["/opt/nodedc/adapter/run_portable_camera_source_component.py"]
@@ -0,0 +1,26 @@
FROM ndc/mission-core-installed-lab-v1-ddrnet-step:439127908dba
ARG NODEDC_SHARED_SHA256
ARG NODEDC_MODULE_SHA256
ARG NODEDC_REVISION
COPY portable_lab_v1_component_adapter.py /opt/nodedc/adapter/portable_lab_v1_component_adapter.py
COPY run_portable_lab_v1_ddrnet_component.py /opt/nodedc/adapter/run_portable_lab_v1_ddrnet_component.py
RUN test "$(sha256sum /opt/nodedc/adapter/portable_lab_v1_component_adapter.py | cut -d' ' -f1)" = "${NODEDC_SHARED_SHA256}" \
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_lab_v1_ddrnet_component.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
&& chmod 0444 /opt/nodedc/adapter/*.py \
&& cd /opt/nodedc/adapter \
&& conda run --no-capture-output --name goose python -B -m py_compile \
portable_lab_v1_component_adapter.py run_portable_lab_v1_ddrnet_component.py \
&& rm -rf /opt/nodedc/adapter/__pycache__
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
com.nodedc.role="ai-module" \
com.nodedc.module-id="ddrnet" \
com.nodedc.managed-by="mission-core-worker"
ENTRYPOINT ["conda","run","--no-capture-output","--name","goose","python"]
CMD ["/opt/nodedc/adapter/run_portable_lab_v1_ddrnet_component.py"]
@@ -0,0 +1,26 @@
FROM ndc/mission-core-installed-lab-v1-eomt-step:ee0efdd9af72
ARG NODEDC_SHARED_SHA256
ARG NODEDC_MODULE_SHA256
ARG NODEDC_REVISION
COPY portable_lab_v1_component_adapter.py /opt/nodedc/adapter/portable_lab_v1_component_adapter.py
COPY run_portable_lab_v1_eomt_component.py /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py
RUN test "$(sha256sum /opt/nodedc/adapter/portable_lab_v1_component_adapter.py | cut -d' ' -f1)" = "${NODEDC_SHARED_SHA256}" \
&& test "$(sha256sum /opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
&& chmod 0444 /opt/nodedc/adapter/*.py \
&& cd /opt/nodedc/adapter \
&& python3 -B -m py_compile portable_lab_v1_component_adapter.py \
run_portable_lab_v1_eomt_component.py \
&& rm -rf /opt/nodedc/adapter/__pycache__
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
com.nodedc.role="ai-module" \
com.nodedc.module-id="eomt" \
com.nodedc.managed-by="mission-core-worker"
ENTRYPOINT ["python3"]
CMD ["/opt/nodedc/adapter/run_portable_lab_v1_eomt_component.py"]
@@ -0,0 +1,20 @@
FROM ndc-k1-perception-ddrnet39-rfdetr-tgs:stage1-joint-20260901
ARG NODEDC_MODULE_SHA256
ARG NODEDC_REVISION
COPY run_ai_module_object_distance.py /opt/nodedc/adapter/run_ai_module_object_distance.py
RUN test "$(sha256sum /opt/nodedc/adapter/run_ai_module_object_distance.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
&& chmod 0444 /opt/nodedc/adapter/run_ai_module_object_distance.py \
&& python3 -B -m py_compile /opt/nodedc/adapter/run_ai_module_object_distance.py \
&& rm -rf /opt/nodedc/adapter/__pycache__
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
com.nodedc.role="ai-module" \
com.nodedc.module-id="object-distance" \
com.nodedc.managed-by="mission-core-worker"
ENTRYPOINT ["python3", "-B", "/opt/nodedc/adapter/run_ai_module_object_distance.py"]
@@ -0,0 +1,20 @@
FROM ndc-k1-perception-ddrnet39-rfdetr-tgs:stage1-joint-20260901
ARG NODEDC_MODULE_SHA256
ARG NODEDC_REVISION
COPY run_ai_module_rf_detr.py /opt/nodedc/adapter/run_ai_module_rf_detr.py
RUN test "$(sha256sum /opt/nodedc/adapter/run_ai_module_rf_detr.py | cut -d' ' -f1)" = "${NODEDC_MODULE_SHA256}" \
&& chmod 0444 /opt/nodedc/adapter/run_ai_module_rf_detr.py \
&& python3 -B -m py_compile /opt/nodedc/adapter/run_ai_module_rf_detr.py \
&& rm -rf /opt/nodedc/adapter/__pycache__
LABEL org.opencontainers.image.revision="${NODEDC_REVISION}" \
com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
com.nodedc.role="ai-module" \
com.nodedc.module-id="rf-detr" \
com.nodedc.managed-by="mission-core-worker"
ENTRYPOINT ["python3", "-B", "/opt/nodedc/adapter/run_ai_module_rf_detr.py"]
@@ -15,7 +15,7 @@ RUN case "${NODEDC_SOURCE_TREE_SHA256}" in *[!0-9a-f]*|'') exit 64 ;; esac \
&& test "${#NODEDC_SOURCE_TREE_SHA256}" -eq 64 \
&& find /opt/nodedc/mission-core/src/k1link -type d -exec chmod 0555 {} + \
&& find /opt/nodedc/mission-core/src/k1link -type f -exec chmod 0444 {} + \
&& python3 -B -c "import k1link.observatory.installed_lab_worker_container_main as entrypoint; import k1link.observatory.installed_lab_worker_service as worker; import k1link.observatory.lab_v1_installed_package_steps as steps; assert callable(entrypoint.main); assert callable(worker.main); assert callable(steps.main)"
&& python3 -B -c "import k1link.observatory.installed_lab_worker_container_main as entrypoint; import k1link.observatory.installed_lab_worker_service as worker; import k1link.observatory.lab_v1_installed_package_steps as legacy_steps; import k1link.observatory.modular_installed_package_steps as modular_steps; assert callable(entrypoint.main); assert callable(worker.main); assert callable(legacy_steps.main); assert callable(modular_steps.main)"
LABEL com.nodedc.product="mission-core" \
com.nodedc.stack="observatory" \
@@ -0,0 +1,281 @@
"""Idle-only two-file control-agent update; sealed compute packages are unchanged.
Offline child images, exact parent/source/create fences, immutable install
receipts, and stopped predecessors retained for explicit rollback. No model
execution, new volume, new resource limit, or queue mutation occurs here.
"""
from __future__ import annotations
import argparse
import copy
import io
import json
import tarfile
import time
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlencode
from migrate_claim_transport_v3 import READINESS, Engine, canonical, require_idle, save, sha
SOURCE_ROOT = "/opt/nodedc/installed-lab/src/k1link/observatory"
BEFORE = {
"worker_agent.py": "91a65fcde45fa65b0894e6927618f35b4d8aa369653d00163df7fd840e6ea5b2",
"worker_http_transport.py": "dfe652d9464d97cba37c4136be8d0fd865f3e93fe75380e471da882b56c517f1",
}
TARGETS = {
"ndc-observatory-m49-worker-agent": (
"d252326dba36a1d4e4194f2862078a00090e93439c03f4d6cef97bf8ef43a607"
),
"ndc-observatory-installed-lab-worker-agent": (
"b9131e995b14a8e42fbf0e5bf0e017c93ce1af33c0910927ae5e4514d900e683"
),
}
LABEL = "com.nodedc.recorded-heartbeat-recovery.plan-sha256"
def probe() -> str:
return f"""import hashlib,json,pathlib
from k1link.observatory import worker_agent
root=pathlib.Path(worker_agent.__file__).resolve().parent
assert str(root)=={SOURCE_ROOT!r}
print(json.dumps({{n:hashlib.sha256((root/n).read_bytes()).hexdigest() for n in {list(BEFORE)!r}}}))
"""
def pack(repository: Path, output: Path) -> None:
output.mkdir(parents=False, exist_ok=False)
files = {}
for name in BEFORE:
payload = (repository / "src/k1link/observatory" / name).read_bytes()
compile(payload, name, "exec")
(output / name).write_bytes(payload)
files[name] = sha(payload)
save(output / "payload.json", {"schema_version": 1, "files": files})
def payload_files(root: Path) -> dict[str, bytes]:
manifest = json.loads((root / "payload.json").read_bytes())
if set(manifest) != {"schema_version", "files"} or manifest["schema_version"] != 1:
raise ValueError("invalid heartbeat payload manifest")
if set(manifest["files"]) != set(BEFORE):
raise ValueError("heartbeat file set changed")
result = {}
for name in BEFORE:
path = root / name
if path.is_symlink() or not path.is_file() or not 0 < path.stat().st_size < 256_000:
raise ValueError("unsafe heartbeat payload")
value = path.read_bytes()
if sha(value) != manifest["files"][name]:
raise ValueError("heartbeat payload changed")
compile(value, name, "exec")
result[name] = value
return result
def create_hash(row: dict) -> str:
return sha(canonical({"Config": row["Config"], "HostConfig": row["HostConfig"]}))
def validate_target(name: str, row: dict) -> None:
config, host = row["Config"], row["HostConfig"]
if row["Name"] != "/" + name or row["Image"] != "sha256:" + TARGETS[name]:
raise ValueError("control-agent identity changed")
if not row["State"]["Running"] or not host["ReadonlyRootfs"]:
raise ValueError("expected durable read-only agent is not running")
if host["NetworkMode"] != "bridge" or host.get("DeviceRequests") or host.get("Privileged"):
raise ValueError("control-agent GPU/network boundary changed")
if config["Labels"].get("com.nodedc.authority") != "observation-only":
raise ValueError("control-agent authority changed")
for item in config["Env"]:
key = item.split("=", 1)[0].upper()
if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET")) and not key.endswith(
"_FILE"
):
raise ValueError("inline credential is forbidden")
def plan(engine: Engine, root: Path) -> dict:
files, targets = payload_files(root), []
for name, parent in TARGETS.items():
row = engine.inspect(name)
validate_target(name, row)
if engine.execute_json(name, probe()) != BEFORE:
raise ValueError("imported source differs from reviewed baseline")
require_idle(engine.execute_json(name, READINESS))
targets.append(
{"name": name, "id": row["Id"], "parent": parent, "create_sha256": create_hash(row)}
)
return {
"schema_version": "missioncore.recorded-heartbeat-recovery-install/v1",
"targets": targets,
"files": {name: sha(value) for name, value in files.items()},
"installer_sha256": sha(Path(__file__).read_bytes()),
"helper_sha256": sha(
Path(__file__).with_name("migrate_claim_transport_v3.py").read_bytes()
),
"compute_packages_changed": False,
}
def fence(engine: Engine, target: dict) -> dict:
row = engine.inspect(target["name"])
validate_target(target["name"], row)
if row["Id"] != target["id"] or create_hash(row) != target["create_sha256"]:
raise ValueError("control agent changed since plan")
require_idle(engine.execute_json(target["name"], READINESS))
return row
def build(engine: Engine, target: dict, files: dict[str, bytes], plan_sha: str) -> str:
created = engine.request(
"POST",
"/containers/create",
{
"Image": "sha256:" + target["parent"],
"Entrypoint": ["/bin/true"],
"Cmd": [],
"HostConfig": {
"NetworkMode": "none",
"CapDrop": ["ALL"],
"PidsLimit": 32,
"SecurityOpt": ["no-new-privileges"],
},
},
)["Id"]
try:
engine.request("POST", f"/containers/{created}/start")
if engine.request("POST", f"/containers/{created}/wait")["StatusCode"] != 0:
raise ValueError("offline layer initialization failed")
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as archive:
for name, payload in files.items():
item = tarfile.TarInfo(name)
item.size, item.mode, item.mtime = len(payload), 0o444, int(time.time())
archive.addfile(item, io.BytesIO(payload))
engine.request(
"PUT",
f"/containers/{created}/archive?" + urlencode({"path": SOURCE_ROOT}),
buffer.getvalue(),
)
changes = engine.request("GET", f"/containers/{created}/changes")
allowed = {str(Path(SOURCE_ROOT) / name) for name in files}
parents = {str(parent) for name in allowed for parent in Path(name).parents}
if not changes or any(
row["Path"] not in allowed | parents or row["Kind"] not in (0, 1) for row in changes
):
raise ValueError("unrelated changes in offline heartbeat layer")
if not allowed.issubset({row["Path"] for row in changes}):
raise ValueError("heartbeat layer omitted a file")
parent = engine.request("GET", f"/images/sha256:{target['parent']}/json")
config = copy.deepcopy(parent["Config"])
config.setdefault("Labels", {})[LABEL] = plan_sha
image = engine.request(
"POST",
"/commit?"
+ urlencode(
{
"container": created,
"repo": target["name"] + "-heartbeat-recovery",
"tag": "v1",
}
),
config,
)["Id"]
after = engine.request("GET", f"/images/{image}/json")
if after["RootFS"]["Layers"][:-1] != parent["RootFS"]["Layers"]:
raise ValueError("parent image layers changed")
return image
finally:
engine.request("DELETE", f"/containers/{created}")
def apply(engine: Engine, root: Path, expected: str, evidence: Path) -> dict:
started, mono = datetime.now(UTC).isoformat(), time.monotonic_ns()
proposal = plan(engine, root)
if sha(canonical(proposal)) != expected:
raise ValueError("heartbeat install plan changed")
evidence.mkdir(parents=False, exist_ok=False)
save(evidence / "plan.json", proposal)
files, results = payload_files(root), []
for target in proposal["targets"]:
name = target["name"]
fence(engine, target)
image = build(engine, target, files, expected)
before = fence(engine, target)
body = copy.deepcopy(before["Config"])
body["Image"] = image
body["Labels"][LABEL] = expected
body["HostConfig"] = copy.deepcopy(before["HostConfig"])
backup = name + "-pre-heartbeat-" + before["Id"][:12]
save(
evidence / (name + "-declaration.json"),
{
"name": name,
"create_body": body,
"rollback_name": backup,
"rollback_container_id": before["Id"],
"parent": target["parent"],
},
)
engine.request("POST", f"/containers/{before['Id']}/stop?t=15")
engine.request(
"POST", f"/containers/{before['Id']}/update", {"RestartPolicy": {"Name": "no"}}
)
engine.request("POST", f"/containers/{before['Id']}/rename?" + urlencode({"name": backup}))
created = engine.request("POST", "/containers/create?" + urlencode({"name": name}), body)[
"Id"
]
engine.request("POST", f"/containers/{created}/start")
time.sleep(3)
after = engine.inspect(name)
if not after["State"]["Running"] or after["RestartCount"] != 0:
raise ValueError("replacement requires reconciliation; predecessor retained")
if engine.execute_json(name, probe()) != proposal["files"]:
raise ValueError("replacement imported another payload")
results.append(
{
"name": name,
"id": created,
"image": image,
"rollback": backup,
"readiness": engine.execute_json(name, READINESS),
}
)
save(evidence / (name + "-acceptance.json"), results[-1])
receipt = {
"plan_sha256": expected,
"agents": results,
"compute_packages_changed": False,
"started_at_utc": started,
"finished_at_utc": datetime.now(UTC).isoformat(),
"started_monotonic_ns": mono,
"finished_monotonic_ns": time.monotonic_ns(),
}
save(evidence / "receipt.json", receipt)
return receipt
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", type=Path)
parser.add_argument("--pack", type=Path)
parser.add_argument("--payload", type=Path)
parser.add_argument("--apply-plan-sha256")
parser.add_argument("--evidence", type=Path)
args = parser.parse_args()
if args.pack:
pack(args.repository, args.pack)
return
engine = Engine()
if args.apply_plan_sha256:
document = apply(engine, args.payload, args.apply_plan_sha256, args.evidence)
else:
document = plan(engine, args.payload)
document = {"plan": document, "plan_sha256": sha(canonical(document))}
print(json.dumps(document, sort_keys=True))
if __name__ == "__main__":
main()
@@ -0,0 +1,308 @@
"""Hash-gated observation-only layer for the two existing recorded agents.
Pack locally, plan read-only on Worker, then apply the exact plan. No downloads,
model changes, resource changes or live jobs. Stopped predecessors are retained.
"""
from __future__ import annotations
import argparse
import copy
import io
import json
import tarfile
import time
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlencode
from migrate_claim_transport_v3 import (
READINESS,
Engine,
canonical,
require_idle,
save,
sha,
)
SOURCE_ROOT = "/opt/nodedc/installed-lab/src/k1link/observatory"
BEFORE = {
"worker_agent.py": "cf81131746ff4c4a3af6b186a852238f2cdde7e66c70aaa8ac1e9a3dc66bde7c",
"worker_http_transport.py": "81bdc27f374cd91358f0eb58603e3e1f53e1f3017bf158d269e75affa4567293",
"m49_portable_executor.py": "19a1b5bdb9c8dfc6a91af42ebc648d7f525cce95637f8a368c7679148b9666f0",
"m49_portable_source.py": "ab178740e5e577d00863cca6d1d1963acad766b3aa6d7a19f9de870953131fca",
"portable_worker_runtime.py": (
"1c62636a6da24cae0b9e632ccd34108043dab7d2b0aefba7868376e3ca3feaf7"
),
"installed_lab_package_runner.py": (
"43195cc53e524ff57307ead691441cdd7c2ba09949bbdae6fe77920a63263eac"
),
"recorded_progress.py": None,
"m49_timing_progress.py": None,
}
TARGETS = {
"ndc-observatory-m49-worker-agent": (
"7aa6ccd2ddba4ebb0c07d793e5331539a2e13b07961c269439d5b79762328194"
),
"ndc-observatory-installed-lab-worker-agent": (
"1b1e335916c1c3d77888b7537331e9d775d078957c4ac0f82e91852301725390"
),
}
def probe() -> str:
return f"""import hashlib, json, pathlib
from k1link.observatory import worker_agent
root=pathlib.Path(worker_agent.__file__).parent
assert str(root) == {SOURCE_ROOT!r}
print(json.dumps({{name: hashlib.sha256((root/name).read_bytes()).hexdigest()
if (root/name).is_file() else None for name in {list(BEFORE)!r}}}))
"""
def pack(repository: Path, output: Path) -> None:
output.mkdir(parents=False, exist_ok=False)
files = {}
for name in BEFORE:
payload = (repository / "src/k1link/observatory" / name).read_bytes()
compile(payload, name, "exec")
(output / name).write_bytes(payload)
files[name] = sha(payload)
save(output / "payload.json", {"schema_version": 1, "files": files})
def payload_files(root: Path) -> dict[str, bytes]:
manifest = json.loads((root / "payload.json").read_bytes())
if set(manifest) != {"schema_version", "files"} or manifest["schema_version"] != 1:
raise ValueError("invalid progress payload manifest")
if set(manifest["files"]) != set(BEFORE):
raise ValueError("progress file set changed")
files = {}
for name, digest in manifest["files"].items():
path = root / name
if path.is_symlink() or not path.is_file() or path.stat().st_size > 256_000:
raise ValueError("unsafe progress payload")
value = path.read_bytes()
if sha(value) != digest:
raise ValueError("progress payload hash mismatch")
compile(value, name, "exec")
files[name] = value
return files
def validate_target(name: str, row: dict) -> None:
host, config = row["HostConfig"], row["Config"]
if row["Name"] != "/" + name or row["Image"] != "sha256:" + TARGETS[name]:
raise ValueError("agent identity changed")
if not row["State"]["Running"] or not host["ReadonlyRootfs"]:
raise ValueError("agent is not in its expected running/read-only state")
if host["NetworkMode"] != "bridge" or host.get("DeviceRequests") or host.get("Privileged"):
raise ValueError("agent GPU/network/privilege boundary changed")
if config["Labels"].get("com.nodedc.authority") != "observation-only":
raise ValueError("agent authority changed")
for entry in config["Env"]:
key = entry.split("=", 1)[0].upper()
if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET")) and not key.endswith(
"_FILE"
):
raise ValueError("inline secret in declaration")
def create_hash(row: dict) -> str:
return sha(canonical({"Config": row["Config"], "HostConfig": row["HostConfig"]}))
def validate_fence(target: dict, row: dict) -> None:
validate_target(target["name"], row)
if row["Id"] != target["id"] or create_hash(row) != target["create_sha256"]:
raise ValueError("agent or declaration changed since plan")
def plan(engine: Engine, root: Path) -> dict:
files = payload_files(root)
targets = []
for name in TARGETS:
row = engine.inspect(name)
validate_target(name, row)
if engine.execute_json(name, probe()) != BEFORE:
raise ValueError("imported agent code is not the reviewed baseline")
require_idle(engine.execute_json(name, READINESS))
targets.append(
{
"name": name,
"id": row["Id"],
"parent": TARGETS[name],
"create_sha256": create_hash(row),
}
)
return {
"schema_version": "missioncore.recorded-progress-install-plan/v1",
"targets": targets,
"files": {name: sha(value) for name, value in files.items()},
"installer_sha256": sha(Path(__file__).read_bytes()),
"engine_helper_sha256": sha(
Path(__file__).with_name("migrate_claim_transport_v3.py").read_bytes()
),
"compute_packages_changed": False,
}
def build(engine: Engine, target: dict, files: dict[str, bytes], plan_sha: str) -> str:
created = engine.request(
"POST",
"/containers/create",
{
"Image": "sha256:" + target["parent"],
"Entrypoint": ["/bin/true"],
"Cmd": [],
"HostConfig": {
"NetworkMode": "none",
"CapDrop": ["ALL"],
"PidsLimit": 32,
"SecurityOpt": ["no-new-privileges"],
},
},
)["Id"]
try:
engine.request("POST", f"/containers/{created}/start")
if engine.request("POST", f"/containers/{created}/wait")["StatusCode"] != 0:
raise ValueError("offline layer initialization failed")
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as archive:
for name, payload in files.items():
member = tarfile.TarInfo(name)
member.size, member.mode, member.mtime = len(payload), 0o644, int(time.time())
archive.addfile(member, io.BytesIO(payload))
engine.request(
"PUT",
f"/containers/{created}/archive?" + urlencode({"path": SOURCE_ROOT}),
buffer.getvalue(),
)
changes = engine.request("GET", f"/containers/{created}/changes")
allowed = {str(Path(SOURCE_ROOT) / name) for name in files}
parents = {str(p) for p in Path(SOURCE_ROOT).parents} | {SOURCE_ROOT}
if not changes or any(
item["Path"] not in allowed | parents or item["Kind"] not in (0, 1) for item in changes
):
raise ValueError("unrelated filesystem changes in progress layer")
if not allowed.issubset({item["Path"] for item in changes}):
raise ValueError("a progress file was omitted from the image layer")
parent = engine.request("GET", f"/images/sha256:{target['parent']}/json")
config = copy.deepcopy(parent["Config"])
config.setdefault("Labels", {}).update(
{
"com.nodedc.recorded-progress.plan-sha256": plan_sha,
"com.nodedc.recorded-progress.parent-sha256": target["parent"],
}
)
image = engine.request(
"POST",
"/commit?"
+ urlencode(
{
"container": created,
"repo": target["name"] + "-progress",
"tag": "v1",
}
),
config,
)["Id"]
result = engine.request("GET", f"/images/{image}/json")
if result["RootFS"]["Layers"][:-1] != parent["RootFS"]["Layers"]:
raise ValueError("parent image layers changed")
return image
finally:
engine.request("DELETE", f"/containers/{created}")
def apply(engine: Engine, root: Path, expected: str, evidence: Path) -> dict:
started_at = datetime.now(UTC).isoformat()
started_mono = time.monotonic_ns()
proposal = plan(engine, root)
if sha(canonical(proposal)) != expected:
raise ValueError("progress install plan changed")
evidence.mkdir(parents=False, exist_ok=False)
save(evidence / "plan.json", proposal)
files = payload_files(root)
results = []
for target in proposal["targets"]:
name = target["name"]
before = engine.inspect(name)
validate_fence(target, before)
image = build(engine, target, files, expected)
require_idle(engine.execute_json(name, READINESS))
validate_fence(target, engine.inspect(name))
body = copy.deepcopy(before["Config"])
body["Image"] = image
body["Labels"]["com.nodedc.recorded-progress.plan-sha256"] = expected
body["HostConfig"] = copy.deepcopy(before["HostConfig"])
backup = name + "-pre-progress-" + before["Id"][:12]
declaration = {
"name": name,
"create_body": body,
"rollback_name": backup,
"rollback_container_id": before["Id"],
"parent": target["parent"],
}
save(evidence / (name + "-declaration.json"), declaration)
engine.request("POST", f"/containers/{before['Id']}/stop?t=15")
engine.request(
"POST", f"/containers/{before['Id']}/update", {"RestartPolicy": {"Name": "no"}}
)
engine.request("POST", f"/containers/{before['Id']}/rename?" + urlencode({"name": backup}))
created = engine.request("POST", "/containers/create?" + urlencode({"name": name}), body)[
"Id"
]
engine.request("POST", f"/containers/{created}/start")
# A started replacement may already own operator work; never auto-delete it.
time.sleep(3)
after = engine.inspect(name)
if not after["State"]["Running"] or after["RestartCount"] != 0:
raise ValueError("replacement needs reconciliation; predecessor retained")
if engine.execute_json(name, probe()) != proposal["files"]:
raise ValueError("replacement imported another progress payload")
results.append(
{
"name": name,
"id": created,
"image": image,
"rollback": backup,
"readiness": engine.execute_json(name, READINESS),
}
)
save(evidence / (name + "-acceptance.json"), results[-1])
receipt = {
"plan_sha256": expected,
"agents": results,
"compute_packages_changed": False,
"started_at_utc": started_at,
"finished_at_utc": datetime.now(UTC).isoformat(),
"started_monotonic_ns": started_mono,
"finished_monotonic_ns": time.monotonic_ns(),
}
save(evidence / "receipt.json", receipt)
return receipt
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", type=Path)
parser.add_argument("--pack", type=Path)
parser.add_argument("--payload", type=Path)
parser.add_argument("--apply-plan-sha256")
parser.add_argument("--evidence", type=Path)
args = parser.parse_args()
if args.pack:
pack(args.repository, args.pack)
return
engine = Engine()
if args.apply_plan_sha256:
result = apply(engine, args.payload, args.apply_plan_sha256, args.evidence)
else:
proposal = plan(engine, args.payload)
result = {"plan": proposal, "plan_sha256": sha(canonical(proposal))}
print(json.dumps(result, sort_keys=True))
if __name__ == "__main__":
main()
@@ -0,0 +1,340 @@
"""Exact offline control-agent update: shared source cache and LiDAR reuse.
Plan is read-only, apply requires its hash and an idle queue. Models, package
definitions, legacy LiDAR producer and existing work/results stay unchanged.
Stopped predecessors and full create declarations are retained for rollback.
"""
from __future__ import annotations
import argparse
import copy
import io
import json
import tarfile
import time
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import urlencode
from migrate_claim_transport_v3 import READINESS, Engine, canonical, require_idle, save, sha
SOURCE_ROOT = "/opt/nodedc/installed-lab/src/k1link"
PRODUCER = "compute/lidar_replay.py"
PRODUCER_SHA = "543a1d63889ad513e6307603cf477f937645c1e4df9a63a169424afd9d2471b8"
BEFORE = {
"compute/lidar_preparation.py": None,
"observatory/m49_portable_source.py": (
"aff9baf5a11732c3f5d4bf53a5a3306df8ac020efbb0dc235202f7193b43c043"
),
"observatory/worker_http_transport.py": (
"363daa574139ee062f0d4141c6ce8ca9c3f25883e1fee6660dd771e15ec71088"
),
"observatory/worker_service.py": (
"a742d1de10e9c76e531f5be78935d6c34c8195d4ecc8c24cf25b987ec162ba1a"
),
"observatory/worker_source_cache.py": None,
}
TARGETS = {
"ndc-observatory-m49-worker-agent": (
"927c3c4f5b00ae6c084d1f5a8bc77f7b262cfc5e83cf4be1c48f615c12c06e80"
),
"ndc-observatory-installed-lab-worker-agent": (
"052af3ccd10e11b162c163943f5427af2dc95b09b93481dd85e954e494ba1107"
),
}
VOLUME = "ndc-observatory-source-cas-v1"
CACHE_PATH = "/source-cache"
CACHE_ENV = "MISSIONCORE_OBSERVATORY_WORKER_SOURCE_CACHE_ROOT"
LABELS = {
"com.nodedc.product": "mission-core",
"com.nodedc.stack": "observatory",
"com.nodedc.role": "source-cache",
"com.nodedc.managed-by": "recorded-source-reuse-v1",
}
def probe() -> str:
names = [PRODUCER, *BEFORE]
return f"""import hashlib,json,pathlib
from k1link.observatory import worker_http_transport
root=pathlib.Path(worker_http_transport.__file__).resolve().parents[1]
assert str(root)=={SOURCE_ROOT!r}
print(json.dumps({{n:hashlib.sha256((root/n).read_bytes()).hexdigest()
if (root/n).is_file() else None for n in {names!r}}}))
"""
def pack(repository: Path, output: Path) -> None:
if sha((repository / "src/k1link" / PRODUCER).read_bytes()) != PRODUCER_SHA:
raise ValueError("legacy LiDAR producer changed")
output.mkdir(parents=False, exist_ok=False)
files = {}
for name in BEFORE:
payload = (repository / "src/k1link" / name).read_bytes()
compile(payload, name, "exec")
target = output / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(payload)
files[name] = sha(payload)
save(output / "payload.json", {"schema_version": 1, "files": files})
def payload_files(root: Path) -> dict[str, bytes]:
manifest = json.loads((root / "payload.json").read_bytes())
if set(manifest) != {"schema_version", "files"} or manifest["schema_version"] != 1:
raise ValueError("invalid source-reuse payload manifest")
if set(manifest["files"]) != set(BEFORE):
raise ValueError("source-reuse file set changed")
result = {}
for name in BEFORE:
path = root / name
if path.is_symlink() or not path.is_file() or path.stat().st_size > 256_000:
raise ValueError("unsafe source-reuse payload")
value = path.read_bytes()
if sha(value) != manifest["files"][name]:
raise ValueError("source-reuse payload changed")
compile(value, name, "exec")
result[name] = value
return result
def create_hash(row: dict) -> str:
return sha(canonical({"Config": row["Config"], "HostConfig": row["HostConfig"]}))
def validate_target(name: str, row: dict) -> None:
config, host = row["Config"], row["HostConfig"]
if row["Name"] != "/" + name or row["Image"] != "sha256:" + TARGETS[name]:
raise ValueError("control-agent identity changed")
if not row["State"]["Running"] or not host["ReadonlyRootfs"]:
raise ValueError("control agent must be running/read-only")
if host["NetworkMode"] != "bridge" or host.get("DeviceRequests") or host.get("Privileged"):
raise ValueError("control-agent network/GPU boundary changed")
if config["Labels"].get("com.nodedc.authority") != "observation-only":
raise ValueError("control-agent authority changed")
if any(mount["Destination"] == CACHE_PATH for mount in row["Mounts"]):
raise ValueError("source-cache mount is already occupied")
for entry in config["Env"]:
key = entry.split("=", 1)[0]
if key == CACHE_ENV:
raise ValueError("source-cache configuration already exists")
if any(
word in key.upper() for word in ("TOKEN", "PASSWORD", "SECRET")
) and not key.endswith("_FILE"):
raise ValueError("inline secret is forbidden in saved declarations")
def volume_state(engine: Engine) -> dict:
response = engine.request(
"GET", "/volumes?" + urlencode({"filters": json.dumps({"name": [VOLUME]})})
)
matches = [row for row in response.get("Volumes", []) or [] if row["Name"] == VOLUME]
if not matches:
return {"exists": False}
row = matches[0]
if row["Driver"] != "local" or row.get("Labels") != LABELS:
raise ValueError("existing source-cache volume has another owner")
return {"exists": True, "name": VOLUME, "driver": "local", "labels": LABELS}
def plan(engine: Engine, root: Path) -> dict:
files = payload_files(root)
targets = []
for name, parent in TARGETS.items():
row = engine.inspect(name)
validate_target(name, row)
if engine.execute_json(name, probe()) != {PRODUCER: PRODUCER_SHA, **BEFORE}:
raise ValueError("imported source differs from reviewed baseline")
require_idle(engine.execute_json(name, READINESS))
targets.append(
{"name": name, "id": row["Id"], "parent": parent, "create_sha256": create_hash(row)}
)
return {
"schema_version": "missioncore.recorded-source-reuse-install-plan/v1",
"targets": targets,
"files": {name: sha(value) for name, value in files.items()},
"producer_sha256": PRODUCER_SHA,
"shared_cache": {"name": VOLUME, "target": CACHE_PATH, "before": volume_state(engine)},
"installer_sha256": sha(Path(__file__).read_bytes()),
"engine_helper_sha256": sha(
Path(__file__).with_name("migrate_claim_transport_v3.py").read_bytes()
),
"compute_packages_changed": False,
}
def fence(engine: Engine, target: dict) -> dict:
row = engine.inspect(target["name"])
validate_target(target["name"], row)
if row["Id"] != target["id"] or create_hash(row) != target["create_sha256"]:
raise ValueError("control agent changed since plan")
require_idle(engine.execute_json(target["name"], READINESS))
return row
def build(engine: Engine, target: dict, files: dict[str, bytes], plan_sha: str) -> str:
created = engine.request(
"POST",
"/containers/create",
{
"Image": "sha256:" + target["parent"],
"Entrypoint": ["/bin/true"],
"Cmd": [],
"HostConfig": {
"NetworkMode": "none",
"CapDrop": ["ALL"],
"PidsLimit": 32,
"SecurityOpt": ["no-new-privileges"],
},
},
)["Id"]
try:
engine.request("POST", f"/containers/{created}/start")
if engine.request("POST", f"/containers/{created}/wait")["StatusCode"] != 0:
raise ValueError("offline layer initialization failed")
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w") as archive:
for name, payload in files.items():
item = tarfile.TarInfo(name)
item.size, item.mode, item.mtime = len(payload), 0o444, int(time.time())
archive.addfile(item, io.BytesIO(payload))
engine.request(
"PUT",
f"/containers/{created}/archive?" + urlencode({"path": SOURCE_ROOT}),
buffer.getvalue(),
)
changes = engine.request("GET", f"/containers/{created}/changes")
allowed = {str(Path(SOURCE_ROOT) / name) for name in files}
parents = {str(parent) for name in allowed for parent in Path(name).parents}
if not changes or any(
row["Path"] not in allowed | parents or row["Kind"] not in (0, 1) for row in changes
):
raise ValueError("unrelated changes in offline source-reuse layer")
if not allowed.issubset({row["Path"] for row in changes}):
raise ValueError("source-reuse layer omitted a file")
parent = engine.request("GET", f"/images/sha256:{target['parent']}/json")
config = copy.deepcopy(parent["Config"])
config.setdefault("Labels", {})["com.nodedc.source-reuse.plan-sha256"] = plan_sha
image = engine.request(
"POST",
"/commit?"
+ urlencode(
{
"container": created,
"repo": target["name"] + "-source-reuse",
"tag": "v1",
}
),
config,
)["Id"]
after = engine.request("GET", f"/images/{image}/json")
if after["RootFS"]["Layers"][:-1] != parent["RootFS"]["Layers"]:
raise ValueError("parent image layers changed")
return image
finally:
engine.request("DELETE", f"/containers/{created}")
def apply(engine: Engine, root: Path, expected: str, evidence: Path) -> dict:
started_at, started_mono = datetime.now(UTC).isoformat(), time.monotonic_ns()
proposal = plan(engine, root)
if sha(canonical(proposal)) != expected:
raise ValueError("source-reuse install plan changed")
evidence.mkdir(parents=False, exist_ok=False)
save(evidence / "plan.json", proposal)
files = payload_files(root)
engine.request("POST", "/volumes/create", {"Name": VOLUME, "Driver": "local", "Labels": LABELS})
volume_state(engine)
results = []
for target in proposal["targets"]:
name = target["name"]
fence(engine, target)
image = build(engine, target, files, expected)
before = fence(engine, target)
body = copy.deepcopy(before["Config"])
body["Image"] = image
body["Env"].append(CACHE_ENV + "=" + CACHE_PATH)
body["Labels"]["com.nodedc.source-reuse.plan-sha256"] = expected
body["HostConfig"] = copy.deepcopy(before["HostConfig"])
body["HostConfig"].setdefault("Mounts", []).append(
{
"Type": "volume",
"Source": VOLUME,
"Target": CACHE_PATH,
"ReadOnly": False,
}
)
backup = name + "-pre-source-reuse-" + before["Id"][:12]
save(
evidence / (name + "-declaration.json"),
{
"name": name,
"create_body": body,
"rollback_name": backup,
"rollback_container_id": before["Id"],
"parent": target["parent"],
},
)
engine.request("POST", f"/containers/{before['Id']}/stop?t=15")
engine.request(
"POST", f"/containers/{before['Id']}/update", {"RestartPolicy": {"Name": "no"}}
)
engine.request("POST", f"/containers/{before['Id']}/rename?" + urlencode({"name": backup}))
created = engine.request("POST", "/containers/create?" + urlencode({"name": name}), body)[
"Id"
]
engine.request("POST", f"/containers/{created}/start")
# Never auto-delete a replacement: it may already own operator work.
time.sleep(3)
after = engine.inspect(name)
if not after["State"]["Running"] or after["RestartCount"] != 0:
raise ValueError("replacement requires reconciliation; predecessor retained")
if engine.execute_json(name, probe()) != {PRODUCER: PRODUCER_SHA, **proposal["files"]}:
raise ValueError("replacement imported another payload")
results.append(
{
"name": name,
"id": created,
"image": image,
"rollback": backup,
"readiness": engine.execute_json(name, READINESS),
}
)
save(evidence / (name + "-acceptance.json"), results[-1])
receipt = {
"plan_sha256": expected,
"agents": results,
"shared_cache": VOLUME,
"compute_packages_changed": False,
"started_at_utc": started_at,
"finished_at_utc": datetime.now(UTC).isoformat(),
"started_monotonic_ns": started_mono,
"finished_monotonic_ns": time.monotonic_ns(),
}
save(evidence / "receipt.json", receipt)
return receipt
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repository", type=Path)
parser.add_argument("--pack", type=Path)
parser.add_argument("--payload", type=Path)
parser.add_argument("--apply-plan-sha256")
parser.add_argument("--evidence", type=Path)
args = parser.parse_args()
if args.pack:
pack(args.repository, args.pack)
return
engine = Engine()
if args.apply_plan_sha256:
result = apply(engine, args.payload, args.apply_plan_sha256, args.evidence)
else:
proposal = plan(engine, args.payload)
result = {"plan": proposal, "plan_sha256": sha(canonical(proposal))}
print(json.dumps(result, sort_keys=True))
if __name__ == "__main__":
main()
@@ -71,7 +71,7 @@ _SAFE_COMPONENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_OBSERVATORY_JOB_ID = re.compile(r"^observatory-run-[a-f0-9]{32}$")
_CAMERA_JOB_ID = re.compile(r"^recorded-camera-[a-f0-9]{24}$")
Component = Literal["eomt", "ddrnet"]
Component = Literal["camera-source", "eomt", "ddrnet"]
AssetKind = Literal["file", "tree"]
AssetVerification = Literal["sha256", "identity-sha256"]
CommandRunner = Callable[[Sequence[str], Optional[Mapping[str, str]]], None] # noqa: UP045
@@ -168,7 +168,7 @@ class RuntimeLayout:
component: Component,
expectations: Sequence[AssetExpectation],
) -> RuntimeLayout:
if component not in ("eomt", "ddrnet"):
if component not in ("camera-source", "eomt", "ddrnet"):
raise ComponentAdapterError("installed package component is invalid")
prepared = Path(PACKAGE_STEP_INPUT_ROOT) / "prepare"
return cls(
@@ -176,7 +176,7 @@ class RuntimeLayout:
camera_job_root=prepared / "camera-job",
output_root=Path(PACKAGE_OUTPUT_ROOT),
effective_ddrnet_config=prepared / "effective-ddrnet-config.json",
eomt_result_root=Path(PACKAGE_STEP_INPUT_ROOT) / "eomt",
eomt_result_root=Path(PACKAGE_STEP_INPUT_ROOT) / "camera-source",
asset_paths={item.asset_id: Path(item.path) for item in expectations},
)
@@ -289,7 +289,7 @@ def load_component_request(
source = _source_binding(document["source"])
paths = _paths(document["paths"], component)
effective_value = document["effective_ddrnet_config_sha256"]
if component == "eomt":
if component != "ddrnet":
if effective_value is not None:
raise ComponentAdapterError("EoMT request contains a DDRNet config")
effective_sha256: str | None = None
@@ -440,6 +440,61 @@ def validate_tree_asset(
return _real_directory(layout.asset_paths[asset_id], f"asset {asset_id}")
def validate_prepared_camera_root(
root: Path,
request: ComponentRequest,
) -> dict[str, Path]:
"""Verify the model-neutral camera-source output before any AI consumes it."""
resolved = _real_directory(root, "prepared camera root")
expected_children = {
"camera-source.json",
"decode-repair.json",
"source-frames",
"source-frames.json",
"timeline.jsonl",
}
if {path.name for path in resolved.iterdir()} != expected_children:
raise ComponentAdapterError("prepared camera artifact set changed")
receipt = load_canonical_json(
resolved / "camera-source.json",
label="prepared camera receipt",
maximum=1024 * 1024,
confinement_root=resolved,
)
source = request.source
if receipt.get("schema_version") != "missioncore.observatory-prepared-camera/v1" or receipt.get(
"source"
) != {
"camera_job_id": source.camera_job_id,
"input_sha256": source.camera_input_sha256,
"frame_count": source.frame_count,
}:
raise ComponentAdapterError("prepared camera identity changed")
rows = receipt.get("artifacts")
expected = {
"decode-repair": "decode-repair.json",
"source-frames": "source-frames.json",
"timeline": "timeline.jsonl",
}
if not isinstance(rows, list) or len(rows) != len(expected):
raise ComponentAdapterError("prepared camera receipt changed")
result: dict[str, Path] = {}
for row in rows:
if not isinstance(row, dict) or set(row) != {"role", "path", "byte_length", "sha256"}:
raise ComponentAdapterError("prepared camera artifact changed")
role = row.get("role")
name = expected.get(role) if isinstance(role, str) else None
if name != row.get("path"):
raise ComponentAdapterError("prepared camera artifact role changed")
path = _real_file(resolved / name, "prepared camera artifact")
if path.parent != resolved or (
row.get("byte_length") != path.stat().st_size or row.get("sha256") != sha256_file(path)
):
raise ComponentAdapterError("prepared camera artifact identity changed")
result[role] = path
return result
def validate_tree_receipt(
request: ComponentRequest,
root: Path,
@@ -833,12 +888,12 @@ def _paths(value: object, component: Component) -> dict[str, str | None]:
f"{prepared}/effective-ddrnet-config.json" if component == "ddrnet" else None
),
"eomt_result_root": (
f"{PACKAGE_STEP_INPUT_ROOT}/eomt" if component == "ddrnet" else None
f"{PACKAGE_STEP_INPUT_ROOT}/camera-source" if component == "ddrnet" else None
),
"decoded_frames_root": (
f"{PACKAGE_OUTPUT_ROOT}/source-frames"
if component == "eomt"
else f"{PACKAGE_STEP_INPUT_ROOT}/eomt/source-frames"
if component == "camera-source"
else f"{PACKAGE_STEP_INPUT_ROOT}/camera-source/source-frames"
),
}
_exact_keys(document, set(legacy), "component paths")
@@ -0,0 +1,63 @@
"""Small synthetic shared-cache proof; never claims work or loads a model."""
from __future__ import annotations
import argparse
import hashlib
import json
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
from k1link.observatory.worker_service import ObservatoryWorkerServiceConfiguration
from k1link.observatory.worker_source_cache import WorkerSourceCache
PAYLOAD = b"missioncore-source-reuse-proof-AsldTM-v1\n" * 4096
SHA256 = hashlib.sha256(PAYLOAD).hexdigest()
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("retain", "restore", "cleanup"))
args = parser.parse_args()
configuration = ObservatoryWorkerServiceConfiguration.from_environment()
assert configuration.source_cache_root == Path("/source-cache")
started, mono = datetime.now(UTC).isoformat(), time.monotonic_ns()
cache = WorkerSourceCache(configuration.source_cache_root)
with tempfile.TemporaryDirectory(
prefix=".source-reuse-proof-", dir=configuration.work_root
) as root:
source = Path(root) / "synthetic.bin"
if args.mode == "retain":
source.write_bytes(PAYLOAD)
assert cache.retain(source, sha256=SHA256, byte_length=len(PAYLOAD))
else:
assert cache.restore(source, sha256=SHA256, byte_length=len(PAYLOAD))
assert source.read_bytes() == PAYLOAD
if args.mode == "cleanup":
cached = cache.root / SHA256
assert cached.read_bytes() == PAYLOAD
cached.unlink() # Only this probe's verified synthetic cache object.
print(
json.dumps(
{
"schema_version": "missioncore.worker-source-cache-proof/v1",
"mode": args.mode,
"source_sha256": SHA256,
"byte_length": len(PAYLOAD),
"exact": True,
"model_jobs": 0,
"temporary_work_removed": True,
"synthetic_cache_removed": args.mode == "cleanup",
"started_at_utc": started,
"finished_at_utc": datetime.now(UTC).isoformat(),
"started_monotonic_ns": mono,
"finished_monotonic_ns": time.monotonic_ns(),
}
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,546 @@
#!/usr/bin/env python3
"""Associate RF-DETR boxes with current K1 LiDAR and publish metric ranges."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import re
import sys
import time
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import cast
import numpy as np
from k1link.compute.lidar_local_surface_shadow import (
K1LocalSurfaceShadowEstimator,
K1LocalSurfaceShadowInput,
)
from k1link.perception.contracts import (
ClockBasis,
ModalityOutcome,
ModalityStatus,
ObjectProposal2D,
SourceEnvelope,
TimestampBundle,
)
from k1link.perception.geometry import (
GEOMETRY_PROVIDER_ID,
GeometryFrame,
GeometryProfile,
Ravnoves00GeometryAssociationProvider,
)
from k1link.perception.geometry_math import GeometryAssociationProfile, Kb4ProjectionProfile
from k1link.perception.providers import SourcePacket
SCHEMA = "missioncore.observatory-ai-module-object-distance-result/v1"
ROW_SCHEMA = "missioncore.observatory-ai-module-object-distance-frame/v1"
RF_ROW_SCHEMA = "missioncore.observatory-ai-module-rf-detr-frame/v1"
AUTHORITY = {
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
"production_accepted": False,
}
_PACKAGE_SOURCE = Path("/missioncore/input/steps/prepare/source-input.json")
_PACKAGE_DETECTIONS = Path("/missioncore/input/steps/rf-detr/detections.jsonl")
_PACKAGE_LIDAR_PACK = Path("/missioncore/input/steps/prepare/lidar-pack")
_PACKAGE_BINDING_INDEX = Path("/missioncore/input/steps/prepare/m49-source/sequence-index.ndjson")
_PACKAGE_CALIBRATION = Path("/opt/nodedc/assets/k1-camera-lidar-calibration")
_PACKAGE_OUTPUT = Path("/missioncore/output")
_MAX_CALIBRATION_PACK_BYTES = 128 * 1024 * 1024
_LIDAR_PACK_ID = re.compile(r"^lidar-replay-pack-([a-f0-9]{64})$")
ASSOCIATION = GeometryAssociationProfile(
bbox_inset_fraction=0.03,
depth_cluster_minimum_gap_m=0.45,
depth_cluster_gap_fraction=0.08,
spatial_cluster_radius_m=0.6,
semantic_minimum_occupied_points=2,
semantic_minimum_occupied_voxels=1,
semantic_voxel_size_m=0.35,
conflict_minimum_classified_points=6,
conflict_surface_fraction=0.8,
geometry_local_radius_m=10.0,
geometry_voxel_size_m=0.45,
geometry_minimum_cluster_points=4,
geometry_minimum_cluster_voxels=1,
maximum_geometry_clusters_per_frame=64,
)
class ObjectDistanceModuleError(RuntimeError):
pass
class _CurrentStore:
def __init__(self, profile: GeometryProfile) -> None:
self.profile = profile
self.current: GeometryFrame | None = None
def frame(self, _packet: SourcePacket) -> GeometryFrame | None:
return self.current
@dataclass(frozen=True)
class _PointFrame:
received_monotonic_ns: int
xyz_map: np.ndarray
@dataclass(frozen=True)
class _PoseFrame:
received_monotonic_ns: int
position_map: tuple[float, float, float]
orientation_map_from_lidar: tuple[float, float, float, float]
class _LidarPack:
"""Narrow reader for the already sealed replay-pack arrays."""
def __init__(self, root: Path) -> None:
candidate = root.expanduser().absolute()
if candidate.is_symlink():
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
self.root = candidate.resolve(strict=True)
manifest_path = self.root / "manifest.json"
if manifest_path.is_symlink() or not manifest_path.is_file():
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
manifest = json.loads(manifest_path.read_text())
pack_id = manifest.get("pack_id") if isinstance(manifest, dict) else None
match = _LIDAR_PACK_ID.fullmatch(pack_id) if isinstance(pack_id, str) else None
if (
self.root.is_symlink()
or not self.root.is_dir()
or not isinstance(manifest, dict)
or manifest.get("schema_version") != "missioncore.lidar-replay-pack/v2"
or match is None
or manifest.get("identity_sha256") != match.group(1)
):
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, list):
raise ObjectDistanceModuleError("LiDAR replay pack artifacts changed")
row = next(
(
item
for item in artifacts
if isinstance(item, dict) and item.get("kind") == "lidar-arrays"
),
None,
)
if row is None or row.get("path") != "lidar-replay.npz":
raise ObjectDistanceModuleError("LiDAR replay arrays are unavailable")
arrays_path = self.root / "lidar-replay.npz"
if (
arrays_path.is_symlink()
or not arrays_path.is_file()
or arrays_path.resolve(strict=True).parent != self.root
or arrays_path.stat().st_size != row.get("byte_length")
or _sha(arrays_path) != row.get("sha256")
):
raise ObjectDistanceModuleError("LiDAR replay arrays identity changed")
self.arrays_path = arrays_path.resolve(strict=True)
archive = np.load(arrays_path, allow_pickle=False)
try:
self.arrays = {name: np.asarray(archive[name]) for name in archive.files}
finally:
archive.close()
required = {
"point_offsets",
"point_xyz_map",
"point_received_monotonic_ns",
"pose_positions_map",
"pose_quaternions_map_from_lidar",
"pose_received_monotonic_ns",
}
if not required.issubset(self.arrays):
raise ObjectDistanceModuleError("LiDAR replay array set changed")
self.pack_id = pack_id
self.point_frame_count = int(self.arrays["point_received_monotonic_ns"].shape[0])
self.pose_frame_count = int(self.arrays["pose_received_monotonic_ns"].shape[0])
self.point_count = int(self.arrays["point_xyz_map"].shape[0])
def point_frame(self, index: int) -> _PointFrame:
if not 0 <= index < self.point_frame_count:
raise ObjectDistanceModuleError("LiDAR frame index is outside the replay pack")
begin, end = (int(self.arrays["point_offsets"][index + offset]) for offset in (0, 1))
points = np.asarray(self.arrays["point_xyz_map"][begin:end], dtype=np.float64)
if points.ndim != 2 or points.shape[1:] != (3,) or not np.isfinite(points).all():
raise ObjectDistanceModuleError("LiDAR frame points changed")
return _PointFrame(int(self.arrays["point_received_monotonic_ns"][index]), points)
def pose_frame(self, index: int) -> _PoseFrame:
if not 0 <= index < self.pose_frame_count:
raise ObjectDistanceModuleError("pose frame index is outside the replay pack")
position = tuple(float(value) for value in self.arrays["pose_positions_map"][index])
orientation = tuple(
float(value) for value in self.arrays["pose_quaternions_map_from_lidar"][index]
)
return _PoseFrame(
int(self.arrays["pose_received_monotonic_ns"][index]),
cast(tuple[float, float, float], position),
cast(tuple[float, float, float, float], orientation),
)
def close(self) -> None:
self.arrays.clear()
def _canonical(value: object) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
def _sha(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _projection(path: Path) -> Kb4ProjectionProfile:
candidate = path.expanduser().resolve(strict=True)
# The admitted E10 source pack contains the three small calibration arrays
# together with the full point cloud. Bound the sealed archive itself while
# retaining strict per-member limits for the arrays read below.
if (
candidate.is_symlink()
or not candidate.is_file()
or candidate.stat().st_size > _MAX_CALIBRATION_PACK_BYTES
):
raise ObjectDistanceModuleError("camera/LiDAR calibration is unavailable")
with zipfile.ZipFile(candidate) as archive:
arrays = {}
for name in ("intrinsic_fx_fy_cx_cy", "distortion_kb4", "t_camera_from_lidar"):
info = archive.getinfo(name + ".npy")
if info.file_size > 4096:
raise ObjectDistanceModuleError("camera/LiDAR calibration exceeds its bound")
with archive.open(info) as stream:
arrays[name] = np.lib.format.read_array(stream, allow_pickle=False)
return Kb4ProjectionProfile(
800,
600,
cast(
tuple[float, float, float, float],
tuple(float(value) for value in arrays["intrinsic_fx_fy_cx_cy"]),
),
cast(
tuple[float, float, float, float],
tuple(float(value) for value in arrays["distortion_kb4"]),
),
np.asarray(arrays["t_camera_from_lidar"], dtype=np.float64),
)
def _detections(path: Path, count: int) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
with path.expanduser().resolve(strict=True).open(encoding="utf-8") as stream:
for raw in stream:
if len(raw) > 8 * 1024 * 1024 or len(rows) >= count:
raise ObjectDistanceModuleError("RF-DETR result exceeds its bound")
row = json.loads(raw)
if not isinstance(row, dict) or row.get("schema_version") != RF_ROW_SCHEMA:
raise ObjectDistanceModuleError("RF-DETR result contract changed")
rows.append(row)
if len(rows) != count:
raise ObjectDistanceModuleError("RF-DETR and LiDAR timelines differ")
return rows
def _integer(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ObjectDistanceModuleError(f"{label} is invalid")
return value
def _number(value: object, label: str) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise ObjectDistanceModuleError(f"{label} is invalid")
number = float(value)
if not math.isfinite(number):
raise ObjectDistanceModuleError(f"{label} is invalid")
return number
def _profile(session_id: str, pack: _LidarPack) -> GeometryProfile:
return GeometryProfile(
profile_id="observatory-object-distance-v1",
provider_id=GEOMETRY_PROVIDER_ID,
source_id="recorded-k1",
session_id=session_id,
source_pack_id=pack.pack_id,
source_pack_sha256=_sha(pack.arrays_path),
frame_count=pack.point_frame_count,
point_count=pack.point_count,
local_surface_model_id="k1-local-surface-shadow-v1",
local_surface_sha256="0" * 64,
valid_frame_count=pack.point_frame_count,
width=800,
height=600,
coordinate_frame="map",
association=ASSOCIATION,
profile_sha256=hashlib.sha256(
_canonical(
{
"module": "object-distance",
"association": ASSOCIATION.__dict__
if hasattr(ASSOCIATION, "__dict__")
else str(ASSOCIATION),
}
)
).hexdigest(),
)
def _packet(
*,
session_id: str,
source_id: str,
frame_id: str,
frame_index: int,
session_seconds: float,
spatial_available: bool,
) -> SourcePacket:
available = ModalityStatus(True, ModalityOutcome.AVAILABLE, "recorded-module-input")
missing = ModalityStatus(False, ModalityOutcome.UNAVAILABLE, "no-synchronous-lidar")
spatial = available if spatial_available else missing
nanoseconds = round(session_seconds * 1_000_000_000)
return SourcePacket(
SourceEnvelope(
source_id=source_id,
session_id=session_id,
frame_id=frame_id,
sequence=frame_index,
timestamps=TimestampBundle(
utc_ns=nanoseconds,
monotonic_ns=nanoseconds,
source_ns=nanoseconds,
clock_basis=ClockBasis.RECORDED_HOST,
),
source_age_ns=0,
binding_reason="camera-lidar-past-only-binding",
calibration_id="camera-1-kb4-05f3ad9b",
representation_id="object-distance-current-cloud-v1",
image=available,
registered_point_increment=spatial,
pose=spatial,
),
b"rf-detr-proposals",
b"current-k1-cloud" if spatial_available else None,
b"current-k1-pose" if spatial_available else None,
)
def _binding_rows(path: Path) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
previous = -1.0
with path.expanduser().resolve(strict=True).open(encoding="utf-8") as stream:
for raw in stream:
if len(raw) > 64 * 1024 or len(rows) >= 250_000:
raise ObjectDistanceModuleError("LiDAR binding timeline exceeds its bound")
row = json.loads(raw)
if (
not isinstance(row, dict)
or row.get("schema_version") != "missioncore.m49-tgs-portable-source-index-row/v1"
or row.get("timeline_frame_index") != len(rows)
):
raise ObjectDistanceModuleError("LiDAR binding row identity changed")
seconds = _number(row.get("session_seconds"), "LiDAR binding time")
if seconds <= previous or not isinstance(row.get("sample_available"), bool):
raise ObjectDistanceModuleError("LiDAR binding timeline changed")
rows.append(row)
previous = seconds
if not rows:
raise ObjectDistanceModuleError("LiDAR binding timeline is empty")
return rows
def _aligned_camera_seconds(
binding: dict[str, object],
rf_row: dict[str, object],
*,
frame_index: int,
previous_camera_seconds: float,
) -> float:
# Validate both clocks, but join the sealed products by their shared frame
# index. Camera-source repairs MP4 discontinuities; the LiDAR binding keeps
# the original segment clock, so equality between their seconds is invalid.
_number(binding.get("session_seconds"), "LiDAR binding time")
if rf_row.get("frame_index") != frame_index:
raise ObjectDistanceModuleError("RF-DETR and LiDAR frame identities differ")
seconds = _number(rf_row.get("session_seconds"), "camera session time")
if seconds <= previous_camera_seconds:
raise ObjectDistanceModuleError("RF-DETR camera timeline changed")
return seconds
def execute(
*,
detections: Path,
lidar_pack: Path,
binding_index: Path,
calibration: Path,
output: Path,
session_id: str,
) -> dict[str, object]:
index = _binding_rows(binding_index)
rf_rows = _detections(detections, len(index))
projection = _projection(calibration)
output = output.expanduser().absolute()
if output.exists():
if output.is_symlink() or not output.is_dir() or any(output.iterdir()):
raise ObjectDistanceModuleError("output root is unsafe")
else:
output.mkdir(mode=0o700, parents=True, exist_ok=False)
if output.is_symlink() or not output.is_dir():
raise ObjectDistanceModuleError("output root is unsafe")
pack = _LidarPack(lidar_pack)
started = time.monotonic()
ranged = 0
proposal_count = 0
unavailable = 0
result_path = output / "object-distances.jsonl"
try:
store = _CurrentStore(_profile(session_id, pack))
provider = Ravnoves00GeometryAssociationProvider(store=store) # type: ignore[arg-type]
surface = K1LocalSurfaceShadowEstimator()
previous_camera_seconds = -1.0
with result_path.open("xb") as stream:
for frame_index, (binding, rf_row) in enumerate(zip(index, rf_rows, strict=True)):
seconds = _aligned_camera_seconds(
binding,
rf_row,
frame_index=frame_index,
previous_camera_seconds=previous_camera_seconds,
)
previous_camera_seconds = seconds
raw_proposals = rf_row.get("proposals")
if not isinstance(raw_proposals, list):
raise ObjectDistanceModuleError("RF-DETR proposals are unavailable")
proposals = tuple(ObjectProposal2D.from_dict(value) for value in raw_proposals)
proposal_count += len(proposals)
store.current = None
available = binding["sample_available"] is True
if available:
point = pack.point_frame(
_integer(binding["selected_lidar_frame_index"], "LiDAR frame index")
)
pose = pack.pose_frame(
_integer(binding["selected_pose_frame_index"], "pose frame index")
)
age_ms = abs(point.received_monotonic_ns - pose.received_monotonic_ns) / 1e6
surface_frame = surface.process(
K1LocalSurfaceShadowInput(
frame_index=frame_index,
source_frame_index=_integer(
binding["source_frame_index"], "source frame index"
),
session_seconds=seconds,
pose_binding_age_ms=age_ms,
points_map=point.xyz_map,
position_map=np.asarray(pose.position_map, dtype=np.float64),
published_monotonic_ns=point.received_monotonic_ns,
)
)
store.current = GeometryFrame(
frame_index,
point.xyz_map,
surface_frame.point_class,
np.asarray(pose.position_map, dtype=np.float64),
np.asarray(pose.orientation_map_from_lidar, dtype=np.float64),
projection,
surface_frame.valid,
)
elif proposals:
unavailable += len(proposals)
source_id = proposals[0].source_id if proposals else "recorded-k1"
frame_id = proposals[0].frame_id if proposals else f"frame-{frame_index + 1:06d}"
observations = tuple(
item
for item in provider.associate(
_packet(
session_id=session_id,
source_id=source_id,
frame_id=frame_id,
frame_index=frame_index,
session_seconds=seconds,
spatial_available=available,
),
proposals,
)
if item.proposal_ids
)
ranged += sum(item.metric_geometry is not None for item in observations)
stream.write(
_canonical(
{
"schema_version": ROW_SCHEMA,
"frame_index": frame_index,
"session_seconds": seconds,
"observations": [item.to_dict() for item in observations],
}
)
+ b"\n"
)
result = {
"schema_version": SCHEMA,
"module_id": "object-distance",
"source_session_id": session_id,
"frame_count": len(index),
"proposal_count": proposal_count,
"ranged_proposal_count": ranged,
"unavailable_proposal_count": unavailable,
"object_distances_sha256": _sha(result_path),
"elapsed_seconds": time.monotonic() - started,
"range_estimator": "median-camera-z-of-owned-current-points/v1",
"authority": AUTHORITY,
}
(output / "result.json").write_bytes(_canonical(result))
return result
finally:
pack.close()
def _package_session(path: Path) -> str:
document = json.loads(path.read_text())
source = document.get("source") if isinstance(document, dict) else None
session_id = source.get("session_id") if isinstance(source, dict) else None
if (
not isinstance(document, dict)
or document.get("schema_version") != "missioncore.observatory-portable-lab-v1-source/v1"
or not isinstance(session_id, str)
):
raise ObjectDistanceModuleError("prepared source identity changed")
return session_id
def main(argv: list[str] | None = None) -> int:
arguments = list(sys.argv[1:] if argv is None else argv)
if arguments == ["--package-step", "object-distance"]:
execute(
detections=_PACKAGE_DETECTIONS,
lidar_pack=_PACKAGE_LIDAR_PACK,
binding_index=_PACKAGE_BINDING_INDEX,
calibration=_PACKAGE_CALIBRATION,
output=_PACKAGE_OUTPUT,
session_id=_package_session(_PACKAGE_SOURCE),
)
return 0
parser = argparse.ArgumentParser()
parser.add_argument("--detections", type=Path, required=True)
parser.add_argument("--lidar-pack", type=Path, required=True)
parser.add_argument("--binding-index", type=Path, required=True)
parser.add_argument("--calibration", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--session-id", required=True)
execute(**vars(parser.parse_args(arguments)))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""Recorded-camera RF-DETR module with a sealed, path-local output contract."""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image
from k1link.perception.contracts import (
ClockBasis,
ModalityOutcome,
ModalityStatus,
SourceEnvelope,
TimestampBundle,
)
from k1link.perception.detector import NativeRfDetrShadowDetectorProvider
from k1link.perception.providers import SourcePacket
from k1link.perception.rf_detr_native_object_detector import (
RF_DETR_NATIVE_ENGINE_SHA256,
TritonNativeRfDetrHttpInferenceBackend,
)
from k1link.perception.yolox_object_detector import load_valid_fov_mask
SCHEMA = "missioncore.observatory-ai-module-rf-detr-result/v1"
ROW_SCHEMA = "missioncore.observatory-ai-module-rf-detr-frame/v1"
AUTHORITY = {
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
"production_accepted": False,
}
VALID_FOV_SHA256 = "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"
_PACKAGE_SOURCE = Path("/missioncore/input/steps/prepare/source-input.json")
_PACKAGE_FRAMES = Path("/missioncore/input/steps/camera-source/source-frames")
_PACKAGE_TIMELINE = Path("/missioncore/input/steps/camera-source/timeline.jsonl")
_PACKAGE_VALID_FOV = Path("/opt/nodedc/assets/valid-fov-mask")
_PACKAGE_ENGINE = Path("/models/rf_detr_large_native_kb4/1/model.plan")
_PACKAGE_OUTPUT = Path("/missioncore/output")
class RfDetrModuleError(RuntimeError):
pass
def _canonical(value: object) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
def _sha(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _file(path: Path, expected: str, label: str) -> Path:
candidate = path.expanduser().absolute()
if candidate.is_symlink():
raise RfDetrModuleError(f"{label} identity changed")
candidate = candidate.resolve(strict=True)
if not candidate.is_file() or _sha(candidate) != expected:
raise RfDetrModuleError(f"{label} identity changed")
return candidate
def _empty(path: Path) -> Path:
candidate = path.expanduser().absolute()
if candidate.exists():
if candidate.is_symlink() or not candidate.is_dir() or any(candidate.iterdir()):
raise RfDetrModuleError("output root is unsafe")
else:
candidate.mkdir(mode=0o700, parents=True, exist_ok=False)
if candidate.is_symlink() or not candidate.is_dir():
raise RfDetrModuleError("output root is unsafe")
return candidate
def _rows(path: Path, *, maximum: int) -> list[dict[str, object]]:
result: list[dict[str, object]] = []
with path.open(encoding="utf-8") as stream:
for raw in stream:
if len(raw) > 64 * 1024 or len(result) >= maximum:
raise RfDetrModuleError("camera timeline exceeds the module bound")
row = json.loads(raw)
if not isinstance(row, dict):
raise RfDetrModuleError("camera timeline row is invalid")
result.append(row)
if not result:
raise RfDetrModuleError("camera timeline is empty")
return result
def _wait_triton(process: subprocess.Popen[bytes]) -> None:
import http.client
deadline = time.monotonic() + 45
while time.monotonic() < deadline:
if process.poll() is not None:
raise RfDetrModuleError("RF-DETR inference runtime stopped during startup")
try:
connection = http.client.HTTPConnection("127.0.0.1", 8000, timeout=1)
connection.request("GET", "/v2/models/rf_detr_large_native_kb4/ready")
response = connection.getresponse()
response.read()
connection.close()
if response.status == 200:
return
except OSError:
pass
time.sleep(0.1)
raise RfDetrModuleError("RF-DETR inference runtime did not become ready")
def _packet(
session_id: str, source_id: str, frame_index: int, session_seconds: float, image: np.ndarray
) -> SourcePacket:
available = ModalityStatus(True, ModalityOutcome.AVAILABLE, "recorded-camera-frame")
unavailable = ModalityStatus(False, ModalityOutcome.UNAVAILABLE, "module-input-not-requested")
nanoseconds = round(session_seconds * 1_000_000_000)
envelope = SourceEnvelope(
source_id=source_id,
session_id=session_id,
frame_id=f"frame-{frame_index + 1:06d}",
sequence=frame_index,
timestamps=TimestampBundle(
utc_ns=nanoseconds,
monotonic_ns=nanoseconds,
source_ns=nanoseconds,
clock_basis=ClockBasis.RECORDED_HOST,
),
source_age_ns=0,
binding_reason="recorded-camera-timeline",
calibration_id="camera-1-kb4-05f3ad9b",
representation_id="rf-detr-native-kb4-v1",
image=available,
registered_point_increment=unavailable,
pose=unavailable,
)
return SourcePacket(envelope, image, None, None)
def execute(
*,
frames: Path,
timeline: Path,
valid_fov: Path,
engine: Path,
output: Path,
session_id: str,
source_id: str,
) -> dict[str, object]:
frames = frames.expanduser().resolve(strict=True)
if frames.is_symlink() or not frames.is_dir():
raise RfDetrModuleError("prepared camera frames are unavailable")
timeline = timeline.expanduser().resolve(strict=True)
valid_fov = _file(valid_fov, VALID_FOV_SHA256, "valid-FOV mask")
_file(engine, RF_DETR_NATIVE_ENGINE_SHA256, "RF-DETR TensorRT engine")
rows = _rows(timeline, maximum=100_000)
names = tuple(f"frame-{index + 1:06d}.png" for index in range(len(rows)))
if tuple(sorted(path.name for path in frames.iterdir())) != names:
raise RfDetrModuleError("prepared camera frame set changed")
output = _empty(output)
log = (output / "triton.log").open("wb")
process = subprocess.Popen(
[
"tritonserver",
"--model-repository=/models",
"--model-control-mode=explicit",
"--load-model=rf_detr_large_native_kb4",
"--allow-grpc=false",
"--allow-metrics=false",
"--http-address=127.0.0.1",
"--pinned-memory-pool-byte-size=16777216",
"--cuda-memory-pool-byte-size=0:16777216",
],
stdout=log,
stderr=subprocess.STDOUT,
start_new_session=True,
)
backend: TritonNativeRfDetrHttpInferenceBackend | None = None
started = time.monotonic()
counts = 0
detections_path = output / "detections.jsonl"
try:
_wait_triton(process)
backend = TritonNativeRfDetrHttpInferenceBackend("http://127.0.0.1:8000")
detector = NativeRfDetrShadowDetectorProvider(
mask=load_valid_fov_mask(valid_fov),
backend=backend,
)
detector.warm_up()
with detections_path.open("xb") as stream:
previous = -1.0
for frame_index, (name, row) in enumerate(zip(names, rows, strict=True)):
seconds = row.get("session_seconds")
if (
row.get("frame_index") != frame_index
or isinstance(seconds, bool)
or not isinstance(seconds, (int, float))
or float(seconds) <= previous
):
raise RfDetrModuleError("camera timeline identity changed")
with Image.open(frames / name) as source:
rgb = np.asarray(source.convert("RGB"), dtype=np.uint8)
if rgb.shape != (600, 800, 3):
raise RfDetrModuleError("camera raster changed")
proposals = detector.detect(
_packet(
session_id,
source_id,
frame_index,
float(seconds),
np.ascontiguousarray(rgb[:, :, ::-1]),
)
)
counts += len(proposals)
stream.write(
_canonical(
{
"schema_version": ROW_SCHEMA,
"frame_index": frame_index,
"session_seconds": float(seconds),
"proposals": [proposal.to_dict() for proposal in proposals],
}
)
+ b"\n"
)
previous = float(seconds)
snapshot = detector.snapshot()
result = {
"schema_version": SCHEMA,
"module_id": "rf-detr",
"source": {"session_id": session_id, "source_id": source_id},
"frame_count": len(rows),
"proposal_count": counts,
"zero_proposal_frame_count": snapshot.zero_proposal_frames,
"detections_sha256": _sha(detections_path),
"elapsed_seconds": time.monotonic() - started,
"authority": AUTHORITY,
}
(output / "result.json").write_bytes(_canonical(result))
return result
finally:
if backend is not None:
backend.close()
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
log.close()
def _package_source(path: Path) -> tuple[str, str]:
document = json.loads(path.read_text())
if (
not isinstance(document, dict)
or document.get("schema_version") != "missioncore.observatory-portable-lab-v1-source/v1"
or not isinstance(document.get("source"), dict)
or not isinstance(document.get("camera_compute_job"), dict)
):
raise RfDetrModuleError("prepared camera source contract changed")
source = document["source"]
camera = document["camera_compute_job"]
session_id = source.get("session_id")
source_id = camera.get("source_id")
if not isinstance(session_id, str) or not isinstance(source_id, str):
raise RfDetrModuleError("prepared camera source identity changed")
return session_id, source_id
def main(argv: list[str] | None = None) -> int:
arguments = list(sys.argv[1:] if argv is None else argv)
if arguments == ["--package-step", "rf-detr"]:
session_id, source_id = _package_source(_PACKAGE_SOURCE)
execute(
frames=_PACKAGE_FRAMES,
timeline=_PACKAGE_TIMELINE,
valid_fov=_PACKAGE_VALID_FOV,
engine=_PACKAGE_ENGINE,
output=_PACKAGE_OUTPUT,
session_id=session_id,
source_id=source_id,
)
return 0
parser = argparse.ArgumentParser()
parser.add_argument("--frames", type=Path, required=True)
parser.add_argument("--timeline", type=Path, required=True)
parser.add_argument("--valid-fov", type=Path, required=True)
parser.add_argument("--engine", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--session-id", required=True)
parser.add_argument("--source-id", required=True)
options = parser.parse_args(arguments)
execute(**vars(options))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Decode one sealed K1 camera epoch without loading any AI model."""
from __future__ import annotations
import os
import shutil
import sys
from pathlib import Path
from portable_lab_v1_component_adapter import (
AssetExpectation,
ComponentAdapterError,
RuntimeLayout,
load_component_request,
resolve_runtime_layout,
run_command,
sha256_file,
validate_camera_compute_job,
validate_tree_asset,
validate_tree_receipt,
)
from run_portable_lab_v1_eomt_component import (
FFMPEG_TREE_BYTE_LENGTH,
FFMPEG_TREE_IDENTITY_SHA256,
FFMPEG_TREE_SOURCE_IMAGE_SHA256,
_decode_camera_epoch,
_source_frame_manifest_document,
_write_canonical_json,
)
FFMPEG_ASSET = AssetExpectation(
"eomt-ffmpeg-runtime",
"/opt/nodedc/assets/ffmpeg-runtime",
"tree",
"identity-sha256",
FFMPEG_TREE_IDENTITY_SHA256,
FFMPEG_TREE_BYTE_LENGTH,
)
def execute(*, request_path: Path, layout: RuntimeLayout) -> None:
request = load_component_request(
request_path, component="camera-source", expectations=(FFMPEG_ASSET,)
)
output = layout.output_root
if output.is_symlink() or not output.is_dir() or any(output.iterdir()):
raise ComponentAdapterError("camera-source output must be an empty real directory")
input_document = validate_camera_compute_job(layout.camera_job_root, request.source)
ffmpeg_root = validate_tree_asset(request, layout, FFMPEG_ASSET.asset_id)
validate_tree_receipt(
request,
ffmpeg_root,
FFMPEG_ASSET.asset_id,
expected_metadata={
"source_image_sha256": FFMPEG_TREE_SOURCE_IMAGE_SHA256,
"source_path": "/usr/lib/ffmpeg/7.0",
},
additional_metadata_keys=frozenset({"binaries"}),
verify_payload=True,
)
workspace = output / ".camera-source-work"
workspace.mkdir(mode=0o700)
try:
frames, timeline, repair = _decode_camera_epoch(
request=request,
input_document=input_document,
camera_job_root=layout.camera_job_root,
output_root=output,
work_root=workspace,
ffmpeg_root=ffmpeg_root,
command_runner=run_command,
)
_write_canonical_json(
output / "source-frames.json", _source_frame_manifest_document(frames, request)
)
_write_canonical_json(output / "decode-repair.json", repair)
os.replace(timeline, output / "timeline.jsonl")
_write_canonical_json(
output / "camera-source.json",
{
"schema_version": "missioncore.observatory-prepared-camera/v1",
"source": {
"camera_job_id": request.source.camera_job_id,
"input_sha256": request.source.camera_input_sha256,
"frame_count": request.source.frame_count,
},
"artifacts": [
{
"role": role,
"path": name,
"byte_length": (output / name).stat().st_size,
"sha256": sha256_file(output / name),
}
for role, name in (
("decode-repair", "decode-repair.json"),
("source-frames", "source-frames.json"),
("timeline", "timeline.jsonl"),
)
],
},
)
finally:
shutil.rmtree(workspace, ignore_errors=True)
def main() -> int:
layout = resolve_runtime_layout(
tuple(sys.argv[1:]), component="camera-source", expectations=(FFMPEG_ASSET,)
)
execute(request_path=layout.request, layout=layout)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Run the sealed LAB V1 DDRNet component over EoMT-decoded K1 frames."""
"""Run DDRNet over independently prepared, immutable K1 camera frames."""
from __future__ import annotations
@@ -38,6 +38,7 @@ from portable_lab_v1_component_adapter import (
validate_file_asset,
validate_fixed_result,
validate_grayscale_png_payload,
validate_prepared_camera_root,
)
DDRNET_RESULT_SCHEMA: Final = "missioncore.lab-v1-goose-vegetation-run/v1"
@@ -136,7 +137,7 @@ def execute_ddrnet_component(
layout.effective_ddrnet_config,
request,
)
eomt_root, source_frames, decode_repair = _validate_eomt_input(
prepared_root, source_frames, decode_repair = _validate_prepared_input(
layout.eomt_result_root,
request,
)
@@ -148,7 +149,7 @@ def execute_ddrnet_component(
# tmpfs makes a full 6,830-frame K1 run fail even though the immutable
# input is valid. Verify every manifest digest through an O_NOFOLLOW
# descriptor, then let the sealed runner read that same read-only tree.
frames_root = _verify_source_frames(eomt_root, source_frames)
frames_root = _verify_source_frames(prepared_root, source_frames)
mapping_copy = workspace / "goose_label_mapping.csv"
shutil.copyfile(assets["ddrnet-goose-mapping"], mapping_copy)
os.chmod(mapping_copy, 0o400)
@@ -182,7 +183,7 @@ def execute_ddrnet_component(
"0",
)
command_runner(argv, _ddrnet_environment())
shutil.copyfile(eomt_root / "decode-repair.json", staging / "decode-repair.json")
shutil.copyfile(prepared_root / "decode-repair.json", staging / "decode-repair.json")
if (
load_json(
staging / "decode-repair.json",
@@ -269,13 +270,29 @@ def _validate_effective_config(
return config
def _validate_eomt_input(
def _validate_prepared_input(
root: Path,
request: ComponentRequest,
) -> tuple[Path, tuple[SourceFrameRow, ...], dict[str, object]]:
if root.is_symlink():
raise ComponentAdapterError("EoMT result root is a symbolic link")
raise ComponentAdapterError("prepared camera root is a symbolic link")
resolved = root.resolve(strict=True)
if not (resolved / "result.json").exists():
validate_prepared_camera_root(resolved, request)
manifest_path = _confined_regular_file(
resolved / "source-frames.json", resolved, "prepared source frame manifest"
)
source_frames = _source_frame_manifest(manifest_path, resolved, request)
repair = load_json(
resolved / "decode-repair.json",
label="camera decode repair",
maximum=1024 * 1024,
confinement_root=resolved,
)
_validate_decode_repair(repair, request.source.frame_count)
_confined_regular_file(resolved / "timeline.jsonl", resolved, "prepared camera timeline")
return resolved, source_frames, repair
# Migration compatibility for already sealed dual-model releases only.
result = validate_fixed_result(
resolved,
schema_version=EOMT_RESULT_SCHEMA,
@@ -37,6 +37,7 @@ from portable_lab_v1_component_adapter import (
validate_fixed_result,
validate_grayscale_png_payload,
validate_identity_manifest,
validate_prepared_camera_root,
validate_tree_asset,
validate_tree_receipt,
)
@@ -50,11 +51,10 @@ MODEL_REVISION: Final = "8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f"
MODEL_ID: Final = "tue-mps/cityscapes_semantic_eomt_large_1024"
MODEL_ARCHITECTURE: Final = "EomtForUniversalSegmentation"
PHYSICAL_CAMERA_SOURCE_ID: Final = "sensor.camera.right"
# Keep a large post-run floor while admitting the full 6,830-frame K1 record on
# Worker 006. The independent ``reserve`` below already accounts for the
# complete worst-case working set, so adding the historical 360 GiB floor made
# the real job miss admission by about 1.4 GB despite 408 GB being free.
DISK_FLOOR_BYTES: Final = 350 * 1024**3
# Owner-approved post-run disk floor (2026-09-03); not a RAM/VRAM reservation.
# The independent ``reserve`` below remains additional to this free-space floor.
# Deploy only through a new sealed image/release, never patch an installed digest.
DISK_FLOOR_BYTES: Final = 250 * 1024**3
FFMPEG_TREE_SOURCE_IMAGE_SHA256: Final = (
"8a364092b03561b9c08ac00730206e363a53d07ea0304f7d543b403b65432b5e"
)
@@ -163,26 +163,49 @@ def execute_eomt_component(
component="eomt",
expectations=EOMT_ASSETS,
)
input_document = validate_camera_compute_job(layout.camera_job_root, request.source)
roots = _validate_release_assets(request, layout)
output = _empty_output_root(layout.output_root)
reserve = request.source.frame_count * 800 * 600 * 7 + request.source.input_byte_length
free_before = available_bytes(output)
if disk_floor_bytes < 0 or free_before < disk_floor_bytes + reserve:
if (
isinstance(disk_floor_bytes, bool)
or not isinstance(disk_floor_bytes, int)
or disk_floor_bytes < 0
or free_before < disk_floor_bytes + reserve
):
raise ComponentAdapterError("EoMT output does not satisfy its disk reserve")
# Fail before hashing the complete recording and several GiB of model assets.
# The typed request supplies a bounded estimate; input/asset validation is
# still mandatory before decoding or model execution.
input_document = validate_camera_compute_job(layout.camera_job_root, request.source)
roots = _validate_release_assets(request, layout)
workspace = _prepare_workspace(output / ".eomt-work" if work_root is None else work_root)
try:
total_started = time.perf_counter()
extract_started = time.perf_counter()
frames_root, timeline_path, decode_repair = _decode_camera_epoch(
request=request,
input_document=input_document,
camera_job_root=layout.camera_job_root,
output_root=output,
work_root=workspace,
ffmpeg_root=roots["eomt-ffmpeg-runtime"],
command_runner=command_runner,
)
if (layout.eomt_result_root / "camera-source.json").is_file():
prepared = validate_prepared_camera_root(layout.eomt_result_root, request)
frames_root = layout.eomt_result_root / "source-frames"
timeline_path = prepared["timeline"]
decode_repair = load_json(
prepared["decode-repair"],
label="prepared camera decode repair",
maximum=1024 * 1024,
confinement_root=layout.eomt_result_root,
)
_validate_source_frame_manifest(prepared["source-frames"], frames_root, request)
else:
# Historical fixed-layout tests and already sealed releases retain
# their old in-component decode path. New modular packages always
# mount camera-source and never make one model prepare another.
frames_root, timeline_path, decode_repair = _decode_camera_epoch(
request=request,
input_document=input_document,
camera_job_root=layout.camera_job_root,
output_root=output,
work_root=workspace,
ffmpeg_root=roots["eomt-ffmpeg-runtime"],
command_runner=command_runner,
)
extract_seconds = _elapsed(extract_started)
free_post_extract = available_bytes(output)
if free_post_extract < disk_floor_bytes:
@@ -68,6 +68,7 @@ M49_RELEASE_SOURCES: Final = (
Path("experiments/perception/worker/m49_t3_travel/build_tgs_full_shadow_binary.sh"),
Path("experiments/perception/worker/m49_t3_travel/build_tgs_full_shadow_evidence.py"),
Path("src/k1link/compute/lidar_replay.py"),
Path("src/k1link/compute/lidar_preparation.py"),
Path("src/k1link/observatory/m49_portable_executor.py"),
Path("src/k1link/observatory/m49_portable_result.py"),
Path("src/k1link/observatory/m49_portable_source.py"),
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
"""Reuse exact v2 inputs before decoding the source, without changing v2 identity.
The legacy producer hashes its own file into every pack. Keep that producer
unchanged: this adapter only selects and verifies an existing pack, or calls
the original builder. A cache hit is not a streaming/cold-start qualification.
"""
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import stat
from pathlib import Path
from typing import Any
from . import lidar_replay
from .lidar_contract import K1_LIDAR_PACK_V2_PROFILE
from .lidar_replay import (
DEFAULT_POSE_COVERAGE_THRESHOLD_MS,
LIDAR_MANIFEST_NAME,
LIDAR_REPLAY_PACK_SCHEMA,
LidarReplayError,
LidarReplayPackV2,
)
_PACK_ID = re.compile(r"^lidar-replay-pack-[a-f0-9]{64}$")
_SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_MAX_MANIFEST_BYTES = 128 * 1024
_HASH_CHUNK_BYTES = 1024 * 1024
type _FileStamp = tuple[int, int, int, int, int]
def prepare_lidar_replay_pack_v2(
capture_path: Path,
output_root: Path,
*,
session_id: str | None = None,
pose_coverage_threshold_ms: float = DEFAULT_POSE_COVERAGE_THRESHOLD_MS,
) -> Path:
"""Return the exact validated input, skipping source decode on a cache hit.
Only current-producer packs with exact raw/metadata/clock-origin digests may
be reused. Their ordinary strict reader still checks artifacts, arrays,
logical content and equivalence. Corruption fails without replacing evidence.
No source or pack array survives this call.
"""
source = capture_path.expanduser().resolve(strict=True)
if source.name != "mqtt.raw.k1mqtt" or not source.is_file():
raise LidarReplayError("LiDAR replay source must be mqtt.raw.k1mqtt")
metadata = source.with_name("mqtt.metadata.jsonl")
if not metadata.is_file():
raise LidarReplayError("exact host timing requires mqtt.metadata.jsonl")
if (
not math.isfinite(pose_coverage_threshold_ms)
or not 0 < pose_coverage_threshold_ms <= 10_000
):
raise LidarReplayError("pose coverage threshold is invalid")
resolved_session = session_id or source.parents[2].name
if _SESSION_ID.fullmatch(resolved_session) is None:
raise LidarReplayError("LiDAR replay session id is unsafe")
parent = output_root.expanduser().absolute()
if parent.is_symlink():
raise LidarReplayError("LiDAR preparation cache cannot be a symlink")
producer = Path(lidar_replay.__file__).resolve(strict=True)
producer_sha256, _ = _hash_regular_file(producer)
candidates = _candidates(parent, resolved_session, producer_sha256)
# A cold directory does not add another whole-source hash pass.
if not candidates:
return lidar_replay.build_lidar_replay_pack_v2(
source,
parent,
session_id=resolved_session,
pose_coverage_threshold_ms=pose_coverage_threshold_ms,
)
evidence, stamps = _source_evidence(source, metadata)
matches = [
(root, identity)
for root, identity in candidates
if identity.get("source_evidence") == evidence
]
if len(matches) > 1:
raise LidarReplayError("LiDAR preparation cache has ambiguous source identity")
if not matches:
result = lidar_replay.build_lidar_replay_pack_v2(
source,
parent,
session_id=resolved_session,
pose_coverage_threshold_ms=pose_coverage_threshold_ms,
)
_check_source_stamps(source, stamps)
return result
root, identity = matches[0]
pack = LidarReplayPackV2(root)
try:
if pack.identity != identity:
raise LidarReplayError("LiDAR preparation cache changed during validation")
pose_binding = pack.quality.get("pose_binding")
if (
not isinstance(pose_binding, dict)
or pose_binding.get("threshold_ms") != pose_coverage_threshold_ms
):
# v2 did not include this report parameter in its identity. Never
# silently return another report or overwrite the existing pack.
raise LidarReplayError("LiDAR cached pose coverage threshold differs")
_check_source_stamps(source, stamps)
return root
finally:
pack.close()
def _candidates(
parent: Path,
session_id: str,
producer_sha256: str,
) -> list[tuple[Path, dict[str, Any]]]:
if not parent.exists():
return []
result: list[tuple[Path, dict[str, Any]]] = []
for root in parent.iterdir():
if _PACK_ID.fullmatch(root.name) is None:
continue
if root.is_symlink() or not root.is_dir():
raise LidarReplayError("LiDAR preparation cache entry is unsafe")
manifest_path = root / LIDAR_MANIFEST_NAME
if manifest_path.is_symlink():
raise LidarReplayError("LiDAR preparation manifest cannot be a symlink")
try:
with manifest_path.open("rb") as stream:
payload = stream.read(_MAX_MANIFEST_BYTES + 1)
if len(payload) > _MAX_MANIFEST_BYTES:
raise ValueError("manifest too large")
manifest = json.loads(payload)
except (OSError, UnicodeDecodeError, ValueError) as exc:
raise LidarReplayError("LiDAR preparation manifest is invalid") from exc
identity = manifest.get("identity") if isinstance(manifest, dict) else None
if not isinstance(identity, dict):
raise LidarReplayError("LiDAR preparation identity is invalid")
# Unrelated profiles/producers are preserved, never eagerly decoded.
if (
identity.get("session_id") != session_id
or identity.get("producer_sha256") != producer_sha256
):
continue
encoded = json.dumps(
identity, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False
).encode()
digest = hashlib.sha256(encoded).hexdigest()
if (
manifest.get("schema_version") != LIDAR_REPLAY_PACK_SCHEMA
or identity.get("schema_version") != LIDAR_REPLAY_PACK_SCHEMA
or manifest.get("pack_id") != root.name
or root.name != f"lidar-replay-pack-{digest}"
or manifest.get("identity_sha256") != digest
or identity.get("lidar_evidence_profile") != K1_LIDAR_PACK_V2_PROFILE.to_dict()
):
raise LidarReplayError("LiDAR preparation identity changed")
result.append((root.resolve(strict=True), identity))
return result
def _stamp(value: os.stat_result) -> _FileStamp:
return value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns
def _hash_regular_file(path: Path) -> tuple[str, _FileStamp]:
before = path.lstat()
if not stat.S_ISREG(before.st_mode):
raise LidarReplayError("LiDAR source evidence must be a regular file")
digest = hashlib.sha256()
with path.open("rb") as stream:
if _stamp(os.fstat(stream.fileno())) != _stamp(before):
raise LidarReplayError("LiDAR source evidence changed before hashing")
while chunk := stream.read(_HASH_CHUNK_BYTES):
digest.update(chunk)
if _stamp(os.fstat(stream.fileno())) != _stamp(before):
raise LidarReplayError("LiDAR source evidence changed during hashing")
if _stamp(path.lstat()) != _stamp(before):
raise LidarReplayError("LiDAR source evidence changed after hashing")
return digest.hexdigest(), _stamp(before)
def _source_evidence(
source: Path,
metadata: Path,
) -> tuple[dict[str, object], dict[Path, _FileStamp]]:
paths = {"raw": source, "metadata": metadata}
origin = source.with_name("mqtt.timeline.origin.json")
if origin.exists():
paths["clock_origin"] = origin
evidence: dict[str, object] = {}
stamps: dict[Path, _FileStamp] = {}
for role, path in paths.items():
digest, stamp = _hash_regular_file(path)
evidence[role] = {"sha256": digest, "byte_length": stamp[2]}
stamps[path] = stamp
return evidence, stamps
def _check_source_stamps(source: Path, stamps: dict[Path, _FileStamp]) -> None:
origin = source.with_name("mqtt.timeline.origin.json")
if origin.exists() != (origin in stamps):
raise LidarReplayError("LiDAR source clock origin changed during preparation")
for path, expected in stamps.items():
if not stat.S_ISREG(path.lstat().st_mode) or _stamp(path.lstat()) != expected:
raise LidarReplayError("LiDAR source evidence changed during preparation")
@@ -15,6 +15,7 @@ import hashlib
import io
import json
import os
import re
import subprocess
import sys
import tempfile
@@ -35,6 +36,10 @@ APPLICATION_ID: Final = "nodedc_mission_core_recorded"
SESSION_TIMELINE: Final = "session_time"
RENDERER_VERSION: Final = "upstream-rerun-0.36.3-encoded-optimized-v6"
REPLAY_RENDERER_VERSION: Final = "upstream-rerun-0.36.3-canonical-replay-v1"
CANONICAL_REPLAY_RESULT_ID: Final = re.compile(
r"^(?:lab-v1-vegetation-shadow|m49-tgs-portable-review|lab-v1-eomt-ddrnet|"
r"ai-layer-(?:ddrnet|eomt|rf-detr|object-distance)|ai-composition)-[a-f0-9]{64}$"
)
MAX_SOURCE_BYTES: Final = 768 * 1024 * 1024
MAX_OVERLAY_BYTES: Final = 256 * 1024 * 1024
MAX_REPLAY_BYTES: Final = 1024 * 1024 * 1024
@@ -137,9 +142,7 @@ class CanonicalLabReplayArtifact:
_render_lock = threading.Lock()
_memory_cache: dict[tuple[str, str, str], CanonicalLabOverlayArtifact] = {}
_replay_lock = threading.Lock()
_replay_memory_cache: dict[
tuple[str, str, str, str], CanonicalLabReplayArtifact
] = {}
_replay_memory_cache: dict[tuple[str, str, str, str], CanonicalLabReplayArtifact] = {}
def canonical_recording_id(path: Path) -> str:
@@ -282,8 +285,7 @@ def canonical_lab_replay(
or not _is_sha256(base_generation_sha256)
or _sha256(base) != base_generation_sha256
or not _artifact_is_regular(overlay)
or not result_id.startswith("lab-v1-vegetation-shadow-")
or len(result_id) != len("lab-v1-vegetation-shadow-") + 64
or CANONICAL_REPLAY_RESULT_ID.fullmatch(result_id) is None
or not recording_id
or len(recording_id) > 128
):
@@ -445,12 +447,11 @@ def _verified_camera_source(root: Path, route: dict[str, Any], jobs_root: Path)
or not isinstance(files, list)
):
raise CanonicalLabOverlayError("camera job source contract changed")
epoch_prefix = PurePosixPath(
"input/camera/sensor.camera.right"
) / f"epoch-{source.get('codec_epoch')}"
epoch_prefix = (
PurePosixPath("input/camera/sensor.camera.right") / f"epoch-{source.get('codec_epoch')}"
)
required = [epoch_prefix / "init.mp4"] + [
epoch_prefix / "segments" / f"{index}.m4s"
for index in range(1, frame_count + 1)
epoch_prefix / "segments" / f"{index}.m4s" for index in range(1, frame_count + 1)
]
descriptors = {
item.get("path"): item
@@ -607,10 +608,7 @@ def _render_overlay(
"/perception/camera/image",
rr.VideoFrameReference(nanoseconds=int(video_references[index])),
)
masks = {
layer_id: _read_mask(archive, index)
for layer_id, archive in archives.items()
}
masks = {layer_id: _read_mask(archive, index) for layer_id, archive in archives.items()}
for layer_id, mask in masks.items():
recording.log(
f"/perception/camera/segmentation/{layer_id}",
@@ -729,15 +727,17 @@ def _video_reference_timestamps(
if (
len(video_timestamps) < int(len(frame_times) * 0.9)
or np.any(np.diff(video_timestamps) < 0)
or abs(int(video_timestamps[-1]) - int(relative_frame_times[-1]))
> 2_000_000_000
or abs(int(video_timestamps[-1]) - int(relative_frame_times[-1])) > 2_000_000_000
):
raise CanonicalLabOverlayError("video proxy timeline changed")
indices = np.searchsorted(
video_timestamps,
relative_frame_times,
side="right",
) - 1
indices = (
np.searchsorted(
video_timestamps,
relative_frame_times,
side="right",
)
- 1
)
return video_timestamps[np.clip(indices, 0, len(video_timestamps) - 1)]
@@ -768,9 +768,7 @@ def _semantic_palette(classes: list[object]) -> tuple[int, ...]:
or not isinstance(color, list)
or len(color) != 3
or any(
not isinstance(channel, int)
or isinstance(channel, bool)
or not 0 <= channel <= 255
not isinstance(channel, int) or isinstance(channel, bool) or not 0 <= channel <= 255
for channel in color
)
):
@@ -818,9 +816,7 @@ def semantic_component_boxes(
mask, class_id, minimum_pixels=minimum_pixels
)[:12]:
score = min(0.99, 0.5 + pixels / 20_000)
candidates.append(
(score, [left, top, right, bottom], f"{label} · {score:.0%}")
)
candidates.append((score, [left, top, right, bottom], f"{label} · {score:.0%}"))
candidates.sort(key=lambda row: (-row[0], row[1][1], row[1][0]))
selected = candidates[:32]
return [row[1] for row in selected], [row[2] for row in selected]
@@ -999,6 +995,8 @@ def _sha256(path: Path) -> str:
def _is_sha256(value: object) -> bool:
return isinstance(value, str) and len(value) == 64 and all(
character in "0123456789abcdef" for character in value
return (
isinstance(value, str)
and len(value) == 64
and all(character in "0123456789abcdef" for character in value)
)
+282
View File
@@ -0,0 +1,282 @@
"""Append-only bindings from one operator composition submission to its jobs."""
from __future__ import annotations
import hashlib
import json
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
from k1link.observatory.modular_composition import CompositionSpec, canonical_bytes
RUN_SCHEMA: Final = "missioncore.observatory-ai-composition-run/v1"
RUN_PROJECTION_SCHEMA: Final = "missioncore.observatory-ai-composition-run-projection/v1"
_RUN = re.compile(r"ai-composition-[a-f0-9]{64}\Z")
_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,159}\Z")
class CompositionRunError(ValueError):
"""A composition-run binding is invalid or changed after admission."""
@dataclass(frozen=True, slots=True)
class CompositionRun:
run_id: str
source_session_id: str
composition_sha256: str
module_ids: tuple[str, ...]
setup_ids: tuple[str, ...]
job_ids: tuple[str, ...]
created_at_utc: str
def __post_init__(self) -> None:
if _RUN.fullmatch(self.run_id) is None:
raise CompositionRunError("invalid composition run id")
if any(
_ID.fullmatch(value) is None
for value in (
self.source_session_id,
*self.module_ids,
*self.setup_ids,
*self.job_ids,
)
):
raise CompositionRunError("invalid composition run identity")
if (
not re.fullmatch(r"[a-f0-9]{64}", self.composition_sha256)
or not self.module_ids
or len(self.setup_ids) != len(self.job_ids)
or len(set(self.setup_ids)) != len(self.setup_ids)
or len(set(self.job_ids)) != len(self.job_ids)
):
raise CompositionRunError("invalid composition run members")
if not self.created_at_utc.endswith("Z"):
raise CompositionRunError("composition run timestamp must be UTC")
def as_dict(self) -> dict[str, object]:
return {
"schema_version": RUN_SCHEMA,
"run_id": self.run_id,
"source_session_id": self.source_session_id,
"composition_sha256": self.composition_sha256,
"module_ids": list(self.module_ids),
"setup_ids": list(self.setup_ids),
"job_ids": list(self.job_ids),
"created_at_utc": self.created_at_utc,
}
class CompositionRunStore:
def __init__(self, root: Path) -> None:
root = root.expanduser().absolute()
root.mkdir(parents=True, exist_ok=True, mode=0o700)
if root.is_symlink() or root.resolve() != root:
raise CompositionRunError("composition run store must be a real directory")
self.root = root
def save(
self,
*,
source_session_id: str,
composition: CompositionSpec,
setup_ids: tuple[str, ...],
job_ids: tuple[str, ...],
idempotency_key: str,
created_at_utc: str,
) -> CompositionRun:
digest = hashlib.sha256(
canonical_bytes(
{
"source_session_id": source_session_id,
"composition_sha256": composition.sha256,
"idempotency_key": idempotency_key,
}
)
).hexdigest()
run = CompositionRun(
run_id=f"ai-composition-{digest}",
source_session_id=source_session_id,
composition_sha256=composition.sha256,
module_ids=tuple(
node.module.module_id
for node in composition.nodes
if node.module.group != "preparation"
),
setup_ids=setup_ids,
job_ids=job_ids,
created_at_utc=created_at_utc,
)
destination = self.root / f"{run.run_id}.json"
payload = canonical_bytes(run.as_dict())
if destination.exists():
if destination.is_symlink() or destination.read_bytes() != payload:
raise CompositionRunError("composition run identity changed")
return run
descriptor, temporary = tempfile.mkstemp(prefix=".run-", dir=self.root)
path = Path(temporary)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
path.chmod(0o444)
path.rename(destination)
finally:
path.unlink(missing_ok=True)
return run
def get(self, run_id: str) -> CompositionRun:
if _RUN.fullmatch(run_id) is None:
raise CompositionRunError("invalid composition run id")
path = self.root / f"{run_id}.json"
if path.is_symlink() or not path.is_file() or path.stat().st_size > 64 * 1024:
raise CompositionRunError("composition run is unavailable")
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CompositionRunError("composition run is unreadable") from exc
return _decode(value)
def list(
self,
*,
source_session_id: str | None = None,
include_hidden: bool = True,
) -> tuple[CompositionRun, ...]:
values: list[CompositionRun] = []
for path in self.root.glob("ai-composition-*.json"):
if _RUN.fullmatch(path.stem) is None:
continue
run = self.get(path.stem)
if (source_session_id is None or run.source_session_id == source_session_id) and (
include_hidden or self.is_visible(run.run_id)
):
values.append(run)
return tuple(sorted(values, key=lambda row: (row.created_at_utc, row.run_id), reverse=True))
def display_name(self, run_id: str) -> str | None:
projection = self._projection(run_id)
value = projection.get("display_name")
return cast(str, value) if isinstance(value, str) else None
def is_visible(self, run_id: str) -> bool:
return self._projection(run_id).get("visible") is not False
def rename_projection(self, run_id: str, display_name: str) -> str:
self.get(run_id)
normalized = display_name.strip()
if not normalized or len(normalized) > 160:
raise CompositionRunError("invalid composition run display name")
projection = self._projection(run_id)
self._write_projection(
run_id, display_name=normalized, visible=projection.get("visible") is not False
)
return normalized
def delete_projection(self, run_id: str) -> None:
self.get(run_id)
projection = self._projection(run_id)
self._write_projection(
run_id,
display_name=cast(str | None, projection.get("display_name")),
visible=False,
)
def _projection(self, run_id: str) -> dict[str, object]:
if _RUN.fullmatch(run_id) is None:
raise CompositionRunError("invalid composition run id")
path = self.root / f"{run_id}.projection.json"
if not path.exists():
return {"display_name": None, "visible": True}
if path.is_symlink() or not path.is_file() or path.stat().st_size > 4 * 1024:
raise CompositionRunError("composition run projection is unavailable")
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CompositionRunError("composition run projection is unreadable") from exc
expected = {"schema_version", "run_id", "display_name", "visible"}
if (
not isinstance(value, dict)
or set(value) != expected
or value.get("schema_version") != RUN_PROJECTION_SCHEMA
or value.get("run_id") != run_id
or not isinstance(value.get("visible"), bool)
or (
value.get("display_name") is not None
and (
not isinstance(value.get("display_name"), str)
or not cast(str, value["display_name"]).strip()
or cast(str, value["display_name"]).strip() != value["display_name"]
or len(cast(str, value["display_name"])) > 160
)
)
):
raise CompositionRunError("invalid composition run projection")
return cast(dict[str, object], value)
def _write_projection(
self,
run_id: str,
*,
display_name: str | None,
visible: bool,
) -> None:
destination = self.root / f"{run_id}.projection.json"
payload = canonical_bytes(
{
"schema_version": RUN_PROJECTION_SCHEMA,
"run_id": run_id,
"display_name": display_name,
"visible": visible,
}
)
descriptor, temporary = tempfile.mkstemp(prefix=".projection-", dir=self.root)
path = Path(temporary)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
path.chmod(0o600)
path.replace(destination)
finally:
path.unlink(missing_ok=True)
def _decode(value: object) -> CompositionRun:
keys = {
"schema_version",
"run_id",
"source_session_id",
"composition_sha256",
"module_ids",
"setup_ids",
"job_ids",
"created_at_utc",
}
if (
not isinstance(value, dict)
or set(value) != keys
or value.get("schema_version") != RUN_SCHEMA
):
raise CompositionRunError("invalid composition run document")
row = cast(dict[str, object], value)
arrays = (row["module_ids"], row["setup_ids"], row["job_ids"])
if any(
not isinstance(items, list) or any(not isinstance(item, str) for item in items)
for items in arrays
):
raise CompositionRunError("invalid composition run member arrays")
return CompositionRun(
run_id=cast(str, row["run_id"]),
source_session_id=cast(str, row["source_session_id"]),
composition_sha256=cast(str, row["composition_sha256"]),
module_ids=tuple(cast(list[str], row["module_ids"])),
setup_ids=tuple(cast(list[str], row["setup_ids"])),
job_ids=tuple(cast(list[str], row["job_ids"])),
created_at_utc=cast(str, row["created_at_utc"]),
)
+265
View File
@@ -0,0 +1,265 @@
"""Strict local Observatory ontology shared by planning, publication and replay UI."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
from k1link.observatory.modular_composition import CompositionSpec
ONTOLOGY_SCHEMA: Final = "missioncore.observatory-domain-ontology/v1"
_ID = re.compile(r"[a-z][a-z0-9._-]{1,95}\Z")
_FIELD_ID = re.compile(r"[a-z][a-z0-9_]{1,63}\Z")
_CARDINALITIES: Final = {
"one-to-one",
"one-to-many",
"many-to-one",
"many-to-many",
"one-to-zero-or-one",
"many-to-zero-or-one",
}
class ObservatoryOntologyError(ValueError):
"""The local ontology cannot answer a product query exactly."""
@dataclass(frozen=True, slots=True)
class ViewerLayer:
layer_id: str
pane_id: str
label: str
control: str
order: int
def as_dict(self) -> dict[str, object]:
return {
"layer_id": self.layer_id,
"pane_id": self.pane_id,
"label": self.label,
"control": self.control,
"order": self.order,
}
@dataclass(frozen=True, slots=True)
class ModuleProjection:
module_id: str
configuration_label: str
layer_ids: tuple[str, ...]
class ObservatoryDomainOntology:
"""Versioned named-query projection; no graph service or Platform dependency."""
def __init__(
self,
*,
document: dict[str, object],
layers: tuple[ViewerLayer, ...],
modules: tuple[ModuleProjection, ...],
) -> None:
self.document = document
self.layers = layers
self._layers = {layer.layer_id: layer for layer in layers}
self._modules = {module.module_id: module for module in modules}
self._module_order = {module.module_id: order for order, module in enumerate(modules)}
if len(self._layers) != len(layers) or len(self._modules) != len(modules):
raise ObservatoryOntologyError("ontology identities must be unique")
for module in modules:
if any(layer not in self._layers for layer in module.layer_ids):
raise ObservatoryOntologyError("module references an unknown viewer layer")
@classmethod
def from_file(cls, path: Path) -> ObservatoryDomainOntology:
candidate = path.expanduser().absolute()
if (
candidate.is_symlink()
or not candidate.is_file()
or candidate.stat().st_size > 1024 * 1024
):
raise ObservatoryOntologyError("ontology must be a bounded regular file")
try:
value = json.loads(candidate.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ObservatoryOntologyError("ontology is unreadable") from exc
if not isinstance(value, dict) or value.get("schema_version") != ONTOLOGY_SCHEMA:
raise ObservatoryOntologyError("unsupported Observatory ontology")
required = {
"schema_version",
"ontology_id",
"version",
"owner",
"lifecycle",
"entities",
"relations",
"panes",
"layers",
"module_projections",
"named_queries",
"platform_sync",
}
if set(value) != required:
raise ObservatoryOntologyError("unexpected Observatory ontology fields")
entity_ids = _validate_entities(value["entities"])
_validate_relations(value["relations"], entity_ids)
pane_ids = _validate_panes(value["panes"])
layers = tuple(_layer(item) for item in _array(value["layers"], "layers"))
if len({layer.layer_id for layer in layers}) != len(layers):
raise ObservatoryOntologyError("ontology layer identities must be unique")
if any(layer.pane_id not in pane_ids for layer in layers):
raise ObservatoryOntologyError("viewer layer references an unknown pane")
modules = tuple(_module(item) for item in _array(value["module_projections"], "modules"))
named = value["named_queries"]
if named != [
"recording.capture-context",
"composition.configuration-label",
"composition.member-results",
"composition.viewer-layers",
"composition.ready-state",
"lab.view-profile",
]:
raise ObservatoryOntologyError("required named queries are absent")
return cls(document=cast(dict[str, object], value), layers=layers, modules=modules)
def project_module_ids(self, module_ids: tuple[str, ...]) -> dict[str, object]:
selected = []
layer_ids: set[str] = set()
for module_id in module_ids:
projection = self._modules.get(module_id)
if projection is None:
raise ObservatoryOntologyError(f"module {module_id} has no ontology projection")
selected.append(projection)
layer_ids.update(projection.layer_ids)
selected.sort(key=lambda row: self._module_order[row.module_id])
layers = sorted(
(self._layers[layer_id] for layer_id in layer_ids),
key=lambda row: (row.pane_id, row.order),
)
return {
"schema_version": "missioncore.observatory-presentation-projection/v1",
"modules": [
{"module_id": row.module_id, "label": row.configuration_label} for row in selected
],
"configuration_label": " · ".join(row.configuration_label for row in selected),
"viewer_layers": [row.as_dict() for row in layers],
}
def project_composition(self, composition: CompositionSpec) -> dict[str, object]:
return self.project_module_ids(
tuple(
node.module.module_id
for node in composition.nodes
if node.module.group != "preparation"
)
)
def _array(value: object, label: str) -> list[object]:
if not isinstance(value, list):
raise ObservatoryOntologyError(f"ontology {label} must be an array")
return value
def _record(value: object, keys: set[str], label: str) -> dict[str, object]:
if not isinstance(value, dict) or set(value) != keys:
raise ObservatoryOntologyError(f"invalid ontology {label}")
return cast(dict[str, object], value)
def _identifier(value: object, label: str) -> str:
if not isinstance(value, str) or not _ID.fullmatch(value):
raise ObservatoryOntologyError(f"invalid {label}")
return value
def _nonempty_text(value: object, label: str) -> str:
if not isinstance(value, str) or not value.strip():
raise ObservatoryOntologyError(f"invalid {label}")
return value
def _validate_entities(value: object) -> set[str]:
identities: set[str] = set()
for item in _array(value, "entities"):
row = _record(item, {"id", "identity", "owner", "lifecycle"}, "entity")
entity_id = _identifier(row["id"], "entity id")
if entity_id in identities:
raise ObservatoryOntologyError("ontology entity identities must be unique")
identities.add(entity_id)
identity_field = row["identity"]
if not isinstance(identity_field, str) or not _FIELD_ID.fullmatch(identity_field):
raise ObservatoryOntologyError("invalid entity identity field")
_nonempty_text(row["owner"], "entity owner")
_nonempty_text(row["lifecycle"], "entity lifecycle")
if not identities:
raise ObservatoryOntologyError("ontology entities must not be empty")
return identities
def _validate_relations(value: object, entity_ids: set[str]) -> None:
identities: set[str] = set()
for item in _array(value, "relations"):
row = _record(item, {"id", "from", "to", "cardinality"}, "relation")
relation_id = _identifier(row["id"], "relation id")
if relation_id in identities:
raise ObservatoryOntologyError("ontology relation identities must be unique")
identities.add(relation_id)
source = _identifier(row["from"], "relation source")
target = _identifier(row["to"], "relation target")
if source not in entity_ids or target not in entity_ids:
raise ObservatoryOntologyError("relation references an unknown entity")
if row["cardinality"] not in _CARDINALITIES:
raise ObservatoryOntologyError("invalid relation cardinality")
def _validate_panes(value: object) -> set[str]:
identities: set[str] = set()
for item in _array(value, "panes"):
row = _record(item, {"pane_id", "label", "order"}, "pane")
pane_id = _identifier(row["pane_id"], "pane id")
if pane_id in identities:
raise ObservatoryOntologyError("ontology pane identities must be unique")
identities.add(pane_id)
_nonempty_text(row["label"], "pane label")
if not isinstance(row["order"], int) or isinstance(row["order"], bool):
raise ObservatoryOntologyError("invalid pane order")
return identities
def _layer(value: object) -> ViewerLayer:
row = _record(value, {"layer_id", "pane_id", "label", "control", "order"}, "layer")
if (
not isinstance(row["label"], str)
or not row["label"]
or row["control"] not in {"toggle", "toggle-with-settings"}
or not isinstance(row["order"], int)
or isinstance(row["order"], bool)
):
raise ObservatoryOntologyError("invalid viewer layer presentation")
return ViewerLayer(
_identifier(row["layer_id"], "layer id"),
_identifier(row["pane_id"], "pane id"),
row["label"],
cast(str, row["control"]),
row["order"],
)
def _module(value: object) -> ModuleProjection:
row = _record(value, {"module_id", "configuration_label", "layers"}, "module projection")
if not isinstance(row["configuration_label"], str) or not row["configuration_label"]:
raise ObservatoryOntologyError("invalid module configuration label")
layers = tuple(
_identifier(item, "viewer layer id") for item in _array(row["layers"], "module layers")
)
if len(layers) != len(set(layers)):
raise ObservatoryOntologyError("module viewer layers must be unique")
return ModuleProjection(
_identifier(row["module_id"], "module id"),
row["configuration_label"],
layers,
)
@@ -47,6 +47,7 @@ from k1link.observatory.portable_worker_runtime import (
PortableWorkerSourceStage,
inspect_runtime_candidate,
)
from k1link.observatory.recorded_progress import report_recorded_progress
from k1link.observatory.worker_agent import ObservatoryWorkerExecutorRegistration
if TYPE_CHECKING:
@@ -159,8 +160,10 @@ class InstalledLabDockerLaunch:
"com.nodedc.definition-sha256",
"com.nodedc.job-id",
"com.nodedc.managed-by",
"com.nodedc.module-id",
"com.nodedc.package-sha256",
"com.nodedc.product",
"com.nodedc.role",
"com.nodedc.stack",
}
if set(self.labels) != required_labels:
@@ -169,7 +172,9 @@ class InstalledLabDockerLaunch:
self.labels["com.nodedc.authority"] != "observation-only"
or self.labels["com.nodedc.component"] != self.container.container_id
or self.labels["com.nodedc.managed-by"] != "mission-core-worker"
or self.labels["com.nodedc.module-id"] != self.container.container_id
or self.labels["com.nodedc.product"] != "mission-core"
or self.labels["com.nodedc.role"] != "ai-module"
or self.labels["com.nodedc.stack"] != "observatory"
):
raise InstalledLabPackageRunnerError("Docker launch labels changed")
@@ -250,14 +255,10 @@ class DockerEngineInstalledLabLauncher:
or len(image_sha256s) != len(set(image_sha256s))
or any(_SHA256.fullmatch(value) is None for value in image_sha256s)
):
raise InstalledLabPackageRunnerError(
"installed LAB image inventory is invalid"
)
raise InstalledLabPackageRunnerError("installed LAB image inventory is invalid")
if self.transport_factory is None:
_require_local_socket(self.socket_path)
transport: httpx.BaseTransport = httpx.HTTPTransport(
uds=str(self.socket_path)
)
transport: httpx.BaseTransport = httpx.HTTPTransport(uds=str(self.socket_path))
else:
transport = self.transport_factory()
with httpx.Client(
@@ -281,7 +282,7 @@ class DockerEngineInstalledLabLauncher:
def _create(self, client: httpx.Client, launch: InstalledLabDockerLaunch) -> str:
component = launch.container.container_id[:32]
name = f"ndc-observatory-{component}-{launch.name_token}"
name = f"ndc-mission-core-ai-module-{component}-{launch.name_token}"
response = self._response(
client,
"POST",
@@ -454,7 +455,9 @@ class InstalledLabPackageProfileRunner:
output_root.mkdir(mode=0o700)
steps_root = job_root / "steps"
steps_root.mkdir(mode=0o700)
plan_path = job_root / "run-plan.json"
plan_root = job_root / "plan"
plan_root.mkdir(mode=0o700)
plan_path = plan_root / "run-plan.json"
_write_exclusive(
plan_path,
canonical_json(
@@ -467,7 +470,9 @@ class InstalledLabPackageProfileRunner:
}
),
)
for container in _topological_containers(self.package):
containers = _topological_containers(self.package)
report_recorded_progress("computing", 0, len(containers), "steps")
for step_index, container in enumerate(containers):
container_output_root = output_root
if container.role == "step":
container_output_root = steps_root / container.container_id
@@ -483,6 +488,8 @@ class InstalledLabPackageProfileRunner:
name_token=attempt_token,
)
)
report_recorded_progress("computing", step_index + 1, len(containers), "steps")
report_recorded_progress("result-assembly", unit="steps")
return _read_result_draft(
output_root,
plan=plan,
@@ -525,11 +532,11 @@ class InstalledLabPackageProfileRunner:
),
InstalledLabDockerMount(
_translate_work_path(
plan_path,
plan_path.parent,
controller_root=self.controller_work_root,
engine_root=self.engine_work_root,
),
INSTALLED_LAB_PLAN_PATH,
str(PurePosixPath(INSTALLED_LAB_PLAN_PATH).parent),
True,
),
InstalledLabDockerMount(
@@ -581,8 +588,10 @@ class InstalledLabPackageProfileRunner:
"com.nodedc.definition-sha256": plan.definition_sha256,
"com.nodedc.job-id": plan.job_id,
"com.nodedc.managed-by": "mission-core-worker",
"com.nodedc.module-id": container.container_id,
"com.nodedc.package-sha256": self.package.package_sha256,
"com.nodedc.product": "mission-core",
"com.nodedc.role": "ai-module",
"com.nodedc.stack": "observatory",
},
name_token=name_token,
@@ -33,7 +33,7 @@ INSTALLED_LAB_PACKAGE_REGISTRY_SCHEMA: Final = (
INSTALLED_LAB_CONTAINER_IO_SCHEMA: Final = "missioncore.observatory-installed-lab-container-io/v2"
INSTALLED_LAB_SOURCE_ROOT: Final = "/missioncore/input/source"
INSTALLED_LAB_PLAN_PATH: Final = "/missioncore/input/run-plan.json"
INSTALLED_LAB_PLAN_PATH: Final = "/missioncore/plan/run-plan.json"
INSTALLED_LAB_STEP_INPUT_ROOT: Final = "/missioncore/input/steps"
INSTALLED_LAB_RESULT_ROOT: Final = "/missioncore/output"
INSTALLED_LAB_WORK_ROOT: Final = "/missioncore/work"
+183
View File
@@ -0,0 +1,183 @@
"""Durable operator display profiles for Observatory LAB results."""
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
PROFILE_SCHEMA: Final = "missioncore.observatory-lab-view-profile/v1"
_RESULT_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,191}\Z")
_COLOR_MODES: Final = {"intensity", "height", "distance", "rgb", "class"}
_PALETTES: Final = {"turbo", "viridis", "plasma", "grayscale"}
class LabViewProfileError(ValueError):
"""A LAB display profile is invalid or unavailable."""
@dataclass(frozen=True, slots=True)
class LabSceneProfile:
point_size: float
accumulation_seconds: float
color_mode: str
palette: str
show_grid: bool
show_labels: bool
show_camera_frustums: bool
def __post_init__(self) -> None:
if (
isinstance(self.point_size, bool)
or not math.isfinite(self.point_size)
or self.point_size < 0.1
or isinstance(self.accumulation_seconds, bool)
or not math.isfinite(self.accumulation_seconds)
or self.accumulation_seconds < 0
or self.color_mode not in _COLOR_MODES
or self.palette not in _PALETTES
or any(
not isinstance(value, bool)
for value in (
self.show_grid,
self.show_labels,
self.show_camera_frustums,
)
)
):
raise LabViewProfileError("invalid LAB scene profile")
def as_dict(self) -> dict[str, object]:
return {
"point_size": self.point_size,
"accumulation_seconds": self.accumulation_seconds,
"color_mode": self.color_mode,
"palette": self.palette,
"show_grid": self.show_grid,
"show_labels": self.show_labels,
"show_camera_frustums": self.show_camera_frustums,
}
@dataclass(frozen=True, slots=True)
class LabViewProfile:
result_id: str
scene_settings: LabSceneProfile
updated_at_utc: str
def __post_init__(self) -> None:
if _RESULT_ID.fullmatch(self.result_id) is None or not self.updated_at_utc.endswith("Z"):
raise LabViewProfileError("invalid LAB view profile identity")
def as_dict(self) -> dict[str, object]:
return {
"schema_version": PROFILE_SCHEMA,
"result_id": self.result_id,
"scene_settings": self.scene_settings.as_dict(),
"updated_at_utc": self.updated_at_utc,
}
class LabViewProfileStore:
"""Mutable, atomic display state keyed by immutable LAB result identity."""
def __init__(self, root: Path) -> None:
root = root.expanduser().absolute()
root.mkdir(parents=True, exist_ok=True, mode=0o700)
if root.is_symlink() or root.resolve() != root:
raise LabViewProfileError("LAB view profile store must be a real directory")
self.root = root
def _path(self, result_id: str) -> Path:
if _RESULT_ID.fullmatch(result_id) is None:
raise LabViewProfileError("invalid LAB result identity")
digest = hashlib.sha256(result_id.encode("utf-8")).hexdigest()
return self.root / f"{digest}.json"
def get(self, result_id: str) -> LabViewProfile | None:
path = self._path(result_id)
if not path.exists():
return None
if path.is_symlink() or not path.is_file() or path.stat().st_size > 64 * 1024:
raise LabViewProfileError("LAB view profile is unavailable")
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise LabViewProfileError("LAB view profile is unreadable") from exc
return _decode(value, expected_result_id=result_id)
def save(self, profile: LabViewProfile) -> LabViewProfile:
destination = self._path(profile.result_id)
payload = (
json.dumps(
profile.as_dict(),
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
+ b"\n"
)
descriptor, temporary = tempfile.mkstemp(prefix=".view-profile-", dir=self.root)
path = Path(temporary)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
path.chmod(0o600)
path.replace(destination)
return profile
finally:
path.unlink(missing_ok=True)
def _decode(value: object, *, expected_result_id: str) -> LabViewProfile:
keys = {"schema_version", "result_id", "scene_settings", "updated_at_utc"}
if (
not isinstance(value, dict)
or set(value) != keys
or value.get("schema_version") != PROFILE_SCHEMA
):
raise LabViewProfileError("invalid LAB view profile document")
row = cast(dict[str, object], value)
if row["result_id"] != expected_result_id or not isinstance(row["updated_at_utc"], str):
raise LabViewProfileError("LAB view profile identity changed")
settings = row["scene_settings"]
setting_keys = {
"point_size",
"accumulation_seconds",
"color_mode",
"palette",
"show_grid",
"show_labels",
"show_camera_frustums",
}
if not isinstance(settings, dict) or set(settings) != setting_keys:
raise LabViewProfileError("invalid LAB scene profile document")
scene = cast(dict[str, object], settings)
if (
not isinstance(scene["point_size"], (int, float))
or not isinstance(scene["accumulation_seconds"], (int, float))
or not isinstance(scene["color_mode"], str)
or not isinstance(scene["palette"], str)
):
raise LabViewProfileError("invalid LAB scene profile values")
return LabViewProfile(
result_id=expected_result_id,
scene_settings=LabSceneProfile(
point_size=float(scene["point_size"]),
accumulation_seconds=float(scene["accumulation_seconds"]),
color_mode=scene["color_mode"],
palette=scene["palette"],
show_grid=scene["show_grid"], # type: ignore[arg-type]
show_labels=scene["show_labels"], # type: ignore[arg-type]
show_camera_frustums=scene["show_camera_frustums"], # type: ignore[arg-type]
),
updated_at_utc=row["updated_at_utc"],
)
@@ -19,7 +19,9 @@ import shutil
import stat
import subprocess
import tempfile
import time
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Protocol, cast
@@ -36,6 +38,7 @@ from k1link.observatory.m49_portable_source import (
materialize_m49_portable_source_from_worker_stage,
validate_m49_portable_source_stage_binding,
)
from k1link.observatory.m49_timing_progress import M49TimingProgress
from k1link.observatory.portable_result_contract import (
OBSERVATION_ONLY_AUTHORITY,
canonical_json,
@@ -53,6 +56,7 @@ from k1link.observatory.portable_worker_runtime import (
PortableWorkerSourceMaterializer,
PortableWorkerSourceStage,
)
from k1link.observatory.recorded_progress import report_recorded_progress
from k1link.observatory.worker_agent import SealedObservatoryRecordedJob
M49_COMPILED_RUNNER_BUILD_SCHEMA: Final = "missioncore.m49-tgs-portable-compiled-runner-build/v1"
@@ -257,6 +261,7 @@ class M49PortableProfileRunnerAdapter:
try:
output = workspace / "outputs"
timing = workspace / "timing.tsv"
report_recorded_progress("computing", 0, stage.timeline_frame_count)
invoker = self.invoker or _invoke_exact_runner
invoker(
binary=self.installation.runner_binary_path,
@@ -267,6 +272,7 @@ class M49PortableProfileRunnerAdapter:
workspace=workspace,
timeout_seconds=self.installation.timeout_seconds,
)
report_recorded_progress("result-assembly", unit="steps")
package = assemble_m49_portable_result(
source_stage=stage,
runner_output_root=output,
@@ -335,19 +341,32 @@ def _invoke_exact_runner(
stderr = workspace / "runner.stderr.log"
try:
with stdout.open("xb") as stdout_stream, stderr.open("xb") as stderr_stream:
completed = subprocess.run(
started = time.monotonic()
progress = M49TimingProgress(timing)
process = subprocess.Popen(
[str(binary), str(sequence), str(schedule), str(output), str(timing)],
cwd=workspace,
env={"LANG": "C", "LC_ALL": "C", "TZ": "UTC"},
stdin=subprocess.DEVNULL,
stdout=stdout_stream,
stderr=stderr_stream,
check=False,
timeout=timeout_seconds,
)
try:
while process.poll() is None:
report_recorded_progress("computing", progress.poll())
remaining = timeout_seconds - (time.monotonic() - started)
if remaining <= 0:
raise subprocess.TimeoutExpired(str(binary), timeout_seconds)
with suppress(subprocess.TimeoutExpired):
process.wait(timeout=min(0.5, remaining))
report_recorded_progress("computing", progress.poll())
finally:
if process.poll() is None:
process.kill()
process.wait()
except (OSError, subprocess.SubprocessError) as exc:
raise M49PortableExecutorError("portable M4.9 runner invocation failed") from exc
if completed.returncode != 0:
if process.returncode != 0:
raise M49PortableExecutorError("portable M4.9 runner rejected its exact source stage")
+16 -2
View File
@@ -32,8 +32,10 @@ from typing import TYPE_CHECKING, Final, cast
import numpy as np
import numpy.typing as npt
from k1link.compute.lidar_replay import LidarReplayPackV2, build_lidar_replay_pack_v2
from k1link.compute.lidar_preparation import prepare_lidar_replay_pack_v2
from k1link.compute.lidar_replay import LidarReplayPackV2
from k1link.observatory.portable_result_contract import canonical_json
from k1link.observatory.recorded_progress import report_recorded_progress
from k1link.observatory.source_admission import (
PORTABLE_SOURCE_BUNDLE_SCHEMA,
PORTABLE_SOURCE_CAPABILITY_SCHEMA,
@@ -175,6 +177,7 @@ def materialize_m49_portable_source_from_worker_stage(
or worker_stage.source_adapter_sha256 != job.source_adapter_sha256
):
raise M49PortableSourceError("Worker source stage belongs to another job")
report_recorded_progress("source-preparation")
root = _safe_directory(worker_stage.root, "Worker source stage")
manifest_payload, manifest = _read_canonical_document(
root / "materialization-manifest.json",
@@ -223,7 +226,7 @@ def materialize_m49_portable_source_from_worker_stage(
if parent.is_symlink() or not parent.is_dir():
raise M49PortableSourceError("portable M4.9 output parent is unsafe")
try:
lidar_pack_root = build_lidar_replay_pack_v2(
lidar_pack_root = prepare_lidar_replay_pack_v2(
root / "mqtt.raw.k1mqtt",
parent / "lidar-replay-packs",
session_id=job.source_session_id,
@@ -487,6 +490,7 @@ def _materialize_stage(
index_rows: list[dict[str, object]] = []
available_slot = 0
sequence_logical = hashlib.sha256()
report_recorded_progress("source-preparation", 0, len(anchors))
for anchor in anchors:
point_index = int(
np.searchsorted(point_times, anchor.session_seconds, side="right") - 1
@@ -534,6 +538,11 @@ def _materialize_stage(
f"{anchor.timeline_frame_index}\t{anchor.source_frame_index}"
f"\t{anchor.session_seconds:.9f}\t-1\t0"
)
report_recorded_progress(
"source-preparation",
anchor.timeline_frame_index + 1,
len(anchors),
)
continue
start_seconds = anchor.session_seconds - profile.history_seconds
@@ -593,6 +602,11 @@ def _materialize_stage(
f"\t{anchor.session_seconds:.9f}\t{available_slot}\t{native.shape[0]}"
)
available_slot += 1
report_recorded_progress(
"source-preparation",
anchor.timeline_frame_index + 1,
len(anchors),
)
if available_slot < 1:
raise M49PortableSourceError("portable K1 source has no admissible LiDAR frames")
@@ -0,0 +1,43 @@
"""Incremental observation of flushed TGS timing rows, not result validation."""
from pathlib import Path
class M49TimingProgress:
def __init__(self, path: Path) -> None:
self.path = path
self.offset = 0
self.pending = b""
self.header = False
self.completed = 0
self.invalid = False
def poll(self) -> int:
if self.invalid:
return self.completed
try:
with self.path.open("rb") as stream:
stream.seek(self.offset)
data = stream.read(64 * 1024)
self.offset += len(data)
except OSError:
return self.completed
lines = (self.pending + data).split(b"\n")
self.pending = lines.pop()
if len(self.pending) > 2048:
self.invalid = True
self.pending = b""
return self.completed
for line in lines:
if not self.header:
self.header = line.startswith(b"timeline_frame_index\tsource_frame_index\t")
if not self.header:
self.invalid = True
break
continue
fields = line.split(b"\t")
if len(fields) != 10 or fields[0] != str(self.completed).encode():
self.invalid = True
break
self.completed += 1
return self.completed
@@ -0,0 +1,457 @@
"""Immutable, data-only AI compositions shared by Core and the installed Worker.
Selections contain no executable instructions. Installed module identities own
their containers; the graph connects typed capabilities, never historical LABs.
"""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Final, cast
from k1link.observatory.portable_run_definitions import canonical_sha256
COMPOSITION_SCHEMA: Final = "missioncore.observatory-ai-composition/v1"
MODULE_SCHEMA: Final = "missioncore.observatory-ai-module/v1"
NODE_INPUT_SCHEMA: Final = "missioncore.observatory-ai-node-input/v1"
GROUPS: Final = ("segmentation", "detection", "geometry", "range", "motion", "policy")
_ID = re.compile(r"[a-z][a-z0-9.-]{1,95}\Z")
_SHA = re.compile(r"[a-f0-9]{64}\Z")
class CompositionError(ValueError):
"""A selection, dependency or immutable identity is not admitted."""
def canonical_bytes(value: object) -> bytes:
try:
return json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
).encode("utf-8")
except (TypeError, ValueError) as exc:
raise CompositionError("composition must contain finite JSON data") from exc
def require_digest(value: object) -> str:
if not isinstance(value, str) or not _SHA.fullmatch(value):
raise CompositionError("invalid immutable digest")
return value
def _identifier(value: object) -> str:
if not isinstance(value, str) or not _ID.fullmatch(value):
raise CompositionError("invalid module or capability identifier")
return value
def _object(value: object, keys: set[str]) -> dict[str, object]:
if not isinstance(value, dict) or set(value) != keys:
raise CompositionError("unexpected composition fields")
return cast(dict[str, object], value)
@dataclass(frozen=True, slots=True)
class ModuleSpec:
"""An installed version; all semantic and execution identities are sealed.
Source ports use the source.* namespace. Preparation modules are installed
infrastructure and are added only when a selected module consumes a port.
"""
module_id: str
label: str
group: str
image_sha256: str
implementation_sha256: str
model_sha256: str | None
contract_sha256: str
requires: tuple[str, ...]
provides: tuple[str, ...]
optional_inputs: tuple[str, ...] = ()
parameter_choices_json: bytes = b"{}"
defaults_json: bytes = b"{}"
state_policy: str = "stateless"
def __post_init__(self) -> None:
_identifier(self.module_id)
if self.group not in (*GROUPS, "preparation"):
raise CompositionError("unknown functional group")
if not self.label or len(self.label) > 120:
raise CompositionError("invalid module label")
for digest in (self.image_sha256, self.implementation_sha256, self.contract_sha256):
require_digest(digest)
if self.model_sha256 is not None:
require_digest(self.model_sha256)
for ports in (self.requires, self.provides, self.optional_inputs):
if tuple(sorted(set(ports))) != ports:
raise CompositionError("module ports must be unique and sorted")
for port in ports:
_identifier(port)
if not self.provides or any(port.startswith("source.") for port in self.provides):
raise CompositionError("a module cannot provide raw source authority")
if set(self.requires) & set(self.optional_inputs):
raise CompositionError("required and optional ports overlap")
if self.state_policy not in ("stateless", "causal-reset-at-source-start"):
raise CompositionError("unqualified temporal state policy")
choices = json.loads(self.parameter_choices_json)
defaults = json.loads(self.defaults_json)
if not isinstance(choices, dict) or not isinstance(defaults, dict):
raise CompositionError("module parameters must be objects")
if set(defaults) != set(choices):
raise CompositionError("every parameter needs an explicit default")
for key, values in choices.items():
_identifier(key)
if not isinstance(values, list) or not values:
raise CompositionError("parameter choices must be finite nonempty lists")
self.resolve_parameters(defaults)
def resolve_parameters(self, supplied: object) -> dict[str, object]:
choices = cast(dict[str, list[object]], json.loads(self.parameter_choices_json))
if not isinstance(supplied, dict) or set(supplied) - set(choices):
raise CompositionError("parameter is not installed for this module")
resolved: dict[str, object] = {
**cast(dict[str, object], json.loads(self.defaults_json)),
**supplied,
}
for key, value in resolved.items():
# JSON identities distinguish true from 1 and reject nonfinite floats.
if canonical_bytes(value) not in [canonical_bytes(item) for item in choices[key]]:
raise CompositionError(f"unsupported value for {key}")
return resolved
def identity_document(self) -> dict[str, object]:
return {
"schema_version": MODULE_SCHEMA,
"module_id": self.module_id,
"group": self.group,
"image_sha256": self.image_sha256,
"implementation_sha256": self.implementation_sha256,
"model_sha256": self.model_sha256,
"contract_sha256": self.contract_sha256,
"requires": list(self.requires),
"provides": list(self.provides),
"optional_inputs": list(self.optional_inputs),
"parameter_choices": json.loads(self.parameter_choices_json),
"defaults": json.loads(self.defaults_json),
"state_policy": self.state_policy,
}
@property
def sha256(self) -> str:
return canonical_sha256(self.identity_document())
@property
def docker_name(self) -> str:
return f"ndc-mission-core-ai-module-{self.module_id}"
def docker_labels(self) -> dict[str, str]:
return {
"com.nodedc.product": "mission-core",
"com.nodedc.stack": "observatory",
"com.nodedc.role": "ai-module",
"com.nodedc.managed-by": "mission-core-worker",
"com.nodedc.module-id": self.module_id,
"com.nodedc.module-sha256": self.sha256,
}
@dataclass(frozen=True, slots=True)
class CompositionNode:
module: ModuleSpec
parameters_json: bytes
# Capability -> provider module ID, or source.* for authoritative source.
inputs: tuple[tuple[str, str], ...]
def as_dict(self) -> dict[str, object]:
return {
"module_id": self.module.module_id,
"module_sha256": self.module.sha256,
"parameters": json.loads(self.parameters_json),
"inputs": dict(self.inputs),
}
@dataclass(frozen=True, slots=True)
class CompositionSpec:
"""Source-independent graph, in deterministic topological execution order."""
nodes: tuple[CompositionNode, ...]
@property
def source_capabilities(self) -> tuple[str, ...]:
return tuple(
sorted(
{
provider
for node in self.nodes
for _, provider in node.inputs
if provider.startswith("source.")
}
)
)
@property
def outputs(self) -> tuple[str, ...]:
return tuple(
sorted(
{
port
for node in self.nodes
if node.module.group != "preparation"
for port in node.module.provides
}
)
)
def as_dict(self) -> dict[str, object]:
return {
"schema_version": COMPOSITION_SCHEMA,
"nodes": [node.as_dict() for node in self.nodes],
"source_capabilities": list(self.source_capabilities),
"outputs": list(self.outputs),
"execution": {"max_parallel_nodes": 1, "mode": "recorded-observation-only"},
}
@property
def sha256(self) -> str:
return canonical_sha256(self.as_dict())
def selection_document(self) -> dict[str, object]:
return {
"schema_version": COMPOSITION_SCHEMA,
"selections": [
{
"group": node.module.group,
"module_id": node.module.module_id,
"module_sha256": node.module.sha256,
"parameters": json.loads(node.parameters_json),
}
for node in self.nodes
if node.module.group != "preparation"
],
}
class ModuleRegistry:
def __init__(self, modules: tuple[ModuleSpec, ...]) -> None:
self.modules = modules
self._by_id = {module.module_id: module for module in modules}
if len(self._by_id) != len(modules):
raise CompositionError("duplicate installed module ID")
@classmethod
def from_file(cls, path: Path) -> ModuleRegistry:
candidate = path.expanduser().absolute()
if (
candidate.is_symlink()
or not candidate.is_file()
or candidate.stat().st_size > 1024 * 1024
):
raise CompositionError("AI module registry must be a bounded regular file")
try:
root = _object(json.loads(candidate.read_text()), {"schema_version", "modules"})
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CompositionError("AI module registry is unreadable") from exc
if root["schema_version"] != "missioncore.observatory-ai-module-registry/v1":
raise CompositionError("unsupported AI module registry schema")
if not isinstance(root["modules"], list):
raise CompositionError("AI module registry rows must be an array")
return cls(tuple(_module_from_dict(value) for value in root["modules"]))
def catalog(self) -> dict[str, object]:
return {
"schema_version": "missioncore.observatory-ai-module-catalog/v1",
"groups": [
{
"group": group,
"modules": [
{
"module_id": module.module_id,
"module_sha256": module.sha256,
"label": module.label,
"docker_name": module.docker_name,
"requires": list(module.requires),
"provides": list(module.provides),
"parameter_choices": json.loads(module.parameter_choices_json),
"defaults": json.loads(module.defaults_json),
}
for module in self.modules
if module.group == group
],
}
for group in GROUPS
],
}
def compose(self, document: object) -> CompositionSpec:
root = _object(document, {"schema_version", "selections"})
if root["schema_version"] != COMPOSITION_SCHEMA:
raise CompositionError("unsupported composition schema")
selections = root["selections"]
if not isinstance(selections, list) or not 1 <= len(selections) <= len(GROUPS):
raise CompositionError("select at least one AI module")
selected: dict[str, tuple[ModuleSpec, bytes]] = {}
groups: set[str] = set()
for raw in selections:
row = _object(raw, {"group", "module_id", "module_sha256", "parameters"})
module = self._by_id.get(_identifier(row["module_id"]))
if module is None or module.sha256 != row["module_sha256"]:
raise CompositionError("module version is not installed")
if module.group != row["group"] or module.group not in GROUPS:
raise CompositionError("module belongs to another functional group")
if module.group in groups:
raise CompositionError(f"select only one provider for {module.group}")
groups.add(module.group)
selected[module.module_id] = (
module,
canonical_bytes(module.resolve_parameters(row["parameters"])),
)
def producers() -> dict[str, str]:
result: dict[str, str] = {}
for module, _ in selected.values():
for port in module.provides:
if port in result:
raise CompositionError(f"ambiguous provider for {port}")
result[port] = module.module_id
return result
# Only source preparation is implicit. Missing analytical dependencies
# must be selected by the operator, never silently added to the LAB.
while True:
supplied = producers()
missing = sorted(
{
port
for module, _ in selected.values()
for port in module.requires
if not port.startswith("source.") and port not in supplied
}
)
if not missing:
break
added = False
for port in missing:
candidates = [
module
for module in self.modules
if module.group == "preparation" and port in module.provides
]
if len(candidates) != 1:
raise CompositionError(f"select a module providing {port}")
module = candidates[0]
if module.module_id not in selected:
selected[module.module_id] = (
module,
canonical_bytes(module.resolve_parameters({})),
)
added = True
if not added:
raise CompositionError("unresolved module dependencies")
supplied = producers()
pending: dict[str, CompositionNode] = {}
for module, parameters in selected.values():
ports = (
*module.requires,
*(port for port in module.optional_inputs if port in supplied),
)
inputs = tuple(
sorted(
(port, port if port.startswith("source.") else supplied[port]) for port in ports
)
)
pending[module.module_id] = CompositionNode(module, parameters, inputs)
ordered: list[CompositionNode] = []
emitted: set[str] = set()
while pending:
ready = sorted(
key
for key, node in pending.items()
if all(
provider.startswith("source.") or provider in emitted
for _, provider in node.inputs
)
)
if not ready:
raise CompositionError("cyclic module dependencies")
for key in ready:
ordered.append(pending.pop(key))
emitted.add(key)
return CompositionSpec(tuple(ordered))
def node_input_identity(
node: CompositionNode,
inputs: dict[str, str],
*,
state_context_sha256: str | None = None,
) -> dict[str, object]:
"""Exact node cache identity, independent of job and unrelated selections.
Each input digest seals bytes AND its I/O envelope (clock, calibration,
preprocessing, cadence, precision, unavailable samples). Stateful nodes
additionally bind their initialization and causal history.
"""
if set(inputs) != {port for port, _ in node.inputs}:
raise CompositionError("node input capabilities disagree with the graph")
for digest in inputs.values():
require_digest(digest)
if node.module.state_policy == "stateless":
if state_context_sha256 is not None:
raise CompositionError("stateless node cannot bind temporal state")
else:
require_digest(state_context_sha256)
return {
"schema_version": NODE_INPUT_SCHEMA,
"module_sha256": node.module.sha256,
"parameters": json.loads(node.parameters_json),
"inputs": dict(sorted(inputs.items())),
"state_policy": node.module.state_policy,
"state_context_sha256": state_context_sha256,
}
def _module_from_dict(value: object) -> ModuleSpec:
row = _object(
value,
{
"module_id",
"label",
"group",
"image_sha256",
"implementation_sha256",
"model_sha256",
"contract_sha256",
"requires",
"provides",
"optional_inputs",
"parameter_choices",
"defaults",
"state_policy",
},
)
arrays: dict[str, tuple[str, ...]] = {}
for name in ("requires", "provides", "optional_inputs"):
raw = row[name]
if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
raise CompositionError(f"module {name} must be an array of strings")
arrays[name] = tuple(raw)
return ModuleSpec(
module_id=_identifier(row["module_id"]),
label=row["label"] if isinstance(row["label"], str) else "",
group=row["group"] if isinstance(row["group"], str) else "",
image_sha256=require_digest(row["image_sha256"]),
implementation_sha256=require_digest(row["implementation_sha256"]),
model_sha256=None if row["model_sha256"] is None else require_digest(row["model_sha256"]),
contract_sha256=require_digest(row["contract_sha256"]),
requires=arrays["requires"],
provides=arrays["provides"],
optional_inputs=arrays["optional_inputs"],
parameter_choices_json=canonical_bytes(row["parameter_choices"]),
defaults_json=canonical_bytes(row["defaults"]),
state_policy=row["state_policy"] if isinstance(row["state_policy"], str) else "",
)
@@ -0,0 +1,49 @@
"""Durable immutable composition catalog owned by Core."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from k1link.observatory.modular_composition import (
CompositionError,
CompositionSpec,
ModuleRegistry,
canonical_bytes,
)
class ModularCompositionStore:
def __init__(self, root: Path, registry: ModuleRegistry) -> None:
root = root.expanduser().absolute()
root.mkdir(parents=True, exist_ok=True, mode=0o700)
if root.is_symlink() or root.resolve() != root:
raise CompositionError("composition store must be a real directory")
self.root = root
self.registry = registry
def save(self, selection: object) -> tuple[CompositionSpec, bool]:
composition = self.registry.compose(selection)
destination = self.root / f"{composition.sha256}.json"
payload = canonical_bytes(composition.as_dict())
if destination.exists():
if destination.is_symlink() or destination.read_bytes() != payload:
raise CompositionError("stored composition identity is damaged")
return composition, False
descriptor, temporary = tempfile.mkstemp(prefix=".composition-", dir=self.root)
path = Path(temporary)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
path.chmod(0o444)
try:
path.rename(destination)
except OSError:
if destination.is_symlink() or destination.read_bytes() != payload:
raise CompositionError("composition publication conflict") from None
return composition, True
finally:
path.unlink(missing_ok=True)
@@ -0,0 +1,528 @@
"""Installed-package prepare and result steps for one independent AI module."""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import sys
from collections.abc import Mapping, Sequence
from datetime import UTC, datetime
from pathlib import Path
from typing import Final, cast
from k1link.observatory.installed_lab_packages import (
INSTALLED_LAB_PLAN_PATH,
INSTALLED_LAB_RESULT_ROOT,
INSTALLED_LAB_SOURCE_ROOT,
INSTALLED_LAB_STEP_INPUT_ROOT,
)
from k1link.observatory.m49_portable_source import (
M49_PORTABLE_STAGE_MANIFEST,
materialize_m49_portable_source_from_worker_stage,
)
from k1link.observatory.modular_result import MODULAR_RESULT_KIND, MODULAR_RESULT_SCHEMA
from k1link.observatory.portable_lab_v1_executor import (
build_portable_ddrnet_effective_config,
portable_lab_v1_source_input_from_document,
)
from k1link.observatory.portable_lab_v1_local_runners import (
PORTABLE_LAB_V1_COMPONENT_REQUEST_SCHEMA,
)
from k1link.observatory.portable_lab_v1_worker import (
_SealedPackageJobView,
materialize_recorded_camera_source_from_worker_stage,
)
from k1link.observatory.portable_result_contract import (
OBSERVATION_ONLY_AUTHORITY,
RESULT_DOCUMENT_ROLE,
RESULT_PACKAGE_MANIFEST_NAME,
PortableResultArtifact,
PortableResultPackageManifest,
canonical_json,
)
from k1link.observatory.portable_run_definitions import (
PortableRunDefinition,
PortableRunDefinitionRegistry,
canonical_sha256,
)
from k1link.observatory.portable_worker_runtime import PortableWorkerSourceStage
from k1link.observatory.recorded_jobs import RecordedExecutorIdentity
from k1link.observatory.worker_agent import SealedObservatoryRecordedJob
MODULAR_PACKAGE_CONTRACT_SCHEMA: Final = (
"missioncore.observatory-ai-module-installed-package-contract/v1"
)
_CONTRACT = Path("/opt/nodedc/package/contract.json")
_DEFINITIONS = Path("/opt/nodedc/package/portable-run-definitions.json")
_DDRNET_PROFILE = Path("/opt/nodedc/package/ddrnet-profile.json")
_M49_PROFILE = Path("/opt/nodedc/package/m49-profile.json")
_PREPARE = Path(INSTALLED_LAB_STEP_INPUT_ROOT) / "prepare"
_MAX_DOCUMENT_BYTES: Final = 2 * 1024 * 1024
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
_ARTIFACTS: Final = {
"ddrnet": (
("ddrnet-decode-repair", "decode-repair.json", "application/json"),
("ddrnet-result-document", "result.json", "application/json"),
("ddrnet-semantic-mask-archive", "semantic-masks.zip", "application/zip"),
),
"eomt": (
("eomt-decode-repair", "decode-repair.json", "application/json"),
("eomt-panoptic-frame-metadata", "frames.jsonl", "application/x-ndjson"),
("eomt-gpu-telemetry", "gpu-telemetry.jsonl", "application/x-ndjson"),
("eomt-panoptic-mask-archive", "masks.tar.gz", "application/gzip"),
("eomt-overlay-video", "perception.mp4", "video/mp4"),
("eomt-result-document", "result.json", "application/json"),
("eomt-run-report", "run-report.json", "application/json"),
("eomt-source-frame-manifest", "source-frames.json", "application/json"),
),
"rf-detr": (
("rf-detr-frame-detections", "detections.jsonl", "application/x-ndjson"),
("rf-detr-result-document", "result.json", "application/json"),
),
"object-distance": (
("rf-detr-frame-detections", "detections.jsonl", "application/x-ndjson"),
("rf-detr-result-document", "result.json", "application/json"),
(
"object-distance-frame-observations",
"object-distances.jsonl",
"application/x-ndjson",
),
("object-distance-result-document", "result.json", "application/json"),
),
}
class ModularPackageStepError(RuntimeError):
"""The selected installed module package changed or is incomplete."""
def main(argv: Sequence[str] | None = None) -> int:
arguments = tuple(sys.argv[1:] if argv is None else argv)
if arguments == ("prepare",):
prepare()
return 0
if arguments == ("assemble",):
assemble()
return 0
raise ModularPackageStepError("AI-module package step is not allowlisted")
def prepare() -> None:
output = _empty_directory(Path(INSTALLED_LAB_RESULT_ROOT), "AI-module prepare output")
contract = _load_contract()
runtime_plan = _runtime_plan()
definition = _definition(runtime_plan)
job = _sealed_job(runtime_plan, definition=definition, contract=contract)
module = _object(contract["module"], "AI module")
module_id = _identifier(module["module_id"], "module id")
stage = PortableWorkerSourceStage(
root=_real_directory(Path(INSTALLED_LAB_SOURCE_ROOT), "AI-module source"),
source_bundle_sha256=_digest(runtime_plan["source_bundle_sha256"], "source bundle"),
source_capability_manifest_sha256=_digest(
runtime_plan["source_capability_manifest_sha256"], "source capability"
),
source_adapter_sha256=_digest(runtime_plan["source_adapter_sha256"], "source adapter"),
)
materialized = materialize_recorded_camera_source_from_worker_stage(
worker_stage=stage,
job=job,
definition=definition,
output_parent=output,
)
camera_target = output / "camera-job"
camera_stage_parent = materialized.camera_job_root.parent
os.replace(materialized.camera_job_root, camera_target)
camera_stage_parent.rmdir()
materialized.root.rmdir()
source = materialized.descriptor.as_dict()
_write(output / "source-input.json", source)
if module_id == "object-distance":
spatial_root = output / "spatial-source"
m49 = materialize_m49_portable_source_from_worker_stage(
worker_stage=stage,
job=job,
profile_path=_M49_PROFILE,
output_parent=spatial_root,
)
manifest = _load_object(
m49.root / M49_PORTABLE_STAGE_MANIFEST,
"object-distance spatial source",
)
identity = _object(manifest.get("identity"), "object-distance spatial identity")
lidar = _object(identity.get("lidar_replay"), "object-distance LiDAR identity")
pack_id = _identifier(lidar.get("pack_id"), "object-distance LiDAR pack id")
pack = _real_directory(
spatial_root / "lidar-replay-packs" / pack_id,
"object-distance LiDAR pack",
)
os.replace(pack, output / "lidar-pack")
os.replace(m49.root, output / "m49-source")
shutil.rmtree(spatial_root)
plan_sha = canonical_sha256(
{
"schema_version": "missioncore.observatory-ai-module-plan/v1",
"job_identity_sha256": job.identity_sha256,
"definition_sha256": definition.definition_sha256,
"module_id": module_id,
"module_sha256": _digest(module["module_sha256"], "module identity"),
"source_input_sha256": materialized.descriptor.identity_sha256,
}
)
assets = _object(contract["component_assets"], "component assets")
images = _object(contract["component_images"], "component images")
_write(
output / "camera-source-request.json",
_component_request(
component="camera-source",
image_sha=_digest(images["camera-source"], "camera image"),
source=source,
plan_sha=plan_sha,
definition=definition,
release_sha=_digest(runtime_plan["candidate_sha256"], "runtime candidate"),
assets=_asset_rows(assets["camera-source"], "camera-source"),
ddrnet_config_sha=None,
),
)
ddrnet_config_sha: str | None = None
if module_id == "ddrnet":
profile = _load_object(_DDRNET_PROFILE, "DDRNet profile")
effective = build_portable_ddrnet_effective_config(
profile,
source=materialized.descriptor,
)
_write(output / "effective-ddrnet-config.json", effective)
ddrnet_config_sha = canonical_sha256(effective)
_write(
output / f"{module_id}-request.json",
_component_request(
component=module_id,
image_sha=_digest(images[module_id], "module image"),
source=source,
plan_sha=plan_sha,
definition=definition,
release_sha=_digest(runtime_plan["candidate_sha256"], "runtime candidate"),
assets=_asset_rows(assets[module_id], module_id),
ddrnet_config_sha=ddrnet_config_sha,
),
)
def assemble() -> None:
output = _empty_directory(Path(INSTALLED_LAB_RESULT_ROOT), "AI-module result output")
artifacts_root = output / "artifacts"
artifacts_root.mkdir(mode=0o700)
contract = _load_contract()
runtime_plan = _runtime_plan()
definition = _definition(runtime_plan)
job = _sealed_job(runtime_plan, definition=definition, contract=contract)
source_input = portable_lab_v1_source_input_from_document(
_load_object(_PREPARE / "source-input.json", "AI-module source input")
)
module = _object(contract["module"], "AI module")
module_id = _identifier(module["module_id"], "module id")
module_root = _real_directory(
Path(INSTALLED_LAB_STEP_INPUT_ROOT) / module_id, "AI-module output"
)
component_result = _regular_file(module_root / "result.json", module_root, "module result")
component_result_sha = _sha256(component_result)
artifacts: list[PortableResultArtifact] = []
for role, name, media_type in _ARTIFACTS[module_id]:
artifact_root = (
_real_directory(
Path(INSTALLED_LAB_STEP_INPUT_ROOT) / "rf-detr",
"RF-DETR dependency output",
)
if module_id == "object-distance" and role.startswith("rf-detr-")
else module_root
)
source_path = _regular_file(artifact_root / name, artifact_root, role)
artifact_name = f"{role}{source_path.suffix}"
if name.endswith(".tar.gz"):
artifact_name = f"{role}.tar.gz"
target_name = f"artifacts/{artifact_name}"
target = artifacts_root / artifact_name
_copy(source_path, target)
artifacts.append(_artifact(role, target_name, media_type, target))
source = {
"session_id": job.source_session_id,
"catalog_sha256": job.source_catalog_sha256,
"bundle_sha256": job.source_bundle_sha256,
"capability_manifest_sha256": job.source_capability_manifest_sha256,
"camera_input_sha256": source_input.camera_input_sha256,
"frame_count": source_input.frame_count,
"timeline_start_seconds": source_input.timeline_start_seconds,
"timeline_end_seconds": source_input.timeline_end_seconds,
}
module_view = {
"module_id": module_id,
"label": _text(module["label"], "module label"),
"module_sha256": _digest(module["module_sha256"], "module identity"),
"image_sha256": _digest(module["image_sha256"], "module image"),
"definition_sha256": definition.definition_sha256,
"component_result_sha256": component_result_sha,
}
identity = {
"job_identity_sha256": job.identity_sha256,
"source_bundle_sha256": job.source_bundle_sha256,
"definition_sha256": definition.definition_sha256,
"module_id": module_id,
"component_result_sha256": component_result_sha,
}
identity_sha = canonical_sha256(identity)
result_id = f"ai-layer-{module_id}-{identity_sha}"
result_document = {
"schema_version": MODULAR_RESULT_SCHEMA,
"result_id": result_id,
"result_kind": MODULAR_RESULT_KIND,
"identity": identity,
"identity_sha256": identity_sha,
"source": source,
"module": module_view,
"artifacts": [item.as_dict() for item in sorted(artifacts, key=lambda item: item.role)],
"authority": dict(OBSERVATION_ONLY_AUTHORITY),
}
result_path = artifacts_root / "result.json"
_write(result_path, result_document)
artifacts.append(
_artifact(
RESULT_DOCUMENT_ROLE,
"artifacts/result.json",
"application/json",
result_path,
)
)
manifest = PortableResultPackageManifest.create(
job=_SealedPackageJobView.from_job(job),
definition=definition,
result_id=result_id,
created_at_utc=datetime.now(UTC).isoformat().replace("+00:00", "Z"),
artifacts=artifacts,
)
(output / RESULT_PACKAGE_MANIFEST_NAME).write_bytes(manifest.canonical_bytes)
os.chmod(output / RESULT_PACKAGE_MANIFEST_NAME, 0o400)
def _component_request(
*,
component: str,
image_sha: str,
source: Mapping[str, object],
plan_sha: str,
definition: PortableRunDefinition,
release_sha: str,
assets: list[dict[str, object]],
ddrnet_config_sha: str | None,
) -> dict[str, object]:
prepared = f"{INSTALLED_LAB_STEP_INPUT_ROOT}/prepare"
return {
"schema_version": PORTABLE_LAB_V1_COMPONENT_REQUEST_SCHEMA,
"component": component,
"component_image_sha256": image_sha,
"plan_sha256": plan_sha,
"definition_sha256": definition.definition_sha256,
"release_candidate_sha256": release_sha,
"source": dict(source),
"paths": {
"camera_job_root": f"{prepared}/camera-job",
"request": f"{prepared}/{component}-request.json",
"output_root": INSTALLED_LAB_RESULT_ROOT,
"effective_ddrnet_config": (
f"{prepared}/effective-ddrnet-config.json" if component == "ddrnet" else None
),
"eomt_result_root": (
f"{INSTALLED_LAB_STEP_INPUT_ROOT}/camera-source" if component == "ddrnet" else None
),
"decoded_frames_root": (
f"{INSTALLED_LAB_RESULT_ROOT}/source-frames"
if component == "camera-source"
else f"{INSTALLED_LAB_STEP_INPUT_ROOT}/camera-source/source-frames"
),
},
"effective_ddrnet_config_sha256": ddrnet_config_sha,
"assets": assets,
"authority": dict(OBSERVATION_ONLY_AUTHORITY),
}
def _sealed_job(
runtime_plan: Mapping[str, object],
*,
definition: PortableRunDefinition,
contract: Mapping[str, object],
) -> SealedObservatoryRecordedJob:
executor = _object(contract["executor"], "package executor")
identity = RecordedExecutorIdentity(
release_sha256=_digest(executor["release_sha256"], "executor release"),
image_sha256=_digest(executor["image_sha256"], "executor image"),
model_manifest_sha256=definition.model_manifest_sha256,
resource_profile_sha256=definition.resource_profile.profile_sha256,
)
return SealedObservatoryRecordedJob(
job_id=_text(runtime_plan["job_id"], "job id"),
request_sha256=_digest(runtime_plan["request_sha256"], "request"),
identity_sha256=_digest(runtime_plan["identity_sha256"], "job identity"),
submission_receipt_sha256=_digest(runtime_plan["submission_receipt_sha256"], "receipt"),
source_session_id=_text(runtime_plan["source_session_id"], "source session"),
source_catalog_sha256=_digest(runtime_plan["source_catalog_sha256"], "catalog"),
source_bundle_sha256=_digest(runtime_plan["source_bundle_sha256"], "bundle"),
source_capability_manifest_sha256=_digest(
runtime_plan["source_capability_manifest_sha256"], "capability"
),
source_adapter_id=_identifier(runtime_plan["source_adapter_id"], "adapter id"),
source_adapter_version=_positive_int(
runtime_plan["source_adapter_version"], "adapter version"
),
source_adapter_sha256=_digest(runtime_plan["source_adapter_sha256"], "adapter"),
setup_id=definition.setup_id,
definition_id=definition.definition_id,
definition_version=definition.version,
definition_sha256=definition.definition_sha256,
executor_release_id=_identifier(executor["release_id"], "executor release id"),
executor_identity=identity,
model_release_ids=definition.learned_models,
resource_profile_id=definition.resource_profile.profile_id,
checkpoint_policy=definition.resource_profile.checkpoint_policy,
allowed_checkpoints=definition.resource_profile.allowed_checkpoints,
claim_generation=_positive_int(runtime_plan["claim_generation"], "claim generation"),
claim_claimed_at_utc=None,
claim_expires_at_utc=None,
claim_heartbeat_at_utc=None,
claim_renewal_count=0,
restart_from_zero=False,
)
def _runtime_plan() -> dict[str, object]:
outer = _load_object(Path(INSTALLED_LAB_PLAN_PATH), "installed AI-module run plan")
if (
outer.get("schema_version") != "missioncore.observatory-installed-lab-run-plan/v1"
or outer.get("authority") != OBSERVATION_ONLY_AUTHORITY
):
raise ModularPackageStepError("installed AI-module run plan changed")
return _object(outer.get("runtime_plan"), "portable runtime plan")
def _definition(runtime_plan: Mapping[str, object]) -> PortableRunDefinition:
return PortableRunDefinitionRegistry.from_file(_DEFINITIONS).resolve(
_identifier(runtime_plan["setup_id"], "setup id"),
_digest(runtime_plan["definition_sha256"], "definition"),
)
def _load_contract() -> dict[str, object]:
value = _load_object(_CONTRACT, "AI-module package contract")
if (
value.get("schema_version") != MODULAR_PACKAGE_CONTRACT_SCHEMA
or value.get("authority") != OBSERVATION_ONLY_AUTHORITY
):
raise ModularPackageStepError("AI-module package contract changed")
return value
def _asset_rows(value: object, component: str) -> list[dict[str, object]]:
if not isinstance(value, list):
raise ModularPackageStepError(f"{component} assets are not an array")
rows = [dict(_object(row, f"{component} asset")) for row in value]
ids = tuple(cast(str, row.get("asset_id")) for row in rows)
if ids != tuple(sorted(ids)) or len(ids) != len(set(ids)):
raise ModularPackageStepError(f"{component} assets are not canonical")
return rows
def _artifact(role: str, relative: str, media: str, path: Path) -> PortableResultArtifact:
return PortableResultArtifact(role, relative, media, path.stat().st_size, _sha256(path))
def _copy(source: Path, target: Path) -> None:
with source.open("rb") as reader, target.open("xb") as writer:
shutil.copyfileobj(reader, writer, 1024 * 1024)
os.chmod(target, 0o400)
if target.stat().st_size != source.stat().st_size or _sha256(target) != _sha256(source):
raise ModularPackageStepError("AI-module artifact copy changed")
def _write(path: Path, value: Mapping[str, object]) -> None:
path.write_bytes(canonical_json(value))
os.chmod(path, 0o400)
def _load_object(path: Path, label: str) -> dict[str, object]:
if (
path.is_symlink()
or not path.is_file()
or not 0 < path.stat().st_size <= _MAX_DOCUMENT_BYTES
):
raise ModularPackageStepError(f"{label} is unavailable")
try:
return _object(json.loads(path.read_bytes()), label)
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ModularPackageStepError(f"{label} is invalid JSON") from exc
def _regular_file(path: Path, root: Path, label: str) -> Path:
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
raise ModularPackageStepError(f"{label} is unavailable")
return path.resolve()
def _empty_directory(path: Path, label: str) -> Path:
root = _real_directory(path, label)
if any(root.iterdir()):
raise ModularPackageStepError(f"{label} is not empty")
return root
def _real_directory(path: Path, label: str) -> Path:
if path.is_symlink() or not path.is_dir():
raise ModularPackageStepError(f"{label} is unavailable")
return path.resolve(strict=True)
def _object(value: object, label: str) -> dict[str, object]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
raise ModularPackageStepError(f"{label} is invalid")
return cast(dict[str, object], value)
def _text(value: object, label: str) -> str:
if not isinstance(value, str) or not value:
raise ModularPackageStepError(f"{label} is invalid")
return value
def _identifier(value: object, label: str) -> str:
text = _text(value, label)
if _IDENTIFIER.fullmatch(text) is None:
raise ModularPackageStepError(f"{label} is invalid")
return text
def _digest(value: object, label: str) -> str:
text = _text(value, label)
if _SHA256.fullmatch(text) is None:
raise ModularPackageStepError(f"{label} is invalid")
return text
def _positive_int(value: object, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ModularPackageStepError(f"{label} is invalid")
return value
def _sha256(path: Path) -> str:
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
if __name__ == "__main__":
try:
raise SystemExit(main())
except ModularPackageStepError as exc:
print(f"installed AI-module package rejected: {exc}", file=sys.stderr)
raise SystemExit(2) from exc
@@ -0,0 +1,196 @@
"""Sealed Worker-local node results; source CAS and final Core LABs are separate.
Entries are published by atomic directory rename only after all regular files
are hashed. Readers validate bytes, not existence. A damaged entry is a miss;
original evidence is never overwritten or removed by a reader.
"""
from __future__ import annotations
import hashlib
import json
import os
import shutil
import stat
import tempfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import cast
from k1link.observatory.modular_composition import (
CompositionError,
canonical_bytes,
require_digest,
)
from k1link.observatory.portable_run_definitions import canonical_sha256
NODE_RESULT_SCHEMA = "missioncore.observatory-ai-node-result/v1"
_MAX_MANIFEST_BYTES = 4 * 1024 * 1024
_MAX_FILES = 20_000
@dataclass(frozen=True, slots=True)
class SealedNodeResult:
root: Path
input_sha256: str
result_sha256: str
manifest_json: bytes
@property
def manifest(self) -> dict[str, object]:
value = json.loads(self.manifest_json)
if not isinstance(value, dict):
raise CompositionError("node manifest must be an object")
return cast(dict[str, object], value)
def _hash_file(path: Path) -> tuple[int, str]:
mode = path.lstat().st_mode
if not stat.S_ISREG(mode):
raise CompositionError("node result must contain only regular files")
digest = hashlib.sha256()
size = 0
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
size += len(chunk)
return size, digest.hexdigest()
def _files(root: Path) -> list[dict[str, object]]:
entries: list[dict[str, object]] = []
for path in sorted(root.rglob("*")):
mode = path.lstat().st_mode
if stat.S_ISDIR(mode):
continue
size, digest = _hash_file(path)
entries.append(
{"path": path.relative_to(root).as_posix(), "byte_length": size, "sha256": digest}
)
if len(entries) > _MAX_FILES:
raise CompositionError("node result exceeds the file limit")
if not entries:
raise CompositionError("empty node output cannot be sealed")
return entries
class ModularNodeCache:
def __init__(self, root: Path) -> None:
root = root.expanduser().absolute()
root.mkdir(parents=True, exist_ok=True, mode=0o700)
if root.is_symlink() or not root.is_dir() or root.resolve() != root:
raise CompositionError("node cache root must be a real directory")
self.root = root
def lookup(self, input_identity: dict[str, object]) -> SealedNodeResult | None:
input_sha256 = canonical_sha256(input_identity)
entry = self.root / input_sha256
try:
if entry.is_symlink() or not entry.is_dir():
return None
manifest_path = entry / "manifest.json"
if (
not stat.S_ISREG(manifest_path.lstat().st_mode)
or manifest_path.stat().st_size > _MAX_MANIFEST_BYTES
):
return None
payload = manifest_path.read_bytes()
manifest = cast(dict[str, object], json.loads(payload))
if set(manifest) != {
"schema_version",
"input_identity",
"input_sha256",
"outputs",
"metadata",
"result_sha256",
}:
return None
identity = {key: value for key, value in manifest.items() if key != "result_sha256"}
if (
manifest["schema_version"] != NODE_RESULT_SCHEMA
or manifest["input_identity"] != input_identity
or manifest["input_sha256"] != input_sha256
or manifest["result_sha256"] != canonical_sha256(identity)
or payload != canonical_bytes(manifest)
):
return None
data = entry / "data"
if data.is_symlink() or not data.is_dir() or _files(data) != manifest["outputs"]:
return None
return SealedNodeResult(data, input_sha256, manifest["result_sha256"], payload)
except (OSError, ValueError, TypeError, KeyError):
return None
def seal(
self,
input_identity: dict[str, object],
output_root: Path,
*,
metadata: dict[str, object],
) -> SealedNodeResult:
existing = self.lookup(input_identity)
if existing is not None:
return existing
input_sha256 = canonical_sha256(input_identity)
require_digest(input_sha256)
destination = self.root / input_sha256
if destination.exists() or destination.is_symlink():
raise CompositionError("damaged node cache entry requires explicit repair")
output_root = output_root.absolute()
if output_root.is_symlink() or output_root.resolve() != output_root:
raise CompositionError("node output root must be a real directory")
entries = _files(output_root)
identity = {
"schema_version": NODE_RESULT_SCHEMA,
"input_identity": input_identity,
"input_sha256": input_sha256,
"outputs": entries,
"metadata": metadata,
}
result_sha256 = canonical_sha256(identity)
payload = canonical_bytes({**identity, "result_sha256": result_sha256})
if len(payload) > _MAX_MANIFEST_BYTES:
raise CompositionError("node manifest exceeds the size limit")
stage = Path(tempfile.mkdtemp(prefix=".node-seal-", dir=self.root))
try:
data = stage / "data"
data.mkdir(mode=0o700)
for entry in entries:
relative = PurePosixPath(cast(str, entry["path"]))
target = data.joinpath(*relative.parts)
target.parent.mkdir(parents=True, exist_ok=True)
# Copy: chmod on a hardlink would mutate the producer's files,
# and a surviving producer could corrupt a supposedly sealed entry.
with (output_root / relative).open("rb") as source, target.open("xb") as out:
shutil.copyfileobj(source, out, 1024 * 1024)
out.flush()
os.fsync(out.fileno())
target.chmod(0o444)
if _files(data) != entries:
raise CompositionError("node output changed while sealing")
with (stage / "manifest.json").open("xb") as out:
out.write(payload)
out.flush()
os.fsync(out.fileno())
(stage / "manifest.json").chmod(0o444)
try:
stage.rename(destination)
except OSError:
# Another process may have atomically won this exact identity.
winner = self.lookup(input_identity)
if winner is None or winner.result_sha256 != result_sha256:
raise CompositionError("node seal conflicts with an existing result") from None
return winner
_fsync_directory(self.root)
return SealedNodeResult(destination / "data", input_sha256, result_sha256, payload)
finally:
if stage.exists():
shutil.rmtree(stage)
def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
+252
View File
@@ -0,0 +1,252 @@
"""Result contract for one independently selected Observatory AI module."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Final, cast
from k1link.observatory.portable_result_contract import (
OBSERVATION_ONLY_AUTHORITY,
PortableResultArtifact,
PortableResultPackageIntegrityError,
PortableResultValidationContext,
)
from k1link.observatory.portable_run_definitions import canonical_sha256
MODULAR_RESULT_SCHEMA: Final = "missioncore.recorded-ai-layer-review/v1"
MODULAR_RESULT_KIND: Final = "recorded-ai-layer-review"
MODULAR_RESULT_CONTRACT_SHA256: Final = (
"2316c027ed6014d3ca5632e38de3bbed448ca79e7243fb300510565b21d56305"
)
MODULE_BY_SETUP: Final = {
"ai-segmentation-ddrnet-v1": "ddrnet",
"ai-segmentation-eomt-v1": "eomt",
"ai-detection-rf-detr-v1": "rf-detr",
"ai-range-object-distance-v1": "object-distance",
}
_EXPECTED_ARTIFACT_ROLES: Final = {
"ddrnet": {
"ddrnet-decode-repair",
"ddrnet-result-document",
"ddrnet-semantic-mask-archive",
},
"eomt": {
"eomt-decode-repair",
"eomt-panoptic-frame-metadata",
"eomt-gpu-telemetry",
"eomt-panoptic-mask-archive",
"eomt-overlay-video",
"eomt-result-document",
"eomt-run-report",
"eomt-source-frame-manifest",
},
"rf-detr": {"rf-detr-frame-detections", "rf-detr-result-document"},
"object-distance": {
"object-distance-frame-observations",
"object-distance-result-document",
"rf-detr-frame-detections",
"rf-detr-result-document",
},
}
def validate_modular_result(context: PortableResultValidationContext) -> None:
"""Re-bind a packaged module result to its exact job and artifacts."""
definition = context.definition
module_id = MODULE_BY_SETUP.get(definition.setup_id)
document = context.result_document
if (
module_id is None
or definition.result_contract.contract_sha256 != MODULAR_RESULT_CONTRACT_SHA256
or definition.result_contract.result_schema != MODULAR_RESULT_SCHEMA
or definition.result_contract.result_kind != MODULAR_RESULT_KIND
or set(document)
!= {
"schema_version",
"result_id",
"result_kind",
"identity",
"identity_sha256",
"source",
"module",
"artifacts",
"authority",
}
or document.get("schema_version") != MODULAR_RESULT_SCHEMA
or document.get("result_kind") != MODULAR_RESULT_KIND
or document.get("authority") != OBSERVATION_ONLY_AUTHORITY
):
raise PortableResultPackageIntegrityError("AI-layer result envelope changed")
identity = _object(document.get("identity"), "AI-layer identity")
identity_sha = document.get("identity_sha256")
if (
not isinstance(identity_sha, str)
or canonical_sha256(identity) != identity_sha
or document.get("result_id") != f"ai-layer-{module_id}-{identity_sha}"
or document.get("result_id") != context.job.result_id
):
raise PortableResultPackageIntegrityError("AI-layer result identity changed")
source = _object(document.get("source"), "AI-layer source")
module = _object(document.get("module"), "AI-layer module")
expected_source = {
"session_id": context.job.source_session_id,
"catalog_sha256": context.job.source_catalog_sha256,
"bundle_sha256": context.job.source_bundle_sha256,
"capability_manifest_sha256": context.job.source_capability_manifest_sha256,
}
if (
any(source.get(key) != value for key, value in expected_source.items())
or module.get("module_id") != module_id
or module.get("definition_sha256") != definition.definition_sha256
or identity
!= {
"job_identity_sha256": context.job.identity_sha256,
"source_bundle_sha256": context.job.source_bundle_sha256,
"definition_sha256": definition.definition_sha256,
"module_id": module_id,
"component_result_sha256": module.get("component_result_sha256"),
}
):
raise PortableResultPackageIntegrityError("AI-layer provenance changed")
declared_value = document.get("artifacts")
if not isinstance(declared_value, list):
raise PortableResultPackageIntegrityError("AI-layer artifacts are invalid")
declared = tuple(_artifact(row) for row in declared_value)
if (
tuple(item.role for item in declared) != tuple(sorted(item.role for item in declared))
or {item.role for item in declared} != _EXPECTED_ARTIFACT_ROLES[module_id]
):
raise PortableResultPackageIntegrityError("AI-layer artifacts are not canonical")
packaged = {item.role: item for item in context.manifest.artifacts}
if set(packaged) != {"result-document", *(item.role for item in declared)}:
raise PortableResultPackageIntegrityError("AI-layer package artifacts changed")
for artifact in declared:
path = context.artifact_paths.get(artifact.role)
if (
packaged.get(artifact.role) != artifact
or path is None
or _sha256(path) != artifact.sha256
):
raise PortableResultPackageIntegrityError("AI-layer artifact content changed")
result_role = f"{module_id}-result-document"
component_path = context.artifact_paths.get(result_role)
if component_path is None or _sha256(component_path) != module.get("component_result_sha256"):
raise PortableResultPackageIntegrityError("AI-layer component result changed")
try:
component = json.loads(component_path.read_bytes())
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise PortableResultPackageIntegrityError("AI-layer component result is invalid") from exc
_validate_component(module_id, _object(component, "component result"), source, packaged)
if module_id == "object-distance":
dependency_path = context.artifact_paths.get("rf-detr-result-document")
if dependency_path is None:
raise PortableResultPackageIntegrityError("RF-DETR dependency result is missing")
try:
dependency = _object(
json.loads(dependency_path.read_bytes()), "RF-DETR dependency result"
)
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise PortableResultPackageIntegrityError(
"RF-DETR dependency result is invalid"
) from exc
_validate_rf_detr(dependency, source, packaged)
def _validate_component(
module_id: str,
result: dict[str, object],
source: dict[str, object],
packaged: dict[str, PortableResultArtifact],
) -> None:
frame_count = source.get("frame_count")
camera_input = source.get("camera_input_sha256")
if not isinstance(frame_count, int) or isinstance(frame_count, bool) or frame_count < 1:
raise PortableResultPackageIntegrityError("AI-layer frame count is invalid")
if module_id == "eomt":
rows = result.get("artifacts")
if not isinstance(rows, list):
raise PortableResultPackageIntegrityError("EoMT artifacts are invalid")
archive = next(
(
row
for row in cast(list[object], rows)
if isinstance(row, dict) and row.get("kind") == "panoptic-mask-archive"
),
None,
)
if (
result.get("schema_version") != "missioncore.recorded-perception-result/v2"
or result.get("session_id") != source.get("session_id")
or result.get("input_sha256") != camera_input
or result.get("frames_processed") != frame_count
or not isinstance(archive, dict)
or archive.get("sha256") != packaged["eomt-panoptic-mask-archive"].sha256
):
raise PortableResultPackageIntegrityError("EoMT result binding changed")
return
if module_id == "rf-detr":
_validate_rf_detr(result, source, packaged)
return
if module_id == "object-distance":
if (
result.get("schema_version")
!= "missioncore.observatory-ai-module-object-distance-result/v1"
or result.get("module_id") != "object-distance"
or result.get("source_session_id") != source.get("session_id")
or result.get("frame_count") != frame_count
or result.get("object_distances_sha256")
!= packaged["object-distance-frame-observations"].sha256
or result.get("range_estimator") != "median-camera-z-of-owned-current-points/v1"
):
raise PortableResultPackageIntegrityError("object-distance result binding changed")
return
semantics = _object(result.get("video_semantics"), "DDRNet semantics")
archive = _object(semantics.get("mask_archive"), "DDRNet mask archive")
source_row = _object(result.get("source"), "DDRNet source")
if (
result.get("schema_version") != "missioncore.lab-v1-goose-vegetation-run/v1"
or source_row.get("input_count") != frame_count
or archive.get("sha256") != packaged["ddrnet-semantic-mask-archive"].sha256
or archive.get("frame_count") != frame_count
):
raise PortableResultPackageIntegrityError("DDRNet result binding changed")
def _validate_rf_detr(
result: dict[str, object],
source: dict[str, object],
packaged: dict[str, PortableResultArtifact],
) -> None:
source_row = _object(result.get("source"), "RF-DETR source")
if (
result.get("schema_version") != "missioncore.observatory-ai-module-rf-detr-result/v1"
or result.get("module_id") != "rf-detr"
or source_row.get("session_id") != source.get("session_id")
or result.get("frame_count") != source.get("frame_count")
or result.get("detections_sha256") != packaged["rf-detr-frame-detections"].sha256
):
raise PortableResultPackageIntegrityError("RF-DETR result binding changed")
def _artifact(value: object) -> PortableResultArtifact:
row = _object(value, "AI-layer artifact")
if set(row) != {"role", "relative_path", "media_type", "byte_length", "sha256"}:
raise PortableResultPackageIntegrityError("AI-layer artifact fields changed")
try:
return PortableResultArtifact(**row) # type: ignore[arg-type]
except (TypeError, ValueError) as exc:
raise PortableResultPackageIntegrityError("AI-layer artifact is invalid") from exc
def _object(value: object, label: str) -> dict[str, object]:
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
raise PortableResultPackageIntegrityError(f"{label} is invalid")
return cast(dict[str, object], value)
def _sha256(path: Path) -> str:
with path.open("rb") as stream:
return hashlib.file_digest(stream, "sha256").hexdigest()
@@ -52,12 +52,8 @@ from k1link.observatory.source_admission import (
)
from k1link.observatory.worker_agent import SealedObservatoryRecordedJob
PORTABLE_LAB_V1_SOURCE_SCHEMA: Final = (
"missioncore.observatory-portable-lab-v1-source/v1"
)
PORTABLE_LAB_V1_PLAN_SCHEMA: Final = (
"missioncore.observatory-portable-lab-v1-orchestration-plan/v1"
)
PORTABLE_LAB_V1_SOURCE_SCHEMA: Final = "missioncore.observatory-portable-lab-v1-source/v1"
PORTABLE_LAB_V1_PLAN_SCHEMA: Final = "missioncore.observatory-portable-lab-v1-orchestration-plan/v1"
PORTABLE_LAB_V1_PLAN_IDENTITY_SCHEMA: Final = (
"missioncore.observatory-portable-lab-v1-orchestration-plan-identity/v1"
)
@@ -74,9 +70,7 @@ PORTABLE_LAB_V1_RELEASE_IDENTITY_SCHEMA: Final = (
PORTABLE_LAB_V1_EXECUTOR_SEAL_SCHEMA: Final = (
"missioncore.observatory-portable-lab-v1-executor-seal/v2"
)
PORTABLE_LAB_V1_DDRNET_PROFILE_SCHEMA: Final = (
"missioncore.lab-v1-eomt-ddrnet-portable-profile/v2"
)
PORTABLE_LAB_V1_DDRNET_PROFILE_SCHEMA: Final = "missioncore.lab-v1-eomt-ddrnet-portable-profile/v2"
PORTABLE_LAB_V1_DDRNET_EFFECTIVE_CONFIG_SCHEMA: Final = (
"missioncore.lab-v1-goose-vegetation-benchmark/v1"
)
@@ -97,9 +91,7 @@ _DDRNET_CANDIDATE_KEY: Final = "ddrnet"
_DDRNET_CHECKPOINT_SHA256: Final = (
"b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6"
)
_GOOSE_MAPPING_SHA256: Final = (
"88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f"
)
_GOOSE_MAPPING_SHA256: Final = "88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f"
_OBSERVATORY_JOB_ID = re.compile(r"^observatory-run-[a-f0-9]{32}$")
_CAMERA_JOB_ID = re.compile(r"^recorded-camera-[a-f0-9]{24}$")
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
@@ -214,9 +206,7 @@ class PortableLabV1SourceInput:
"session_id": self.source_session_id,
"catalog_sha256": self.source_catalog_sha256,
"bundle_sha256": self.source_bundle_sha256,
"capability_manifest_sha256": (
self.source_capability_manifest_sha256
),
"capability_manifest_sha256": (self.source_capability_manifest_sha256),
"adapter_sha256": self.source_adapter_sha256,
},
"camera_compute_job": {
@@ -341,13 +331,8 @@ def materialize_lab_v1_source_input(
)
if hashlib.sha256(source_bundle_bytes).hexdigest() != job.source_bundle_sha256:
raise PortableLabV1SourceError("source bundle digest differs from the sealed job")
if (
hashlib.sha256(capability_bytes).hexdigest()
!= job.source_capability_manifest_sha256
):
raise PortableLabV1SourceError(
"source capability digest differs from the sealed job"
)
if hashlib.sha256(capability_bytes).hexdigest() != job.source_capability_manifest_sha256:
raise PortableLabV1SourceError("source capability digest differs from the sealed job")
_validate_source_documents(
source_bundle=source_bundle,
capability=capability,
@@ -484,9 +469,7 @@ class PortableLabV1ReleaseCandidate:
_digest(self.executor_image_sha256, "release executor image sha256")
ids = tuple(asset.asset_id for asset in self.assets)
if ids != tuple(sorted(ids)) or len(ids) != len(set(ids)):
raise PortableLabV1ReleaseError(
"release assets must be unique and canonically ordered"
)
raise PortableLabV1ReleaseError("release assets must be unique and canonically ordered")
if not self.phases or len(self.phases) != len(set(self.phases)):
raise PortableLabV1ReleaseError("release phases are invalid")
for phase in self.phases:
@@ -561,9 +544,7 @@ class PortableLabV1ReleaseCandidate:
return cls(
release_id=_string(document["release_id"], "release id"),
setup_id=_string(document["setup_id"], "release setup id"),
definition_id=_string(
document["definition_id"], "release definition id"
),
definition_id=_string(document["definition_id"], "release definition id"),
definition_version=_positive_int(
document["definition_version"], "release definition version"
),
@@ -579,9 +560,7 @@ class PortableLabV1ReleaseCandidate:
assets=assets,
phases=phases,
declared_blockers=blockers,
candidate_sha256=_string(
document["candidate_sha256"], "release candidate sha256"
),
candidate_sha256=_string(document["candidate_sha256"], "release candidate sha256"),
repository_root=_real_directory(repository_root, "repository root"),
)
@@ -606,14 +585,10 @@ class PortableLabV1ReleaseCandidate:
definition.setup_id != self.setup_id
or definition.definition_id != self.definition_id
or definition.version != self.definition_version
or definition.executable_contract_sha256
!= self.definition_contract_sha256
or definition.result_contract.contract_sha256
!= self.result_contract_sha256
or definition.executable_contract_sha256 != self.definition_contract_sha256
or definition.result_contract.contract_sha256 != self.result_contract_sha256
):
raise PortableLabV1ReleaseError(
"release candidate belongs to another RunDefinition"
)
raise PortableLabV1ReleaseError("release candidate belongs to another RunDefinition")
def inspect(
self,
@@ -660,9 +635,7 @@ class PortableLabV1ReleaseCandidate:
or self.executor_image_sha256 is None
or len(inspection.matched_assets) != len(self.assets)
):
raise PortableLabV1ReleaseError(
"portable LAB V1 executor candidate is not sealable"
)
raise PortableLabV1ReleaseError("portable LAB V1 executor candidate is not sealable")
identity = {
"schema_version": PORTABLE_LAB_V1_EXECUTOR_SEAL_SCHEMA,
"release_id": self.release_id,
@@ -725,9 +698,8 @@ class PortableLabV1PlanPhase:
def __post_init__(self) -> None:
_pattern(self.phase_id, _IDENTIFIER, "plan phase id")
if (
not self.component_sha256s
or self.component_sha256s != tuple(sorted(self.component_sha256s))
if not self.component_sha256s or self.component_sha256s != tuple(
sorted(self.component_sha256s)
):
raise PortableLabV1PlanError("plan component identities are not canonical")
for digest_value in self.component_sha256s:
@@ -787,10 +759,8 @@ class PortableLabV1OrchestrationPlan:
_digest(value, label)
if (
self.source_input.observatory_job_id != self.observatory_job_id
or self.source_input.observatory_request_sha256
!= self.observatory_request_sha256
or self.source_input.observatory_identity_sha256
!= self.observatory_identity_sha256
or self.source_input.observatory_request_sha256 != self.observatory_request_sha256
or self.source_input.observatory_identity_sha256 != self.observatory_identity_sha256
):
raise PortableLabV1PlanError("plan source belongs to another job")
if self.blockers != tuple(sorted(self.blockers)) or len(self.blockers) != len(
@@ -804,10 +774,7 @@ class PortableLabV1OrchestrationPlan:
"result-v2-assembly",
):
raise PortableLabV1PlanError("combined LAB V1 phase order changed")
if (
canonical_sha256(self.effective_ddrnet_config)
!= self.effective_ddrnet_config_sha256
):
if canonical_sha256(self.effective_ddrnet_config) != self.effective_ddrnet_config_sha256:
raise PortableLabV1PlanError("effective DDRNet config digest changed")
if canonical_sha256(self.identity_document()) != self.plan_sha256:
raise PortableLabV1PlanError("orchestration plan identity changed")
@@ -830,8 +797,7 @@ class PortableLabV1OrchestrationPlan:
raise PortableLabV1PlanError("release inspection belongs to another candidate")
expected_asset_ids = tuple(asset.asset_id for asset in release.assets)
if release_inspection.ready and (
release_inspection.matched_assets != expected_asset_ids
or release_inspection.blockers
release_inspection.matched_assets != expected_asset_ids or release_inspection.blockers
):
raise PortableLabV1PlanError(
"ready release inspection does not admit every exact asset"
@@ -1147,13 +1113,8 @@ class PortableLabV1ResultAssembly:
roles = tuple(item.role for item in self.artifacts)
if roles != tuple(sorted(roles)) or len(roles) != len(set(roles)):
raise PortableLabV1ResultError("assembled artifacts are not canonical")
result_artifact = tuple(
item for item in self.artifacts if item.role == "result-document"
)
if (
len(result_artifact) != 1
or result_artifact[0].sha256 != self.result_document_sha256
):
result_artifact = tuple(item for item in self.artifacts if item.role == "result-document")
if len(result_artifact) != 1 or result_artifact[0].sha256 != self.result_document_sha256:
raise PortableLabV1ResultError("assembled result document is not bound")
@@ -1298,9 +1259,7 @@ def assemble_lab_v1_result_v2(
"session_id": plan.source_input.source_session_id,
"catalog_sha256": plan.source_input.source_catalog_sha256,
"bundle_sha256": plan.source_input.source_bundle_sha256,
"capability_manifest_sha256": (
plan.source_input.source_capability_manifest_sha256
),
"capability_manifest_sha256": (plan.source_input.source_capability_manifest_sha256),
"camera_input_sha256": plan.source_input.camera_input_sha256,
"frame_count": plan.source_input.frame_count,
"timeline_start_seconds": plan.source_input.timeline_start_seconds,
@@ -1311,9 +1270,7 @@ def assemble_lab_v1_result_v2(
"definition_id": definition.definition_id,
"version": definition.version,
"definition_sha256": definition.definition_sha256,
"result_contract_sha256": (
definition.result_contract.contract_sha256
),
"result_contract_sha256": (definition.result_contract.contract_sha256),
"release_candidate_sha256": plan.release_candidate_sha256,
"plan_sha256": plan.plan_sha256,
},
@@ -1343,9 +1300,7 @@ def assemble_lab_v1_result_v2(
)
final = parent / result_id
if final.exists():
raise PortableLabV1ResultError(
"an assembled result with this identity already exists"
)
raise PortableLabV1ResultError("an assembled result with this identity already exists")
_fsync_tree(staging)
os.replace(staging, final)
_fsync_directory(parent)
@@ -1429,8 +1384,7 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
definition.setup_id != _EXPECTED_SETUP_ID
or definition.definition_id != _EXPECTED_DEFINITION_ID
or definition.result_contract.result_schema != PORTABLE_LAB_V1_RESULT_SCHEMA
or definition.result_contract.contract_sha256
!= _EXPECTED_RESULT_CONTRACT_SHA256
or definition.result_contract.contract_sha256 != _EXPECTED_RESULT_CONTRACT_SHA256
):
raise PortableLabV1ResultError("LAB V1 validator received another definition")
document = dict(context.result_document)
@@ -1444,8 +1398,7 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
identity = _object(document["identity"], "portable LAB V1 identity")
if (
canonical_sha256(identity) != document["identity_sha256"]
or document["result_id"]
!= f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
or document["result_id"] != f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
):
raise PortableLabV1ResultError("portable LAB V1 result identity changed")
source = _object(document["source"], "portable LAB V1 source")
@@ -1484,8 +1437,7 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
source.get("session_id") != context.job.source_session_id
or source.get("catalog_sha256") != context.job.source_catalog_sha256
or source.get("bundle_sha256") != context.job.source_bundle_sha256
or source.get("capability_manifest_sha256")
!= context.job.source_capability_manifest_sha256
or source.get("capability_manifest_sha256") != context.job.source_capability_manifest_sha256
or run_definition.get("setup_id") != definition.setup_id
or run_definition.get("definition_id") != definition.definition_id
or run_definition.get("version") != definition.version
@@ -1499,13 +1451,10 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
raise PortableLabV1ResultError("portable LAB V1 artifacts are not an array")
declared = tuple(_artifact_from_document(row) for row in artifact_rows)
declared_roles = tuple(artifact.role for artifact in declared)
if (
declared_roles != tuple(sorted(declared_roles))
or len(declared_roles) != len(set(declared_roles))
if declared_roles != tuple(sorted(declared_roles)) or len(declared_roles) != len(
set(declared_roles)
):
raise PortableLabV1ResultError(
"portable result artifacts are not canonical"
)
raise PortableLabV1ResultError("portable result artifacts are not canonical")
declared_by_role = {artifact.role: artifact for artifact in declared}
package_by_role = {artifact.role: artifact for artifact in context.manifest.artifacts}
if set(package_by_role) != {*declared_by_role, "result-document"}:
@@ -1540,15 +1489,13 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
if (
source_input.identity_sha256 != identity.get("source_input_sha256")
or plan.plan_sha256 != run_definition.get("plan_sha256")
or plan.release_candidate_sha256
!= run_definition.get("release_candidate_sha256")
or plan.release_candidate_sha256 != run_definition.get("release_candidate_sha256")
or plan.observatory_job_id != context.job.job_id
or plan.observatory_request_sha256 != context.job.request_sha256
or plan.observatory_identity_sha256 != context.job.identity_sha256
or source.get("camera_input_sha256") != source_input.camera_input_sha256
or source.get("frame_count") != source_input.frame_count
or source.get("timeline_start_seconds")
!= source_input.timeline_start_seconds
or source.get("timeline_start_seconds") != source_input.timeline_start_seconds
or source.get("timeline_end_seconds") != source_input.timeline_end_seconds
):
raise PortableLabV1ResultError("portable source or plan artifact changed")
@@ -1621,9 +1568,7 @@ def portable_lab_v1_source_input_from_document(
"portable camera compute job",
)
return PortableLabV1SourceInput(
observatory_job_id=_string(
observatory_job["job_id"], "portable source Observatory job id"
),
observatory_job_id=_string(observatory_job["job_id"], "portable source Observatory job id"),
observatory_request_sha256=_string(
observatory_job["request_sha256"],
"portable source Observatory request sha256",
@@ -1633,23 +1578,15 @@ def portable_lab_v1_source_input_from_document(
"portable source Observatory identity sha256",
),
source_session_id=_string(source["session_id"], "portable source session id"),
source_catalog_sha256=_string(
source["catalog_sha256"], "portable source catalog sha256"
),
source_bundle_sha256=_string(
source["bundle_sha256"], "portable source bundle sha256"
),
source_catalog_sha256=_string(source["catalog_sha256"], "portable source catalog sha256"),
source_bundle_sha256=_string(source["bundle_sha256"], "portable source bundle sha256"),
source_capability_manifest_sha256=_string(
source["capability_manifest_sha256"],
"portable source capability sha256",
),
source_adapter_sha256=_string(
source["adapter_sha256"], "portable source adapter sha256"
),
source_adapter_sha256=_string(source["adapter_sha256"], "portable source adapter sha256"),
camera_job_id=_string(camera["job_id"], "portable camera job id"),
camera_input_sha256=_string(
camera["input_sha256"], "portable camera input sha256"
),
camera_input_sha256=_string(camera["input_sha256"], "portable camera input sha256"),
camera_source_id=_string(camera["source_id"], "portable camera source id"),
codec_epoch=_positive_int(camera["codec_epoch"], "portable codec epoch"),
input_byte_length=_positive_int(
@@ -1659,15 +1596,11 @@ def portable_lab_v1_source_input_from_document(
timeline_start_seconds=_finite_float(
camera["timeline_start_seconds"], "portable timeline start"
),
timeline_end_seconds=_finite_float(
camera["timeline_end_seconds"], "portable timeline end"
),
timeline_end_seconds=_finite_float(camera["timeline_end_seconds"], "portable timeline end"),
camera_generation_sha256=_string(
camera["generation_sha256"], "portable camera generation sha256"
),
calibration_sha256=_string(
camera["calibration_sha256"], "portable calibration sha256"
),
calibration_sha256=_string(camera["calibration_sha256"], "portable calibration sha256"),
)
@@ -1758,9 +1691,7 @@ def portable_lab_v1_orchestration_plan_from_document(
raise PortableLabV1ResultError("portable plan admission changed")
phases = tuple(_plan_phase_from_document(value) for value in phases_value)
plan = PortableLabV1OrchestrationPlan(
observatory_job_id=_string(
observatory_job["job_id"], "portable plan Observatory job id"
),
observatory_job_id=_string(observatory_job["job_id"], "portable plan Observatory job id"),
observatory_request_sha256=_string(
observatory_job["request_sha256"],
"portable plan Observatory request sha256",
@@ -1770,9 +1701,7 @@ def portable_lab_v1_orchestration_plan_from_document(
"portable plan Observatory identity sha256",
),
setup_id=_string(run_definition["setup_id"], "portable plan setup id"),
definition_id=_string(
run_definition["definition_id"], "portable plan definition id"
),
definition_id=_string(run_definition["definition_id"], "portable plan definition id"),
definition_version=_positive_int(
run_definition["version"], "portable plan definition version"
),
@@ -1817,8 +1746,7 @@ def _plan_phase_from_document(value: object) -> PortableLabV1PlanPhase:
input_values = row["input_roles"]
output_values = row["output_roles"]
if not all(
isinstance(values, list)
for values in (component_values, input_values, output_values)
isinstance(values, list) for values in (component_values, input_values, output_values)
):
raise PortableLabV1ResultError("portable plan phase arrays changed")
return PortableLabV1PlanPhase(
@@ -1828,12 +1756,10 @@ def _plan_phase_from_document(value: object) -> PortableLabV1PlanPhase:
for item in cast(list[object], component_values)
),
input_roles=tuple(
_string(item, "portable plan input role")
for item in cast(list[object], input_values)
_string(item, "portable plan input role") for item in cast(list[object], input_values)
),
output_roles=tuple(
_string(item, "portable plan output role")
for item in cast(list[object], output_values)
_string(item, "portable plan output role") for item in cast(list[object], output_values)
),
)
@@ -1859,8 +1785,7 @@ def _validate_source_documents(
source_bundle.get("source_session_id") != job.source_session_id
or capability.get("source_session_id") != job.source_session_id
or camera_job.session_id != job.source_session_id
or source_bundle.get("source_catalog_sha256")
!= job.source_catalog_sha256
or source_bundle.get("source_catalog_sha256") != job.source_catalog_sha256
or capability.get("source_catalog_sha256") != job.source_catalog_sha256
or capability.get("source_bundle_sha256") != job.source_bundle_sha256
or capability.get("source_adapter_sha256") != job.source_adapter_sha256
@@ -1897,8 +1822,7 @@ def _validate_source_documents(
raise PortableLabV1SourceError("source camera segment count changed")
if (
video.get("source_id") != requirements.camera_source_id
or video.get("semantic_channel_id")
!= requirements.camera_semantic_channel_id
or video.get("semantic_channel_id") != requirements.camera_semantic_channel_id
or video.get("seekable") is not True
or camera_job.source_id != camera.get("public_source_id")
or camera_job.codec_epoch != epoch.get("ordinal")
@@ -1934,18 +1858,19 @@ def _validate_source_documents(
if not isinstance(files, list):
raise PortableLabV1SourceError("camera compute job file set is invalid")
file_rows = {
PurePosixPath(_string(_object(row, "camera file").get("path"), "camera file path")).name:
_object(row, "camera file")
PurePosixPath(
_string(_object(row, "camera file").get("path"), "camera file path")
).name: _object(row, "camera file")
for row in files
if PurePosixPath(
_string(_object(row, "camera file").get("path"), "camera file path")
).parent.name
in (f"epoch-{camera_job.codec_epoch}", "segments")
}
if (
init.get("sha256") != _object(file_rows.get("init.mp4"), "camera init file").get("sha256")
or init.get("byte_length")
!= _object(file_rows.get("init.mp4"), "camera init file").get("byte_length")
if init.get("sha256") != _object(file_rows.get("init.mp4"), "camera init file").get(
"sha256"
) or init.get("byte_length") != _object(file_rows.get("init.mp4"), "camera init file").get(
"byte_length"
):
raise PortableLabV1SourceError("camera init differs from the admitted source")
for index, row in enumerate(segments, start=1):
@@ -1964,11 +1889,7 @@ def _verify_definition_and_job(
job: SealedObservatoryRecordedJob,
) -> None:
if (
definition.setup_id != _EXPECTED_SETUP_ID
or definition.definition_id != _EXPECTED_DEFINITION_ID
or definition.result_contract.contract_sha256
!= _EXPECTED_RESULT_CONTRACT_SHA256
or job.setup_id != definition.setup_id
job.setup_id != definition.setup_id
or job.definition_id != definition.definition_id
or job.definition_version != definition.version
or job.definition_sha256 != definition.definition_sha256
@@ -1977,14 +1898,14 @@ def _verify_definition_and_job(
or job.source_adapter_sha256 != definition.source_adapter.contract_sha256
or job.model_release_ids != definition.learned_models
or job.resource_profile_id != definition.resource_profile.profile_id
or job.executor_identity.model_manifest_sha256
!= definition.model_manifest_sha256
or job.executor_identity.model_manifest_sha256 != definition.model_manifest_sha256
or job.executor_identity.resource_profile_sha256
!= definition.resource_profile.profile_sha256
or job.checkpoint_policy != definition.resource_profile.checkpoint_policy
or job.allowed_checkpoints != definition.resource_profile.allowed_checkpoints
or definition.authority.as_dict() != OBSERVATION_ONLY_AUTHORITY
):
raise PortableLabV1SourceError("job is not the exact portable LAB V1 definition")
raise PortableLabV1SourceError("job is not the exact portable definition")
def _verify_source_and_job(
@@ -2008,8 +1929,7 @@ def _verify_source_and_job(
or source.source_session_id != job.source_session_id
or source.source_catalog_sha256 != job.source_catalog_sha256
or source.source_bundle_sha256 != job.source_bundle_sha256
or source.source_capability_manifest_sha256
!= job.source_capability_manifest_sha256
or source.source_capability_manifest_sha256 != job.source_capability_manifest_sha256
or source.source_adapter_sha256 != job.source_adapter_sha256
or source.calibration_sha256 != requirements.calibration_identity_sha256
):
@@ -2069,8 +1989,7 @@ def _verify_plan_definition(
or plan.definition_id != definition.definition_id
or plan.definition_version != definition.version
or plan.definition_sha256 != definition.definition_sha256
or plan.result_contract_sha256
!= definition.result_contract.contract_sha256
or plan.result_contract_sha256 != definition.result_contract.contract_sha256
):
raise PortableLabV1PlanError("orchestration plan belongs to another definition")
@@ -2092,8 +2011,7 @@ def _validate_eomt_component(
or document.get("source_id") != plan.source_input.camera_source_id
or document.get("codec_epoch") != plan.source_input.codec_epoch
or document.get("timestamp_basis") != "session-time-seconds"
or document.get("timeline_start_seconds")
!= plan.source_input.timeline_start_seconds
or document.get("timeline_start_seconds") != plan.source_input.timeline_start_seconds
or document.get("timeline_end_seconds") != plan.source_input.timeline_end_seconds
or document.get("frames_processed") != plan.source_input.frame_count
or document.get("ground_truth") is not False
@@ -2186,10 +2104,8 @@ def _validate_ddrnet_component(
or semantics.get("center_crop_xyxy") != [100, 0, 700, 600]
or semantics.get("outside_crop_state") != "undefined"
or semantics.get("base_m4_result_id") is not None
or provenance.get("config_sha256")
!= plan.effective_ddrnet_config_sha256
or provenance.get("policy_sha256")
!= components["vegetation-mission-policy-v1"].sha256
or provenance.get("config_sha256") != plan.effective_ddrnet_config_sha256
or provenance.get("policy_sha256") != components["vegetation-mission-policy-v1"].sha256
or provenance.get("provider_map_sha256")
!= components["vegetation-provider-label-map-v1"].sha256
or authority
@@ -2290,8 +2206,7 @@ def _validate_assembly(
if (
canonical_sha256(identity) != document["identity_sha256"]
or document["result_id"] != assembly.result_id
or document["result_id"]
!= f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
or document["result_id"] != f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
):
raise PortableLabV1ResultError("assembled result identity changed")
for artifact in assembly.artifacts:
@@ -2450,10 +2365,8 @@ def _validate_published_eomt_component(
or document.get("source_id") != plan.source_input.camera_source_id
or document.get("codec_epoch") != plan.source_input.codec_epoch
or document.get("timestamp_basis") != "session-time-seconds"
or document.get("timeline_start_seconds")
!= plan.source_input.timeline_start_seconds
or document.get("timeline_end_seconds")
!= plan.source_input.timeline_end_seconds
or document.get("timeline_start_seconds") != plan.source_input.timeline_start_seconds
or document.get("timeline_end_seconds") != plan.source_input.timeline_end_seconds
or document.get("frames_processed") != plan.source_input.frame_count
or document.get("ground_truth") is not False
or semantic.get("id") != model.model_id
@@ -2472,9 +2385,7 @@ def _validate_published_eomt_component(
}
if not isinstance(rows, list) or len(rows) != len(expected_kinds):
raise PortableLabV1ResultError("published EoMT artifact set changed")
package_by_role = {
artifact.role: artifact for artifact in context.manifest.artifacts
}
package_by_role = {artifact.role: artifact for artifact in context.manifest.artifacts}
observed_kinds: set[str] = set()
for value in rows:
row = _object(value, "published EoMT artifact")
@@ -2482,13 +2393,9 @@ def _validate_published_eomt_component(
if kind not in expected_kinds or kind in observed_kinds:
raise PortableLabV1ResultError("published EoMT artifact roles changed")
observed_kinds.add(kind)
relative = _safe_relative_path(
_string(row.get("path"), "published EoMT artifact path")
)
relative = _safe_relative_path(_string(row.get("path"), "published EoMT artifact path"))
if len(relative.parts) != 1:
raise PortableLabV1ResultError(
"published EoMT artifact path changed"
)
raise PortableLabV1ResultError("published EoMT artifact path changed")
packaged = package_by_role.get(f"eomt-{kind}")
if (
packaged is None
@@ -2496,9 +2403,7 @@ def _validate_published_eomt_component(
or row.get("byte_length") != packaged.byte_length
or row.get("sha256") != packaged.sha256
):
raise PortableLabV1ResultError(
"published EoMT artifact identity changed"
)
raise PortableLabV1ResultError("published EoMT artifact identity changed")
def _validate_published_ddrnet_component(
@@ -2557,10 +2462,8 @@ def _validate_published_ddrnet_component(
or semantics.get("center_crop_xyxy") != [100, 0, 700, 600]
or semantics.get("outside_crop_state") != "undefined"
or semantics.get("base_m4_result_id") is not None
or provenance.get("config_sha256")
!= plan.effective_ddrnet_config_sha256
or provenance.get("policy_sha256")
!= components["vegetation-mission-policy-v1"].sha256
or provenance.get("config_sha256") != plan.effective_ddrnet_config_sha256
or provenance.get("policy_sha256") != components["vegetation-mission-policy-v1"].sha256
or provenance.get("provider_map_sha256")
!= components["vegetation-provider-label-map-v1"].sha256
or package_archive is None
@@ -2595,6 +2498,8 @@ def _validate_published_ddrnet_component(
"lab-v1-ravnoves-video-ddrnet-" + canonical_sha256(identity_value)
):
raise PortableLabV1ResultError("published DDRNet result identity changed")
def _model(definition: PortableRunDefinition, release_id: str): # type: ignore[no-untyped-def]
for model in definition.models:
if model.release_id == release_id:
@@ -2644,9 +2549,8 @@ def _release_binding_matches(
if path.is_symlink() or not path.is_file():
return False
return (
(asset.byte_length is None or path.stat().st_size == asset.byte_length)
and _sha256_file(path) == asset.sha256
)
asset.byte_length is None or path.stat().st_size == asset.byte_length
) and _sha256_file(path) == asset.sha256
except OSError:
return False
@@ -94,9 +94,7 @@ _EXPECTED_RESULT_CONTRACT_SHA256: Final = (
"b3dfaa8e20a0f22fc510d062ac469f010a3281c650059d9ea134f0b3ccb38d9a"
)
_PORTABLE_CONFIG_ASSET_ID: Final = "ddrnet-portable-config"
_WORKER_INSTALLATION_RECEIPT_ASSET_ID: Final = (
"lab-v1-worker-installation-receipt"
)
_WORKER_INSTALLATION_RECEIPT_ASSET_ID: Final = "lab-v1-worker-installation-receipt"
_MAX_SOURCE_DOCUMENT_BYTES: Final = 8 * 1024 * 1024
_MAX_MATERIALIZATION_MANIFEST_BYTES: Final = 64 * 1024 * 1024
_MAX_SOURCE_MEMBERS: Final = 100_000
@@ -432,6 +430,27 @@ def materialize_lab_v1_source_from_worker_stage(
"""Build a deterministic camera job from only manifested Worker members."""
_verify_definition(definition)
return materialize_recorded_camera_source_from_worker_stage(
worker_stage=worker_stage,
job=job,
definition=definition,
output_parent=output_parent,
)
def materialize_recorded_camera_source_from_worker_stage(
*,
worker_stage: PortableWorkerSourceStage,
job: SealedObservatoryRecordedJob,
definition: PortableRunDefinition,
output_parent: Path,
) -> PortableLabV1MaterializedSource:
"""Build the shared recorded-camera input for any exact installed definition.
The historical LAB entrypoint above retains its setup-specific admission.
New modular packages use this model-neutral source preparation boundary.
"""
if (
worker_stage.source_bundle_sha256 != job.source_bundle_sha256
or worker_stage.source_capability_manifest_sha256 != job.source_capability_manifest_sha256
@@ -574,8 +593,7 @@ def _verify_candidate_release(
or candidate.definition_id != release.definition_id
or candidate.definition_version != release.definition_version
or candidate.definition_sha256 != definition.definition_sha256
or release.definition_contract_sha256
!= definition.executable_contract_sha256
or release.definition_contract_sha256 != definition.executable_contract_sha256
or candidate.result_contract_sha256 != release.result_contract_sha256
or tuple(phase.phase_id for phase in candidate.phases) != PORTABLE_LAB_V1_RUNTIME_PHASES
or executor is None
@@ -0,0 +1,237 @@
"""Project sealed RF-DETR boxes and optional K1 ranges without inference."""
from __future__ import annotations
import hashlib
import json
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
import rerun as rr
from k1link.artifact_gateway import CentralArtifactStore
from k1link.observatory.portable_tgs_replay import PortableReplayError, read_json, verified_file
from k1link.perception.contracts import ObjectProposal2D, ObstacleObservation
RESULT_SCHEMA = "missioncore.recorded-ai-layer-review/v1"
RF_RESULT_SCHEMA = "missioncore.observatory-ai-module-rf-detr-result/v1"
RANGE_RESULT_SCHEMA = "missioncore.observatory-ai-module-object-distance-result/v1"
RF_ROW_SCHEMA = "missioncore.observatory-ai-module-rf-detr-frame/v1"
RANGE_ROW_SCHEMA = "missioncore.observatory-ai-module-object-distance-frame/v1"
RENDERER_VERSION = "portable-rf-detr-k1-range-rerun-0.36.3-v1"
_MAX_FRAMES = 250_000
_MAX_ROW_BYTES = 8 * 1024 * 1024
@dataclass(frozen=True)
class ObjectReplayFrame:
session_seconds: float
proposals: tuple[ObjectProposal2D, ...]
ranges_m: dict[str, float | None]
@dataclass(frozen=True)
class ObjectReplayData:
frames: tuple[ObjectReplayFrame, ...]
end_seconds: float
include_ranges: bool
def _artifact(
members: dict[str, dict[str, Any]],
store: CentralArtifactStore,
role: str,
media_type: str,
) -> Path:
member = members.get(role)
if member is None or member.get("media_type") != media_type:
raise PortableReplayError("object replay artifact is missing")
return verified_file(
store.object_path(member["sha256"]), member["sha256"], member["byte_length"]
)
def _rows(path: Path, *, schema: str, count: int) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
with path.open(encoding="utf-8") as stream:
for raw in stream:
if len(raw) > _MAX_ROW_BYTES or len(result) >= count:
raise PortableReplayError("object replay rows exceed their bound")
try:
row = json.loads(raw)
except json.JSONDecodeError as exc:
raise PortableReplayError("object replay row is invalid") from exc
if not isinstance(row, dict) or row.get("schema_version") != schema:
raise PortableReplayError("object replay row contract changed")
result.append(row)
if len(result) != count:
raise PortableReplayError("object replay frame count changed")
return result
def _time(value: object) -> float:
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise PortableReplayError("object replay time is invalid")
number = float(value)
if not math.isfinite(number):
raise PortableReplayError("object replay time is invalid")
return number
def load_object_data(
view: dict[str, Any],
store: CentralArtifactStore,
*,
source_bundle_sha256: str,
starts: list[float],
end_seconds: float,
) -> ObjectReplayData:
doc = view["result_document"]
source = doc.get("source")
module = doc.get("module")
if not isinstance(source, dict) or not isinstance(module, dict):
raise PortableReplayError("object replay result is malformed")
module_id = module.get("module_id")
if (
doc.get("schema_version") != RESULT_SCHEMA
or doc.get("result_id") != view["result_id"]
or module_id not in {"rf-detr", "object-distance"}
or source.get("session_id") != view["source_session_id"]
or source.get("bundle_sha256") != source_bundle_sha256
or not 0 < len(starts) <= _MAX_FRAMES
or source.get("frame_count") != len(starts)
or source.get("timeline_start_seconds") != starts[0]
or source.get("timeline_end_seconds") != end_seconds
or not np.isfinite([*starts, end_seconds]).all()
or np.any(np.diff([*starts, end_seconds]) <= 0)
):
raise PortableReplayError("object result and source clock disagree")
members = {row["role"]: row for row in view["artifacts"]}
if len(members) != len(view["artifacts"]):
raise PortableReplayError("object artifact roles are duplicated")
rf_path = _artifact(members, store, "rf-detr-result-document", "application/json")
rf = read_json(rf_path)
detection_path = _artifact(members, store, "rf-detr-frame-detections", "application/x-ndjson")
if (
rf.get("schema_version") != RF_RESULT_SCHEMA
or rf.get("module_id") != "rf-detr"
or not isinstance(rf.get("source"), dict)
or rf["source"].get("session_id") != view["source_session_id"]
or rf.get("frame_count") != len(starts)
or rf.get("detections_sha256") != members["rf-detr-frame-detections"]["sha256"]
or (
module_id == "rf-detr"
and module.get("component_result_sha256")
!= members["rf-detr-result-document"]["sha256"]
)
):
raise PortableReplayError("RF-DETR result binding changed")
detection_rows = _rows(detection_path, schema=RF_ROW_SCHEMA, count=len(starts))
range_rows: list[dict[str, Any]] | None = None
if module_id == "object-distance":
range_path = _artifact(
members,
store,
"object-distance-frame-observations",
"application/x-ndjson",
)
ranged = read_json(
_artifact(members, store, "object-distance-result-document", "application/json")
)
if (
ranged.get("schema_version") != RANGE_RESULT_SCHEMA
or ranged.get("module_id") != "object-distance"
or ranged.get("source_session_id") != view["source_session_id"]
or ranged.get("frame_count") != len(starts)
or ranged.get("object_distances_sha256")
!= members["object-distance-frame-observations"]["sha256"]
or module.get("component_result_sha256")
!= members["object-distance-result-document"]["sha256"]
):
raise PortableReplayError("object-distance result binding changed")
range_rows = _rows(range_path, schema=RANGE_ROW_SCHEMA, count=len(starts))
frames: list[ObjectReplayFrame] = []
try:
for index, (timestamp, row) in enumerate(zip(starts, detection_rows, strict=True)):
if row.get("frame_index") != index or _time(row.get("session_seconds")) != timestamp:
raise PortableReplayError("RF-DETR frame clock changed")
raw_proposals = row.get("proposals")
if not isinstance(raw_proposals, list):
raise PortableReplayError("RF-DETR proposals are unavailable")
proposals = tuple(ObjectProposal2D.from_dict(value) for value in raw_proposals)
proposal_ids = {item.proposal_id for item in proposals}
if len(proposal_ids) != len(proposals) or any(
item.region.x_max > 800 or item.region.y_max > 600 for item in proposals
):
raise PortableReplayError("RF-DETR proposal geometry changed")
ranges: dict[str, float | None] = {}
if range_rows is not None:
range_row = range_rows[index]
if (
range_row.get("frame_index") != index
or _time(range_row.get("session_seconds")) != timestamp
or not isinstance(range_row.get("observations"), list)
):
raise PortableReplayError("object-distance frame clock changed")
for raw in range_row["observations"]:
observation = ObstacleObservation.from_dict(raw)
distance = (
None
if observation.metric_geometry is None
else observation.metric_geometry.range_m
)
for proposal_id in observation.proposal_ids:
if proposal_id not in proposal_ids or proposal_id in ranges:
raise PortableReplayError("object-distance proposal binding changed")
ranges[proposal_id] = distance
if set(ranges) != proposal_ids:
raise PortableReplayError("object-distance coverage changed")
frames.append(ObjectReplayFrame(timestamp, proposals, ranges))
except (KeyError, TypeError, ValueError) as exc:
if isinstance(exc, PortableReplayError):
raise
raise PortableReplayError("object replay contract changed") from exc
return ObjectReplayData(tuple(frames), end_seconds, range_rows is not None)
def _color(label: str) -> list[int]:
digest = hashlib.sha256(f"mission-core-object-{label}".encode()).digest()
return [80 + digest[channel] % 160 for channel in range(3)] + [255]
def log_objects(recording: rr.RecordingStream, data: ObjectReplayData) -> None:
for frame in data.frames:
recording.set_time(
"session_time",
duration=np.timedelta64(round(frame.session_seconds * 1e9), "ns"),
)
if not frame.proposals:
recording.log("/perception/camera/detections", rr.Clear(recursive=False))
continue
labels: list[str] = []
for proposal in frame.proposals:
label = proposal.semantic_hint or "объект"
confidence = f"{proposal.objectness * 100:.0f}%"
if data.include_ranges:
distance = frame.ranges_m[proposal.proposal_id]
suffix = "дальность н/д" if distance is None else f"{distance:.1f} м"
labels.append(f"{label} · {confidence} · {suffix}")
else:
labels.append(f"{label} · {confidence}")
recording.log(
"/perception/camera/detections",
rr.Boxes2D(
array=[proposal.region.as_tuple() for proposal in frame.proposals],
array_format=rr.Box2DFormat.XYXY,
labels=labels,
colors=[_color(proposal.semantic_hint or "object") for proposal in frame.proposals],
show_labels=True,
),
)
recording.set_time("session_time", duration=np.timedelta64(round(data.end_seconds * 1e9), "ns"))
recording.log("/perception/camera/detections", rr.Clear(recursive=False))

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