feat(observatory): ship modular AI inference labs
This commit is contained in:
@@ -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 { 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 type { SceneSettings } from "../sceneSettings";
|
||||||
import {
|
import {
|
||||||
@@ -138,6 +145,15 @@ export interface RerunViewportProps {
|
|||||||
|
|
||||||
interface RerunBlueprintChannel {
|
interface RerunBlueprintChannel {
|
||||||
endpointUrl: string;
|
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: {
|
channel: {
|
||||||
readonly ready: boolean;
|
readonly ready: boolean;
|
||||||
send_rrd: (rrdBytes: Uint8Array) => void;
|
send_rrd: (rrdBytes: Uint8Array) => void;
|
||||||
@@ -166,11 +182,12 @@ interface RecordedRerunIdentity {
|
|||||||
|
|
||||||
const RECORDED_RRD_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
|
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 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-[a-f0-9]{64}\/replays\/[a-f0-9]{64}\/recording\.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 {
|
export function isRecordedRrdSource(path: string): boolean {
|
||||||
return RECORDED_RRD_PATH.test(path) || LAB_RECORDED_REPLAY_PATH.test(path)
|
return RECORDED_RRD_PATH.test(path) || LAB_RECORDED_REPLAY_PATH.test(path)
|
||||||
|| PORTABLE_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_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 RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
|
||||||
@@ -267,7 +284,9 @@ export function resolveRecordedBlueprintUrl(
|
|||||||
if (explicitSourceUrl !== undefined) {
|
if (explicitSourceUrl !== undefined) {
|
||||||
const explicit = explicitSourceUrl.trim();
|
const explicit = explicitSourceUrl.trim();
|
||||||
if (
|
if (
|
||||||
!(LAB_RECORDED_REPLAY_PATH.test(normalized) || PORTABLE_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)
|
|| !RECORDED_BLUEPRINT_PATH.test(explicit)
|
||||||
) return null;
|
) return null;
|
||||||
const endpoint = new URL(explicit, `${base.origin}/`);
|
const endpoint = new URL(explicit, `${base.origin}/`);
|
||||||
@@ -393,7 +412,11 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
followTrajectory = false,
|
followTrajectory = false,
|
||||||
semanticLayer,
|
semanticLayer,
|
||||||
unifiedPerception,
|
unifiedPerception,
|
||||||
|
unifiedCameraShare = 0.46,
|
||||||
planView = false,
|
planView = false,
|
||||||
|
cameraEye,
|
||||||
|
currentTimeNs,
|
||||||
|
onCameraMaxOrbitalRadius,
|
||||||
perceptionLayers = {
|
perceptionLayers = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
detections2d: false,
|
detections2d: false,
|
||||||
@@ -410,7 +433,11 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
followTrajectory?: boolean;
|
followTrajectory?: boolean;
|
||||||
semanticLayer?: "city" | "vegetation";
|
semanticLayer?: "city" | "vegetation";
|
||||||
unifiedPerception?: boolean;
|
unifiedPerception?: boolean;
|
||||||
|
unifiedCameraShare?: number;
|
||||||
planView?: boolean;
|
planView?: boolean;
|
||||||
|
cameraEye?: RecordedRerunCameraEye;
|
||||||
|
currentTimeNs?: number | null;
|
||||||
|
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||||
perceptionLayers?: RecordedPerceptionLayers;
|
perceptionLayers?: RecordedPerceptionLayers;
|
||||||
fetcher?: typeof globalThis.fetch;
|
fetcher?: typeof globalThis.fetch;
|
||||||
},
|
},
|
||||||
@@ -426,10 +453,8 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
!RECORDED_BLUEPRINT_PATH.test(endpoint.pathname) ||
|
!RECORDED_BLUEPRINT_PATH.test(endpoint.pathname) ||
|
||||||
!Number.isFinite(settings.accumulationSeconds) ||
|
!Number.isFinite(settings.accumulationSeconds) ||
|
||||||
settings.accumulationSeconds < 0 ||
|
settings.accumulationSeconds < 0 ||
|
||||||
settings.accumulationSeconds > 3600 ||
|
|
||||||
!Number.isFinite(settings.pointSize) ||
|
!Number.isFinite(settings.pointSize) ||
|
||||||
settings.pointSize < 0.1 ||
|
settings.pointSize < 0.1 ||
|
||||||
settings.pointSize > 32 ||
|
|
||||||
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
|
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
|
||||||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
|
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
|
||||||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
||||||
@@ -437,9 +462,21 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
![0, 1].includes(viewResetGeneration) ||
|
![0, 1].includes(viewResetGeneration) ||
|
||||||
(semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) ||
|
(semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) ||
|
||||||
typeof resolvedUnifiedPerception !== "boolean" ||
|
typeof resolvedUnifiedPerception !== "boolean" ||
|
||||||
|
!Number.isFinite(unifiedCameraShare) ||
|
||||||
|
unifiedCameraShare < 0.1 ||
|
||||||
|
unifiedCameraShare > 0.9 ||
|
||||||
typeof planView !== "boolean" ||
|
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.enabled,
|
||||||
|
perceptionLayers.cameraImage ?? true,
|
||||||
perceptionLayers.detections2d,
|
perceptionLayers.detections2d,
|
||||||
perceptionLayers.segmentation,
|
perceptionLayers.segmentation,
|
||||||
perceptionLayers.cuboids3d,
|
perceptionLayers.cuboids3d,
|
||||||
@@ -474,9 +511,17 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
view_reset_generation: viewResetGeneration,
|
view_reset_generation: viewResetGeneration,
|
||||||
follow_trajectory: followTrajectory,
|
follow_trajectory: followTrajectory,
|
||||||
unified_perception: resolvedUnifiedPerception,
|
unified_perception: resolvedUnifiedPerception,
|
||||||
|
unified_camera_share: unifiedCameraShare,
|
||||||
semantic_layer: semanticLayer ?? null,
|
semantic_layer: semanticLayer ?? null,
|
||||||
plan_view: planView,
|
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_detections_2d: perceptionLayers.detections2d,
|
||||||
|
show_camera_image: perceptionLayers.cameraImage ?? true,
|
||||||
show_segmentation: perceptionLayers.segmentation,
|
show_segmentation: perceptionLayers.segmentation,
|
||||||
show_cuboids_3d: perceptionLayers.cuboids3d,
|
show_cuboids_3d: perceptionLayers.cuboids3d,
|
||||||
...(perceptionLayers.costmap === undefined ? {} : {
|
...(perceptionLayers.costmap === undefined ? {} : {
|
||||||
@@ -506,9 +551,34 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
) {
|
) {
|
||||||
throw new Error("Invalid recorded blueprint RRD");
|
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;
|
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(
|
export async function fetchRecordedPerceptionRrd(
|
||||||
endpointUrl: string,
|
endpointUrl: string,
|
||||||
identity: RecordedRerunIdentity,
|
identity: RecordedRerunIdentity,
|
||||||
@@ -717,6 +787,7 @@ export function RerunViewport({
|
|||||||
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
|
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
|
||||||
const recordedSemanticLayer = recordedProfile?.semanticLayer;
|
const recordedSemanticLayer = recordedProfile?.semanticLayer;
|
||||||
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
|
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
|
||||||
|
const recordedUnifiedCameraShare = recordedProfile?.unifiedCameraShare ?? 0.46;
|
||||||
const recordedPlanView = recordedProfile?.planView ?? false;
|
const recordedPlanView = recordedProfile?.planView ?? false;
|
||||||
const recordedPerceptionRetryGeneration =
|
const recordedPerceptionRetryGeneration =
|
||||||
recordedProfile?.perceptionRetryGeneration ?? 0;
|
recordedProfile?.perceptionRetryGeneration ?? 0;
|
||||||
@@ -762,9 +833,9 @@ export function RerunViewport({
|
|||||||
: sourceUrl
|
: sourceUrl
|
||||||
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
|
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
|
||||||
: null;
|
: null;
|
||||||
const recordedPointColorsUrl = sourceUrl
|
const recordedPointColorsUrl = recordedBlueprintUrl
|
||||||
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin)
|
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
|
||||||
: null;
|
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
|
||||||
const presentationStatus = rerunPresentationStatus(
|
const presentationStatus = rerunPresentationStatus(
|
||||||
status,
|
status,
|
||||||
presentationGate,
|
presentationGate,
|
||||||
@@ -836,6 +907,9 @@ export function RerunViewport({
|
|||||||
diagnosticLifecycle.verifyBuild();
|
diagnosticLifecycle.verifyBuild();
|
||||||
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
const blueprintOwnerId = crypto.randomUUID().replaceAll("-", "");
|
||||||
|
blueprintSessionIdRef.current = blueprintOwnerId;
|
||||||
|
let releaseBlueprintSession = () => {};
|
||||||
let disposeViewer: (() => void) | undefined;
|
let disposeViewer: (() => void) | undefined;
|
||||||
let recordedOpenWatchdog: {
|
let recordedOpenWatchdog: {
|
||||||
arm: () => void;
|
arm: () => void;
|
||||||
@@ -1082,6 +1156,11 @@ export function RerunViewport({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
activeViewerLifecycleRef.current = disposeActiveViewerLifecycle;
|
activeViewerLifecycleRef.current = disposeActiveViewerLifecycle;
|
||||||
|
const restoreAfterPageCache = (event: PageTransitionEvent) => {
|
||||||
|
if (event.persisted) setRetryNonce((nonce) => nonce + 1);
|
||||||
|
};
|
||||||
|
window.addEventListener("pagehide", disposeActiveViewerLifecycle);
|
||||||
|
window.addEventListener("pageshow", restoreAfterPageCache);
|
||||||
|
|
||||||
host.replaceChildren();
|
host.replaceChildren();
|
||||||
appliedPointColorKeyRef.current = null;
|
appliedPointColorKeyRef.current = null;
|
||||||
@@ -1089,15 +1168,22 @@ export function RerunViewport({
|
|||||||
setRecordingBufferProgress(null);
|
setRecordingBufferProgress(null);
|
||||||
onStatusChange?.("loading");
|
onStatusChange?.("loading");
|
||||||
|
|
||||||
void import("@rerun-io/web-viewer")
|
const isolatedHost = isRecordedSource ? createIsolatedRerunHost(host) : null;
|
||||||
.then(async ({ WebViewer }) => {
|
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
|
// React StrictMode intentionally disposes the first effect while the
|
||||||
// dynamic import is still pending. Never start that stale viewer (the
|
// dynamic import is still pending. Never start that stale viewer (the
|
||||||
// vendor API treats a missing host as document.body).
|
// vendor API treats a missing host as document.body).
|
||||||
if (disposed) return;
|
if (disposed) {
|
||||||
|
viewer.stop();
|
||||||
const viewer = new WebViewer();
|
isolatedHost?.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
disposeViewer = createReentrantViewerDisposer(() => {
|
disposeViewer = createReentrantViewerDisposer(() => {
|
||||||
|
releaseBlueprintSession();
|
||||||
clearRecordingTimers();
|
clearRecordingTimers();
|
||||||
clearPlaybackRangeTimer();
|
clearPlaybackRangeTimer();
|
||||||
playbackTimeUpdates.cancel();
|
playbackTimeUpdates.cancel();
|
||||||
@@ -1155,6 +1241,7 @@ export function RerunViewport({
|
|||||||
// A partially initialized WASM handle can already be gone after a
|
// A partially initialized WASM handle can already be gone after a
|
||||||
// startup failure.
|
// startup failure.
|
||||||
}
|
}
|
||||||
|
isolatedHost?.dispose();
|
||||||
host.replaceChildren();
|
host.replaceChildren();
|
||||||
});
|
});
|
||||||
if (isRecordedSource && recordedArtifact) {
|
if (isRecordedSource && recordedArtifact) {
|
||||||
@@ -1191,6 +1278,13 @@ export function RerunViewport({
|
|||||||
event.application_id === "nodedc_mission_core_recorded" &&
|
event.application_id === "nodedc_mission_core_recorded" &&
|
||||||
/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(event.recording_id)
|
/^[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;
|
const current = recordedIdentityRef.current;
|
||||||
if (
|
if (
|
||||||
current?.applicationId !== event.application_id ||
|
current?.applicationId !== event.application_id ||
|
||||||
@@ -1500,7 +1594,7 @@ export function RerunViewport({
|
|||||||
};
|
};
|
||||||
await viewer.start(
|
await viewer.start(
|
||||||
rerunViewerInitialSource(resolvedSource),
|
rerunViewerInitialSource(resolvedSource),
|
||||||
host,
|
mount,
|
||||||
viewerOptions,
|
viewerOptions,
|
||||||
);
|
);
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
@@ -1518,7 +1612,34 @@ export function RerunViewport({
|
|||||||
|
|
||||||
if (recordedBlueprintUrl) {
|
if (recordedBlueprintUrl) {
|
||||||
const channel = viewer.open_channel("missioncore/recorded-blueprint");
|
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;
|
blueprintChannelRef.current = blueprintChannel;
|
||||||
setBlueprintChannelRevision((revision) => revision + 1);
|
setBlueprintChannelRevision((revision) => revision + 1);
|
||||||
}
|
}
|
||||||
@@ -1578,6 +1699,8 @@ export function RerunViewport({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener("pagehide", disposeActiveViewerLifecycle);
|
||||||
|
window.removeEventListener("pageshow", restoreAfterPageCache);
|
||||||
if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) {
|
if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) {
|
||||||
activeViewerLifecycleRef.current = null;
|
activeViewerLifecycleRef.current = null;
|
||||||
}
|
}
|
||||||
@@ -1959,6 +2082,22 @@ export function RerunViewport({
|
|||||||
!active.channel.ready
|
!active.channel.ready
|
||||||
) return;
|
) return;
|
||||||
const abort = new AbortController();
|
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, {
|
void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
|
||||||
origin: window.location.origin,
|
origin: window.location.origin,
|
||||||
blueprintSessionId: blueprintSessionIdRef.current,
|
blueprintSessionId: blueprintSessionIdRef.current,
|
||||||
@@ -1969,7 +2108,15 @@ export function RerunViewport({
|
|||||||
perceptionLayers: recordedPerceptionLayers,
|
perceptionLayers: recordedPerceptionLayers,
|
||||||
semanticLayer: recordedSemanticLayer,
|
semanticLayer: recordedSemanticLayer,
|
||||||
unifiedPerception: recordedUnifiedPerception,
|
unifiedPerception: recordedUnifiedPerception,
|
||||||
|
unifiedCameraShare: recordedUnifiedCameraShare,
|
||||||
planView: recordedPlanView,
|
planView: recordedPlanView,
|
||||||
|
cameraEye,
|
||||||
|
currentTimeNs: active.getCurrentTimeNs?.(),
|
||||||
|
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
||||||
|
if (blueprintChannelRef.current === active) {
|
||||||
|
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
|
||||||
|
}
|
||||||
|
},
|
||||||
}).then((payload) => {
|
}).then((payload) => {
|
||||||
if (
|
if (
|
||||||
abort.signal.aborted ||
|
abort.signal.aborted ||
|
||||||
@@ -1980,6 +2127,9 @@ export function RerunViewport({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
active.channel.send_rrd(payload);
|
active.channel.send_rrd(payload);
|
||||||
|
active.setCameraViewportStart?.(
|
||||||
|
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
||||||
|
);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// The recording remains usable with its embedded default blueprint.
|
// The recording remains usable with its embedded default blueprint.
|
||||||
// A later settings change retries through the same small channel.
|
// A later settings change retries through the same small channel.
|
||||||
@@ -1993,8 +2143,10 @@ export function RerunViewport({
|
|||||||
recordedFollowTrajectory,
|
recordedFollowTrajectory,
|
||||||
recordedSemanticLayer,
|
recordedSemanticLayer,
|
||||||
recordedUnifiedPerception,
|
recordedUnifiedPerception,
|
||||||
|
recordedUnifiedCameraShare,
|
||||||
recordedPlanView,
|
recordedPlanView,
|
||||||
recordedPerceptionLayers.enabled,
|
recordedPerceptionLayers.enabled,
|
||||||
|
recordedPerceptionLayers.cameraImage,
|
||||||
recordedPerceptionLayers.detections2d,
|
recordedPerceptionLayers.detections2d,
|
||||||
recordedPerceptionLayers.segmentation,
|
recordedPerceptionLayers.segmentation,
|
||||||
recordedPerceptionLayers.cuboids3d,
|
recordedPerceptionLayers.cuboids3d,
|
||||||
|
|||||||
@@ -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,
|
mediaLayerControls,
|
||||||
spatialLayerControls,
|
spatialLayerControls,
|
||||||
spatialLeadingControl,
|
spatialLeadingControl,
|
||||||
|
spatialTrailingControl,
|
||||||
|
mediaModeControlsVisible = true,
|
||||||
|
paneToolbarsAlwaysVisible = false,
|
||||||
mediaMultiLayer = false,
|
mediaMultiLayer = false,
|
||||||
mediaContent,
|
mediaContent,
|
||||||
spatialContent,
|
spatialContent,
|
||||||
@@ -116,10 +119,14 @@ export function CanonicalRecordedLabReplay<
|
|||||||
mediaLayerControls?: ReactNode;
|
mediaLayerControls?: ReactNode;
|
||||||
spatialLayerControls?: ReactNode;
|
spatialLayerControls?: ReactNode;
|
||||||
spatialLeadingControl?: ReactNode;
|
spatialLeadingControl?: ReactNode;
|
||||||
|
spatialTrailingControl?: ReactNode;
|
||||||
|
mediaModeControlsVisible?: boolean;
|
||||||
|
/** Keeps renderer-local controls inside their viewport when only one pane is present. */
|
||||||
|
paneToolbarsAlwaysVisible?: boolean;
|
||||||
mediaMultiLayer?: boolean;
|
mediaMultiLayer?: boolean;
|
||||||
mediaContent?: ReactNode;
|
mediaContent?: ReactNode;
|
||||||
spatialContent?: 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;
|
unifiedContent?: ReactNode;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
deckOverlays?: ReactNode;
|
deckOverlays?: ReactNode;
|
||||||
@@ -162,14 +169,14 @@ export function CanonicalRecordedLabReplay<
|
|||||||
aria-label={mediaAriaLabel}
|
aria-label={mediaAriaLabel}
|
||||||
hidden={mediaMode === "none"}
|
hidden={mediaMode === "none"}
|
||||||
>
|
>
|
||||||
{splitView ? (
|
{splitView || paneToolbarsAlwaysVisible ? (
|
||||||
<div
|
<div
|
||||||
className="m4-replay-threat-visual__pane-toolbar"
|
className="m4-replay-threat-visual__pane-toolbar"
|
||||||
data-pane-toolbar="media"
|
data-pane-toolbar="media"
|
||||||
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
|
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
|
||||||
>
|
>
|
||||||
{mediaLayerControls}
|
{mediaLayerControls}
|
||||||
{mediaModeControls}
|
{mediaModeControlsVisible ? mediaModeControls : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{mediaContent}
|
{mediaContent}
|
||||||
@@ -181,7 +188,7 @@ export function CanonicalRecordedLabReplay<
|
|||||||
data-pane="spatial"
|
data-pane="spatial"
|
||||||
aria-label={spatialAriaLabel}
|
aria-label={spatialAriaLabel}
|
||||||
>
|
>
|
||||||
{splitView ? (
|
{splitView || paneToolbarsAlwaysVisible ? (
|
||||||
<div
|
<div
|
||||||
className="m4-replay-threat-visual__pane-toolbar"
|
className="m4-replay-threat-visual__pane-toolbar"
|
||||||
data-pane-toolbar="spatial"
|
data-pane-toolbar="spatial"
|
||||||
@@ -190,6 +197,7 @@ export function CanonicalRecordedLabReplay<
|
|||||||
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
||||||
{spatialLayerControls}
|
{spatialLayerControls}
|
||||||
{spatialModeControls}
|
{spatialModeControls}
|
||||||
|
{spatialTrailingControl}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -216,7 +224,7 @@ export function CanonicalRecordedLabReplay<
|
|||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onModeChange={onMediaModeChange}
|
onModeChange={onMediaModeChange}
|
||||||
onExpandedChange={onExpandedChange}
|
onExpandedChange={onExpandedChange}
|
||||||
modeControlsVisible={!splitView}
|
modeControlsVisible={!splitView && !paneToolbarsAlwaysVisible}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
overlay={overlay}
|
overlay={overlay}
|
||||||
transport={transport}
|
transport={transport}
|
||||||
@@ -241,7 +249,7 @@ export function CanonicalRecordedLabReplay<
|
|||||||
orientation={splitOrientation}
|
orientation={splitOrientation}
|
||||||
minPrimarySize={splitView ? 24 : 0}
|
minPrimarySize={splitView ? 24 : 0}
|
||||||
minSecondarySize={splitView ? 24 : 0}
|
minSecondarySize={splitView ? 24 : 0}
|
||||||
resizable={splitView && !unifiedContent}
|
resizable={splitView}
|
||||||
separatorLabel="Изменить размер видео/камеры и 3D/плана"
|
separatorLabel="Изменить размер видео/камеры и 3D/плана"
|
||||||
/>
|
/>
|
||||||
{mediaMode === "none" && spatialMode === "none" ? (
|
{mediaMode === "none" && spatialMode === "none" ? (
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ import {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
|
||||||
type PointerEvent as ReactPointerEvent,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Button,
|
Button,
|
||||||
|
Checker,
|
||||||
|
ControlRow,
|
||||||
Icon,
|
Icon,
|
||||||
SegmentedControl,
|
IconButton,
|
||||||
|
Inspector,
|
||||||
|
InspectorSelectField,
|
||||||
|
RangeControl,
|
||||||
|
ToastStack,
|
||||||
|
Window,
|
||||||
|
type ToastItem,
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { ObservationTimeline } from "../ObservationTimeline";
|
import { ObservationTimeline } from "../ObservationTimeline";
|
||||||
@@ -26,18 +32,24 @@ import {
|
|||||||
CanonicalRecordedLabReplay,
|
CanonicalRecordedLabReplay,
|
||||||
useCanonicalRecordedLabReplayState,
|
useCanonicalRecordedLabReplayState,
|
||||||
} from "../laboratory/CanonicalRecordedLabReplay";
|
} from "../laboratory/CanonicalRecordedLabReplay";
|
||||||
import {
|
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
|
||||||
type CanonicalLabReplayDescriptor,
|
|
||||||
} from "../../core/laboratory/canonicalLabReplay";
|
|
||||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||||
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||||
import { defaultSceneSettings } from "../../sceneSettings";
|
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 = "video" | "camera";
|
type MediaMode = "camera";
|
||||||
type SpatialMode = "3d" | "plan";
|
type SpatialMode = "3d" | "plan";
|
||||||
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
|
|
||||||
type SemanticLayer = "city" | "vegetation";
|
|
||||||
|
|
||||||
interface CanonicalReplayLaunch {
|
interface CanonicalReplayLaunch {
|
||||||
base: ObservationSessionReplayLaunch;
|
base: ObservationSessionReplayLaunch;
|
||||||
@@ -45,7 +57,80 @@ interface CanonicalReplayLaunch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
|
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
|
||||||
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
|
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({
|
export function CanonicalResultRerunReplay({
|
||||||
resultId,
|
resultId,
|
||||||
@@ -53,15 +138,22 @@ export function CanonicalResultRerunReplay({
|
|||||||
initialPlaybackStartSeconds,
|
initialPlaybackStartSeconds,
|
||||||
resolveReplay,
|
resolveReplay,
|
||||||
semantics = false,
|
semantics = false,
|
||||||
|
detections = false,
|
||||||
costmap = false,
|
costmap = false,
|
||||||
label = "Сохранённый результат · синхронизированный повтор",
|
moduleIds = [],
|
||||||
|
viewerLayers = [],
|
||||||
|
label = "Сохранённый результат",
|
||||||
}: {
|
}: {
|
||||||
resultId: string;
|
resultId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
initialPlaybackStartSeconds?: number;
|
initialPlaybackStartSeconds?: number;
|
||||||
resolveReplay: (resultId: string, launch: ObservationSessionReplayLaunch, options: { signal: AbortSignal }) => Promise<CanonicalLabReplayDescriptor>;
|
resolveReplay: (resultId: string, launch: ObservationSessionReplayLaunch, options: { signal: AbortSignal }) => Promise<CanonicalLabReplayDescriptor>;
|
||||||
semantics?: boolean;
|
semantics?: boolean;
|
||||||
|
detections?: boolean;
|
||||||
costmap?: boolean;
|
costmap?: boolean;
|
||||||
|
portable?: boolean;
|
||||||
|
moduleIds?: readonly string[];
|
||||||
|
viewerLayers?: readonly AIViewerLayer[];
|
||||||
label?: string;
|
label?: string;
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
@@ -75,96 +167,106 @@ export function CanonicalResultRerunReplay({
|
|||||||
onSplitPrimarySizeChange,
|
onSplitPrimarySizeChange,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
|
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
|
||||||
initialMediaMode: "video",
|
initialMediaMode: "camera",
|
||||||
initialSpatialMode: "3d",
|
initialSpatialMode: "3d",
|
||||||
});
|
});
|
||||||
const splitView = mediaMode !== null && spatialMode !== null;
|
const layerIds = useMemo(
|
||||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
() => new Set(viewerLayers.map((layer) => layer.layerId)),
|
||||||
const [showSemantics, setShowSemantics] = useState(true);
|
[viewerLayers],
|
||||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>(costmap ? "tgs" : "source");
|
);
|
||||||
// Give the portable composition its own initial native view identity.
|
const hasProjectedLayers = viewerLayers.length > 0;
|
||||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(costmap ? 1 : 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 [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||||
const [playbackController, setPlaybackController] =
|
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||||
useState<RerunPlaybackController | null>(null);
|
|
||||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
|
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
|
||||||
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||||
const [launchError, setLaunchError] = useState<string | null>(null);
|
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||||
const viewerFrameRef = useRef<HTMLDivElement>(null);
|
const sceneDraftRef = useRef(sceneDraft);
|
||||||
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
|
||||||
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
|
|
||||||
const previousSplitViewRef = useRef(splitView);
|
const previousSplitViewRef = useRef(splitView);
|
||||||
const trackedLaunchSha256Ref = useRef<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!splitView) stopNativeSplitTracking();
|
|
||||||
const launchSha256 = launch?.replay.sha256 ?? null;
|
const launchSha256 = launch?.replay.sha256 ?? null;
|
||||||
if (
|
if (trackedLaunchSha256Ref.current !== launchSha256 || (splitView && !previousSplitViewRef.current)) {
|
||||||
trackedLaunchSha256Ref.current !== launchSha256
|
onSplitPrimarySizeChange(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||||
|| (splitView && !previousSplitViewRef.current)
|
setBlueprintSplitPrimarySize(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||||
) {
|
|
||||||
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
|
|
||||||
}
|
}
|
||||||
trackedLaunchSha256Ref.current = launchSha256;
|
trackedLaunchSha256Ref.current = launchSha256;
|
||||||
previousSplitViewRef.current = splitView;
|
previousSplitViewRef.current = splitView;
|
||||||
const cameraPanePercent = mediaMode === null
|
}, [launch?.replay.sha256, onSplitPrimarySizeChange, splitView]);
|
||||||
? 0
|
|
||||||
: splitView
|
useEffect(() => {
|
||||||
? nativeSplitPercentRef.current
|
if (!splitView) return;
|
||||||
: 100;
|
const timer = window.setTimeout(() => setBlueprintSplitPrimarySize(splitPrimarySize), 75);
|
||||||
viewerFrameRef.current?.style.setProperty(
|
return () => window.clearTimeout(timer);
|
||||||
"--canonical-rerun-camera-pane",
|
}, [splitPrimarySize, splitView]);
|
||||||
`${cameraPanePercent}%`,
|
|
||||||
);
|
useEffect(() => {
|
||||||
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
|
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(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -179,16 +281,12 @@ export function CanonicalResultRerunReplay({
|
|||||||
onUpdate: () => undefined,
|
onUpdate: () => undefined,
|
||||||
}).then(async (value) => ({
|
}).then(async (value) => ({
|
||||||
base: value,
|
base: value,
|
||||||
replay: await resolveReplay(resultId, value, {
|
replay: await resolveReplay(resultId, value, { signal: controller.signal }),
|
||||||
signal: controller.signal,
|
|
||||||
}),
|
|
||||||
})).then((value) => {
|
})).then((value) => {
|
||||||
if (!controller.signal.aborted) setLaunch(value);
|
if (!controller.signal.aborted) setLaunch(value);
|
||||||
}).catch((caught: unknown) => {
|
}).catch((caught: unknown) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setLaunchError(
|
setLaunchError(caught instanceof Error ? caught.message : "Сохранённая запись недоступна.");
|
||||||
caught instanceof Error ? caught.message : "Сохранённая запись недоступна.",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
@@ -198,17 +296,15 @@ export function CanonicalResultRerunReplay({
|
|||||||
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
|
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
|
||||||
const presentationState = launchError || viewerStatus === "error"
|
const presentationState = launchError || viewerStatus === "error"
|
||||||
? "error"
|
? "error"
|
||||||
: presentationReady
|
: presentationReady ? "ready" : "loading";
|
||||||
? "ready"
|
const sceneSettings = useMemo<SceneSettings>(() => ({
|
||||||
: "loading";
|
...sceneDraft,
|
||||||
const sceneSettings = useMemo(() => ({
|
projection: spatialMode === "plan" ? "2d" : "3d",
|
||||||
...defaultSceneSettings,
|
showPoints: spatialMode !== null && (showSourcePoints || showLocalSlam),
|
||||||
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
|
showTrajectory: spatialMode !== null && showLocalSlam,
|
||||||
showPoints: spatialMode !== null,
|
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
|
||||||
showTrajectory: spatialMode !== null,
|
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
|
||||||
showGrid: spatialMode !== null,
|
const semanticVisible = showDDRNet || showEoMT;
|
||||||
pointSize: 3.8,
|
|
||||||
}), [spatialLayer, spatialMode]);
|
|
||||||
const profile = launch ? recordedSessionRerunProfile({
|
const profile = launch ? recordedSessionRerunProfile({
|
||||||
sourceUrl: launch.replay.sourceUrl,
|
sourceUrl: launch.replay.sourceUrl,
|
||||||
artifact: {
|
artifact: {
|
||||||
@@ -222,88 +318,99 @@ export function CanonicalResultRerunReplay({
|
|||||||
presentationGate: "ready",
|
presentationGate: "ready",
|
||||||
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
|
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
|
||||||
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
|
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
|
||||||
// Seek just inside the first portable sample, not onto the exact latest-at
|
|
||||||
// boundary. This changes only the initial cursor, never source timestamps.
|
|
||||||
initialPlaybackStartSeconds: initialPlaybackStartSeconds
|
initialPlaybackStartSeconds: initialPlaybackStartSeconds
|
||||||
?? ((launch.base.mediaSources[0]?.timelineStartSeconds ?? launch.base.timelineStartSeconds)
|
?? ((launch.base.mediaSources[0]?.timelineStartSeconds ?? launch.base.timelineStartSeconds)
|
||||||
+ (costmap ? 0.000001 : 0)),
|
+ (hasTgs ? 0.000001 : 0)),
|
||||||
view: mediaMode !== null ? "perception" : "spatial",
|
view: hasCameraPane && !hasSpatialPane ? "perception" : "spatial",
|
||||||
viewResetGeneration,
|
viewResetGeneration,
|
||||||
followTrajectory: true,
|
followTrajectory: followTarget,
|
||||||
semanticLayer,
|
semanticLayer: showEoMT ? "city" : "vegetation",
|
||||||
unifiedPerception: splitView,
|
unifiedPerception: splitView,
|
||||||
|
unifiedCameraShare: blueprintSplitPrimarySize / 100,
|
||||||
planView: spatialMode === "plan",
|
planView: spatialMode === "plan",
|
||||||
perceptionLayers: {
|
perceptionLayers: {
|
||||||
enabled: mediaMode !== null,
|
enabled: true,
|
||||||
detections2d: semantics && mediaMode === "video",
|
cameraImage: showCamera,
|
||||||
segmentation: semantics && mediaMode === "video" && showSemantics,
|
detections2d: hasDetections && showDetections,
|
||||||
|
segmentation: semantics && semanticVisible,
|
||||||
cuboids3d: false,
|
cuboids3d: false,
|
||||||
...(costmap ? { costmap: spatialMode !== null && spatialLayer === "tgs" } : {}),
|
costmap: hasTgs && showTgs,
|
||||||
},
|
},
|
||||||
perceptionRetryGeneration: 0,
|
perceptionRetryGeneration: 0,
|
||||||
lockPerceptionCameraInteraction: mediaMode !== null,
|
lockPerceptionCameraInteraction: false,
|
||||||
}) : null;
|
}) : null;
|
||||||
|
|
||||||
const mediaLayerControls = semantics ? (
|
const toggle = (caption: string, active: boolean, onClick: () => void) => (
|
||||||
<div
|
<Button key={caption} size="dense" shape="pill" variant={active ? "primary" : "secondary"}
|
||||||
className="m4-replay-threat-visual__pane-layer-controls"
|
aria-pressed={active} onClick={onClick}>
|
||||||
role="group"
|
{caption}
|
||||||
aria-label="Слои камеры и видео"
|
</Button>
|
||||||
>
|
);
|
||||||
<Button
|
const openSlamSettings = useCallback(() => {
|
||||||
size="dense"
|
if (expanded) onExpandedChange(false);
|
||||||
shape="pill"
|
setSettingsOpen(true);
|
||||||
variant={showSemantics ? "primary" : "secondary"}
|
}, [expanded, onExpandedChange]);
|
||||||
aria-pressed={showSemantics}
|
const patchSceneDraft = useCallback((patch: Partial<SceneSettings>) => {
|
||||||
onClick={() => setShowSemantics((visible) => !visible)}
|
const next = { ...sceneDraftRef.current, ...patch };
|
||||||
>
|
sceneDraftRef.current = next;
|
||||||
СЕМАНТИКА
|
setSceneDraft(next);
|
||||||
</Button>
|
}, []);
|
||||||
<SegmentedControl
|
const closeSlamSettings = useCallback(() => {
|
||||||
value={semanticLayer}
|
const next = normalizeLabSceneSettings(sceneDraftRef.current);
|
||||||
items={[
|
sceneDraftRef.current = next;
|
||||||
{ value: "city", label: "ГОРОД · EoMT" },
|
setSceneDraft(next);
|
||||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
setSettingsOpen(false);
|
||||||
]}
|
void saveLabViewProfile(resultId, next).then((settings) => {
|
||||||
label="Источник семантики"
|
sceneDraftRef.current = settings;
|
||||||
size="dense"
|
setSceneDraft(settings);
|
||||||
onChange={(value) => {
|
setToasts((current) => current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST));
|
||||||
setSemanticLayer(value);
|
}).catch(() => {
|
||||||
setShowSemantics(true);
|
setToasts((current) => [
|
||||||
}}
|
...current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST),
|
||||||
/>
|
{
|
||||||
</div>
|
id: LAB_VIEW_PROFILE_ERROR_TOAST,
|
||||||
) : undefined;
|
tone: "error",
|
||||||
const spatialLayerControls = (
|
title: "Настройки LAB не сохранились",
|
||||||
<div
|
description: "Изменения остались в текущем просмотре. Откройте настройки и повторите.",
|
||||||
className="m4-replay-threat-visual__pane-layer-controls"
|
},
|
||||||
role="group"
|
]);
|
||||||
aria-label="Пространственные слои результата"
|
});
|
||||||
>
|
}, [resultId]);
|
||||||
<SegmentedControl
|
const mediaLayerControls = (
|
||||||
value={spatialLayer}
|
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои камеры">
|
||||||
items={[
|
{toggle("КАМЕРА", showCamera, () => setShowCamera((value) => !value))}
|
||||||
{ value: "source", label: "ИСХ. ТОЧКИ" },
|
{hasEoMT ? toggle("EOMT", showEoMT, () => setShowEoMT((value) => !value)) : null}
|
||||||
{ value: "local", label: "ЛОК. SLAM" },
|
{hasDDRNet || hasFallbackSemantics
|
||||||
{ value: "tgs", label: "TGS", disabled: !costmap },
|
? toggle("DDRNET", showDDRNet, () => setShowDDRNet((value) => !value)) : null}
|
||||||
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
|
{hasDetections
|
||||||
]}
|
? toggle("РАМКИ", showDetections, () => setShowDetections((value) => !value)) : null}
|
||||||
label="Пространственные слои"
|
|
||||||
size="dense"
|
|
||||||
onChange={setSpatialLayer}
|
|
||||||
/>
|
|
||||||
</div>
|
</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 = (
|
const resetSpatialView = (
|
||||||
<Button
|
<IconButton label="Сбросить положение видов"
|
||||||
size="dense"
|
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}>
|
||||||
variant="ghost"
|
<Icon name="refresh" size={16} />
|
||||||
icon={<Icon name="refresh" size={14} />}
|
</IconButton>
|
||||||
aria-label="Сбросить положение 3D камеры"
|
|
||||||
title="Сбросить положение 3D камеры"
|
|
||||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
|
|
||||||
>
|
|
||||||
</Button>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const transport = presentationReady && playback && playbackController ? (
|
const transport = presentationReady && playback && playbackController ? (
|
||||||
@@ -322,65 +429,39 @@ export function CanonicalResultRerunReplay({
|
|||||||
showJumpToEnd={false}
|
showJumpToEnd={false}
|
||||||
/>
|
/>
|
||||||
) : undefined;
|
) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="canonical-vegetation-rerun-replay" data-presentation-state={presentationState}
|
||||||
className="canonical-vegetation-rerun-replay"
|
aria-busy={presentationState === "loading"}>
|
||||||
data-presentation-state={presentationState}
|
|
||||||
aria-busy={presentationState === "loading"}
|
|
||||||
>
|
|
||||||
<CanonicalRecordedLabReplay
|
<CanonicalRecordedLabReplay
|
||||||
label={label}
|
label={label}
|
||||||
mediaMode={mediaMode ?? "none"}
|
mediaMode={hasCameraPane ? mediaMode ?? "camera" : "none"}
|
||||||
mediaModes={[
|
mediaModes={[{ value: "camera", label: "КАМЕРА" }]}
|
||||||
{ value: "video", label: "ВИДЕО" },
|
spatialMode={hasSpatialPane ? spatialMode ?? "3d" : "none"}
|
||||||
{ value: "camera", label: "КАМЕРА" },
|
spatialModes={[{ value: "3d", label: "3D" }, { value: "plan", label: "ПЛАН" }]}
|
||||||
]}
|
|
||||||
spatialMode={spatialMode ?? "none"}
|
|
||||||
spatialModes={[
|
|
||||||
{ value: "3d", label: "3D" },
|
|
||||||
{ value: "plan", label: "ПЛАН" },
|
|
||||||
]}
|
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
splitPrimarySize={splitPrimarySize}
|
splitPrimarySize={splitPrimarySize}
|
||||||
splitOrientation={splitOrientation}
|
splitOrientation={splitView ? "vertical" : splitOrientation}
|
||||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
mediaAriaLabel="Камера и рассчитанные слои"
|
||||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||||
mediaLayerControls={mediaLayerControls}
|
mediaLayerControls={mediaLayerControls}
|
||||||
spatialLayerControls={spatialLayerControls}
|
spatialLayerControls={spatialLayerControls}
|
||||||
spatialLeadingControl={resetSpatialView}
|
spatialTrailingControl={followTargetControl}
|
||||||
|
mediaModeControlsVisible={false}
|
||||||
|
paneToolbarsAlwaysVisible
|
||||||
mediaMultiLayer
|
mediaMultiLayer
|
||||||
|
actions={resetSpatialView}
|
||||||
unifiedContent={profile ? (
|
unifiedContent={profile ? (
|
||||||
<div
|
<div className="canonical-vegetation-rerun-replay__viewport-lock"
|
||||||
ref={viewerFrameRef}
|
data-split-view={splitView ? "true" : undefined}>
|
||||||
className="canonical-vegetation-rerun-replay__viewport-lock"
|
<RerunViewport profile={profile} sceneSettings={sceneSettings}
|
||||||
data-split-view={splitView ? "true" : undefined}
|
onStatusChange={setViewerStatus} onPlaybackChange={setPlayback}
|
||||||
style={{
|
onPlaybackControllerChange={setPlaybackController} />
|
||||||
"--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>
|
</div>
|
||||||
) : launchError ? (
|
) : launchError ? (
|
||||||
<div className="l3-visual-audit__state" role="alert">
|
<div className="l3-visual-audit__state" role="alert">{launchError}</div>
|
||||||
{launchError}
|
) : <div aria-hidden="true" />}
|
||||||
</div>
|
emptyMessage="Слои результата недоступны."
|
||||||
) : (
|
|
||||||
<div aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
|
||||||
deckOverlays={presentationState === "loading" ? (
|
deckOverlays={presentationState === "loading" ? (
|
||||||
<div className="canonical-vegetation-rerun-replay__loading">
|
<div className="canonical-vegetation-rerun-replay__loading">
|
||||||
<ActivityIndicator label="Загружаем синхронизированную запись" />
|
<ActivityIndicator label="Загружаем синхронизированную запись" />
|
||||||
@@ -392,6 +473,52 @@ export function CanonicalResultRerunReplay({
|
|||||||
onExpandedChange={onExpandedChange}
|
onExpandedChange={onExpandedChange}
|
||||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,51 @@
|
|||||||
import { GlassSurface, StatusBadge } from "@nodedc/ui-react";
|
import { GlassSurface } from "@nodedc/ui-react";
|
||||||
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
|
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
|
||||||
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
|
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
|
||||||
import type { ObservatoryPortableResultReview } from "../../core/observatory/recordedRun";
|
import type { ObservatoryPortableResultReview } from "../../core/observatory/recordedRun";
|
||||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||||
|
|
||||||
function resolvePortableReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
function resolvePortableTgsReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||||
options: { signal: AbortSignal }) {
|
options: { signal: AbortSignal }) {
|
||||||
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-tgs" });
|
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-tgs" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PortableResultReplay({ review }: { review: ObservatoryPortableResultReview }) {
|
function resolvePortableSemanticReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||||
const tgs = review.resultDocument.schema_version === "missioncore.recorded-tgs-costmap-review/v2";
|
options: { signal: AbortSignal }) {
|
||||||
if (!tgs) return (
|
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">
|
<GlassSurface className="observatory-replay-state" padding="lg">
|
||||||
<div><StatusBadge tone="success">Проверено</StatusBadge>
|
<p role="alert">Сохранённый результат этого профиля пока не поддерживает визуальный разбор.</p>
|
||||||
<h3>{review.resultKind}</h3><p>Связанных артефактов: {review.artifacts.length}</p>
|
|
||||||
<details><summary>Документ результата</summary>
|
|
||||||
<pre>{JSON.stringify(review.resultDocument, null, 2)}</pre></details>
|
|
||||||
</div>
|
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
);
|
);
|
||||||
return <>
|
return <CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
|
||||||
<p>Камера, исходное облако и TGS · общая шкала времени. Зелёный — опора,
|
sessionId={review.sourceSessionId} portable costmap={tgs} semantics={semantics}
|
||||||
красный — занятое пространство, жёлтый — отклонённые точки.
|
detections={objects}
|
||||||
Отсутствие ячеек не означает свободный путь. Сегментация и детекция в этот профиль не входят.</p>
|
moduleIds={typeof moduleId === "string" ? [moduleId] : []}
|
||||||
<CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
|
label={label}
|
||||||
sessionId={review.sourceSessionId} costmap resolveReplay={resolvePortableReplay} />
|
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 };
|
||||||
@@ -28,19 +28,25 @@ export async function resolveCanonicalLabReplay(
|
|||||||
origin?: string;
|
origin?: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
fetcher?: typeof globalThis.fetch;
|
fetcher?: typeof globalThis.fetch;
|
||||||
sourceKind?: "legacy-vegetation" | "portable-tgs";
|
sourceKind?: "legacy-vegetation" | "portable-tgs" | "portable-semantic" | "portable-objects";
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<CanonicalLabReplayDescriptor> {
|
): Promise<CanonicalLabReplayDescriptor> {
|
||||||
const base = new URL(origin);
|
const base = new URL(origin);
|
||||||
if (
|
if (
|
||||||
!(sourceKind === "portable-tgs" ? /^m49-tgs-portable-review-[a-f0-9]{64}$/.test(resultId) : 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)
|
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|
||||||
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||||
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||||
) {
|
) {
|
||||||
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
|
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
|
||||||
}
|
}
|
||||||
const sourceUrl = sourceKind === "portable-tgs"
|
const sourceUrl = sourceKind !== "legacy-vegetation"
|
||||||
? `/api/v1/observatory/portable-results/${resultId}/replays/${launch.sha256}/recording.rrd`
|
? `/api/v1/observatory/portable-results/${resultId}/replays/${launch.sha256}/recording.rrd`
|
||||||
: `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/canonical-replay.rrd`;
|
: `/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/canonical-replay.rrd`;
|
||||||
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
||||||
@@ -76,3 +82,53 @@ export async function resolveCanonicalLabReplay(
|
|||||||
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
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,6 +24,7 @@ export interface RerunPlaybackController {
|
|||||||
|
|
||||||
export interface RecordedPerceptionLayers {
|
export interface RecordedPerceptionLayers {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
cameraImage?: boolean;
|
||||||
detections2d: boolean;
|
detections2d: boolean;
|
||||||
segmentation: boolean;
|
segmentation: boolean;
|
||||||
cuboids3d: boolean;
|
cuboids3d: boolean;
|
||||||
@@ -76,6 +77,8 @@ export interface RecordedSessionRerunProfile {
|
|||||||
semanticLayer?: "city" | "vegetation";
|
semanticLayer?: "city" | "vegetation";
|
||||||
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
|
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
|
||||||
unifiedPerception?: boolean;
|
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. */
|
/** Requests the canonical top-down eye without changing the world coordinate frame. */
|
||||||
planView?: boolean;
|
planView?: boolean;
|
||||||
perceptionRetryGeneration: number;
|
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 {
|
import {
|
||||||
fetchObservationSessionCatalog,
|
|
||||||
type ObservationLabInstance,
|
type ObservationLabInstance,
|
||||||
type ObservationSessionFetch,
|
type ObservationSessionFetch,
|
||||||
type ObservationSessionSummary,
|
type ObservationSessionSummary,
|
||||||
} from "../observation/sessionArchive";
|
} from "../observation/sessionArchive";
|
||||||
|
import { fetchObservationSessionCatalogPage } from "../observation/sessionCatalogPage";
|
||||||
import {
|
import {
|
||||||
observatoryRecordedRunBinding,
|
observatoryRecordedRunBinding,
|
||||||
type ObservatoryRecordedRunBinding,
|
type ObservatoryRecordedRunBinding,
|
||||||
@@ -164,19 +164,23 @@ function boundedLimit(limit: number): number {
|
|||||||
|
|
||||||
function evidenceFromSession(
|
function evidenceFromSession(
|
||||||
session: ObservationSessionSummary,
|
session: ObservationSessionSummary,
|
||||||
): ObservatoryEvidence {
|
): ObservatoryEvidence | null {
|
||||||
if (!session.lab) {
|
if (!session.lab) {
|
||||||
throw new ObservatoryCatalogContractError(
|
throw new ObservatoryCatalogContractError(
|
||||||
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
|
`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 {
|
return {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
label: session.label,
|
label: session.label,
|
||||||
status: session.status,
|
status: session.status,
|
||||||
publishedAtUtc: session.lab.publishedAtUtc,
|
publishedAtUtc: session.lab.publishedAtUtc,
|
||||||
lab: session.lab,
|
lab: session.lab,
|
||||||
recordedRun: observatoryRecordedRunBinding(session.id, session.lab),
|
recordedRun,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,11 +212,11 @@ export function buildObservatoryCatalog(
|
|||||||
const unresolvedEvidence: ObservatoryEvidence[] = [];
|
const unresolvedEvidence: ObservatoryEvidence[] = [];
|
||||||
for (const laboratorySession of laboratorySessions) {
|
for (const laboratorySession of laboratorySessions) {
|
||||||
const evidence = evidenceFromSession(laboratorySession);
|
const evidence = evidenceFromSession(laboratorySession);
|
||||||
|
if (!evidence) continue;
|
||||||
const sourceId = evidence.lab.sourceSessionId;
|
const sourceId = evidence.lab.sourceSessionId;
|
||||||
if (!sources.has(sourceId)) {
|
if (!sources.has(sourceId)) {
|
||||||
// Both API projections are bounded newest-first windows without a
|
// A bounded traversal may stop before this source. Absence alone is
|
||||||
// cursor. Absence from the source window is not proof of a broken
|
// not proof of a broken relationship or permission to remove data.
|
||||||
// relationship and must not be presented as an integrity fault.
|
|
||||||
unresolvedEvidence.push(evidence);
|
unresolvedEvidence.push(evidence);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -236,7 +240,8 @@ export function buildObservatoryCatalog(
|
|||||||
window: {
|
window: {
|
||||||
limit: safeLimit,
|
limit: safeLimit,
|
||||||
sourceCount: sourceSessions.length,
|
sourceCount: sourceSessions.length,
|
||||||
laboratoryCount: laboratorySessions.length,
|
laboratoryCount: [...linked.values()].reduce((count, items) => count + items.length, 0)
|
||||||
|
+ unresolvedEvidence.length,
|
||||||
sourceLimitReached: sourceSessions.length >= safeLimit,
|
sourceLimitReached: sourceSessions.length >= safeLimit,
|
||||||
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
|
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
|
||||||
},
|
},
|
||||||
@@ -253,9 +258,64 @@ export async function fetchObservatoryCatalog({
|
|||||||
fetcher?: ObservationSessionFetch;
|
fetcher?: ObservationSessionFetch;
|
||||||
} = {}): Promise<ObservatoryCatalog> {
|
} = {}): Promise<ObservatoryCatalog> {
|
||||||
const safeLimit = boundedLimit(limit);
|
const safeLimit = boundedLimit(limit);
|
||||||
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
|
const request = new AbortController();
|
||||||
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
|
const abort = () => request.abort(signal?.reason);
|
||||||
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
|
if (signal?.aborted) abort();
|
||||||
]);
|
else signal?.addEventListener("abort", abort, { once: true });
|
||||||
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
|
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 изменился.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ export async function fetchObservatoryRecordedJobs(
|
|||||||
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
|
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
|
||||||
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
|
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
|
||||||
observationAuthority(row.authority);
|
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
|
job.sourceSessionId === sourceSessionId && job.setupId === setupId
|
||||||
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
|
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
|
||||||
));
|
));
|
||||||
@@ -125,7 +125,7 @@ export async function submitObservatoryRecordedJob(
|
|||||||
});
|
});
|
||||||
const body = await responseBody(response);
|
const body = await responseBody(response);
|
||||||
if (!response.ok) throw apiError(body, response.status);
|
if (!response.ok) throw apiError(body, response.status);
|
||||||
const job = decodeJob(body);
|
const job = decodeObservatoryRecordedJob(body);
|
||||||
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|
||||||
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
|
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
|
||||||
throw new ObservatoryRecordedJobContractError(
|
throw new ObservatoryRecordedJobContractError(
|
||||||
@@ -156,7 +156,7 @@ export async function retryObservatoryRecordedJobPublication(
|
|||||||
);
|
);
|
||||||
const body = await responseBody(response);
|
const body = await responseBody(response);
|
||||||
if (!response.ok) throw apiError(body, response.status);
|
if (!response.ok) throw apiError(body, response.status);
|
||||||
const job = decodeJob(body);
|
const job = decodeObservatoryRecordedJob(body);
|
||||||
if (job.jobId !== jobId) {
|
if (job.jobId !== jobId) {
|
||||||
throw new ObservatoryRecordedJobContractError(
|
throw new ObservatoryRecordedJobContractError(
|
||||||
"Повтор публикации вернул другой расчёт.",
|
"Повтор публикации вернул другой расчёт.",
|
||||||
@@ -165,7 +165,7 @@ export async function retryObservatoryRecordedJobPublication(
|
|||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeJob(value: unknown): ObservatoryRecordedJob {
|
export function decodeObservatoryRecordedJob(value: unknown): ObservatoryRecordedJob {
|
||||||
const row = record(value, "расчёт");
|
const row = record(value, "расчёт");
|
||||||
exactKeys(row, [
|
exactKeys(row, [
|
||||||
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
|
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface RecordedProgressView {
|
|||||||
readonly completed: number;
|
readonly completed: number;
|
||||||
readonly total: number | null;
|
readonly total: number | null;
|
||||||
readonly ageSeconds: number;
|
readonly ageSeconds: number;
|
||||||
|
readonly elapsedSeconds: number;
|
||||||
|
readonly phaseElapsedSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRecordedProgress(
|
export async function fetchRecordedProgress(
|
||||||
@@ -71,7 +73,8 @@ export function decodeRecordedProgress(
|
|||||||
const completed = integer(progress.completed);
|
const completed = integer(progress.completed);
|
||||||
const total = progress.total === null ? null : integer(progress.total, 1);
|
const total = progress.total === null ? null : integer(progress.total, 1);
|
||||||
const elapsed = finite(progress.elapsed_seconds);
|
const elapsed = finite(progress.elapsed_seconds);
|
||||||
if ((total !== null && completed > total) || finite(progress.phase_elapsed_seconds) > elapsed) {
|
const phaseElapsed = finite(progress.phase_elapsed_seconds);
|
||||||
|
if ((total !== null && completed > total) || phaseElapsed > elapsed) {
|
||||||
throw new Error("Некорректные счётчики прогресса.");
|
throw new Error("Некорректные счётчики прогресса.");
|
||||||
}
|
}
|
||||||
integer(progress.phase_index);
|
integer(progress.phase_index);
|
||||||
@@ -79,6 +82,7 @@ export function decodeRecordedProgress(
|
|||||||
jobId: job.jobId, claimGeneration: integer(progress.claim_generation, 1),
|
jobId: job.jobId, claimGeneration: integer(progress.claim_generation, 1),
|
||||||
sequence: integer(progress.sequence, 1), phase: progress.phase as Phase,
|
sequence: integer(progress.sequence, 1), phase: progress.phase as Phase,
|
||||||
unit: progress.unit as Unit, completed, total, ageSeconds: finite(row.age_seconds),
|
unit: progress.unit as Unit, completed, total, ageSeconds: finite(row.age_seconds),
|
||||||
|
elapsedSeconds: elapsed, phaseElapsedSeconds: phaseElapsed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +91,8 @@ export function recordedProgressLabel(
|
|||||||
): string {
|
): string {
|
||||||
if (job?.publication.state === "pending") return "Сохраняем результат";
|
if (job?.publication.state === "pending") return "Сохраняем результат";
|
||||||
if (!job || job.state === "accepted" || job.state === "queued") 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 === "paused" || job.state === "preemption-pending") return "Расчёт приостановлен";
|
||||||
if (job.state === "reconciliation-required") return "Проверяем состояние расчёта";
|
if (job.state === "reconciliation-required") return "Проверяем состояние расчёта";
|
||||||
if (!progress || progress.jobId !== job.jobId
|
if (!progress || progress.jobId !== job.jobId
|
||||||
|
|||||||
@@ -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), []),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -171,9 +171,9 @@ export const workspaces: WorkspaceDefinition[] = [
|
|||||||
id: "observatory",
|
id: "observatory",
|
||||||
root: "polygon",
|
root: "polygon",
|
||||||
label: "Обсерватория",
|
label: "Обсерватория",
|
||||||
title: "Проверка компьютерного зрения",
|
title: "AI inference",
|
||||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||||
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
description: "Сессии и квалификация без доступа к управлению.",
|
||||||
icon: "eye",
|
icon: "eye",
|
||||||
kind: "observatory",
|
kind: "observatory",
|
||||||
groups: [],
|
groups: [],
|
||||||
|
|||||||
@@ -89,16 +89,6 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canonical-vegetation-rerun-replay__viewport-lock
|
|
||||||
.rerun-viewport__camera-lock {
|
|
||||||
width: var(--canonical-rerun-camera-pane, 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.canonical-vegetation-rerun-replay__viewport-lock[data-split-view="true"]
|
|
||||||
.rerun-viewport__camera-lock {
|
|
||||||
width: calc(var(--canonical-rerun-camera-pane, 46%) - 0.75rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.canonical-vegetation-rerun-replay__loading {
|
.canonical-vegetation-rerun-replay__loading {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
@@ -155,29 +145,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
align-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
.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;
|
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-evidence-viewer[data-mode-controls="content"]:has(
|
||||||
.m4-replay-threat-visual__review-controls
|
.m4-replay-threat-visual__review-controls
|
||||||
) .m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
) .m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
||||||
@@ -185,12 +162,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
|
.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-evidence-viewer[data-mode-controls="content"]
|
||||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
|
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"],
|
||||||
right: 3.8rem;
|
.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 > *,
|
.m4-replay-threat-visual__pane-toolbar > *,
|
||||||
@@ -199,18 +181,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__spatial-toolbar-end {
|
.m4-replay-threat-visual__spatial-toolbar-end {
|
||||||
display: flex;
|
display: contents;
|
||||||
min-width: 0;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.35rem;
|
|
||||||
overflow-x: auto;
|
|
||||||
overscroll-behavior-inline: contain;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.m4-replay-threat-visual__spatial-toolbar-end::-webkit-scrollbar {
|
.m4-replay-threat-visual__pane-toolbar > .m4-replay-threat-visual__pane-layer-controls,
|
||||||
display: none;
|
.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 {
|
.m4-replay-threat-visual__pane-mode-controls {
|
||||||
@@ -259,7 +238,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
border: 1px solid var(--nodedc-glass-outline);
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
border-radius: var(--nodedc-radius-control-compact);
|
border-radius: var(--nodedc-radius-control-compact);
|
||||||
background: var(--nodedc-floating-surface);
|
background: var(--nodedc-floating-surface);
|
||||||
padding: 0.42rem 0.58rem;
|
padding: 0.42rem 0.58rem;
|
||||||
@@ -280,6 +260,15 @@
|
|||||||
pointer-events: none;
|
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 {
|
.m4-replay-threat-evidence-viewer[data-mode-controls="content"] .laboratory-evidence-viewer__controls > .nodedc-icon-button {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
@@ -304,6 +293,30 @@
|
|||||||
scrollbar-width: none;
|
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__review-controls > *,
|
.m4-replay-threat-visual__review-controls > *,
|
||||||
.m4-replay-threat-visual__single-pane-controls,
|
.m4-replay-threat-visual__single-pane-controls,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-lead,
|
|
||||||
.observatory-catalog-bar,
|
.observatory-catalog-bar,
|
||||||
.observatory-catalog-bar__controls,
|
.observatory-catalog-bar__controls,
|
||||||
.observatory-notice,
|
.observatory-notice,
|
||||||
@@ -21,18 +20,15 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-lead,
|
|
||||||
.observatory-notice {
|
.observatory-notice {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-lead > div,
|
|
||||||
.observatory-catalog-bar__copy,
|
.observatory-catalog-bar__copy,
|
||||||
.observatory-evidence header > div {
|
.observatory-evidence header > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-lead h2,
|
|
||||||
.observatory-catalog-bar h3,
|
.observatory-catalog-bar h3,
|
||||||
.observatory-session-summary h3,
|
.observatory-session-summary h3,
|
||||||
.observatory-evidence h3 {
|
.observatory-evidence h3 {
|
||||||
@@ -47,7 +43,6 @@
|
|||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-lead p,
|
|
||||||
.observatory-state p,
|
.observatory-state p,
|
||||||
.observatory-evidence-empty p {
|
.observatory-evidence-empty p {
|
||||||
max-width: 52rem;
|
max-width: 52rem;
|
||||||
@@ -197,6 +192,50 @@
|
|||||||
padding: 0 0.2rem;
|
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 {
|
.observatory-evidence-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
@@ -241,6 +280,14 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.observatory-evidence-card__copy span {
|
||||||
color: var(--nodedc-text-secondary);
|
color: var(--nodedc-text-secondary);
|
||||||
font-size: var(--nodedc-font-size-sm);
|
font-size: var(--nodedc-font-size-sm);
|
||||||
@@ -251,6 +298,28 @@
|
|||||||
font-size: var(--nodedc-font-size-xs);
|
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-evidence-card__actions,
|
||||||
.observatory-replay__header,
|
.observatory-replay__header,
|
||||||
.observatory-replay-state,
|
.observatory-replay-state,
|
||||||
@@ -282,6 +351,7 @@
|
|||||||
.observatory-replay {
|
.observatory-replay {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
|
font-family: var(--nodedc-font-family);
|
||||||
}
|
}
|
||||||
|
|
||||||
.observatory-replay__header {
|
.observatory-replay__header {
|
||||||
@@ -293,10 +363,37 @@
|
|||||||
margin: 0.3rem 0 0;
|
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__header p,
|
||||||
.observatory-replay-state p {
|
.observatory-replay-state p,
|
||||||
|
.observatory-replay__description {
|
||||||
margin: 0.35rem 0 0;
|
margin: 0.35rem 0 0;
|
||||||
color: var(--nodedc-text-muted);
|
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 {
|
.observatory-evidence-empty {
|
||||||
@@ -369,7 +466,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@container observatory-workspace (max-width: 920px) {
|
@container observatory-workspace (max-width: 920px) {
|
||||||
.observatory-lead,
|
|
||||||
.observatory-catalog-bar,
|
.observatory-catalog-bar,
|
||||||
.observatory-catalog-bar__controls,
|
.observatory-catalog-bar__controls,
|
||||||
.observatory-notice {
|
.observatory-notice {
|
||||||
@@ -399,3 +495,146 @@
|
|||||||
flex-direction: column;
|
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));
|
height: calc(100% + var(--rerun-native-chrome-height));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rerun-viewport__runtime {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.rerun-viewport__canvas canvas {
|
.rerun-viewport__canvas canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||||
|
import { AICompositionReplay } from "../../components/laboratory/AICompositionReplay";
|
||||||
import { PortableResultReplay } from "../../components/laboratory/PortableResultReplay";
|
import { PortableResultReplay } from "../../components/laboratory/PortableResultReplay";
|
||||||
|
import { AIConfigurationWindow } from "../../components/observatory/AIConfigurationWindow";
|
||||||
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
|
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
|
||||||
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
|
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
|
||||||
import {
|
import {
|
||||||
@@ -32,9 +34,14 @@ import {
|
|||||||
renameObservatoryLabProjection,
|
renameObservatoryLabProjection,
|
||||||
} from "../../core/observatory/catalogMutations";
|
} from "../../core/observatory/catalogMutations";
|
||||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||||
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
|
import { useAICompositionJobs } from "../../core/observatory/useAICompositionJobs";
|
||||||
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
|
import {
|
||||||
import { recordedProgressLabel } from "../../core/observatory/recordedProgress";
|
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";
|
import type { WorkspaceDefinition } from "../../productModel";
|
||||||
|
|
||||||
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
||||||
@@ -53,8 +60,31 @@ type ObservatoryMutationReconciliation =
|
|||||||
readonly sessionId: string;
|
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 =
|
type ObservatoryReplayState =
|
||||||
| { readonly kind: "closed" }
|
| { readonly kind: "closed" }
|
||||||
|
| { readonly kind: "composition"; readonly run: AICompositionRun }
|
||||||
| {
|
| {
|
||||||
readonly kind: "loading";
|
readonly kind: "loading";
|
||||||
readonly binding: ObservatoryRecordedRunBinding;
|
readonly binding: ObservatoryRecordedRunBinding;
|
||||||
@@ -114,15 +144,198 @@ function formatDuration(seconds: number): string {
|
|||||||
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
: `${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 {
|
function mutationErrorMessage(error: unknown): string {
|
||||||
return error instanceof Error && error.message.trim()
|
return error instanceof Error && error.message.trim()
|
||||||
? error.message
|
? error.message
|
||||||
: "Не удалось изменить лабораторный результат в Обсерватории.";
|
: "Не удалось изменить лабораторный результат в Обсерватории.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function evidenceResultSubtitle(evidence: ObservatoryEvidence): string {
|
function evidenceConfigurationLabel(
|
||||||
const profileName = evidence.lab.calculationProfile?.displayName;
|
evidence: ObservatoryEvidence,
|
||||||
return profileName ? `${evidence.label} · ${profileName}` : evidence.label;
|
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({
|
export function ObservatoryWorkspace({
|
||||||
@@ -132,22 +345,21 @@ export function ObservatoryWorkspace({
|
|||||||
}) {
|
}) {
|
||||||
const controller = useObservatoryCatalog();
|
const controller = useObservatoryCatalog();
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||||
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
|
const [aiConfigurationOpen, setAIConfigurationOpen] = useState(false);
|
||||||
const recordedJobsController = useObservatoryRecordedJobs(
|
const [evidenceSortDirection, setEvidenceSortDirection] = useState<EvidenceSortDirection>("newest");
|
||||||
selectedSessionId,
|
const aiJobsController = useAICompositionJobs(selectedSessionId);
|
||||||
setupController.selectedSetupId,
|
|
||||||
setupController.selectedSetup?.runDefinition?.definitionSha256 ?? "",
|
|
||||||
);
|
|
||||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||||
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
||||||
|
const [compositionRenameTarget, setCompositionRenameTarget] = useState<AICompositionRun | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
const [renameValue, setRenameValue] = useState("");
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
|
||||||
|
const [compositionDeleteTarget, setCompositionDeleteTarget] = useState<AICompositionRun | null>(null);
|
||||||
const [mutationPending, setMutationPending] = useState<"rename" | "delete" | null>(null);
|
const [mutationPending, setMutationPending] = useState<"rename" | "delete" | null>(null);
|
||||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||||
const [mutationReconciliation, setMutationReconciliation] = useState<
|
const [mutationReconciliation, setMutationReconciliation] = useState<
|
||||||
ObservatoryMutationReconciliation | null
|
ObservatoryMutationReconciliation | null
|
||||||
>(null);
|
>(null);
|
||||||
const overviewRef = useRef<HTMLElement | null>(null);
|
const overviewRef = useRef<HTMLDivElement | null>(null);
|
||||||
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
|
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
|
||||||
const refreshedPublishedJobRef = useRef<string | null>(null);
|
const refreshedPublishedJobRef = useRef<string | null>(null);
|
||||||
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
|
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
|
||||||
@@ -165,6 +377,7 @@ export function ObservatoryWorkspace({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (items.some((item) => item.source.id === selectedSessionId)) return;
|
if (items.some((item) => item.source.id === selectedSessionId)) return;
|
||||||
closeReplay();
|
closeReplay();
|
||||||
|
setAIConfigurationOpen(false);
|
||||||
setSelectedSessionId(items[0]?.source.id ?? "");
|
setSelectedSessionId(items[0]?.source.id ?? "");
|
||||||
}, [closeReplay, items, selectedSessionId]);
|
}, [closeReplay, items, selectedSessionId]);
|
||||||
|
|
||||||
@@ -173,62 +386,54 @@ export function ObservatoryWorkspace({
|
|||||||
const selectedSession = items.find(
|
const selectedSession = items.find(
|
||||||
(item) => item.source.id === selectedSessionId,
|
(item) => item.source.id === selectedSessionId,
|
||||||
) ?? null;
|
) ?? 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 }) => ({
|
const options = useMemo(() => items.map(({ source, evidence }) => ({
|
||||||
value: source.id,
|
value: source.id,
|
||||||
label: source.label,
|
label: source.label,
|
||||||
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
|
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
|
||||||
})), [items]);
|
})), [items]);
|
||||||
const setupOptions = useMemo(() => (
|
const compositionJobIds = useMemo(() => new Set(
|
||||||
setupController.selectableSetups.map((setup) => ({
|
presentedCompositionRuns.flatMap((run) => run.jobIds),
|
||||||
value: setup.setupId,
|
), [presentedCompositionRuns]);
|
||||||
label: setup.displayName,
|
const presentedAIJobs = presentLatestAIJobs(aiJobsController.jobs).filter(
|
||||||
description: setup.description,
|
(job) => !compositionJobIds.has(job.jobId),
|
||||||
}))
|
|
||||||
), [setupController.selectableSetups]);
|
|
||||||
const preflightCandidate = setupController.preflight.kind === "ready"
|
|
||||||
? setupController.preflight.value
|
|
||||||
: null;
|
|
||||||
const runPreflight = preflightCandidate
|
|
||||||
&& preflightCandidate.sourceSessionId === selectedSession?.source.id
|
|
||||||
&& preflightCandidate.setupId === setupController.selectedSetup?.setupId
|
|
||||||
&& preflightCandidate.definitionSha256
|
|
||||||
=== setupController.selectedSetup?.runDefinition?.definitionSha256
|
|
||||||
? preflightCandidate
|
|
||||||
: null;
|
|
||||||
const queueSubmissionAllowed = Boolean(
|
|
||||||
setupController.selectedSetup?.runDefinition
|
|
||||||
&& setupController.selectedSetup.compatibility.compatible
|
|
||||||
&& runPreflight?.outcome === "queueable"
|
|
||||||
&& runPreflight.submissionAllowed,
|
|
||||||
);
|
);
|
||||||
const presentedJob = recordedJobsController.activeJob ?? recordedJobsController.latestJob;
|
const presentedRows = useMemo<readonly ObservatoryEvidenceRow[]>(() => {
|
||||||
const publicationFailed = presentedJob?.publication.state === "failed";
|
const rows: ObservatoryEvidenceRow[] = [
|
||||||
const calculationPending = recordedJobsController.activeJob !== null
|
...presentedCompositionRuns.map((run): ObservatoryEvidenceRow => ({
|
||||||
|| recordedJobsController.publicationPending
|
kind: "composition",
|
||||||
|| recordedJobsController.state === "submitting"
|
id: run.runId,
|
||||||
|| recordedJobsController.state === "retrying-publication";
|
timestamp: compositionEventTimestamp(run),
|
||||||
const showCalculate = setupController.selectedSetup !== null
|
run,
|
||||||
&& !calculationPending && !publicationFailed;
|
})),
|
||||||
const canSubmitRecordedJob = queueSubmissionAllowed
|
...presentedAIJobs.map((job): ObservatoryEvidenceRow => ({
|
||||||
&& setupController.state === "ready"
|
kind: "job",
|
||||||
&& recordedJobsController.state === "ready"
|
id: job.jobId,
|
||||||
&& !calculationPending && !publicationFailed;
|
timestamp: job.updatedAtUtc,
|
||||||
const queueStatusError = setupController.preflight.kind === "error"
|
job,
|
||||||
? setupController.preflight.message
|
})),
|
||||||
: recordedJobsController.error
|
...presentedEvidence.map((evidence): ObservatoryEvidenceRow => ({
|
||||||
?? (publicationFailed
|
kind: "evidence",
|
||||||
? presentedJob?.publication.error ?? "Не удалось опубликовать результат."
|
id: evidence.sessionId,
|
||||||
: recordedJobsController.computationFailed
|
timestamp: evidence.publishedAtUtc,
|
||||||
? "Не удалось выполнить расчёт. Можно повторить запуск."
|
evidence,
|
||||||
: runPreflight?.outcome === "blocked"
|
})),
|
||||||
? runPreflight.checks.filter((check) => check.outcome === "fail")
|
];
|
||||||
.map((check) => check.message).join(" ") || "Запуск профиля недоступен."
|
const direction = evidenceSortDirection === "newest" ? -1 : 1;
|
||||||
: null);
|
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
|
const initialLoading = !controller.catalog
|
||||||
&& ["idle", "loading"].includes(controller.state);
|
&& ["idle", "loading"].includes(controller.state);
|
||||||
const unavailable = !controller.catalog && controller.state === "error";
|
const unavailable = !controller.catalog && controller.state === "error";
|
||||||
const replayEvidenceId = replay.kind === "closed"
|
const replayEvidenceId = replay.kind === "closed" || replay.kind === "composition"
|
||||||
? null
|
? null
|
||||||
: replay.binding.evidenceSessionId;
|
: replay.binding.evidenceSessionId;
|
||||||
const replayEvidence = replayEvidenceId === null
|
const replayEvidence = replayEvidenceId === null
|
||||||
@@ -238,14 +443,13 @@ export function ObservatoryWorkspace({
|
|||||||
) ?? null;
|
) ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const publishedJob = recordedJobsController.jobs.find(
|
const publishedJob = aiJobsController.jobs.find(
|
||||||
(job) => job.publication.state === "published" && job.resultId !== null,
|
(job) => job.publication.state === "published" && job.resultId !== null,
|
||||||
);
|
);
|
||||||
if (!publishedJob || refreshedPublishedJobRef.current === publishedJob.jobId) return;
|
if (!publishedJob || refreshedPublishedJobRef.current === publishedJob.jobId) return;
|
||||||
refreshedPublishedJobRef.current = publishedJob.jobId;
|
refreshedPublishedJobRef.current = publishedJob.jobId;
|
||||||
void controller.refresh();
|
void controller.refresh();
|
||||||
setupController.refresh();
|
}, [aiJobsController.jobs, controller.refresh]);
|
||||||
}, [controller.refresh, recordedJobsController.jobs, setupController.refresh]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -276,8 +480,7 @@ export function ObservatoryWorkspace({
|
|||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setMutationError(null);
|
setMutationError(null);
|
||||||
setMutationReconciliation(null);
|
setMutationReconciliation(null);
|
||||||
if (mutationReconciliation.kind === "delete") setupController.refresh();
|
}, [controller.catalog, mutationPending, mutationReconciliation]);
|
||||||
}, [controller.catalog, mutationPending, mutationReconciliation, setupController.refresh]);
|
|
||||||
|
|
||||||
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
||||||
const attempt = replayCoordinatorRef.current.begin();
|
const attempt = replayCoordinatorRef.current.begin();
|
||||||
@@ -307,6 +510,8 @@ export function ObservatoryWorkspace({
|
|||||||
|
|
||||||
const openRename = useCallback((evidence: ObservatoryEvidence) => {
|
const openRename = useCallback((evidence: ObservatoryEvidence) => {
|
||||||
if (!evidence.recordedRun) return;
|
if (!evidence.recordedRun) return;
|
||||||
|
setCompositionRenameTarget(null);
|
||||||
|
setCompositionDeleteTarget(null);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setMutationError(null);
|
setMutationError(null);
|
||||||
setMutationReconciliation(null);
|
setMutationReconciliation(null);
|
||||||
@@ -316,13 +521,52 @@ export function ObservatoryWorkspace({
|
|||||||
|
|
||||||
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
|
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
|
||||||
if (!evidence.recordedRun) return;
|
if (!evidence.recordedRun) return;
|
||||||
|
setCompositionRenameTarget(null);
|
||||||
|
setCompositionDeleteTarget(null);
|
||||||
setRenameTarget(null);
|
setRenameTarget(null);
|
||||||
setMutationError(null);
|
setMutationError(null);
|
||||||
setMutationReconciliation(null);
|
setMutationReconciliation(null);
|
||||||
setDeleteTarget(evidence);
|
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 () => {
|
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;
|
if (!renameTarget?.recordedRun || mutationPending !== null) return;
|
||||||
const requestedDisplayName = renameValue.trim();
|
const requestedDisplayName = renameValue.trim();
|
||||||
const reconciliation: ObservatoryMutationReconciliation = {
|
const reconciliation: ObservatoryMutationReconciliation = {
|
||||||
@@ -356,9 +600,27 @@ export function ObservatoryWorkspace({
|
|||||||
} finally {
|
} finally {
|
||||||
setMutationPending(null);
|
setMutationPending(null);
|
||||||
}
|
}
|
||||||
}, [controller, mutationPending, renameTarget, renameValue]);
|
}, [aiJobsController, compositionRenameTarget, controller, mutationPending, renameTarget, renameValue]);
|
||||||
|
|
||||||
const confirmDelete = useCallback(async () => {
|
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;
|
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
|
||||||
const reconciliation: ObservatoryMutationReconciliation = {
|
const reconciliation: ObservatoryMutationReconciliation = {
|
||||||
kind: "delete",
|
kind: "delete",
|
||||||
@@ -370,6 +632,7 @@ export function ObservatoryWorkspace({
|
|||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
replay.kind !== "closed"
|
replay.kind !== "closed"
|
||||||
|
&& replay.kind !== "composition"
|
||||||
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
|
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
|
||||||
) {
|
) {
|
||||||
flushSync(() => {
|
flushSync(() => {
|
||||||
@@ -378,7 +641,6 @@ export function ObservatoryWorkspace({
|
|||||||
}
|
}
|
||||||
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
|
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
|
||||||
controller.applyEvidenceDeletion(deleteTarget.sessionId);
|
controller.applyEvidenceDeletion(deleteTarget.sessionId);
|
||||||
setupController.refresh();
|
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
setMutationReconciliation(null);
|
setMutationReconciliation(null);
|
||||||
void controller.refresh();
|
void controller.refresh();
|
||||||
@@ -399,25 +661,16 @@ export function ObservatoryWorkspace({
|
|||||||
} finally {
|
} finally {
|
||||||
setMutationPending(null);
|
setMutationPending(null);
|
||||||
}
|
}
|
||||||
}, [closeReplay, controller, deleteTarget, mutationPending, replay, setupController.refresh]);
|
}, [aiJobsController, closeReplay, compositionDeleteTarget, controller, deleteTarget, mutationPending, replay]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={overviewRef}
|
||||||
className="observatory-workspace"
|
className="observatory-workspace"
|
||||||
|
data-workspace-id={definition.id}
|
||||||
data-observatory-authority="observation-only"
|
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">
|
<GlassSurface className="observatory-catalog-bar" padding="sm">
|
||||||
<div className="observatory-catalog-bar__copy">
|
<div className="observatory-catalog-bar__copy">
|
||||||
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
|
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
|
||||||
@@ -436,83 +689,23 @@ export function ObservatoryWorkspace({
|
|||||||
menuWidth={460}
|
menuWidth={460}
|
||||||
onChange={selectSession}
|
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
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
variant="secondary"
|
variant="primary"
|
||||||
disabled={
|
disabled={!selectedSession}
|
||||||
controller.state === "loading"
|
onClick={() => setAIConfigurationOpen(true)}
|
||||||
|| controller.state === "refreshing"
|
|
||||||
|| setupController.state === "loading"
|
|
||||||
|| setupController.state === "refreshing"
|
|
||||||
}
|
|
||||||
icon={<Icon name="refresh" />}
|
|
||||||
onClick={() => {
|
|
||||||
void controller.refresh();
|
|
||||||
setupController.refresh();
|
|
||||||
recordedJobsController.refresh();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Обновить
|
Сконфигурировать AI-слой
|
||||||
</Button>
|
</Button>
|
||||||
<div className="observatory-catalog-bar__run" aria-busy={calculationPending}>
|
|
||||||
{calculationPending ? (
|
|
||||||
<>
|
|
||||||
<ActivityIndicator size="compact" />
|
|
||||||
<span role="status">
|
|
||||||
{recordedProgressLabel(presentedJob, recordedJobsController.progress)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : 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>
|
</div>
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
|
|
||||||
{queueStatusError ? (
|
{aiJobsController.error ? (
|
||||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||||
<span className="observatory-notice__copy">{queueStatusError}</span>
|
<span className="observatory-notice__copy">{aiJobsController.error}</span>
|
||||||
{publicationFailed ? (
|
<Button size="compact" variant="ghost" onClick={aiJobsController.refresh}>
|
||||||
<Button
|
Повторить
|
||||||
size="compact"
|
</Button>
|
||||||
variant="ghost"
|
|
||||||
disabled={recordedJobsController.state === "retrying-publication"}
|
|
||||||
onClick={() => void recordedJobsController.retryPublication()}
|
|
||||||
>
|
|
||||||
Повторить публикацию
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -524,13 +717,6 @@ export function ObservatoryWorkspace({
|
|||||||
</GlassSurface>
|
</GlassSurface>
|
||||||
) : null}
|
) : 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
|
||||||
&& (controller.catalog.window.sourceLimitReached
|
&& (controller.catalog.window.sourceLimitReached
|
||||||
|| controller.catalog.window.laboratoryLimitReached) ? (
|
|| controller.catalog.window.laboratoryLimitReached) ? (
|
||||||
@@ -616,23 +802,150 @@ export function ObservatoryWorkspace({
|
|||||||
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
|
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
|
||||||
<h3>Лабораторные доказательства</h3>
|
<h3>Лабораторные доказательства</h3>
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
|
<div className="observatory-evidence__ordering">
|
||||||
{selectedSession.evidence.length}
|
<StatusBadge tone={presentedRows.length > 0 ? "accent" : "neutral"}>
|
||||||
</StatusBadge>
|
{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>
|
</header>
|
||||||
{selectedSession.evidence.length > 0 ? (
|
{presentedRows.length > 0 ? (
|
||||||
<ol className="observatory-evidence-list">
|
<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}>
|
<li key={evidence.sessionId}>
|
||||||
<div className="observatory-evidence-card">
|
<div className="observatory-evidence-card">
|
||||||
<span className="observatory-evidence-card__icon" aria-hidden="true">
|
<span className="observatory-evidence-card__icon" aria-hidden="true">
|
||||||
<Icon name="clipboard" size={18} />
|
<Icon name="clipboard" size={18} />
|
||||||
</span>
|
</span>
|
||||||
<div className="observatory-evidence-card__copy">
|
<div className="observatory-evidence-card__copy">
|
||||||
<strong>{evidence.lab.labId}</strong>
|
<strong>{evidenceConfigurationLabel(
|
||||||
<span>{evidenceResultSubtitle(evidence)}</span>
|
evidence,
|
||||||
|
aiJobsController.moduleLabelsBySetup,
|
||||||
|
aiJobsController.jobs,
|
||||||
|
)}</strong>
|
||||||
<small>
|
<small>
|
||||||
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
|
{formatTimestamp(row.timestamp)}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="observatory-evidence-card__actions">
|
<div className="observatory-evidence-card__actions">
|
||||||
@@ -668,7 +981,8 @@ export function ObservatoryWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ol>
|
</ol>
|
||||||
) : (
|
) : (
|
||||||
<div className="observatory-evidence-empty">
|
<div className="observatory-evidence-empty">
|
||||||
@@ -711,6 +1025,20 @@ export function ObservatoryWorkspace({
|
|||||||
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
|
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
|
||||||
</div>
|
</div>
|
||||||
</GlassSurface>
|
</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" ? (
|
) : replay.kind === "ready" ? (
|
||||||
<section className="observatory-replay" aria-label="Визуальный разбор результата">
|
<section className="observatory-replay" aria-label="Визуальный разбор результата">
|
||||||
<header className="observatory-replay__header">
|
<header className="observatory-replay__header">
|
||||||
@@ -718,12 +1046,13 @@ export function ObservatoryWorkspace({
|
|||||||
<span className="section-eyebrow">
|
<span className="section-eyebrow">
|
||||||
СОХРАНЁННЫЙ РЕЗУЛЬТАТ / ЗАПИСАННАЯ СЕССИЯ
|
СОХРАНЁННЫЙ РЕЗУЛЬТАТ / ЗАПИСАННАЯ СЕССИЯ
|
||||||
</span>
|
</span>
|
||||||
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
|
<h3>{replayEvidence && selectedSession
|
||||||
<p>
|
? evidenceConfigurationLabel(
|
||||||
{replay.review.kind === "canonical-recorded-rerun"
|
replayEvidence,
|
||||||
? "Записанный маршрут синхронизирован по общей временной шкале."
|
aiJobsController.moduleLabelsBySetup,
|
||||||
: "Просмотр сохранённых данных результата без повторного расчёта."}
|
aiJobsController.jobs,
|
||||||
</p>
|
)
|
||||||
|
: replay.binding.resultId}</h3>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
@@ -740,7 +1069,14 @@ export function ObservatoryWorkspace({
|
|||||||
review={replay.review.review}
|
review={replay.review.review}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<PortableResultReplay review={replay.review} />
|
<PortableResultReplay review={replay.review}
|
||||||
|
label={replayEvidence && selectedSession
|
||||||
|
? evidenceConfigurationLabel(
|
||||||
|
replayEvidence,
|
||||||
|
aiJobsController.moduleLabelsBySetup,
|
||||||
|
aiJobsController.jobs,
|
||||||
|
)
|
||||||
|
: replay.binding.resultId} />
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -756,7 +1092,7 @@ export function ObservatoryWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Window
|
<Window
|
||||||
open={renameTarget !== null}
|
open={renameTarget !== null || compositionRenameTarget !== null}
|
||||||
title="Переименовать лабораторный результат"
|
title="Переименовать лабораторный результат"
|
||||||
subtitle="Меняется только отображаемое название в Обсерватории"
|
subtitle="Меняется только отображаемое название в Обсерватории"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -765,6 +1101,7 @@ export function ObservatoryWorkspace({
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (mutationPending === "rename") return;
|
if (mutationPending === "rename") return;
|
||||||
setRenameTarget(null);
|
setRenameTarget(null);
|
||||||
|
setCompositionRenameTarget(null);
|
||||||
setMutationError(null);
|
setMutationError(null);
|
||||||
setMutationReconciliation(null);
|
setMutationReconciliation(null);
|
||||||
}}
|
}}
|
||||||
@@ -774,6 +1111,7 @@ export function ObservatoryWorkspace({
|
|||||||
disabled={mutationPending === "rename"}
|
disabled={mutationPending === "rename"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRenameTarget(null);
|
setRenameTarget(null);
|
||||||
|
setCompositionRenameTarget(null);
|
||||||
setMutationError(null);
|
setMutationError(null);
|
||||||
setMutationReconciliation(null);
|
setMutationReconciliation(null);
|
||||||
}}
|
}}
|
||||||
@@ -808,19 +1146,26 @@ export function ObservatoryWorkspace({
|
|||||||
disabled={mutationPending === "rename"}
|
disabled={mutationPending === "rename"}
|
||||||
onChange={(event) => setRenameValue(event.currentTarget.value)}
|
onChange={(event) => setRenameValue(event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
{mutationError && renameTarget ? (
|
{mutationError && (renameTarget || compositionRenameTarget) ? (
|
||||||
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</Window>
|
</Window>
|
||||||
|
<AIConfigurationWindow
|
||||||
|
open={aiConfigurationOpen && selectedSession !== null}
|
||||||
|
sourceSessionId={selectedSession?.source.id ?? ""}
|
||||||
|
sourceLabel={selectedSession?.source.label ?? ""}
|
||||||
|
onClose={() => setAIConfigurationOpen(false)}
|
||||||
|
onCalculated={() => aiJobsController.refresh()}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null || compositionDeleteTarget !== null}
|
||||||
title="Удалить результат из Обсерватории?"
|
title="Удалить результат из Обсерватории?"
|
||||||
description={deleteTarget ? (
|
description={deleteTarget || compositionDeleteTarget ? (
|
||||||
<div className="observatory-delete-confirmation">
|
<div className="observatory-delete-confirmation">
|
||||||
<p>
|
<p>
|
||||||
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
|
Будут удалены только каталожная проекция <strong>{deleteTarget?.label ?? compositionDeleteTarget?.displayName ?? compositionDeleteTarget?.presentation.configurationLabel}</strong>
|
||||||
{" "}и её отображаемое название в Обсерватории.
|
{" "}и её отображаемое название в Обсерватории.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
@@ -838,6 +1183,7 @@ export function ObservatoryWorkspace({
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
if (mutationPending === "delete") return;
|
if (mutationPending === "delete") return;
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
|
setCompositionDeleteTarget(null);
|
||||||
setMutationError(null);
|
setMutationError(null);
|
||||||
setMutationReconciliation(null);
|
setMutationReconciliation(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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-authority="observation-only"/);
|
||||||
assert.match(
|
assert.match(
|
||||||
observatory,
|
observatory,
|
||||||
/data-observatory-viewer={replay\.kind === "ready" \? "attached" : "detached"}/,
|
/data-observatory-viewer={replay\.kind === "ready" \|\| replay\.kind === "composition" \? "attached" : "detached"}/,
|
||||||
);
|
);
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
`${observatory}\n${observatoryCore}`,
|
`${observatory}\n${observatoryCore}`,
|
||||||
@@ -144,8 +144,8 @@ test("Observatory owns a bounded explicit-open lifecycle around the shared recor
|
|||||||
);
|
);
|
||||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||||
assert.match(sharedReplay, /useState<0 \| 1>\(costmap \? 1 : 0\)/);
|
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
|
||||||
assert.match(sharedReplay, /costmap \? 0\.000001 : 0/);
|
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
|
||||||
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
|
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
|
||||||
const source = await read(`components/laboratory/${adapter}.tsx`);
|
const source = await read(`components/laboratory/${adapter}.tsx`);
|
||||||
assert.match(source, /<CanonicalResultRerunReplay/);
|
assert.match(source, /<CanonicalResultRerunReplay/);
|
||||||
|
|||||||
@@ -133,9 +133,12 @@ test("canonical LAB resolves one generation-bound merged RRD", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("portable TGS resolves Core cache for the exact result and base, never a legacy LAB", async () => {
|
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 base = "a".repeat(64), generation = "b".repeat(64);
|
||||||
const resultId = `m49-tgs-portable-review-${"c".repeat(64)}`;
|
const resultId = `${prefix}-${"c".repeat(64)}`;
|
||||||
const launch = { sourceUrl: "/api/v1/observation-sessions/source/recording.rrd",
|
const launch = { sourceUrl: "/api/v1/observation-sessions/source/recording.rrd",
|
||||||
viewerSourceUrl: `/api/v1/observation-sessions/source/recording.rrd?generation=${base}`,
|
viewerSourceUrl: `/api/v1/observation-sessions/source/recording.rrd?generation=${base}`,
|
||||||
sha256: base };
|
sha256: base };
|
||||||
@@ -147,11 +150,14 @@ test("portable TGS resolves Core cache for the exact result and base, never a le
|
|||||||
return new Response(null, { headers: { "Content-Type": "application/vnd.rerun.rrd",
|
return new Response(null, { headers: { "Content-Type": "application/vnd.rerun.rrd",
|
||||||
"Content-Length": "100", "ETag": `"${generation}"`, "X-Rerun-Format": "RRF2" } });
|
"Content-Length": "100", "ETag": `"${generation}"`, "X-Rerun-Format": "RRF2" } });
|
||||||
};
|
};
|
||||||
const options = { sourceKind: "portable-tgs", origin: "http://mission-core.test", fetcher };
|
const options = { sourceKind, origin: "http://mission-core.test", fetcher };
|
||||||
const replay = await resolveCanonicalLabReplay(resultId, launch, options);
|
const replay = await resolveCanonicalLabReplay(resultId, launch, options);
|
||||||
assert.equal(replay.viewerSourceUrl, `${replay.sourceUrl}?generation=${generation}`);
|
assert.equal(replay.viewerSourceUrl, `${replay.sourceUrl}?generation=${generation}`);
|
||||||
assert.equal(replay.blueprintSourceUrl, "/api/v1/observation-sessions/source/blueprint.rrd");
|
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(`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));
|
await assert.rejects(resolveCanonicalLabReplay(resultId, { ...launch, sha256: "d".repeat(64) }, options));
|
||||||
assert.equal(calls, 1);
|
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, /primary=\{mediaPane\}/);
|
||||||
assert.match(canonical, /secondary=\{spatialPane/);
|
assert.match(canonical, /secondary=\{spatialPane/);
|
||||||
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
|
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, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
||||||
assert.match(canonical, /secondaryMode=\{\{/);
|
assert.match(canonical, /secondaryMode=\{\{/);
|
||||||
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ let formatAccumulationDuration;
|
|||||||
let resolveRerunSourceUrl;
|
let resolveRerunSourceUrl;
|
||||||
let resolveRecordedBlueprintUrl;
|
let resolveRecordedBlueprintUrl;
|
||||||
let fetchRecordedBlueprintRrd;
|
let fetchRecordedBlueprintRrd;
|
||||||
|
let recordedCameraJournalContract;
|
||||||
let resolveRecordedPerceptionUrl;
|
let resolveRecordedPerceptionUrl;
|
||||||
let fetchRecordedPerceptionRrd;
|
let fetchRecordedPerceptionRrd;
|
||||||
let resolveRecordedPerceptionViewerSourceUrl;
|
let resolveRecordedPerceptionViewerSourceUrl;
|
||||||
@@ -110,6 +111,7 @@ before(async () => {
|
|||||||
resolveRerunSourceUrl,
|
resolveRerunSourceUrl,
|
||||||
resolveRecordedBlueprintUrl,
|
resolveRecordedBlueprintUrl,
|
||||||
fetchRecordedBlueprintRrd,
|
fetchRecordedBlueprintRrd,
|
||||||
|
recordedCameraJournalContract,
|
||||||
resolveRecordedPerceptionUrl,
|
resolveRecordedPerceptionUrl,
|
||||||
fetchRecordedPerceptionRrd,
|
fetchRecordedPerceptionRrd,
|
||||||
resolveRecordedPerceptionViewerSourceUrl,
|
resolveRecordedPerceptionViewerSourceUrl,
|
||||||
@@ -118,7 +120,7 @@ before(async () => {
|
|||||||
fetchRecordedPointColorsRrd,
|
fetchRecordedPointColorsRrd,
|
||||||
recordedPointColorKey,
|
recordedPointColorKey,
|
||||||
isRecordedPlaybackFullyBuffered,
|
isRecordedPlaybackFullyBuffered,
|
||||||
} = await server.ssrLoadModule(
|
} = await server.ssrLoadModule(
|
||||||
"/src/components/RerunViewport.tsx",
|
"/src/components/RerunViewport.tsx",
|
||||||
));
|
));
|
||||||
({ recordedObservationSources } = await server.ssrLoadModule(
|
({ 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", () => {
|
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||||
const bounds = { width: 1280, height: 720 };
|
const bounds = { width: 1280, height: 720 };
|
||||||
const left = initialObservationWindowRect(0, 2, bounds);
|
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 () => {
|
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
const cameraLimits = [];
|
||||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||||
const result = await fetchRecordedBlueprintRrd(
|
const result = await fetchRecordedBlueprintRrd(
|
||||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||||
@@ -464,6 +486,9 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
|||||||
activeView: "perception3d",
|
activeView: "perception3d",
|
||||||
viewResetGeneration: 1,
|
viewResetGeneration: 1,
|
||||||
followTrajectory: true,
|
followTrajectory: true,
|
||||||
|
currentTimeNs: 39_215_263_458,
|
||||||
|
onCameraMaxOrbitalRadius: value => cameraLimits.push(value),
|
||||||
|
unifiedCameraShare: 0.73,
|
||||||
perceptionLayers: {
|
perceptionLayers: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
detections2d: 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)) });
|
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||||
return new Response(payload, {
|
return new Response(payload, {
|
||||||
status: 200,
|
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",
|
active_view: "perception3d",
|
||||||
view_reset_generation: 1,
|
view_reset_generation: 1,
|
||||||
follow_trajectory: true,
|
follow_trajectory: true,
|
||||||
|
current_time_ns: 39_215_263_458,
|
||||||
unified_perception: true,
|
unified_perception: true,
|
||||||
|
unified_camera_share: 0.73,
|
||||||
semantic_layer: null,
|
semantic_layer: null,
|
||||||
plan_view: false,
|
plan_view: false,
|
||||||
|
eye_position: null,
|
||||||
|
eye_look_target: null,
|
||||||
|
eye_up: null,
|
||||||
|
show_camera_image: true,
|
||||||
show_detections_2d: true,
|
show_detections_2d: true,
|
||||||
show_segmentation: false,
|
show_segmentation: false,
|
||||||
show_cuboids_3d: true,
|
show_cuboids_3d: true,
|
||||||
});
|
});
|
||||||
|
assert.deepEqual(cameraLimits, [686.024231]);
|
||||||
|
|
||||||
await fetchRecordedBlueprintRrd(
|
await fetchRecordedBlueprintRrd(
|
||||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||||
@@ -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_perception, false);
|
||||||
|
assert.equal(calls[1].body.unified_camera_share, 0.46);
|
||||||
assert.equal(calls[1].body.show_cuboids_3d, true);
|
assert.equal(calls[1].body.show_cuboids_3d, true);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
|
|||||||
@@ -63,14 +63,31 @@ function evidence(id, sourceSessionId, publishedAtUtc) {
|
|||||||
labId: `LAB-${id}`,
|
labId: `LAB-${id}`,
|
||||||
sourceSessionId,
|
sourceSessionId,
|
||||||
resultKind: "recorded-evidence",
|
resultKind: "recorded-evidence",
|
||||||
resultId: `result-${id}`,
|
resultId: id,
|
||||||
sourceResultId: null,
|
sourceResultId: null,
|
||||||
configSha256: "a".repeat(64),
|
configSha256: "a".repeat(64),
|
||||||
runCreatedAtUtc: publishedAtUtc,
|
runCreatedAtUtc: publishedAtUtc,
|
||||||
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,
|
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 calls = [];
|
||||||
const fetcher = async (input, init) => {
|
const fetcher = async (input, init) => {
|
||||||
calls.push({ input: String(input), method: init?.method });
|
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,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
@@ -175,8 +194,8 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
|
|||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
calls.map(({ input }) => input).sort(),
|
calls.map(({ input }) => input).sort(),
|
||||||
[
|
[
|
||||||
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3",
|
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3&pagination=cursor-v1",
|
||||||
"/api/v1/observation-sessions?limit=50&scope=source",
|
"/api/v1/observation-sessions?limit=50&scope=source&pagination=cursor-v1",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
|
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 canonicalResultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||||
const canonical = evidence(
|
const canonical = evidence(
|
||||||
canonicalResultId,
|
canonicalResultId,
|
||||||
@@ -227,20 +246,144 @@ test("Observatory projects a typed canonical run only through its exact sourceSe
|
|||||||
commandsEnabled: false,
|
commandsEnabled: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const before = structuredClone(canonical);
|
||||||
const catalog = buildObservatoryCatalog(
|
const catalog = buildObservatoryCatalog(
|
||||||
[source("20260828T130511Z_viewer_live", "2026-08-28T13:05:11Z")],
|
[source("20260828T130511Z_viewer_live", "2026-08-28T13:05:11Z")],
|
||||||
[canonical],
|
[canonical],
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(catalog.items[0].evidence[0].recordedRun, {
|
assert.deepEqual(catalog.items[0].evidence, []);
|
||||||
kind: "canonical-recorded-rerun",
|
assert.equal(catalog.window.laboratoryCount, 0);
|
||||||
evidenceSessionId: canonicalResultId,
|
assert.deepEqual(canonical, before);
|
||||||
sourceSessionId: "20260828T130511Z_viewer_live",
|
});
|
||||||
resultId: canonicalResultId,
|
|
||||||
viewerProfile: "recorded-session",
|
test("only admitted portable evidence is visible regardless of LAB label, age or installed version", () => {
|
||||||
timeline: "session_time",
|
const legacy = evidence("legacy", "source", "2026-09-03T13:00:00Z");
|
||||||
activation: "explicit",
|
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", () => {
|
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ test("missing and stale telemetry show no fabricated advancement", () => {
|
|||||||
assert.equal(label(job, null), "Ожидаем данные расчёта");
|
assert.equal(label(job, null), "Ожидаем данные расчёта");
|
||||||
assert.equal(label(job, decode({ ...view(), age_seconds: 30 }, job)), "Ожидаем обновление прогресса");
|
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 () => {
|
test("progress is a read-only request bound to the active job", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const progress = await fetchProgress(job, { fetcher: async (path, init) => {
|
const progress = await fetchProgress(job, { fetcher: async (path, init) => {
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ test("Observatory is the third independent Polygon workspace", () => {
|
|||||||
id: "observatory",
|
id: "observatory",
|
||||||
root: "polygon",
|
root: "polygon",
|
||||||
label: "Обсерватория",
|
label: "Обсерватория",
|
||||||
title: "Проверка компьютерного зрения",
|
title: "AI inference",
|
||||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||||
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
description: "Сессии и квалификация без доступа к управлению.",
|
||||||
icon: "eye",
|
icon: "eye",
|
||||||
kind: "observatory",
|
kind: "observatory",
|
||||||
groups: [],
|
groups: [],
|
||||||
@@ -73,9 +73,15 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
|||||||
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
|
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
|
||||||
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
|
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
|
||||||
assert.match(workspace, /useObservatoryCatalog/);
|
assert.match(workspace, /useObservatoryCatalog/);
|
||||||
|
assert.doesNotMatch(workspace, /observatory-lead/);
|
||||||
|
assert.doesNotMatch(workspace, /Расчёт записанных маршрутов выбранными профилями/);
|
||||||
assert.match(workspace, /Связанных результатов нет/);
|
assert.match(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.doesNotMatch(workspace, /MAX_PRESENTED_EVIDENCE|\.evidence\.slice\(/);
|
||||||
assert.match(workspace, /Полнота исторических/);
|
assert.match(workspace, /Полнота исторических/);
|
||||||
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, /observatory-evidence-card__copy/);
|
||||||
assert.match(
|
assert.match(
|
||||||
workspace,
|
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(
|
assert.match(
|
||||||
workspace,
|
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(
|
assert.match(
|
||||||
workspace,
|
workspace,
|
||||||
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
|
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
|
||||||
@@ -101,9 +108,15 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
|||||||
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||||
assert.match(workspace, /<PortableResultReplay review=\{replay\.review\}/);
|
assert.match(workspace, /<PortableResultReplay review=\{replay\.review\}/);
|
||||||
const portable = await read("components/laboratory/PortableResultReplay.tsx");
|
const portable = await read("components/laboratory/PortableResultReplay.tsx");
|
||||||
assert.match(portable, /<summary>Документ результата<\/summary>/);
|
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.match(portable, /<CanonicalResultRerunReplay/);
|
||||||
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
|
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
|
||||||
|
assert.doesNotMatch(workspace, /evidence\.lab\.resultKind|без запуска тяжёлого/);
|
||||||
assert.match(workspace, /Проверяем точную связь результата/);
|
assert.match(workspace, /Проверяем точную связь результата/);
|
||||||
assert.match(workspace, /role="alert"/);
|
assert.match(workspace, /role="alert"/);
|
||||||
assert.match(workspace, /Повторить/);
|
assert.match(workspace, /Повторить/);
|
||||||
@@ -112,7 +125,10 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
|||||||
workspace,
|
workspace,
|
||||||
/const returnToOverview[\s\S]*closeReplay\(\);[\s\S]*scrollIntoView\(\{ block: "start" \}\)/,
|
/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.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/);
|
||||||
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
||||||
assert.match(workspace, /data-observatory-authority="observation-only"/);
|
assert.match(workspace, /data-observatory-authority="observation-only"/);
|
||||||
@@ -186,6 +202,10 @@ test("Observatory rename and delete use admitted projection mutations and canoni
|
|||||||
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
|
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
|
||||||
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
|
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
|
||||||
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
|
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
|
||||||
|
assert.match(workspace, /renameAICompositionRunProjection/);
|
||||||
|
assert.match(workspace, /deleteAICompositionRunProjection/);
|
||||||
|
assert.match(workspace, /openCompositionRename\(run\)/);
|
||||||
|
assert.match(workspace, /openCompositionDelete\(run\)/);
|
||||||
assert.match(
|
assert.match(
|
||||||
workspace,
|
workspace,
|
||||||
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
|
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
|
||||||
@@ -196,9 +216,12 @@ test("Observatory rename and delete use admitted projection mutations and canoni
|
|||||||
);
|
);
|
||||||
|
|
||||||
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
|
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);
|
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 teardown = deleteFlow.indexOf("closeReplay();");
|
||||||
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
|
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
|
||||||
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
|
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
|
||||||
@@ -219,77 +242,128 @@ test("Observatory rename and delete use admitted projection mutations and canoni
|
|||||||
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
|
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Observatory keeps one compact selector axis without the obsolete setup detail", async () => {
|
test("Observatory configures independent AI modules and shows queued evidence progress", async () => {
|
||||||
const [workspace, styles, setupHook, jobsHook] = await Promise.all([
|
const [workspace, styles, configWindow, jobsHook, sharedReplay] = await Promise.all([
|
||||||
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||||
read("styles/observatory.css"),
|
read("styles/observatory.css"),
|
||||||
read("core/observatory/useObservatoryLaboratorySetups.ts"),
|
read("components/observatory/AIConfigurationWindow.tsx"),
|
||||||
read("core/observatory/useObservatoryRecordedJobs.ts"),
|
read("core/observatory/useAICompositionJobs.ts"),
|
||||||
|
read("components/laboratory/CanonicalResultRerunReplay.tsx"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
|
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
|
||||||
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
|
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
|
||||||
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
|
assert.match(workspace, />\s*Сконфигурировать AI-слой\s*<\/Button>/);
|
||||||
assert.match(workspace, /setupController\.error \? \(/);
|
assert.doesNotMatch(workspace, /label="Выбрать сетап лаборатории"/);
|
||||||
|
assert.doesNotMatch(workspace, />\s*Обновить\s*<\/Button>/);
|
||||||
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
|
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
|
||||||
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
|
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
|
||||||
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
|
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(
|
assert.match(
|
||||||
workspace,
|
configWindow,
|
||||||
/runPreflight\?\.outcome === "queueable"[\s\S]*runPreflight\.submissionAllowed/,
|
/<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(
|
assert.match(
|
||||||
workspace,
|
styles,
|
||||||
/showCalculate \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
|
/\.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, /useAICompositionJobs\(selectedSessionId\)/);
|
||||||
assert.match(workspace, /setupController\.selectableSetups\.map/);
|
assert.match(workspace, /observatory-evidence-card--progress/);
|
||||||
assert.match(workspace, /showCalculate = setupController\.selectedSetup !== null/);
|
assert.match(workspace, /const active = run\.state === "running"/);
|
||||||
assert.match(workspace, /disabled=\{!canSubmitRecordedJob\}/);
|
assert.match(workspace, /\{active \? \([\s\S]*observatory-evidence-card__progress/);
|
||||||
assert.match(workspace, /aria-busy=\{calculationPending\}/);
|
assert.match(workspace, /run\.state === "ready" \? <Icon name="clipboard"/);
|
||||||
const runBar = workspace.slice(workspace.indexOf('<div className="observatory-catalog-bar__run"'), workspace.indexOf("{queueStatusError ?"));
|
assert.match(workspace, /function aiJobStage\(/);
|
||||||
assert.doesNotMatch(runBar, /StatusBadge/);
|
assert.match(workspace, /return "Инициализация"/);
|
||||||
assert.match(setupHook, /fetchObservatoryPortableLaboratorySetups/);
|
assert.match(workspace, /return "Передача на сервер"/);
|
||||||
assert.doesNotMatch(setupHook, /fetchObservatoryLaboratorySetups|mergeSetupCatalogs|legacyResult/);
|
assert.match(workspace, /return "Просчёт"/);
|
||||||
assert.match(setupHook, /selectableObservatorySetups\(next\)/);
|
assert.match(workspace, /Собираем видеоряд для модели/);
|
||||||
assert.match(setupHook, /selectable\[0\]\?\.setupId \?\? ""/);
|
assert.match(workspace, /измеренная скорость Worker 006: 17–18 FPS/);
|
||||||
assert.match(setupHook, /selectedSetupId, sourceSessionId, selectedDefinitionSha256/);
|
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(
|
assert.match(
|
||||||
workspace,
|
styles,
|
||||||
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
|
/\.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(
|
assert.match(
|
||||||
jobsHook,
|
sharedReplay,
|
||||||
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
|
/showPoints: spatialMode !== null && \(showSourcePoints \|\| showLocalSlam\)/,
|
||||||
);
|
);
|
||||||
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
|
assert.match(sharedReplay, /showTrajectory: spatialMode !== null && showLocalSlam/);
|
||||||
assert.match(jobsHook, /JSON\.stringify\(\[sourceSessionId, setupId, definitionSha256\]\)/);
|
assert.match(
|
||||||
assert.match(jobsHook, /snapshot\.selectionKey === selectionKey \? snapshot : null/);
|
sharedReplay,
|
||||||
assert.doesNotMatch(jobsHook, /observatory-ui:[^`]*sourceSessionId|\.slice\(0, 160\)/);
|
/accumulationSeconds: showLocalSlam \? sceneDraft\.accumulationSeconds : 0/,
|
||||||
assert.match(workspace, /recordedJobsController\.refresh\(\)/);
|
);
|
||||||
assert.match(jobsHook, /POLL_INTERVAL_MS = 1_500/);
|
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.match(jobsHook, /globalThis\.setTimeout/);
|
||||||
assert.doesNotMatch(jobsHook, /setInterval/);
|
assert.doesNotMatch(jobsHook, /setInterval/);
|
||||||
assert.match(workspace, /job\.publication\.state === "published"/);
|
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(
|
assert.match(
|
||||||
styles,
|
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;/,
|
/\.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(
|
assert.match(styles, /\.observatory-evidence-card__progress \{/);
|
||||||
styles,
|
assert.match(styles, /\.observatory-ai-module-group > header \{/);
|
||||||
/\.observatory-catalog-bar__run \{[\s\S]*display: flex;[\s\S]*align-items: center;/,
|
assert.match(styles, /\.observatory-ai-module-group \{[\s\S]*border: 0;[\s\S]*outline: 0;/);
|
||||||
);
|
|
||||||
assert.match(
|
assert.match(
|
||||||
styles,
|
styles,
|
||||||
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar,[\s\S]*\.observatory-catalog-bar__controls,[\s\S]*flex-direction: column;/,
|
/@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/);
|
||||||
|
});
|
||||||
@@ -90,8 +90,15 @@ test("one canonical LAB replay generation reaches the same native receiver", ()
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("portable replay uses recorded admission and the shared source blueprint", () => {
|
for (const prefix of [
|
||||||
const source = `/api/v1/observatory/portable-results/m49-tgs-portable-review-${"c".repeat(64)}/replays/${"d".repeat(64)}/recording.rrd`;
|
"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";
|
const blueprint = "/api/v1/observation-sessions/source/blueprint.rrd";
|
||||||
assert.equal(isRecordedRrdSource(source), true);
|
assert.equal(isRecordedRrdSource(source), true);
|
||||||
assert.equal(isRecordedRrdSource(source.replace("/replays/", "/untrusted/")), false);
|
assert.equal(isRecordedRrdSource(source.replace("/replays/", "/untrusted/")), false);
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ test("M4 keeps independent semantic controls in media and spatial panes", async
|
|||||||
assert.match(source, /aria-label="Слои 3D и плана"/);
|
assert.match(source, /aria-label="Слои 3D и плана"/);
|
||||||
assert.match(canonical, /data-pane-mode="media"/);
|
assert.match(canonical, /data-pane-mode="media"/);
|
||||||
assert.match(canonical, /data-pane-mode="spatial"/);
|
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\}/);
|
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -497,14 +497,17 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
|||||||
assert.match(rerunSource, /resolveReplay\(resultId, value/);
|
assert.match(rerunSource, /resolveReplay\(resultId, value/);
|
||||||
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
|
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
|
||||||
assert.match(rerunSource, /unifiedPerception: splitView/);
|
assert.match(rerunSource, /unifiedPerception: splitView/);
|
||||||
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
|
assert.match(rerunSource, /lockPerceptionCameraInteraction: false/);
|
||||||
assert.match(rerunSource, /!splitView[\s\S]*event\.button !== 0/);
|
assert.match(rerunSource, /&& !semantics[\s\S]*&& !detections[\s\S]*&& !costmap/);
|
||||||
assert.match(rerunSource, /if \(!splitView\) stopNativeSplitTracking\(\)/);
|
assert.doesNotMatch(rerunSource, /trackNativeSplit|rerunNativeCursor|rerunNativeInput/);
|
||||||
assert.match(rerunSource, /event\.target instanceof HTMLCanvasElement/);
|
assert.match(canonicalSource, /resizable=\{splitView\}/);
|
||||||
assert.match(rerunSource, /--canonical-rerun-camera-pane/);
|
assert.match(rerunSource, /unifiedCameraShare: blueprintSplitPrimarySize \/ 100/);
|
||||||
assert.match(rerunSource, /onPointerDownCapture=\{splitView \? trackNativeSplit : undefined\}/);
|
|
||||||
assert.match(rerunSource, /data-split-view=\{splitView \? "true" : undefined\}/);
|
assert.match(rerunSource, /data-split-view=\{splitView \? "true" : undefined\}/);
|
||||||
assert.match(rerunSource, /mediaMode === null[\s\S]*\? 0[\s\S]*: splitView[\s\S]*\? nativeSplitPercentRef\.current[\s\S]*: 100/);
|
assert.match(rerunSource, /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, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
|
||||||
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
|
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
|
||||||
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
|
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
|
||||||
@@ -517,22 +520,14 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
|||||||
replayStyles,
|
replayStyles,
|
||||||
/canonical-vegetation-rerun-replay__timeline \.observation-timeline__playback \{[\s\S]*grid-template-columns: auto auto minmax\(0, 1fr\) auto;/,
|
/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.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
|
||||||
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
||||||
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: !costmap/);
|
assert.match(rerunSource, /hasTgs \? toggle\("TGS", showTgs/);
|
||||||
assert.match(canonicalSource, /primary=\{mediaPane\}/);
|
assert.match(canonicalSource, /primary=\{mediaPane\}/);
|
||||||
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
||||||
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
||||||
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
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, /linked canonical M4\.9 TGS evidence/);
|
||||||
assert.match(resultSource, /linkedTgsResultId/);
|
assert.match(resultSource, /linkedTgsResultId/);
|
||||||
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
target: "esnext",
|
target: "esnext",
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
app: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||||
|
rerun: fileURLToPath(new URL("./rerun-runtime.html", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -227,9 +227,13 @@ Mission Core already has three semantic mechanisms:
|
|||||||
3. `docs/domain-model/mission-core-experimental-vocabulary-v0alpha2.md` for
|
3. `docs/domain-model/mission-core-experimental-vocabulary-v0alpha2.md` for
|
||||||
meanings that are not yet stable enough for Platform Ontology.
|
meanings that are not yet stable enough for Platform Ontology.
|
||||||
|
|
||||||
These mechanisms are sufficient before A3. A new RDF/graph store, ontology
|
These mechanisms were sufficient before A3. Observatory modular compositions
|
||||||
service, or parallel entity catalog would introduce duplicated identity and
|
crossed the gate on 2026-09-04: planning, Worker dispatch, LAB publication and
|
||||||
migration work without a current query or integration consumer.
|
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:
|
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;
|
- the graph answers named queries used by the product or automation;
|
||||||
- promotion or synchronization with NODE.DC Platform Ontology is defined.
|
- promotion or synchronization with NODE.DC Platform Ontology is defined.
|
||||||
|
|
||||||
Until those conditions hold, new stable meanings extend the versioned local
|
Other domains continue to extend the versioned local vocabulary and executable
|
||||||
vocabulary and executable contracts. They do not create a second runtime model.
|
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
|
## Automated boundary gate
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,191 @@
|
|||||||
|
# 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: четыре этапа — записанные лаборатории → переносимые профили → борт
|
# Observatory: четыре этапа — записанные лаборатории → переносимые профили → борт
|
||||||
|
|
||||||
## Актуальный маршрут — 2026-09-03
|
## Актуальный маршрут — 2026-09-03
|
||||||
|
|
||||||
Сверено с кодом `bee8552`, установкой `54c8d82`, исправлением reindex `bd947b4`
|
Сверено с кодом `bee8552`, установкой `54c8d82`, исправлением reindex `bd947b4`
|
||||||
и последними решениями владельца. M4.9T5 ×004TREE рассчитан через UI, опубликован
|
и последними решениями владельца. Проверки и сборка текущего инкремента поверх
|
||||||
и повторно открыт как документ из кэша после перезапуска. Визуальный replay ещё
|
`eff60e4` завершены; возврат к основному маршруту зафиксирован ниже. M4.9T5 ×004TREE рассчитан через UI, опубликован
|
||||||
не реализован для этого portable-результата. Разделы до журнала инкрементов — текущий
|
и повторно открыт из кэша после перезапуска. Общий camera/TGS replay реализован;
|
||||||
|
подробная визуальная и memory-lifecycle приёмка ещё открыта. Разделы до журнала инкрементов — текущий
|
||||||
маршрут; исторические «следующий шаг» и «этап открыт/закрыт» ниже не команды.
|
маршрут; исторические «следующий шаг» и «этап открыт/закрыт» ниже не команды.
|
||||||
|
|
||||||
### Цель и результат
|
### Цель и результат
|
||||||
@@ -29,9 +209,11 @@ PASS через Wi-Fi/LTE не является условием готовно
|
|||||||
рассчитаны → выбор пуст, «Рассчитать» нет, «Обновить» остаётся. Плашек завершения
|
рассчитаны → выбор пуст, «Рассчитать» нет, «Обновить» остаётся. Плашек завершения
|
||||||
нет; во время работы общий индикатор показывает настоящую фазу и доступные счётчики.
|
нет; во время работы общий индикатор показывает настоящую фазу и доступные счётчики.
|
||||||
Внутри первичного decode/передачи архива детальные счётчики ещё отсутствуют.
|
Внутри первичного decode/передачи архива детальные счётчики ещё отсутствуют.
|
||||||
Лимит первых 6 карточек снят,
|
Обсерватория теперь показывает только identity-bound portable результаты;
|
||||||
но пагинация сотен записей ещё не доказана.
|
исторические LAB остаются в Legacy. Каталог дочитывает cursor pages по100
|
||||||
- **Последняя приёмка:** 726 frontend tests, 128 focused progress/queue/API/
|
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,
|
transport tests, отдельно40 runtime/wiring и3 installer tests, typecheck/build,
|
||||||
Ruff/mypy и browser QA. Для найденного при рестарте cache-дефекта — ещё89
|
Ruff/mypy и browser QA. Для найденного при рестарте cache-дефекта — ещё89
|
||||||
session/publication и72 admission/cache/queue/API tests. Эти проходы пересекаются,
|
session/publication и72 admission/cache/queue/API tests. Эти проходы пересекаются,
|
||||||
@@ -47,12 +229,49 @@ PASS через Wi-Fi/LTE не является условием готовно
|
|||||||
- **Текущие составы:** M4.9T5 — CPU TGS; LAB V1 — последовательные EoMT и DDRNet.
|
- **Текущие составы:** M4.9T5 — CPU TGS; LAB V1 — последовательные EoMT и DDRNet.
|
||||||
Ни один не равен полному будущему AI-профилю рига. Архивные overlays в одном
|
Ни один не равен полному будущему AI-профилю рига. Архивные overlays в одном
|
||||||
viewer не доказывают, что один Docker вычислил все слои.
|
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/
|
- **Есть инженерный прототип полного графа:** DDRNet/RF-DETR/geometry/distance/
|
||||||
motion/TGS/costmap/policy-shadow и короткие потоковые proofs. Не пишем заново.
|
motion/TGS/costmap/policy-shadow и короткие потоковые proofs. Не пишем заново.
|
||||||
Самостоятельный продуктовый образ и полный recorded-run этого состава не приняты.
|
Самостоятельный продуктовый образ и полный recorded-run этого состава не приняты.
|
||||||
- **Не закрыто:** полный recorded-analysis текущими
|
- **Не закрыто:** полный recorded-analysis текущими
|
||||||
профилями, синхронный сохранённый visual replay, матрица двух профилей/записей, полный поиск/
|
профилями, синхронный сохранённый visual replay, матрица двух профилей/записей,
|
||||||
пагинация каталога, standalone и фактический перенос на борт.
|
оставшиеся recovery cases, standalone и фактический перенос на борт.
|
||||||
|
|
||||||
Источники: [ADR 0050](adr/0050-recorded-observatory-first.md),
|
Источники: [ADR 0050](adr/0050-recorded-observatory-first.md),
|
||||||
[cache/UI evidence](../experiments/perception/OBSERVATORY_PUBLISHED_CACHE_2026-09-03.md),
|
[cache/UI evidence](../experiments/perception/OBSERVATORY_PUBLISHED_CACHE_2026-09-03.md),
|
||||||
@@ -62,6 +281,8 @@ Desktop final-status — операторская сводка и история
|
|||||||
сохранение выполненной работы, не неподтверждённые сведения о runtime.
|
сохранение выполненной работы, не неподтверждённые сведения о runtime.
|
||||||
Текущее исполнение и точные declarations:
|
Текущее исполнение и точные declarations:
|
||||||
[progress / первый запуск](../experiments/perception/OBSERVATORY_RECORDED_PROGRESS_2026-09-03.md).
|
[progress / первый запуск](../experiments/perception/OBSERVATORY_RECORDED_PROGRESS_2026-09-03.md).
|
||||||
|
Последняя сверка и проверки:
|
||||||
|
[возврат к основному сценарию](../experiments/perception/OBSERVATORY_MAINLINE_RECONCILIATION_2026-09-03.md).
|
||||||
|
|
||||||
## Границы исполнения
|
## Границы исполнения
|
||||||
|
|
||||||
@@ -106,8 +327,12 @@ normal/expanded UI; последний код `e436fb5` и отчёты выше
|
|||||||
|
|
||||||
**Частично выполнено:** bounded attempt-scoped progress реализован и активирован.
|
**Частично выполнено:** bounded attempt-scoped progress реализован и активирован.
|
||||||
Новый M49-run использовал прежний профиль и полностью выполнил его расписание.
|
Новый M49-run использовал прежний профиль и полностью выполнил его расписание.
|
||||||
Это не закрывает 2A: целиковый input barrier и `_capture_arrays()` до replay-cache
|
Это не закрывает2A: целиковый input barrier ещё сохранён. Новый preparation adapter
|
||||||
lookup ещё сохранены. Progress observation не является новым режимом исполнения.
|
проверяет 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 притормаживает
|
- Сохранить source timestamps и causal ordering. Медленный Worker притормаживает
|
||||||
подачу, а не выбрасывает обязательные кадры ради 1×. Учитывается каждый вход/
|
подачу, а не выбрасывает обязательные кадры ради 1×. Учитывается каждый вход/
|
||||||
@@ -133,32 +358,45 @@ timeline. Это приёмка CPU TGS, не полного detector/segmentati
|
|||||||
точный cache hit, исчезновение M49 из выбора и открытие документа после рестарта.
|
точный cache hit, исчезновение M49 из выбора и открытие документа после рестарта.
|
||||||
Найден и исправлен no-op reindex: служебная переиндексация больше не меняет
|
Найден и исправлен no-op reindex: служебная переиндексация больше не меняет
|
||||||
source identity; реальное изменение по-прежнему требует нового расчёта.
|
source identity; реальное изменение по-прежнему требует нового расчёта.
|
||||||
**Осталось в2B:** portable review сейчас JSON, не проигрыватель. Подключить
|
Общий `CanonicalRecordedLabReplay` уже получает сохранённый camera/source/TGS
|
||||||
typed result/frame reader и общий `CanonicalRecordedLabReplay`, ленивое чтение
|
RRD с Core; JSON не является единственным review. **Осталось в2B:** сохранение
|
||||||
сохранённых TGS/costmap и точную камеру/временную привязку. Проверить coverage,
|
ручного ракурса при blueprint activation, подробная визуальная оценка и
|
||||||
seek/play/pause, normal/expanded/restore/Escape и повторное открытие без job.
|
подтверждённое освобождение памяти закрытого viewer. Использовать уже sealed
|
||||||
Сначала использовать уже sealed пакет; не считать заново и не подмешивать
|
пакет, не считать заново и не подмешивать архивные E47/M49 semantics. Название
|
||||||
архивные E47/M49 semantics. Название «полный маршрут и воспроизведение» само
|
«полный маршрут» не доказывает покрытия всех исходных timestamps.
|
||||||
по себе не доказывает доступность replay или полноту всей исходной сессии.
|
|
||||||
|
|
||||||
**2C. LAB V1 и другая запись.** Выполнить LAB V1 × 004TREE, затем оба профиля
|
**2C. LAB V1 и другая запись.** Выполнить LAB V1 × 004TREE, затем оба профиля
|
||||||
× RAVNOVES00, строго последовательно. Медленная EoMT не возвращает нас к гонке
|
× RAVNOVES00, строго последовательно. Медленная EoMT не возвращает нас к гонке
|
||||||
за сетевым FPS. Не менять незаметно состав, cadence или разрешение ради PASS.
|
за сетевым FPS. Не менять незаметно состав, cadence или разрешение ради PASS.
|
||||||
RAVNOVES01 — отрицательный случай до подтверждённой аттестации.
|
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. Повторное использование, сбои и большой каталог.**
|
**2D. Повторное использование, сбои и большой каталог.**
|
||||||
|
|
||||||
- Refresh, повторный вход и перезапуск открывают тот же результат без новой job.
|
- Refresh, повторный вход и перезапуск открывают тот же результат без новой job.
|
||||||
Все текущие профили рассчитаны → выбор пуст, «Рассчитать» нет; новая версия → новый расчёт,
|
Все текущие профили рассчитаны → выбор пуст, «Рассчитать» нет; новая версия → новый расчёт,
|
||||||
старая остаётся ниже. Проверить transient disconnect, compute failure,
|
старая остаётся ниже. Проверить transient disconnect, compute failure,
|
||||||
publication retry, missing/corrupt artifacts и отсутствие дубликатов.
|
publication retry, missing/corrupt artifacts и отсутствие дубликатов.
|
||||||
- Исторические результаты получают допуск к review только при достаточном
|
- **Реализовано:** Обсерватория не показывает Legacy/canonical архивные LAB;
|
||||||
evidence, без подделки provenance. Если binding отсутствует — исправить
|
они остаются в существующем архиве без миграции/удаления. Portable результаты
|
||||||
проверяемую проекцию либо выполнить новый расчёт, не объявлять legacy новым.
|
прошлых версий сохраняются при точном binding, независимо от имён и даты.
|
||||||
- Довести постраничный поиск записей и результатов. Нельзя спрятать профиль
|
- **Реализовано и проверено:** постраничный каталог и поиск по загруженным
|
||||||
|
источникам. Нельзя спрятать профиль
|
||||||
из-за cache hit, соответствующий результат которого нельзя найти/открыть
|
из-за cache hit, соответствующий результат которого нельзя найти/открыть
|
||||||
из-за окна каталога. Сотни metadata entries проверяются функциональными
|
из-за окна каталога. Сотни metadata entries проверяются функциональными
|
||||||
fixtures, не нагрузкой на Mac и не загрузкой всех видео в RAM.
|
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 совместимые записи, полные обязательные выходы,
|
**Приёмка:** матрица 2 профиля × 2 совместимые записи, полные обязательные выходы,
|
||||||
честный прогресс, published package, сохранённый просмотр без compute после
|
честный прогресс, published package, сохранённый просмотр без compute после
|
||||||
@@ -218,12 +456,16 @@ watchdog, stop/slow при пропаже данных, моторы и авто
|
|||||||
- [x] 2026-09-03 — этап 1: queue/cache/identity/selector реализованы и локально проверены.
|
- [x] 2026-09-03 — этап 1: queue/cache/identity/selector реализованы и локально проверены.
|
||||||
- [x] 2026-09-03 — история сохранена, completed-status UI удалён.
|
- [x] 2026-09-03 — история сохранена, completed-status UI удалён.
|
||||||
- [x] Этап 2A, часть progress — реальные bounded Worker → backend → UI snapshots.
|
- [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.
|
- [ ] Этап 2A, остаток — versioned recorded-analysis и устранение полного input barrier.
|
||||||
- [x] Этап 2B, compute/publish/cache — M4.9 ×004TREE завершён; документ повторно открыт после рестарта.
|
- [x] Этап 2B, compute/publish/cache — M4.9 ×004TREE завершён; документ повторно открыт после рестарта.
|
||||||
- [x] Найденный reindex-дефект exact cache исправлен; исходная связь восстановлена по точному SHA proof.
|
- [x] Найденный reindex-дефект exact cache исправлен; исходная связь восстановлена по точному SHA proof.
|
||||||
- [x] Этап 2B, saved replay — camera/source cloud/TGS в одном native Rerun, выдача с Core после рестарта без compute.
|
- [x] Этап 2B, saved replay — camera/source cloud/TGS в одном native Rerun, выдача с Core после рестарта без compute.
|
||||||
- [ ] Этап 2B, visual acceptance — сохранение ручного ракурса и подробная визуальная оценка; не подменять работающим проигрывателем.
|
- [ ] Этап 2B, visual acceptance — сохранение ручного ракурса и подробная визуальная оценка; не подменять работающим проигрывателем.
|
||||||
- [ ] Этап 2C–2D — LAB V1/вторая запись, cache/recovery/каталог.
|
- [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 — самостоятельный полный профиль и сравнение конфигураций.
|
- [ ] Этап 3 — самостоятельный полный профиль и сравнение конфигураций.
|
||||||
- [ ] Этап 4 — кандидат, затем фактическая бортовая квалификация.
|
- [ ] Этап 4 — кандидат, затем фактическая бортовая квалификация.
|
||||||
- [x] 2026-09-03 — план и Desktop-сводка синхронизируются с фактическим исполнением.
|
- [x] 2026-09-03 — план и Desktop-сводка синхронизируются с фактическим исполнением.
|
||||||
@@ -236,12 +478,40 @@ Core хранит/выдаёт 193,622,178-byte replay; Worker не нужен
|
|||||||
blueprint clone activation. Не объявлять stable source ID сохранением eye;
|
blueprint clone activation. Не объявлять stable source ID сохранением eye;
|
||||||
выбирать только поддержанный native-механизм либо отдельно согласованный upgrade,
|
выбирать только поддержанный native-механизм либо отдельно согласованный upgrade,
|
||||||
не патчить WASM и не создавать второй renderer. Подробная визуальная приёмка открыта.
|
не патчить WASM и не создавать второй renderer. Подробная визуальная приёмка открыта.
|
||||||
Первым действием после нормализации памяти Mac повторить browser first-open для
|
Начальный cursor +1мкс показал camera/TGS в промежуточной проверке, но в финальном
|
||||||
последней правки initial cursor +1мкс; до этого не писать PASS начальной сцены.
|
expanded first-frame камера была пустой до playback; устойчивость не принята.
|
||||||
Автотесты/build проходят; последняя визуальная проверка остановлена resource gate.
|
Прежний facade функционально работает, GPU715→375 MB освобождается,
|
||||||
Отдельно закрыть остаток2A версионированным incremental input;
|
но renderer112→829 MB после закрытия. Follow-up с `vmmap`: один цикл834→215 MB
|
||||||
не объявлять прежний whole-recording materializer потоковым.
|
за35с с исчезновением крупных VM областей, повторный812→824 MB за91с.
|
||||||
Не начинать снова с claim/v3, memory repair, селектора или сетевого canary.
|
Это не принято как стабильное исправление 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. Лабы в целом ещё не закрыты.
|
||||||
|
|
||||||
## Карта продолжения, проверка и восстановление
|
## Карта продолжения, проверка и восстановление
|
||||||
|
|
||||||
@@ -268,6 +538,9 @@ visual entities требуют отдельного решения. Провер
|
|||||||
- 2026-09-02, владелец: recorded-first вместо сетевого realtime-first; старые
|
- 2026-09-02, владелец: recorded-first вместо сетевого realtime-first; старые
|
||||||
FAIL сохраняются, лаборатории не зависят от LTE/борта.
|
FAIL сохраняются, лаборатории не зависят от LTE/борта.
|
||||||
- 2026-09-03, владелец: результат внизу вместо completion button/status — реализовано.
|
- 2026-09-03, владелец: результат внизу вместо completion button/status — реализовано.
|
||||||
|
- 2026-09-03, владелец: активной операции разрешён необходимый расход памяти;
|
||||||
|
завершённая обязана освободить временные ресурсы. Не заменять cleanup снижением
|
||||||
|
качества/новыми жёсткими RAM caps. Little Snitch не трогать; файлы результатов сохранить.
|
||||||
- 2026-09-03, актуализация: этап 1 — реализованная основа, сквозной proof явно
|
- 2026-09-03, актуализация: этап 1 — реализованная основа, сквозной proof явно
|
||||||
в этапе 2. Это уточнение границы приёмки, не приписывание успешного end-to-end run.
|
в этапе 2. Это уточнение границы приёмки, не приписывание успешного end-to-end run.
|
||||||
- Desktop17/20, утверждение об отсутствии online prototype и безусловный1×
|
- Desktop17/20, утверждение об отсутствии online prototype и безусловный1×
|
||||||
|
|||||||
@@ -110,3 +110,48 @@ not a realtime inference or navigation claim.
|
|||||||
- The migration does not improve DDRNet quality, prove terrain traversability
|
- The migration does not improve DDRNet quality, prove terrain traversability
|
||||||
or grant navigation/actuation authority. Those remain separate model and
|
or grant navigation/actuation authority. Those remain separate model and
|
||||||
safety acceptance questions.
|
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
|
# 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.
|
Date: 2026-09-02. Decision accepted by the owner; implementation in progress.
|
||||||
This supersedes ADR 0049's realtime-first product gate and execution order,
|
This supersedes ADR 0049's realtime-first product gate and execution order,
|
||||||
not its live-stream integrity, ownership or safety contracts.
|
not its live-stream integrity, ownership or safety contracts.
|
||||||
@@ -182,6 +187,29 @@ See [real-run and restart evidence](../../experiments/perception/OBSERVATORY_REC
|
|||||||
|
|
||||||
## Boundaries
|
## 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,
|
No Synology deployment, hardware actuation, motor integration, new capture,
|
||||||
silent model substitutions or deletion of recordings/results. A Docker image's
|
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.
|
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.
|
||||||
@@ -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,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"]
|
||||||
+20
@@ -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"]
|
||||||
+1
-1
@@ -15,7 +15,7 @@ RUN case "${NODEDC_SOURCE_TREE_SHA256}" in *[!0-9a-f]*|'') exit 64 ;; esac \
|
|||||||
&& test "${#NODEDC_SOURCE_TREE_SHA256}" -eq 64 \
|
&& 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 d -exec chmod 0555 {} + \
|
||||||
&& find /opt/nodedc/mission-core/src/k1link -type f -exec chmod 0444 {} + \
|
&& 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" \
|
LABEL com.nodedc.product="mission-core" \
|
||||||
com.nodedc.stack="observatory" \
|
com.nodedc.stack="observatory" \
|
||||||
|
|||||||
+281
@@ -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,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()
|
||||||
+62
-7
@@ -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}$")
|
_OBSERVATORY_JOB_ID = re.compile(r"^observatory-run-[a-f0-9]{32}$")
|
||||||
_CAMERA_JOB_ID = re.compile(r"^recorded-camera-[a-f0-9]{24}$")
|
_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"]
|
AssetKind = Literal["file", "tree"]
|
||||||
AssetVerification = Literal["sha256", "identity-sha256"]
|
AssetVerification = Literal["sha256", "identity-sha256"]
|
||||||
CommandRunner = Callable[[Sequence[str], Optional[Mapping[str, str]]], None] # noqa: UP045
|
CommandRunner = Callable[[Sequence[str], Optional[Mapping[str, str]]], None] # noqa: UP045
|
||||||
@@ -168,7 +168,7 @@ class RuntimeLayout:
|
|||||||
component: Component,
|
component: Component,
|
||||||
expectations: Sequence[AssetExpectation],
|
expectations: Sequence[AssetExpectation],
|
||||||
) -> RuntimeLayout:
|
) -> RuntimeLayout:
|
||||||
if component not in ("eomt", "ddrnet"):
|
if component not in ("camera-source", "eomt", "ddrnet"):
|
||||||
raise ComponentAdapterError("installed package component is invalid")
|
raise ComponentAdapterError("installed package component is invalid")
|
||||||
prepared = Path(PACKAGE_STEP_INPUT_ROOT) / "prepare"
|
prepared = Path(PACKAGE_STEP_INPUT_ROOT) / "prepare"
|
||||||
return cls(
|
return cls(
|
||||||
@@ -176,7 +176,7 @@ class RuntimeLayout:
|
|||||||
camera_job_root=prepared / "camera-job",
|
camera_job_root=prepared / "camera-job",
|
||||||
output_root=Path(PACKAGE_OUTPUT_ROOT),
|
output_root=Path(PACKAGE_OUTPUT_ROOT),
|
||||||
effective_ddrnet_config=prepared / "effective-ddrnet-config.json",
|
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},
|
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"])
|
source = _source_binding(document["source"])
|
||||||
paths = _paths(document["paths"], component)
|
paths = _paths(document["paths"], component)
|
||||||
effective_value = document["effective_ddrnet_config_sha256"]
|
effective_value = document["effective_ddrnet_config_sha256"]
|
||||||
if component == "eomt":
|
if component != "ddrnet":
|
||||||
if effective_value is not None:
|
if effective_value is not None:
|
||||||
raise ComponentAdapterError("EoMT request contains a DDRNet config")
|
raise ComponentAdapterError("EoMT request contains a DDRNet config")
|
||||||
effective_sha256: str | None = None
|
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}")
|
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(
|
def validate_tree_receipt(
|
||||||
request: ComponentRequest,
|
request: ComponentRequest,
|
||||||
root: Path,
|
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
|
f"{prepared}/effective-ddrnet-config.json" if component == "ddrnet" else None
|
||||||
),
|
),
|
||||||
"eomt_result_root": (
|
"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": (
|
"decoded_frames_root": (
|
||||||
f"{PACKAGE_OUTPUT_ROOT}/source-frames"
|
f"{PACKAGE_OUTPUT_ROOT}/source-frames"
|
||||||
if component == "eomt"
|
if component == "camera-source"
|
||||||
else f"{PACKAGE_STEP_INPUT_ROOT}/eomt/source-frames"
|
else f"{PACKAGE_STEP_INPUT_ROOT}/camera-source/source-frames"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
_exact_keys(document, set(legacy), "component paths")
|
_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())
|
||||||
+116
@@ -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())
|
||||||
+23
-6
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@ from portable_lab_v1_component_adapter import (
|
|||||||
validate_file_asset,
|
validate_file_asset,
|
||||||
validate_fixed_result,
|
validate_fixed_result,
|
||||||
validate_grayscale_png_payload,
|
validate_grayscale_png_payload,
|
||||||
|
validate_prepared_camera_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
DDRNET_RESULT_SCHEMA: Final = "missioncore.lab-v1-goose-vegetation-run/v1"
|
DDRNET_RESULT_SCHEMA: Final = "missioncore.lab-v1-goose-vegetation-run/v1"
|
||||||
@@ -136,7 +137,7 @@ def execute_ddrnet_component(
|
|||||||
layout.effective_ddrnet_config,
|
layout.effective_ddrnet_config,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
eomt_root, source_frames, decode_repair = _validate_eomt_input(
|
prepared_root, source_frames, decode_repair = _validate_prepared_input(
|
||||||
layout.eomt_result_root,
|
layout.eomt_result_root,
|
||||||
request,
|
request,
|
||||||
)
|
)
|
||||||
@@ -148,7 +149,7 @@ def execute_ddrnet_component(
|
|||||||
# tmpfs makes a full 6,830-frame K1 run fail even though the immutable
|
# 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
|
# input is valid. Verify every manifest digest through an O_NOFOLLOW
|
||||||
# descriptor, then let the sealed runner read that same read-only tree.
|
# 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"
|
mapping_copy = workspace / "goose_label_mapping.csv"
|
||||||
shutil.copyfile(assets["ddrnet-goose-mapping"], mapping_copy)
|
shutil.copyfile(assets["ddrnet-goose-mapping"], mapping_copy)
|
||||||
os.chmod(mapping_copy, 0o400)
|
os.chmod(mapping_copy, 0o400)
|
||||||
@@ -182,7 +183,7 @@ def execute_ddrnet_component(
|
|||||||
"0",
|
"0",
|
||||||
)
|
)
|
||||||
command_runner(argv, _ddrnet_environment())
|
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 (
|
if (
|
||||||
load_json(
|
load_json(
|
||||||
staging / "decode-repair.json",
|
staging / "decode-repair.json",
|
||||||
@@ -269,13 +270,29 @@ def _validate_effective_config(
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _validate_eomt_input(
|
def _validate_prepared_input(
|
||||||
root: Path,
|
root: Path,
|
||||||
request: ComponentRequest,
|
request: ComponentRequest,
|
||||||
) -> tuple[Path, tuple[SourceFrameRow, ...], dict[str, object]]:
|
) -> tuple[Path, tuple[SourceFrameRow, ...], dict[str, object]]:
|
||||||
if root.is_symlink():
|
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)
|
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(
|
result = validate_fixed_result(
|
||||||
resolved,
|
resolved,
|
||||||
schema_version=EOMT_RESULT_SCHEMA,
|
schema_version=EOMT_RESULT_SCHEMA,
|
||||||
|
|||||||
+40
-17
@@ -37,6 +37,7 @@ from portable_lab_v1_component_adapter import (
|
|||||||
validate_fixed_result,
|
validate_fixed_result,
|
||||||
validate_grayscale_png_payload,
|
validate_grayscale_png_payload,
|
||||||
validate_identity_manifest,
|
validate_identity_manifest,
|
||||||
|
validate_prepared_camera_root,
|
||||||
validate_tree_asset,
|
validate_tree_asset,
|
||||||
validate_tree_receipt,
|
validate_tree_receipt,
|
||||||
)
|
)
|
||||||
@@ -50,11 +51,10 @@ MODEL_REVISION: Final = "8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f"
|
|||||||
MODEL_ID: Final = "tue-mps/cityscapes_semantic_eomt_large_1024"
|
MODEL_ID: Final = "tue-mps/cityscapes_semantic_eomt_large_1024"
|
||||||
MODEL_ARCHITECTURE: Final = "EomtForUniversalSegmentation"
|
MODEL_ARCHITECTURE: Final = "EomtForUniversalSegmentation"
|
||||||
PHYSICAL_CAMERA_SOURCE_ID: Final = "sensor.camera.right"
|
PHYSICAL_CAMERA_SOURCE_ID: Final = "sensor.camera.right"
|
||||||
# Keep a large post-run floor while admitting the full 6,830-frame K1 record on
|
# Owner-approved post-run disk floor (2026-09-03); not a RAM/VRAM reservation.
|
||||||
# Worker 006. The independent ``reserve`` below already accounts for the
|
# The independent ``reserve`` below remains additional to this free-space floor.
|
||||||
# complete worst-case working set, so adding the historical 360 GiB floor made
|
# Deploy only through a new sealed image/release, never patch an installed digest.
|
||||||
# the real job miss admission by about 1.4 GB despite 408 GB being free.
|
DISK_FLOOR_BYTES: Final = 250 * 1024**3
|
||||||
DISK_FLOOR_BYTES: Final = 350 * 1024**3
|
|
||||||
FFMPEG_TREE_SOURCE_IMAGE_SHA256: Final = (
|
FFMPEG_TREE_SOURCE_IMAGE_SHA256: Final = (
|
||||||
"8a364092b03561b9c08ac00730206e363a53d07ea0304f7d543b403b65432b5e"
|
"8a364092b03561b9c08ac00730206e363a53d07ea0304f7d543b403b65432b5e"
|
||||||
)
|
)
|
||||||
@@ -163,26 +163,49 @@ def execute_eomt_component(
|
|||||||
component="eomt",
|
component="eomt",
|
||||||
expectations=EOMT_ASSETS,
|
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)
|
output = _empty_output_root(layout.output_root)
|
||||||
reserve = request.source.frame_count * 800 * 600 * 7 + request.source.input_byte_length
|
reserve = request.source.frame_count * 800 * 600 * 7 + request.source.input_byte_length
|
||||||
free_before = available_bytes(output)
|
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")
|
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)
|
workspace = _prepare_workspace(output / ".eomt-work" if work_root is None else work_root)
|
||||||
try:
|
try:
|
||||||
total_started = time.perf_counter()
|
total_started = time.perf_counter()
|
||||||
extract_started = time.perf_counter()
|
extract_started = time.perf_counter()
|
||||||
frames_root, timeline_path, decode_repair = _decode_camera_epoch(
|
if (layout.eomt_result_root / "camera-source.json").is_file():
|
||||||
request=request,
|
prepared = validate_prepared_camera_root(layout.eomt_result_root, request)
|
||||||
input_document=input_document,
|
frames_root = layout.eomt_result_root / "source-frames"
|
||||||
camera_job_root=layout.camera_job_root,
|
timeline_path = prepared["timeline"]
|
||||||
output_root=output,
|
decode_repair = load_json(
|
||||||
work_root=workspace,
|
prepared["decode-repair"],
|
||||||
ffmpeg_root=roots["eomt-ffmpeg-runtime"],
|
label="prepared camera decode repair",
|
||||||
command_runner=command_runner,
|
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)
|
extract_seconds = _elapsed(extract_started)
|
||||||
free_post_extract = available_bytes(output)
|
free_post_extract = available_bytes(output)
|
||||||
if free_post_extract < disk_floor_bytes:
|
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_binary.sh"),
|
||||||
Path("experiments/perception/worker/m49_t3_travel/build_tgs_full_shadow_evidence.py"),
|
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_replay.py"),
|
||||||
|
Path("src/k1link/compute/lidar_preparation.py"),
|
||||||
Path("src/k1link/observatory/m49_portable_executor.py"),
|
Path("src/k1link/observatory/m49_portable_executor.py"),
|
||||||
Path("src/k1link/observatory/m49_portable_result.py"),
|
Path("src/k1link/observatory/m49_portable_result.py"),
|
||||||
Path("src/k1link/observatory/m49_portable_source.py"),
|
Path("src/k1link/observatory/m49_portable_source.py"),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
@@ -36,6 +36,10 @@ APPLICATION_ID: Final = "nodedc_mission_core_recorded"
|
|||||||
SESSION_TIMELINE: Final = "session_time"
|
SESSION_TIMELINE: Final = "session_time"
|
||||||
RENDERER_VERSION: Final = "upstream-rerun-0.36.3-encoded-optimized-v6"
|
RENDERER_VERSION: Final = "upstream-rerun-0.36.3-encoded-optimized-v6"
|
||||||
REPLAY_RENDERER_VERSION: Final = "upstream-rerun-0.36.3-canonical-replay-v1"
|
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_SOURCE_BYTES: Final = 768 * 1024 * 1024
|
||||||
MAX_OVERLAY_BYTES: Final = 256 * 1024 * 1024
|
MAX_OVERLAY_BYTES: Final = 256 * 1024 * 1024
|
||||||
MAX_REPLAY_BYTES: Final = 1024 * 1024 * 1024
|
MAX_REPLAY_BYTES: Final = 1024 * 1024 * 1024
|
||||||
@@ -138,9 +142,7 @@ class CanonicalLabReplayArtifact:
|
|||||||
_render_lock = threading.Lock()
|
_render_lock = threading.Lock()
|
||||||
_memory_cache: dict[tuple[str, str, str], CanonicalLabOverlayArtifact] = {}
|
_memory_cache: dict[tuple[str, str, str], CanonicalLabOverlayArtifact] = {}
|
||||||
_replay_lock = threading.Lock()
|
_replay_lock = threading.Lock()
|
||||||
_replay_memory_cache: dict[
|
_replay_memory_cache: dict[tuple[str, str, str, str], CanonicalLabReplayArtifact] = {}
|
||||||
tuple[str, str, str, str], CanonicalLabReplayArtifact
|
|
||||||
] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_recording_id(path: Path) -> str:
|
def canonical_recording_id(path: Path) -> str:
|
||||||
@@ -283,9 +285,7 @@ def canonical_lab_replay(
|
|||||||
or not _is_sha256(base_generation_sha256)
|
or not _is_sha256(base_generation_sha256)
|
||||||
or _sha256(base) != base_generation_sha256
|
or _sha256(base) != base_generation_sha256
|
||||||
or not _artifact_is_regular(overlay)
|
or not _artifact_is_regular(overlay)
|
||||||
or re.fullmatch(
|
or CANONICAL_REPLAY_RESULT_ID.fullmatch(result_id) is None
|
||||||
r"(?:lab-v1-vegetation-shadow|m49-tgs-portable-review)-[a-f0-9]{64}", result_id
|
|
||||||
) is None
|
|
||||||
or not recording_id
|
or not recording_id
|
||||||
or len(recording_id) > 128
|
or len(recording_id) > 128
|
||||||
):
|
):
|
||||||
@@ -447,12 +447,11 @@ def _verified_camera_source(root: Path, route: dict[str, Any], jobs_root: Path)
|
|||||||
or not isinstance(files, list)
|
or not isinstance(files, list)
|
||||||
):
|
):
|
||||||
raise CanonicalLabOverlayError("camera job source contract changed")
|
raise CanonicalLabOverlayError("camera job source contract changed")
|
||||||
epoch_prefix = PurePosixPath(
|
epoch_prefix = (
|
||||||
"input/camera/sensor.camera.right"
|
PurePosixPath("input/camera/sensor.camera.right") / f"epoch-{source.get('codec_epoch')}"
|
||||||
) / f"epoch-{source.get('codec_epoch')}"
|
)
|
||||||
required = [epoch_prefix / "init.mp4"] + [
|
required = [epoch_prefix / "init.mp4"] + [
|
||||||
epoch_prefix / "segments" / f"{index}.m4s"
|
epoch_prefix / "segments" / f"{index}.m4s" for index in range(1, frame_count + 1)
|
||||||
for index in range(1, frame_count + 1)
|
|
||||||
]
|
]
|
||||||
descriptors = {
|
descriptors = {
|
||||||
item.get("path"): item
|
item.get("path"): item
|
||||||
@@ -609,10 +608,7 @@ def _render_overlay(
|
|||||||
"/perception/camera/image",
|
"/perception/camera/image",
|
||||||
rr.VideoFrameReference(nanoseconds=int(video_references[index])),
|
rr.VideoFrameReference(nanoseconds=int(video_references[index])),
|
||||||
)
|
)
|
||||||
masks = {
|
masks = {layer_id: _read_mask(archive, index) for layer_id, archive in archives.items()}
|
||||||
layer_id: _read_mask(archive, index)
|
|
||||||
for layer_id, archive in archives.items()
|
|
||||||
}
|
|
||||||
for layer_id, mask in masks.items():
|
for layer_id, mask in masks.items():
|
||||||
recording.log(
|
recording.log(
|
||||||
f"/perception/camera/segmentation/{layer_id}",
|
f"/perception/camera/segmentation/{layer_id}",
|
||||||
@@ -731,15 +727,17 @@ def _video_reference_timestamps(
|
|||||||
if (
|
if (
|
||||||
len(video_timestamps) < int(len(frame_times) * 0.9)
|
len(video_timestamps) < int(len(frame_times) * 0.9)
|
||||||
or np.any(np.diff(video_timestamps) < 0)
|
or np.any(np.diff(video_timestamps) < 0)
|
||||||
or abs(int(video_timestamps[-1]) - int(relative_frame_times[-1]))
|
or abs(int(video_timestamps[-1]) - int(relative_frame_times[-1])) > 2_000_000_000
|
||||||
> 2_000_000_000
|
|
||||||
):
|
):
|
||||||
raise CanonicalLabOverlayError("video proxy timeline changed")
|
raise CanonicalLabOverlayError("video proxy timeline changed")
|
||||||
indices = np.searchsorted(
|
indices = (
|
||||||
video_timestamps,
|
np.searchsorted(
|
||||||
relative_frame_times,
|
video_timestamps,
|
||||||
side="right",
|
relative_frame_times,
|
||||||
) - 1
|
side="right",
|
||||||
|
)
|
||||||
|
- 1
|
||||||
|
)
|
||||||
return video_timestamps[np.clip(indices, 0, len(video_timestamps) - 1)]
|
return video_timestamps[np.clip(indices, 0, len(video_timestamps) - 1)]
|
||||||
|
|
||||||
|
|
||||||
@@ -770,9 +768,7 @@ def _semantic_palette(classes: list[object]) -> tuple[int, ...]:
|
|||||||
or not isinstance(color, list)
|
or not isinstance(color, list)
|
||||||
or len(color) != 3
|
or len(color) != 3
|
||||||
or any(
|
or any(
|
||||||
not isinstance(channel, int)
|
not isinstance(channel, int) or isinstance(channel, bool) or not 0 <= channel <= 255
|
||||||
or isinstance(channel, bool)
|
|
||||||
or not 0 <= channel <= 255
|
|
||||||
for channel in color
|
for channel in color
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
@@ -820,9 +816,7 @@ def semantic_component_boxes(
|
|||||||
mask, class_id, minimum_pixels=minimum_pixels
|
mask, class_id, minimum_pixels=minimum_pixels
|
||||||
)[:12]:
|
)[:12]:
|
||||||
score = min(0.99, 0.5 + pixels / 20_000)
|
score = min(0.99, 0.5 + pixels / 20_000)
|
||||||
candidates.append(
|
candidates.append((score, [left, top, right, bottom], f"{label} · {score:.0%}"))
|
||||||
(score, [left, top, right, bottom], f"{label} · {score:.0%}")
|
|
||||||
)
|
|
||||||
candidates.sort(key=lambda row: (-row[0], row[1][1], row[1][0]))
|
candidates.sort(key=lambda row: (-row[0], row[1][1], row[1][0]))
|
||||||
selected = candidates[:32]
|
selected = candidates[:32]
|
||||||
return [row[1] for row in selected], [row[2] for row in selected]
|
return [row[1] for row in selected], [row[2] for row in selected]
|
||||||
@@ -1001,6 +995,8 @@ def _sha256(path: Path) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _is_sha256(value: object) -> bool:
|
def _is_sha256(value: object) -> bool:
|
||||||
return isinstance(value, str) and len(value) == 64 and all(
|
return (
|
||||||
character in "0123456789abcdef" for character in value
|
isinstance(value, str)
|
||||||
|
and len(value) == 64
|
||||||
|
and all(character in "0123456789abcdef" for character in value)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"]),
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -160,8 +160,10 @@ class InstalledLabDockerLaunch:
|
|||||||
"com.nodedc.definition-sha256",
|
"com.nodedc.definition-sha256",
|
||||||
"com.nodedc.job-id",
|
"com.nodedc.job-id",
|
||||||
"com.nodedc.managed-by",
|
"com.nodedc.managed-by",
|
||||||
|
"com.nodedc.module-id",
|
||||||
"com.nodedc.package-sha256",
|
"com.nodedc.package-sha256",
|
||||||
"com.nodedc.product",
|
"com.nodedc.product",
|
||||||
|
"com.nodedc.role",
|
||||||
"com.nodedc.stack",
|
"com.nodedc.stack",
|
||||||
}
|
}
|
||||||
if set(self.labels) != required_labels:
|
if set(self.labels) != required_labels:
|
||||||
@@ -170,7 +172,9 @@ class InstalledLabDockerLaunch:
|
|||||||
self.labels["com.nodedc.authority"] != "observation-only"
|
self.labels["com.nodedc.authority"] != "observation-only"
|
||||||
or self.labels["com.nodedc.component"] != self.container.container_id
|
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.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.product"] != "mission-core"
|
||||||
|
or self.labels["com.nodedc.role"] != "ai-module"
|
||||||
or self.labels["com.nodedc.stack"] != "observatory"
|
or self.labels["com.nodedc.stack"] != "observatory"
|
||||||
):
|
):
|
||||||
raise InstalledLabPackageRunnerError("Docker launch labels changed")
|
raise InstalledLabPackageRunnerError("Docker launch labels changed")
|
||||||
@@ -251,14 +255,10 @@ class DockerEngineInstalledLabLauncher:
|
|||||||
or len(image_sha256s) != len(set(image_sha256s))
|
or len(image_sha256s) != len(set(image_sha256s))
|
||||||
or any(_SHA256.fullmatch(value) is None for value in image_sha256s)
|
or any(_SHA256.fullmatch(value) is None for value in image_sha256s)
|
||||||
):
|
):
|
||||||
raise InstalledLabPackageRunnerError(
|
raise InstalledLabPackageRunnerError("installed LAB image inventory is invalid")
|
||||||
"installed LAB image inventory is invalid"
|
|
||||||
)
|
|
||||||
if self.transport_factory is None:
|
if self.transport_factory is None:
|
||||||
_require_local_socket(self.socket_path)
|
_require_local_socket(self.socket_path)
|
||||||
transport: httpx.BaseTransport = httpx.HTTPTransport(
|
transport: httpx.BaseTransport = httpx.HTTPTransport(uds=str(self.socket_path))
|
||||||
uds=str(self.socket_path)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
transport = self.transport_factory()
|
transport = self.transport_factory()
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
@@ -282,7 +282,7 @@ class DockerEngineInstalledLabLauncher:
|
|||||||
|
|
||||||
def _create(self, client: httpx.Client, launch: InstalledLabDockerLaunch) -> str:
|
def _create(self, client: httpx.Client, launch: InstalledLabDockerLaunch) -> str:
|
||||||
component = launch.container.container_id[:32]
|
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(
|
response = self._response(
|
||||||
client,
|
client,
|
||||||
"POST",
|
"POST",
|
||||||
@@ -455,7 +455,9 @@ class InstalledLabPackageProfileRunner:
|
|||||||
output_root.mkdir(mode=0o700)
|
output_root.mkdir(mode=0o700)
|
||||||
steps_root = job_root / "steps"
|
steps_root = job_root / "steps"
|
||||||
steps_root.mkdir(mode=0o700)
|
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(
|
_write_exclusive(
|
||||||
plan_path,
|
plan_path,
|
||||||
canonical_json(
|
canonical_json(
|
||||||
@@ -530,11 +532,11 @@ class InstalledLabPackageProfileRunner:
|
|||||||
),
|
),
|
||||||
InstalledLabDockerMount(
|
InstalledLabDockerMount(
|
||||||
_translate_work_path(
|
_translate_work_path(
|
||||||
plan_path,
|
plan_path.parent,
|
||||||
controller_root=self.controller_work_root,
|
controller_root=self.controller_work_root,
|
||||||
engine_root=self.engine_work_root,
|
engine_root=self.engine_work_root,
|
||||||
),
|
),
|
||||||
INSTALLED_LAB_PLAN_PATH,
|
str(PurePosixPath(INSTALLED_LAB_PLAN_PATH).parent),
|
||||||
True,
|
True,
|
||||||
),
|
),
|
||||||
InstalledLabDockerMount(
|
InstalledLabDockerMount(
|
||||||
@@ -586,8 +588,10 @@ class InstalledLabPackageProfileRunner:
|
|||||||
"com.nodedc.definition-sha256": plan.definition_sha256,
|
"com.nodedc.definition-sha256": plan.definition_sha256,
|
||||||
"com.nodedc.job-id": plan.job_id,
|
"com.nodedc.job-id": plan.job_id,
|
||||||
"com.nodedc.managed-by": "mission-core-worker",
|
"com.nodedc.managed-by": "mission-core-worker",
|
||||||
|
"com.nodedc.module-id": container.container_id,
|
||||||
"com.nodedc.package-sha256": self.package.package_sha256,
|
"com.nodedc.package-sha256": self.package.package_sha256,
|
||||||
"com.nodedc.product": "mission-core",
|
"com.nodedc.product": "mission-core",
|
||||||
|
"com.nodedc.role": "ai-module",
|
||||||
"com.nodedc.stack": "observatory",
|
"com.nodedc.stack": "observatory",
|
||||||
},
|
},
|
||||||
name_token=name_token,
|
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_CONTAINER_IO_SCHEMA: Final = "missioncore.observatory-installed-lab-container-io/v2"
|
||||||
|
|
||||||
INSTALLED_LAB_SOURCE_ROOT: Final = "/missioncore/input/source"
|
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_STEP_INPUT_ROOT: Final = "/missioncore/input/steps"
|
||||||
INSTALLED_LAB_RESULT_ROOT: Final = "/missioncore/output"
|
INSTALLED_LAB_RESULT_ROOT: Final = "/missioncore/output"
|
||||||
INSTALLED_LAB_WORK_ROOT: Final = "/missioncore/work"
|
INSTALLED_LAB_WORK_ROOT: Final = "/missioncore/work"
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
)
|
||||||
@@ -32,7 +32,8 @@ from typing import TYPE_CHECKING, Final, cast
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import numpy.typing as npt
|
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.portable_result_contract import canonical_json
|
||||||
from k1link.observatory.recorded_progress import report_recorded_progress
|
from k1link.observatory.recorded_progress import report_recorded_progress
|
||||||
from k1link.observatory.source_admission import (
|
from k1link.observatory.source_admission import (
|
||||||
@@ -225,7 +226,7 @@ def materialize_m49_portable_source_from_worker_stage(
|
|||||||
if parent.is_symlink() or not parent.is_dir():
|
if parent.is_symlink() or not parent.is_dir():
|
||||||
raise M49PortableSourceError("portable M4.9 output parent is unsafe")
|
raise M49PortableSourceError("portable M4.9 output parent is unsafe")
|
||||||
try:
|
try:
|
||||||
lidar_pack_root = build_lidar_replay_pack_v2(
|
lidar_pack_root = prepare_lidar_replay_pack_v2(
|
||||||
root / "mqtt.raw.k1mqtt",
|
root / "mqtt.raw.k1mqtt",
|
||||||
parent / "lidar-replay-packs",
|
parent / "lidar-replay-packs",
|
||||||
session_id=job.source_session_id,
|
session_id=job.source_session_id,
|
||||||
@@ -538,7 +539,9 @@ def _materialize_stage(
|
|||||||
f"\t{anchor.session_seconds:.9f}\t-1\t0"
|
f"\t{anchor.session_seconds:.9f}\t-1\t0"
|
||||||
)
|
)
|
||||||
report_recorded_progress(
|
report_recorded_progress(
|
||||||
"source-preparation", anchor.timeline_frame_index + 1, len(anchors),
|
"source-preparation",
|
||||||
|
anchor.timeline_frame_index + 1,
|
||||||
|
len(anchors),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -600,7 +603,9 @@ def _materialize_stage(
|
|||||||
)
|
)
|
||||||
available_slot += 1
|
available_slot += 1
|
||||||
report_recorded_progress(
|
report_recorded_progress(
|
||||||
"source-preparation", anchor.timeline_frame_index + 1, len(anchors),
|
"source-preparation",
|
||||||
|
anchor.timeline_frame_index + 1,
|
||||||
|
len(anchors),
|
||||||
)
|
)
|
||||||
|
|
||||||
if available_slot < 1:
|
if available_slot < 1:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
from k1link.observatory.worker_agent import SealedObservatoryRecordedJob
|
||||||
|
|
||||||
PORTABLE_LAB_V1_SOURCE_SCHEMA: Final = (
|
PORTABLE_LAB_V1_SOURCE_SCHEMA: Final = "missioncore.observatory-portable-lab-v1-source/v1"
|
||||||
"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_SCHEMA: Final = (
|
|
||||||
"missioncore.observatory-portable-lab-v1-orchestration-plan/v1"
|
|
||||||
)
|
|
||||||
PORTABLE_LAB_V1_PLAN_IDENTITY_SCHEMA: Final = (
|
PORTABLE_LAB_V1_PLAN_IDENTITY_SCHEMA: Final = (
|
||||||
"missioncore.observatory-portable-lab-v1-orchestration-plan-identity/v1"
|
"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 = (
|
PORTABLE_LAB_V1_EXECUTOR_SEAL_SCHEMA: Final = (
|
||||||
"missioncore.observatory-portable-lab-v1-executor-seal/v2"
|
"missioncore.observatory-portable-lab-v1-executor-seal/v2"
|
||||||
)
|
)
|
||||||
PORTABLE_LAB_V1_DDRNET_PROFILE_SCHEMA: Final = (
|
PORTABLE_LAB_V1_DDRNET_PROFILE_SCHEMA: Final = "missioncore.lab-v1-eomt-ddrnet-portable-profile/v2"
|
||||||
"missioncore.lab-v1-eomt-ddrnet-portable-profile/v2"
|
|
||||||
)
|
|
||||||
PORTABLE_LAB_V1_DDRNET_EFFECTIVE_CONFIG_SCHEMA: Final = (
|
PORTABLE_LAB_V1_DDRNET_EFFECTIVE_CONFIG_SCHEMA: Final = (
|
||||||
"missioncore.lab-v1-goose-vegetation-benchmark/v1"
|
"missioncore.lab-v1-goose-vegetation-benchmark/v1"
|
||||||
)
|
)
|
||||||
@@ -97,9 +91,7 @@ _DDRNET_CANDIDATE_KEY: Final = "ddrnet"
|
|||||||
_DDRNET_CHECKPOINT_SHA256: Final = (
|
_DDRNET_CHECKPOINT_SHA256: Final = (
|
||||||
"b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6"
|
"b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6"
|
||||||
)
|
)
|
||||||
_GOOSE_MAPPING_SHA256: Final = (
|
_GOOSE_MAPPING_SHA256: Final = "88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f"
|
||||||
"88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f"
|
|
||||||
)
|
|
||||||
_OBSERVATORY_JOB_ID = re.compile(r"^observatory-run-[a-f0-9]{32}$")
|
_OBSERVATORY_JOB_ID = re.compile(r"^observatory-run-[a-f0-9]{32}$")
|
||||||
_CAMERA_JOB_ID = re.compile(r"^recorded-camera-[a-f0-9]{24}$")
|
_CAMERA_JOB_ID = re.compile(r"^recorded-camera-[a-f0-9]{24}$")
|
||||||
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
|
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
|
||||||
@@ -214,9 +206,7 @@ class PortableLabV1SourceInput:
|
|||||||
"session_id": self.source_session_id,
|
"session_id": self.source_session_id,
|
||||||
"catalog_sha256": self.source_catalog_sha256,
|
"catalog_sha256": self.source_catalog_sha256,
|
||||||
"bundle_sha256": self.source_bundle_sha256,
|
"bundle_sha256": self.source_bundle_sha256,
|
||||||
"capability_manifest_sha256": (
|
"capability_manifest_sha256": (self.source_capability_manifest_sha256),
|
||||||
self.source_capability_manifest_sha256
|
|
||||||
),
|
|
||||||
"adapter_sha256": self.source_adapter_sha256,
|
"adapter_sha256": self.source_adapter_sha256,
|
||||||
},
|
},
|
||||||
"camera_compute_job": {
|
"camera_compute_job": {
|
||||||
@@ -341,13 +331,8 @@ def materialize_lab_v1_source_input(
|
|||||||
)
|
)
|
||||||
if hashlib.sha256(source_bundle_bytes).hexdigest() != job.source_bundle_sha256:
|
if hashlib.sha256(source_bundle_bytes).hexdigest() != job.source_bundle_sha256:
|
||||||
raise PortableLabV1SourceError("source bundle digest differs from the sealed job")
|
raise PortableLabV1SourceError("source bundle digest differs from the sealed job")
|
||||||
if (
|
if hashlib.sha256(capability_bytes).hexdigest() != job.source_capability_manifest_sha256:
|
||||||
hashlib.sha256(capability_bytes).hexdigest()
|
raise PortableLabV1SourceError("source capability digest differs from the sealed job")
|
||||||
!= job.source_capability_manifest_sha256
|
|
||||||
):
|
|
||||||
raise PortableLabV1SourceError(
|
|
||||||
"source capability digest differs from the sealed job"
|
|
||||||
)
|
|
||||||
_validate_source_documents(
|
_validate_source_documents(
|
||||||
source_bundle=source_bundle,
|
source_bundle=source_bundle,
|
||||||
capability=capability,
|
capability=capability,
|
||||||
@@ -484,9 +469,7 @@ class PortableLabV1ReleaseCandidate:
|
|||||||
_digest(self.executor_image_sha256, "release executor image sha256")
|
_digest(self.executor_image_sha256, "release executor image sha256")
|
||||||
ids = tuple(asset.asset_id for asset in self.assets)
|
ids = tuple(asset.asset_id for asset in self.assets)
|
||||||
if ids != tuple(sorted(ids)) or len(ids) != len(set(ids)):
|
if ids != tuple(sorted(ids)) or len(ids) != len(set(ids)):
|
||||||
raise PortableLabV1ReleaseError(
|
raise PortableLabV1ReleaseError("release assets must be unique and canonically ordered")
|
||||||
"release assets must be unique and canonically ordered"
|
|
||||||
)
|
|
||||||
if not self.phases or len(self.phases) != len(set(self.phases)):
|
if not self.phases or len(self.phases) != len(set(self.phases)):
|
||||||
raise PortableLabV1ReleaseError("release phases are invalid")
|
raise PortableLabV1ReleaseError("release phases are invalid")
|
||||||
for phase in self.phases:
|
for phase in self.phases:
|
||||||
@@ -561,9 +544,7 @@ class PortableLabV1ReleaseCandidate:
|
|||||||
return cls(
|
return cls(
|
||||||
release_id=_string(document["release_id"], "release id"),
|
release_id=_string(document["release_id"], "release id"),
|
||||||
setup_id=_string(document["setup_id"], "release setup id"),
|
setup_id=_string(document["setup_id"], "release setup id"),
|
||||||
definition_id=_string(
|
definition_id=_string(document["definition_id"], "release definition id"),
|
||||||
document["definition_id"], "release definition id"
|
|
||||||
),
|
|
||||||
definition_version=_positive_int(
|
definition_version=_positive_int(
|
||||||
document["definition_version"], "release definition version"
|
document["definition_version"], "release definition version"
|
||||||
),
|
),
|
||||||
@@ -579,9 +560,7 @@ class PortableLabV1ReleaseCandidate:
|
|||||||
assets=assets,
|
assets=assets,
|
||||||
phases=phases,
|
phases=phases,
|
||||||
declared_blockers=blockers,
|
declared_blockers=blockers,
|
||||||
candidate_sha256=_string(
|
candidate_sha256=_string(document["candidate_sha256"], "release candidate sha256"),
|
||||||
document["candidate_sha256"], "release candidate sha256"
|
|
||||||
),
|
|
||||||
repository_root=_real_directory(repository_root, "repository root"),
|
repository_root=_real_directory(repository_root, "repository root"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -606,14 +585,10 @@ class PortableLabV1ReleaseCandidate:
|
|||||||
definition.setup_id != self.setup_id
|
definition.setup_id != self.setup_id
|
||||||
or definition.definition_id != self.definition_id
|
or definition.definition_id != self.definition_id
|
||||||
or definition.version != self.definition_version
|
or definition.version != self.definition_version
|
||||||
or definition.executable_contract_sha256
|
or definition.executable_contract_sha256 != self.definition_contract_sha256
|
||||||
!= self.definition_contract_sha256
|
or definition.result_contract.contract_sha256 != self.result_contract_sha256
|
||||||
or definition.result_contract.contract_sha256
|
|
||||||
!= self.result_contract_sha256
|
|
||||||
):
|
):
|
||||||
raise PortableLabV1ReleaseError(
|
raise PortableLabV1ReleaseError("release candidate belongs to another RunDefinition")
|
||||||
"release candidate belongs to another RunDefinition"
|
|
||||||
)
|
|
||||||
|
|
||||||
def inspect(
|
def inspect(
|
||||||
self,
|
self,
|
||||||
@@ -660,9 +635,7 @@ class PortableLabV1ReleaseCandidate:
|
|||||||
or self.executor_image_sha256 is None
|
or self.executor_image_sha256 is None
|
||||||
or len(inspection.matched_assets) != len(self.assets)
|
or len(inspection.matched_assets) != len(self.assets)
|
||||||
):
|
):
|
||||||
raise PortableLabV1ReleaseError(
|
raise PortableLabV1ReleaseError("portable LAB V1 executor candidate is not sealable")
|
||||||
"portable LAB V1 executor candidate is not sealable"
|
|
||||||
)
|
|
||||||
identity = {
|
identity = {
|
||||||
"schema_version": PORTABLE_LAB_V1_EXECUTOR_SEAL_SCHEMA,
|
"schema_version": PORTABLE_LAB_V1_EXECUTOR_SEAL_SCHEMA,
|
||||||
"release_id": self.release_id,
|
"release_id": self.release_id,
|
||||||
@@ -725,9 +698,8 @@ class PortableLabV1PlanPhase:
|
|||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
_pattern(self.phase_id, _IDENTIFIER, "plan phase id")
|
_pattern(self.phase_id, _IDENTIFIER, "plan phase id")
|
||||||
if (
|
if not self.component_sha256s or self.component_sha256s != tuple(
|
||||||
not self.component_sha256s
|
sorted(self.component_sha256s)
|
||||||
or self.component_sha256s != tuple(sorted(self.component_sha256s))
|
|
||||||
):
|
):
|
||||||
raise PortableLabV1PlanError("plan component identities are not canonical")
|
raise PortableLabV1PlanError("plan component identities are not canonical")
|
||||||
for digest_value in self.component_sha256s:
|
for digest_value in self.component_sha256s:
|
||||||
@@ -787,10 +759,8 @@ class PortableLabV1OrchestrationPlan:
|
|||||||
_digest(value, label)
|
_digest(value, label)
|
||||||
if (
|
if (
|
||||||
self.source_input.observatory_job_id != self.observatory_job_id
|
self.source_input.observatory_job_id != self.observatory_job_id
|
||||||
or self.source_input.observatory_request_sha256
|
or self.source_input.observatory_request_sha256 != self.observatory_request_sha256
|
||||||
!= self.observatory_request_sha256
|
or self.source_input.observatory_identity_sha256 != self.observatory_identity_sha256
|
||||||
or self.source_input.observatory_identity_sha256
|
|
||||||
!= self.observatory_identity_sha256
|
|
||||||
):
|
):
|
||||||
raise PortableLabV1PlanError("plan source belongs to another job")
|
raise PortableLabV1PlanError("plan source belongs to another job")
|
||||||
if self.blockers != tuple(sorted(self.blockers)) or len(self.blockers) != len(
|
if self.blockers != tuple(sorted(self.blockers)) or len(self.blockers) != len(
|
||||||
@@ -804,10 +774,7 @@ class PortableLabV1OrchestrationPlan:
|
|||||||
"result-v2-assembly",
|
"result-v2-assembly",
|
||||||
):
|
):
|
||||||
raise PortableLabV1PlanError("combined LAB V1 phase order changed")
|
raise PortableLabV1PlanError("combined LAB V1 phase order changed")
|
||||||
if (
|
if canonical_sha256(self.effective_ddrnet_config) != self.effective_ddrnet_config_sha256:
|
||||||
canonical_sha256(self.effective_ddrnet_config)
|
|
||||||
!= self.effective_ddrnet_config_sha256
|
|
||||||
):
|
|
||||||
raise PortableLabV1PlanError("effective DDRNet config digest changed")
|
raise PortableLabV1PlanError("effective DDRNet config digest changed")
|
||||||
if canonical_sha256(self.identity_document()) != self.plan_sha256:
|
if canonical_sha256(self.identity_document()) != self.plan_sha256:
|
||||||
raise PortableLabV1PlanError("orchestration plan identity changed")
|
raise PortableLabV1PlanError("orchestration plan identity changed")
|
||||||
@@ -830,8 +797,7 @@ class PortableLabV1OrchestrationPlan:
|
|||||||
raise PortableLabV1PlanError("release inspection belongs to another candidate")
|
raise PortableLabV1PlanError("release inspection belongs to another candidate")
|
||||||
expected_asset_ids = tuple(asset.asset_id for asset in release.assets)
|
expected_asset_ids = tuple(asset.asset_id for asset in release.assets)
|
||||||
if release_inspection.ready and (
|
if release_inspection.ready and (
|
||||||
release_inspection.matched_assets != expected_asset_ids
|
release_inspection.matched_assets != expected_asset_ids or release_inspection.blockers
|
||||||
or release_inspection.blockers
|
|
||||||
):
|
):
|
||||||
raise PortableLabV1PlanError(
|
raise PortableLabV1PlanError(
|
||||||
"ready release inspection does not admit every exact asset"
|
"ready release inspection does not admit every exact asset"
|
||||||
@@ -1147,13 +1113,8 @@ class PortableLabV1ResultAssembly:
|
|||||||
roles = tuple(item.role for item in self.artifacts)
|
roles = tuple(item.role for item in self.artifacts)
|
||||||
if roles != tuple(sorted(roles)) or len(roles) != len(set(roles)):
|
if roles != tuple(sorted(roles)) or len(roles) != len(set(roles)):
|
||||||
raise PortableLabV1ResultError("assembled artifacts are not canonical")
|
raise PortableLabV1ResultError("assembled artifacts are not canonical")
|
||||||
result_artifact = tuple(
|
result_artifact = tuple(item for item in self.artifacts if item.role == "result-document")
|
||||||
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:
|
||||||
)
|
|
||||||
if (
|
|
||||||
len(result_artifact) != 1
|
|
||||||
or result_artifact[0].sha256 != self.result_document_sha256
|
|
||||||
):
|
|
||||||
raise PortableLabV1ResultError("assembled result document is not bound")
|
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,
|
"session_id": plan.source_input.source_session_id,
|
||||||
"catalog_sha256": plan.source_input.source_catalog_sha256,
|
"catalog_sha256": plan.source_input.source_catalog_sha256,
|
||||||
"bundle_sha256": plan.source_input.source_bundle_sha256,
|
"bundle_sha256": plan.source_input.source_bundle_sha256,
|
||||||
"capability_manifest_sha256": (
|
"capability_manifest_sha256": (plan.source_input.source_capability_manifest_sha256),
|
||||||
plan.source_input.source_capability_manifest_sha256
|
|
||||||
),
|
|
||||||
"camera_input_sha256": plan.source_input.camera_input_sha256,
|
"camera_input_sha256": plan.source_input.camera_input_sha256,
|
||||||
"frame_count": plan.source_input.frame_count,
|
"frame_count": plan.source_input.frame_count,
|
||||||
"timeline_start_seconds": plan.source_input.timeline_start_seconds,
|
"timeline_start_seconds": plan.source_input.timeline_start_seconds,
|
||||||
@@ -1311,9 +1270,7 @@ def assemble_lab_v1_result_v2(
|
|||||||
"definition_id": definition.definition_id,
|
"definition_id": definition.definition_id,
|
||||||
"version": definition.version,
|
"version": definition.version,
|
||||||
"definition_sha256": definition.definition_sha256,
|
"definition_sha256": definition.definition_sha256,
|
||||||
"result_contract_sha256": (
|
"result_contract_sha256": (definition.result_contract.contract_sha256),
|
||||||
definition.result_contract.contract_sha256
|
|
||||||
),
|
|
||||||
"release_candidate_sha256": plan.release_candidate_sha256,
|
"release_candidate_sha256": plan.release_candidate_sha256,
|
||||||
"plan_sha256": plan.plan_sha256,
|
"plan_sha256": plan.plan_sha256,
|
||||||
},
|
},
|
||||||
@@ -1343,9 +1300,7 @@ def assemble_lab_v1_result_v2(
|
|||||||
)
|
)
|
||||||
final = parent / result_id
|
final = parent / result_id
|
||||||
if final.exists():
|
if final.exists():
|
||||||
raise PortableLabV1ResultError(
|
raise PortableLabV1ResultError("an assembled result with this identity already exists")
|
||||||
"an assembled result with this identity already exists"
|
|
||||||
)
|
|
||||||
_fsync_tree(staging)
|
_fsync_tree(staging)
|
||||||
os.replace(staging, final)
|
os.replace(staging, final)
|
||||||
_fsync_directory(parent)
|
_fsync_directory(parent)
|
||||||
@@ -1429,8 +1384,7 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
|
|||||||
definition.setup_id != _EXPECTED_SETUP_ID
|
definition.setup_id != _EXPECTED_SETUP_ID
|
||||||
or definition.definition_id != _EXPECTED_DEFINITION_ID
|
or definition.definition_id != _EXPECTED_DEFINITION_ID
|
||||||
or definition.result_contract.result_schema != PORTABLE_LAB_V1_RESULT_SCHEMA
|
or definition.result_contract.result_schema != PORTABLE_LAB_V1_RESULT_SCHEMA
|
||||||
or definition.result_contract.contract_sha256
|
or definition.result_contract.contract_sha256 != _EXPECTED_RESULT_CONTRACT_SHA256
|
||||||
!= _EXPECTED_RESULT_CONTRACT_SHA256
|
|
||||||
):
|
):
|
||||||
raise PortableLabV1ResultError("LAB V1 validator received another definition")
|
raise PortableLabV1ResultError("LAB V1 validator received another definition")
|
||||||
document = dict(context.result_document)
|
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")
|
identity = _object(document["identity"], "portable LAB V1 identity")
|
||||||
if (
|
if (
|
||||||
canonical_sha256(identity) != document["identity_sha256"]
|
canonical_sha256(identity) != document["identity_sha256"]
|
||||||
or document["result_id"]
|
or document["result_id"] != f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
|
||||||
!= f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
|
|
||||||
):
|
):
|
||||||
raise PortableLabV1ResultError("portable LAB V1 result identity changed")
|
raise PortableLabV1ResultError("portable LAB V1 result identity changed")
|
||||||
source = _object(document["source"], "portable LAB V1 source")
|
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
|
source.get("session_id") != context.job.source_session_id
|
||||||
or source.get("catalog_sha256") != context.job.source_catalog_sha256
|
or source.get("catalog_sha256") != context.job.source_catalog_sha256
|
||||||
or source.get("bundle_sha256") != context.job.source_bundle_sha256
|
or source.get("bundle_sha256") != context.job.source_bundle_sha256
|
||||||
or source.get("capability_manifest_sha256")
|
or source.get("capability_manifest_sha256") != context.job.source_capability_manifest_sha256
|
||||||
!= context.job.source_capability_manifest_sha256
|
|
||||||
or run_definition.get("setup_id") != definition.setup_id
|
or run_definition.get("setup_id") != definition.setup_id
|
||||||
or run_definition.get("definition_id") != definition.definition_id
|
or run_definition.get("definition_id") != definition.definition_id
|
||||||
or run_definition.get("version") != definition.version
|
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")
|
raise PortableLabV1ResultError("portable LAB V1 artifacts are not an array")
|
||||||
declared = tuple(_artifact_from_document(row) for row in artifact_rows)
|
declared = tuple(_artifact_from_document(row) for row in artifact_rows)
|
||||||
declared_roles = tuple(artifact.role for artifact in declared)
|
declared_roles = tuple(artifact.role for artifact in declared)
|
||||||
if (
|
if declared_roles != tuple(sorted(declared_roles)) or len(declared_roles) != len(
|
||||||
declared_roles != tuple(sorted(declared_roles))
|
set(declared_roles)
|
||||||
or len(declared_roles) != len(set(declared_roles))
|
|
||||||
):
|
):
|
||||||
raise PortableLabV1ResultError(
|
raise PortableLabV1ResultError("portable result artifacts are not canonical")
|
||||||
"portable result artifacts are not canonical"
|
|
||||||
)
|
|
||||||
declared_by_role = {artifact.role: artifact for artifact in declared}
|
declared_by_role = {artifact.role: artifact for artifact in declared}
|
||||||
package_by_role = {artifact.role: artifact for artifact in context.manifest.artifacts}
|
package_by_role = {artifact.role: artifact for artifact in context.manifest.artifacts}
|
||||||
if set(package_by_role) != {*declared_by_role, "result-document"}:
|
if set(package_by_role) != {*declared_by_role, "result-document"}:
|
||||||
@@ -1540,15 +1489,13 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
|
|||||||
if (
|
if (
|
||||||
source_input.identity_sha256 != identity.get("source_input_sha256")
|
source_input.identity_sha256 != identity.get("source_input_sha256")
|
||||||
or plan.plan_sha256 != run_definition.get("plan_sha256")
|
or plan.plan_sha256 != run_definition.get("plan_sha256")
|
||||||
or plan.release_candidate_sha256
|
or plan.release_candidate_sha256 != run_definition.get("release_candidate_sha256")
|
||||||
!= run_definition.get("release_candidate_sha256")
|
|
||||||
or plan.observatory_job_id != context.job.job_id
|
or plan.observatory_job_id != context.job.job_id
|
||||||
or plan.observatory_request_sha256 != context.job.request_sha256
|
or plan.observatory_request_sha256 != context.job.request_sha256
|
||||||
or plan.observatory_identity_sha256 != context.job.identity_sha256
|
or plan.observatory_identity_sha256 != context.job.identity_sha256
|
||||||
or source.get("camera_input_sha256") != source_input.camera_input_sha256
|
or source.get("camera_input_sha256") != source_input.camera_input_sha256
|
||||||
or source.get("frame_count") != source_input.frame_count
|
or source.get("frame_count") != source_input.frame_count
|
||||||
or source.get("timeline_start_seconds")
|
or source.get("timeline_start_seconds") != source_input.timeline_start_seconds
|
||||||
!= source_input.timeline_start_seconds
|
|
||||||
or source.get("timeline_end_seconds") != source_input.timeline_end_seconds
|
or source.get("timeline_end_seconds") != source_input.timeline_end_seconds
|
||||||
):
|
):
|
||||||
raise PortableLabV1ResultError("portable source or plan artifact changed")
|
raise PortableLabV1ResultError("portable source or plan artifact changed")
|
||||||
@@ -1621,9 +1568,7 @@ def portable_lab_v1_source_input_from_document(
|
|||||||
"portable camera compute job",
|
"portable camera compute job",
|
||||||
)
|
)
|
||||||
return PortableLabV1SourceInput(
|
return PortableLabV1SourceInput(
|
||||||
observatory_job_id=_string(
|
observatory_job_id=_string(observatory_job["job_id"], "portable source Observatory job id"),
|
||||||
observatory_job["job_id"], "portable source Observatory job id"
|
|
||||||
),
|
|
||||||
observatory_request_sha256=_string(
|
observatory_request_sha256=_string(
|
||||||
observatory_job["request_sha256"],
|
observatory_job["request_sha256"],
|
||||||
"portable source Observatory request sha256",
|
"portable source Observatory request sha256",
|
||||||
@@ -1633,23 +1578,15 @@ def portable_lab_v1_source_input_from_document(
|
|||||||
"portable source Observatory identity sha256",
|
"portable source Observatory identity sha256",
|
||||||
),
|
),
|
||||||
source_session_id=_string(source["session_id"], "portable source session id"),
|
source_session_id=_string(source["session_id"], "portable source session id"),
|
||||||
source_catalog_sha256=_string(
|
source_catalog_sha256=_string(source["catalog_sha256"], "portable source catalog sha256"),
|
||||||
source["catalog_sha256"], "portable source catalog sha256"
|
source_bundle_sha256=_string(source["bundle_sha256"], "portable source bundle sha256"),
|
||||||
),
|
|
||||||
source_bundle_sha256=_string(
|
|
||||||
source["bundle_sha256"], "portable source bundle sha256"
|
|
||||||
),
|
|
||||||
source_capability_manifest_sha256=_string(
|
source_capability_manifest_sha256=_string(
|
||||||
source["capability_manifest_sha256"],
|
source["capability_manifest_sha256"],
|
||||||
"portable source capability sha256",
|
"portable source capability sha256",
|
||||||
),
|
),
|
||||||
source_adapter_sha256=_string(
|
source_adapter_sha256=_string(source["adapter_sha256"], "portable source adapter sha256"),
|
||||||
source["adapter_sha256"], "portable source adapter sha256"
|
|
||||||
),
|
|
||||||
camera_job_id=_string(camera["job_id"], "portable camera job id"),
|
camera_job_id=_string(camera["job_id"], "portable camera job id"),
|
||||||
camera_input_sha256=_string(
|
camera_input_sha256=_string(camera["input_sha256"], "portable camera input sha256"),
|
||||||
camera["input_sha256"], "portable camera input sha256"
|
|
||||||
),
|
|
||||||
camera_source_id=_string(camera["source_id"], "portable camera source id"),
|
camera_source_id=_string(camera["source_id"], "portable camera source id"),
|
||||||
codec_epoch=_positive_int(camera["codec_epoch"], "portable codec epoch"),
|
codec_epoch=_positive_int(camera["codec_epoch"], "portable codec epoch"),
|
||||||
input_byte_length=_positive_int(
|
input_byte_length=_positive_int(
|
||||||
@@ -1659,15 +1596,11 @@ def portable_lab_v1_source_input_from_document(
|
|||||||
timeline_start_seconds=_finite_float(
|
timeline_start_seconds=_finite_float(
|
||||||
camera["timeline_start_seconds"], "portable timeline start"
|
camera["timeline_start_seconds"], "portable timeline start"
|
||||||
),
|
),
|
||||||
timeline_end_seconds=_finite_float(
|
timeline_end_seconds=_finite_float(camera["timeline_end_seconds"], "portable timeline end"),
|
||||||
camera["timeline_end_seconds"], "portable timeline end"
|
|
||||||
),
|
|
||||||
camera_generation_sha256=_string(
|
camera_generation_sha256=_string(
|
||||||
camera["generation_sha256"], "portable camera generation sha256"
|
camera["generation_sha256"], "portable camera generation sha256"
|
||||||
),
|
),
|
||||||
calibration_sha256=_string(
|
calibration_sha256=_string(camera["calibration_sha256"], "portable calibration sha256"),
|
||||||
camera["calibration_sha256"], "portable calibration sha256"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1758,9 +1691,7 @@ def portable_lab_v1_orchestration_plan_from_document(
|
|||||||
raise PortableLabV1ResultError("portable plan admission changed")
|
raise PortableLabV1ResultError("portable plan admission changed")
|
||||||
phases = tuple(_plan_phase_from_document(value) for value in phases_value)
|
phases = tuple(_plan_phase_from_document(value) for value in phases_value)
|
||||||
plan = PortableLabV1OrchestrationPlan(
|
plan = PortableLabV1OrchestrationPlan(
|
||||||
observatory_job_id=_string(
|
observatory_job_id=_string(observatory_job["job_id"], "portable plan Observatory job id"),
|
||||||
observatory_job["job_id"], "portable plan Observatory job id"
|
|
||||||
),
|
|
||||||
observatory_request_sha256=_string(
|
observatory_request_sha256=_string(
|
||||||
observatory_job["request_sha256"],
|
observatory_job["request_sha256"],
|
||||||
"portable plan Observatory request sha256",
|
"portable plan Observatory request sha256",
|
||||||
@@ -1770,9 +1701,7 @@ def portable_lab_v1_orchestration_plan_from_document(
|
|||||||
"portable plan Observatory identity sha256",
|
"portable plan Observatory identity sha256",
|
||||||
),
|
),
|
||||||
setup_id=_string(run_definition["setup_id"], "portable plan setup id"),
|
setup_id=_string(run_definition["setup_id"], "portable plan setup id"),
|
||||||
definition_id=_string(
|
definition_id=_string(run_definition["definition_id"], "portable plan definition id"),
|
||||||
run_definition["definition_id"], "portable plan definition id"
|
|
||||||
),
|
|
||||||
definition_version=_positive_int(
|
definition_version=_positive_int(
|
||||||
run_definition["version"], "portable plan definition version"
|
run_definition["version"], "portable plan definition version"
|
||||||
),
|
),
|
||||||
@@ -1817,8 +1746,7 @@ def _plan_phase_from_document(value: object) -> PortableLabV1PlanPhase:
|
|||||||
input_values = row["input_roles"]
|
input_values = row["input_roles"]
|
||||||
output_values = row["output_roles"]
|
output_values = row["output_roles"]
|
||||||
if not all(
|
if not all(
|
||||||
isinstance(values, list)
|
isinstance(values, list) for values in (component_values, input_values, output_values)
|
||||||
for values in (component_values, input_values, output_values)
|
|
||||||
):
|
):
|
||||||
raise PortableLabV1ResultError("portable plan phase arrays changed")
|
raise PortableLabV1ResultError("portable plan phase arrays changed")
|
||||||
return PortableLabV1PlanPhase(
|
return PortableLabV1PlanPhase(
|
||||||
@@ -1828,12 +1756,10 @@ def _plan_phase_from_document(value: object) -> PortableLabV1PlanPhase:
|
|||||||
for item in cast(list[object], component_values)
|
for item in cast(list[object], component_values)
|
||||||
),
|
),
|
||||||
input_roles=tuple(
|
input_roles=tuple(
|
||||||
_string(item, "portable plan input role")
|
_string(item, "portable plan input role") for item in cast(list[object], input_values)
|
||||||
for item in cast(list[object], input_values)
|
|
||||||
),
|
),
|
||||||
output_roles=tuple(
|
output_roles=tuple(
|
||||||
_string(item, "portable plan output role")
|
_string(item, "portable plan output role") for item in cast(list[object], output_values)
|
||||||
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
|
source_bundle.get("source_session_id") != job.source_session_id
|
||||||
or capability.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 camera_job.session_id != job.source_session_id
|
||||||
or source_bundle.get("source_catalog_sha256")
|
or source_bundle.get("source_catalog_sha256") != job.source_catalog_sha256
|
||||||
!= job.source_catalog_sha256
|
|
||||||
or capability.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_bundle_sha256") != job.source_bundle_sha256
|
||||||
or capability.get("source_adapter_sha256") != job.source_adapter_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")
|
raise PortableLabV1SourceError("source camera segment count changed")
|
||||||
if (
|
if (
|
||||||
video.get("source_id") != requirements.camera_source_id
|
video.get("source_id") != requirements.camera_source_id
|
||||||
or video.get("semantic_channel_id")
|
or video.get("semantic_channel_id") != requirements.camera_semantic_channel_id
|
||||||
!= requirements.camera_semantic_channel_id
|
|
||||||
or video.get("seekable") is not True
|
or video.get("seekable") is not True
|
||||||
or camera_job.source_id != camera.get("public_source_id")
|
or camera_job.source_id != camera.get("public_source_id")
|
||||||
or camera_job.codec_epoch != epoch.get("ordinal")
|
or camera_job.codec_epoch != epoch.get("ordinal")
|
||||||
@@ -1934,18 +1858,19 @@ def _validate_source_documents(
|
|||||||
if not isinstance(files, list):
|
if not isinstance(files, list):
|
||||||
raise PortableLabV1SourceError("camera compute job file set is invalid")
|
raise PortableLabV1SourceError("camera compute job file set is invalid")
|
||||||
file_rows = {
|
file_rows = {
|
||||||
PurePosixPath(_string(_object(row, "camera file").get("path"), "camera file path")).name:
|
PurePosixPath(
|
||||||
_object(row, "camera file")
|
_string(_object(row, "camera file").get("path"), "camera file path")
|
||||||
|
).name: _object(row, "camera file")
|
||||||
for row in files
|
for row in files
|
||||||
if PurePosixPath(
|
if PurePosixPath(
|
||||||
_string(_object(row, "camera file").get("path"), "camera file path")
|
_string(_object(row, "camera file").get("path"), "camera file path")
|
||||||
).parent.name
|
).parent.name
|
||||||
in (f"epoch-{camera_job.codec_epoch}", "segments")
|
in (f"epoch-{camera_job.codec_epoch}", "segments")
|
||||||
}
|
}
|
||||||
if (
|
if init.get("sha256") != _object(file_rows.get("init.mp4"), "camera init file").get(
|
||||||
init.get("sha256") != _object(file_rows.get("init.mp4"), "camera init file").get("sha256")
|
"sha256"
|
||||||
or init.get("byte_length")
|
) or init.get("byte_length") != _object(file_rows.get("init.mp4"), "camera init file").get(
|
||||||
!= _object(file_rows.get("init.mp4"), "camera init file").get("byte_length")
|
"byte_length"
|
||||||
):
|
):
|
||||||
raise PortableLabV1SourceError("camera init differs from the admitted source")
|
raise PortableLabV1SourceError("camera init differs from the admitted source")
|
||||||
for index, row in enumerate(segments, start=1):
|
for index, row in enumerate(segments, start=1):
|
||||||
@@ -1964,11 +1889,7 @@ def _verify_definition_and_job(
|
|||||||
job: SealedObservatoryRecordedJob,
|
job: SealedObservatoryRecordedJob,
|
||||||
) -> None:
|
) -> None:
|
||||||
if (
|
if (
|
||||||
definition.setup_id != _EXPECTED_SETUP_ID
|
job.setup_id != definition.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
|
|
||||||
or job.definition_id != definition.definition_id
|
or job.definition_id != definition.definition_id
|
||||||
or job.definition_version != definition.version
|
or job.definition_version != definition.version
|
||||||
or job.definition_sha256 != definition.definition_sha256
|
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.source_adapter_sha256 != definition.source_adapter.contract_sha256
|
||||||
or job.model_release_ids != definition.learned_models
|
or job.model_release_ids != definition.learned_models
|
||||||
or job.resource_profile_id != definition.resource_profile.profile_id
|
or job.resource_profile_id != definition.resource_profile.profile_id
|
||||||
or job.executor_identity.model_manifest_sha256
|
or job.executor_identity.model_manifest_sha256 != definition.model_manifest_sha256
|
||||||
!= definition.model_manifest_sha256
|
|
||||||
or job.executor_identity.resource_profile_sha256
|
or job.executor_identity.resource_profile_sha256
|
||||||
!= definition.resource_profile.profile_sha256
|
!= definition.resource_profile.profile_sha256
|
||||||
or job.checkpoint_policy != definition.resource_profile.checkpoint_policy
|
or job.checkpoint_policy != definition.resource_profile.checkpoint_policy
|
||||||
or job.allowed_checkpoints != definition.resource_profile.allowed_checkpoints
|
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(
|
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_session_id != job.source_session_id
|
||||||
or source.source_catalog_sha256 != job.source_catalog_sha256
|
or source.source_catalog_sha256 != job.source_catalog_sha256
|
||||||
or source.source_bundle_sha256 != job.source_bundle_sha256
|
or source.source_bundle_sha256 != job.source_bundle_sha256
|
||||||
or source.source_capability_manifest_sha256
|
or source.source_capability_manifest_sha256 != job.source_capability_manifest_sha256
|
||||||
!= job.source_capability_manifest_sha256
|
|
||||||
or source.source_adapter_sha256 != job.source_adapter_sha256
|
or source.source_adapter_sha256 != job.source_adapter_sha256
|
||||||
or source.calibration_sha256 != requirements.calibration_identity_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_id != definition.definition_id
|
||||||
or plan.definition_version != definition.version
|
or plan.definition_version != definition.version
|
||||||
or plan.definition_sha256 != definition.definition_sha256
|
or plan.definition_sha256 != definition.definition_sha256
|
||||||
or plan.result_contract_sha256
|
or plan.result_contract_sha256 != definition.result_contract.contract_sha256
|
||||||
!= definition.result_contract.contract_sha256
|
|
||||||
):
|
):
|
||||||
raise PortableLabV1PlanError("orchestration plan belongs to another definition")
|
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("source_id") != plan.source_input.camera_source_id
|
||||||
or document.get("codec_epoch") != plan.source_input.codec_epoch
|
or document.get("codec_epoch") != plan.source_input.codec_epoch
|
||||||
or document.get("timestamp_basis") != "session-time-seconds"
|
or document.get("timestamp_basis") != "session-time-seconds"
|
||||||
or document.get("timeline_start_seconds")
|
or document.get("timeline_start_seconds") != plan.source_input.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_end_seconds") != plan.source_input.timeline_end_seconds
|
||||||
or document.get("frames_processed") != plan.source_input.frame_count
|
or document.get("frames_processed") != plan.source_input.frame_count
|
||||||
or document.get("ground_truth") is not False
|
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("center_crop_xyxy") != [100, 0, 700, 600]
|
||||||
or semantics.get("outside_crop_state") != "undefined"
|
or semantics.get("outside_crop_state") != "undefined"
|
||||||
or semantics.get("base_m4_result_id") is not None
|
or semantics.get("base_m4_result_id") is not None
|
||||||
or provenance.get("config_sha256")
|
or provenance.get("config_sha256") != plan.effective_ddrnet_config_sha256
|
||||||
!= plan.effective_ddrnet_config_sha256
|
or provenance.get("policy_sha256") != components["vegetation-mission-policy-v1"].sha256
|
||||||
or provenance.get("policy_sha256")
|
|
||||||
!= components["vegetation-mission-policy-v1"].sha256
|
|
||||||
or provenance.get("provider_map_sha256")
|
or provenance.get("provider_map_sha256")
|
||||||
!= components["vegetation-provider-label-map-v1"].sha256
|
!= components["vegetation-provider-label-map-v1"].sha256
|
||||||
or authority
|
or authority
|
||||||
@@ -2290,8 +2206,7 @@ def _validate_assembly(
|
|||||||
if (
|
if (
|
||||||
canonical_sha256(identity) != document["identity_sha256"]
|
canonical_sha256(identity) != document["identity_sha256"]
|
||||||
or document["result_id"] != assembly.result_id
|
or document["result_id"] != assembly.result_id
|
||||||
or document["result_id"]
|
or document["result_id"] != f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
|
||||||
!= f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
|
|
||||||
):
|
):
|
||||||
raise PortableLabV1ResultError("assembled result identity changed")
|
raise PortableLabV1ResultError("assembled result identity changed")
|
||||||
for artifact in assembly.artifacts:
|
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("source_id") != plan.source_input.camera_source_id
|
||||||
or document.get("codec_epoch") != plan.source_input.codec_epoch
|
or document.get("codec_epoch") != plan.source_input.codec_epoch
|
||||||
or document.get("timestamp_basis") != "session-time-seconds"
|
or document.get("timestamp_basis") != "session-time-seconds"
|
||||||
or document.get("timeline_start_seconds")
|
or document.get("timeline_start_seconds") != plan.source_input.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_end_seconds")
|
|
||||||
!= plan.source_input.timeline_end_seconds
|
|
||||||
or document.get("frames_processed") != plan.source_input.frame_count
|
or document.get("frames_processed") != plan.source_input.frame_count
|
||||||
or document.get("ground_truth") is not False
|
or document.get("ground_truth") is not False
|
||||||
or semantic.get("id") != model.model_id
|
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):
|
if not isinstance(rows, list) or len(rows) != len(expected_kinds):
|
||||||
raise PortableLabV1ResultError("published EoMT artifact set changed")
|
raise PortableLabV1ResultError("published EoMT artifact set changed")
|
||||||
package_by_role = {
|
package_by_role = {artifact.role: artifact for artifact in context.manifest.artifacts}
|
||||||
artifact.role: artifact for artifact in context.manifest.artifacts
|
|
||||||
}
|
|
||||||
observed_kinds: set[str] = set()
|
observed_kinds: set[str] = set()
|
||||||
for value in rows:
|
for value in rows:
|
||||||
row = _object(value, "published EoMT artifact")
|
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:
|
if kind not in expected_kinds or kind in observed_kinds:
|
||||||
raise PortableLabV1ResultError("published EoMT artifact roles changed")
|
raise PortableLabV1ResultError("published EoMT artifact roles changed")
|
||||||
observed_kinds.add(kind)
|
observed_kinds.add(kind)
|
||||||
relative = _safe_relative_path(
|
relative = _safe_relative_path(_string(row.get("path"), "published EoMT artifact path"))
|
||||||
_string(row.get("path"), "published EoMT artifact path")
|
|
||||||
)
|
|
||||||
if len(relative.parts) != 1:
|
if len(relative.parts) != 1:
|
||||||
raise PortableLabV1ResultError(
|
raise PortableLabV1ResultError("published EoMT artifact path changed")
|
||||||
"published EoMT artifact path changed"
|
|
||||||
)
|
|
||||||
packaged = package_by_role.get(f"eomt-{kind}")
|
packaged = package_by_role.get(f"eomt-{kind}")
|
||||||
if (
|
if (
|
||||||
packaged is None
|
packaged is None
|
||||||
@@ -2496,9 +2403,7 @@ def _validate_published_eomt_component(
|
|||||||
or row.get("byte_length") != packaged.byte_length
|
or row.get("byte_length") != packaged.byte_length
|
||||||
or row.get("sha256") != packaged.sha256
|
or row.get("sha256") != packaged.sha256
|
||||||
):
|
):
|
||||||
raise PortableLabV1ResultError(
|
raise PortableLabV1ResultError("published EoMT artifact identity changed")
|
||||||
"published EoMT artifact identity changed"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_published_ddrnet_component(
|
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("center_crop_xyxy") != [100, 0, 700, 600]
|
||||||
or semantics.get("outside_crop_state") != "undefined"
|
or semantics.get("outside_crop_state") != "undefined"
|
||||||
or semantics.get("base_m4_result_id") is not None
|
or semantics.get("base_m4_result_id") is not None
|
||||||
or provenance.get("config_sha256")
|
or provenance.get("config_sha256") != plan.effective_ddrnet_config_sha256
|
||||||
!= plan.effective_ddrnet_config_sha256
|
or provenance.get("policy_sha256") != components["vegetation-mission-policy-v1"].sha256
|
||||||
or provenance.get("policy_sha256")
|
|
||||||
!= components["vegetation-mission-policy-v1"].sha256
|
|
||||||
or provenance.get("provider_map_sha256")
|
or provenance.get("provider_map_sha256")
|
||||||
!= components["vegetation-provider-label-map-v1"].sha256
|
!= components["vegetation-provider-label-map-v1"].sha256
|
||||||
or package_archive is None
|
or package_archive is None
|
||||||
@@ -2595,6 +2498,8 @@ def _validate_published_ddrnet_component(
|
|||||||
"lab-v1-ravnoves-video-ddrnet-" + canonical_sha256(identity_value)
|
"lab-v1-ravnoves-video-ddrnet-" + canonical_sha256(identity_value)
|
||||||
):
|
):
|
||||||
raise PortableLabV1ResultError("published DDRNet result identity changed")
|
raise PortableLabV1ResultError("published DDRNet result identity changed")
|
||||||
|
|
||||||
|
|
||||||
def _model(definition: PortableRunDefinition, release_id: str): # type: ignore[no-untyped-def]
|
def _model(definition: PortableRunDefinition, release_id: str): # type: ignore[no-untyped-def]
|
||||||
for model in definition.models:
|
for model in definition.models:
|
||||||
if model.release_id == release_id:
|
if model.release_id == release_id:
|
||||||
@@ -2644,9 +2549,8 @@ def _release_binding_matches(
|
|||||||
if path.is_symlink() or not path.is_file():
|
if path.is_symlink() or not path.is_file():
|
||||||
return False
|
return False
|
||||||
return (
|
return (
|
||||||
(asset.byte_length is None or path.stat().st_size == asset.byte_length)
|
asset.byte_length is None or path.stat().st_size == asset.byte_length
|
||||||
and _sha256_file(path) == asset.sha256
|
) and _sha256_file(path) == asset.sha256
|
||||||
)
|
|
||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -94,9 +94,7 @@ _EXPECTED_RESULT_CONTRACT_SHA256: Final = (
|
|||||||
"b3dfaa8e20a0f22fc510d062ac469f010a3281c650059d9ea134f0b3ccb38d9a"
|
"b3dfaa8e20a0f22fc510d062ac469f010a3281c650059d9ea134f0b3ccb38d9a"
|
||||||
)
|
)
|
||||||
_PORTABLE_CONFIG_ASSET_ID: Final = "ddrnet-portable-config"
|
_PORTABLE_CONFIG_ASSET_ID: Final = "ddrnet-portable-config"
|
||||||
_WORKER_INSTALLATION_RECEIPT_ASSET_ID: Final = (
|
_WORKER_INSTALLATION_RECEIPT_ASSET_ID: Final = "lab-v1-worker-installation-receipt"
|
||||||
"lab-v1-worker-installation-receipt"
|
|
||||||
)
|
|
||||||
_MAX_SOURCE_DOCUMENT_BYTES: Final = 8 * 1024 * 1024
|
_MAX_SOURCE_DOCUMENT_BYTES: Final = 8 * 1024 * 1024
|
||||||
_MAX_MATERIALIZATION_MANIFEST_BYTES: Final = 64 * 1024 * 1024
|
_MAX_MATERIALIZATION_MANIFEST_BYTES: Final = 64 * 1024 * 1024
|
||||||
_MAX_SOURCE_MEMBERS: Final = 100_000
|
_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."""
|
"""Build a deterministic camera job from only manifested Worker members."""
|
||||||
|
|
||||||
_verify_definition(definition)
|
_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 (
|
if (
|
||||||
worker_stage.source_bundle_sha256 != job.source_bundle_sha256
|
worker_stage.source_bundle_sha256 != job.source_bundle_sha256
|
||||||
or worker_stage.source_capability_manifest_sha256 != job.source_capability_manifest_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_id != release.definition_id
|
||||||
or candidate.definition_version != release.definition_version
|
or candidate.definition_version != release.definition_version
|
||||||
or candidate.definition_sha256 != definition.definition_sha256
|
or candidate.definition_sha256 != definition.definition_sha256
|
||||||
or release.definition_contract_sha256
|
or release.definition_contract_sha256 != definition.executable_contract_sha256
|
||||||
!= definition.executable_contract_sha256
|
|
||||||
or candidate.result_contract_sha256 != release.result_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 tuple(phase.phase_id for phase in candidate.phases) != PORTABLE_LAB_V1_RUNTIME_PHASES
|
||||||
or executor is None
|
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))
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Iterator
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
@@ -14,6 +14,7 @@ from .portable_artifact_transport import (
|
|||||||
from .portable_result_contract import PortableResultPublisherError
|
from .portable_result_contract import PortableResultPublisherError
|
||||||
from .portable_result_publisher import PortableObservatoryResultPublisher
|
from .portable_result_publisher import PortableObservatoryResultPublisher
|
||||||
from .recorded_jobs import (
|
from .recorded_jobs import (
|
||||||
|
ObservatoryRecordedJob,
|
||||||
ObservatoryRecordedJobQueue,
|
ObservatoryRecordedJobQueue,
|
||||||
ObservatoryRecordedQueueError,
|
ObservatoryRecordedQueueError,
|
||||||
)
|
)
|
||||||
@@ -55,20 +56,19 @@ class PortablePublicationReconciler:
|
|||||||
now = self.clock()
|
now = self.clock()
|
||||||
if now.tzinfo is None:
|
if now.tzinfo is None:
|
||||||
raise ValueError("publication reconciliation clock must be timezone-aware")
|
raise ValueError("publication reconciliation clock must be timezone-aware")
|
||||||
candidates = self.queue.pending_publications()[:limit]
|
examined = 0
|
||||||
published = 0
|
published = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
deferred = 0
|
deferred = 0
|
||||||
exhausted = 0
|
exhausted = 0
|
||||||
for job in candidates:
|
for job in self._candidates():
|
||||||
|
examined += 1
|
||||||
attempts = job.publication_attempts
|
attempts = job.publication_attempts
|
||||||
if attempts >= self.maximum_attempts:
|
if attempts >= self.maximum_attempts:
|
||||||
exhausted += 1
|
exhausted += 1
|
||||||
continue
|
continue
|
||||||
updated_at = _timestamp(job.updated_at_utc)
|
updated_at = _timestamp(job.updated_at_utc)
|
||||||
retry_at = updated_at + timedelta(
|
retry_at = updated_at + timedelta(seconds=self.retry_delays_seconds[attempts])
|
||||||
seconds=self.retry_delays_seconds[attempts]
|
|
||||||
)
|
|
||||||
if now < retry_at:
|
if now < retry_at:
|
||||||
deferred += 1
|
deferred += 1
|
||||||
continue
|
continue
|
||||||
@@ -82,24 +82,37 @@ class PortablePublicationReconciler:
|
|||||||
with suppress(ObservatoryRecordedQueueError):
|
with suppress(ObservatoryRecordedQueueError):
|
||||||
self.queue.mark_publication_failed(job.job_id, message=message)
|
self.queue.mark_publication_failed(job.job_id, message=message)
|
||||||
failed += 1
|
failed += 1
|
||||||
|
# Bound actual publication work, not the first rows of the outbox.
|
||||||
|
# Exhausted/backoff entries remain evidence but cannot indefinitely
|
||||||
|
# hide later ready results behind the same prefix on every tick.
|
||||||
|
if published + failed >= limit:
|
||||||
|
break
|
||||||
return PortablePublicationReconciliation(
|
return PortablePublicationReconciliation(
|
||||||
examined=len(candidates),
|
examined=examined,
|
||||||
published=published,
|
published=published,
|
||||||
failed=failed,
|
failed=failed,
|
||||||
deferred=deferred,
|
deferred=deferred,
|
||||||
exhausted=exhausted,
|
exhausted=exhausted,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _candidates(self) -> Iterator[ObservatoryRecordedJob]:
|
||||||
|
after: tuple[str, str] | None = None
|
||||||
|
while True:
|
||||||
|
page = self.queue.pending_publications(limit=32, after=after)
|
||||||
|
if not page:
|
||||||
|
return
|
||||||
|
yield from page
|
||||||
|
last = page[-1]
|
||||||
|
after = (last.created_at_utc, last.job_id)
|
||||||
|
if len(page) < 32:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _timestamp(value: str) -> datetime:
|
def _timestamp(value: str) -> datetime:
|
||||||
try:
|
try:
|
||||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ObservatoryRecordedQueueError(
|
raise ObservatoryRecordedQueueError("recorded publication timestamp is invalid") from exc
|
||||||
"recorded publication timestamp is invalid"
|
|
||||||
) from exc
|
|
||||||
if parsed.tzinfo is None:
|
if parsed.tzinfo is None:
|
||||||
raise ObservatoryRecordedQueueError(
|
raise ObservatoryRecordedQueueError("recorded publication timestamp has no timezone")
|
||||||
"recorded publication timestamp has no timezone"
|
|
||||||
)
|
|
||||||
return parsed
|
return parsed
|
||||||
|
|||||||
@@ -160,6 +160,19 @@ class PortableRecordedQueueBindingService:
|
|||||||
source_service = self._source_service(portable)
|
source_service = self._source_service(portable)
|
||||||
return self._bind(recorded, source_service.check(source_session_id))
|
return self._bind(recorded, source_service.check(source_session_id))
|
||||||
|
|
||||||
|
def prepare_check(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
source_session_id: str,
|
||||||
|
setup_id: str,
|
||||||
|
definition_sha256: str,
|
||||||
|
) -> PortableRecordedRunPreparation:
|
||||||
|
"""Prepare missing camera metadata, then seal the usual non-persistent check."""
|
||||||
|
|
||||||
|
portable, recorded = self._resolve_definition(setup_id, definition_sha256)
|
||||||
|
source_service = self._source_service(portable)
|
||||||
|
return self._bind(recorded, source_service.prepare_check(source_session_id))
|
||||||
|
|
||||||
def admit(
|
def admit(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -25,7 +25,18 @@ from k1link.laboratory.canonical_rerun_overlay import (
|
|||||||
canonical_lab_replay,
|
canonical_lab_replay,
|
||||||
canonical_recording_id,
|
canonical_recording_id,
|
||||||
)
|
)
|
||||||
|
from k1link.observatory.portable_object_replay import (
|
||||||
|
RENDERER_VERSION as OBJECT_RENDERER_VERSION,
|
||||||
|
)
|
||||||
|
from k1link.observatory.portable_object_replay import load_object_data, log_objects
|
||||||
from k1link.observatory.portable_result_view import PortableResultViewService
|
from k1link.observatory.portable_result_view import PortableResultViewService
|
||||||
|
from k1link.observatory.portable_semantic_replay import (
|
||||||
|
RENDERER_VERSION as SEMANTIC_RENDERER_VERSION,
|
||||||
|
)
|
||||||
|
from k1link.observatory.portable_semantic_replay import (
|
||||||
|
load_semantic_data,
|
||||||
|
log_semantics,
|
||||||
|
)
|
||||||
from k1link.observatory.portable_tgs_replay import (
|
from k1link.observatory.portable_tgs_replay import (
|
||||||
PortableReplayError,
|
PortableReplayError,
|
||||||
load_tgs_data,
|
load_tgs_data,
|
||||||
@@ -39,7 +50,11 @@ from k1link.sessions.media import RecordedMediaEpoch, RecordedMediaInspector
|
|||||||
|
|
||||||
RENDERER_VERSION = "portable-tgs-camera-rerun-0.36.3-v1"
|
RENDERER_VERSION = "portable-tgs-camera-rerun-0.36.3-v1"
|
||||||
_SHA = re.compile(r"^[a-f0-9]{64}$")
|
_SHA = re.compile(r"^[a-f0-9]{64}$")
|
||||||
_RESULT = re.compile(r"^m49-tgs-portable-review-[a-f0-9]{64}$")
|
_RESULT = re.compile(
|
||||||
|
r"^(?:m49-tgs-portable-review|lab-v1-eomt-ddrnet|"
|
||||||
|
r"ai-layer-(?:ddrnet|eomt|rf-detr|object-distance))-[a-f0-9]{64}$"
|
||||||
|
)
|
||||||
|
_COMPOSITION = re.compile(r"^ai-composition-[a-f0-9]{64}$")
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +80,16 @@ class PortableReplayService:
|
|||||||
if _RESULT.fullmatch(result_id) is None or _SHA.fullmatch(base_sha) is None:
|
if _RESULT.fullmatch(result_id) is None or _SHA.fullmatch(base_sha) is None:
|
||||||
raise PortableReplayError("unsupported replay identity")
|
raise PortableReplayError("unsupported replay identity")
|
||||||
view = cast(dict[str, Any], self.view.read(result_id))
|
view = cast(dict[str, Any], self.view.read(result_id))
|
||||||
identity = [RENDERER_VERSION, result_id, base_sha, view["artifact_manifest_id"]]
|
module = view.get("result_document", {}).get("module", {})
|
||||||
|
module_id = module.get("module_id") if isinstance(module, dict) else None
|
||||||
|
renderer = (
|
||||||
|
OBJECT_RENDERER_VERSION
|
||||||
|
if module_id in {"rf-detr", "object-distance"}
|
||||||
|
else SEMANTIC_RENDERER_VERSION
|
||||||
|
if result_id.startswith(("lab-v1-", "ai-layer-"))
|
||||||
|
else RENDERER_VERSION
|
||||||
|
)
|
||||||
|
identity = [renderer, result_id, base_sha, view["artifact_manifest_id"]]
|
||||||
return hashlib.sha256("\0".join(identity).encode()).hexdigest(), view
|
return hashlib.sha256("\0".join(identity).encode()).hexdigest(), view
|
||||||
|
|
||||||
def cached(self, result_id: str, base_sha: str) -> CanonicalLabReplayArtifact | None:
|
def cached(self, result_id: str, base_sha: str) -> CanonicalLabReplayArtifact | None:
|
||||||
@@ -141,15 +165,35 @@ class PortableReplayService:
|
|||||||
):
|
):
|
||||||
raise PortableReplayError("source catalog changed since calculation")
|
raise PortableReplayError("source catalog changed since calculation")
|
||||||
epoch = self._camera(view["source_session_id"], bundle)
|
epoch = self._camera(view["source_session_id"], bundle)
|
||||||
data = load_tgs_data(view, self.view.artifacts, source_bundle_sha256=bundle_sha)
|
|
||||||
starts = [epoch.timeline_start_seconds] + [
|
starts = [epoch.timeline_start_seconds] + [
|
||||||
epoch.timeline_start_seconds + part.end_time_seconds for part in epoch.segments[:-1]
|
epoch.timeline_start_seconds + part.end_time_seconds for part in epoch.segments[:-1]
|
||||||
]
|
]
|
||||||
if len(starts) != len(data.rows) or any(
|
tgs = None
|
||||||
abs(start - row["session_seconds"]) > 1e-8
|
semantics = None
|
||||||
for start, row in zip(starts, data.rows, strict=True)
|
objects = None
|
||||||
):
|
if result_id.startswith("m49-tgs-portable-review-"):
|
||||||
raise PortableReplayError("camera and costmap anchor clocks disagree")
|
tgs = load_tgs_data(view, self.view.artifacts, source_bundle_sha256=bundle_sha)
|
||||||
|
if len(starts) != len(tgs.rows) or any(
|
||||||
|
abs(start - row["session_seconds"]) > 1e-8
|
||||||
|
for start, row in zip(starts, tgs.rows, strict=True)
|
||||||
|
):
|
||||||
|
raise PortableReplayError("camera and costmap anchor clocks disagree")
|
||||||
|
elif result_id.startswith(("ai-layer-rf-detr-", "ai-layer-object-distance-")):
|
||||||
|
objects = load_object_data(
|
||||||
|
view,
|
||||||
|
self.view.artifacts,
|
||||||
|
source_bundle_sha256=bundle_sha,
|
||||||
|
starts=starts,
|
||||||
|
end_seconds=epoch.timeline_end_seconds,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
semantics = load_semantic_data(
|
||||||
|
view,
|
||||||
|
self.view.artifacts,
|
||||||
|
source_bundle_sha256=bundle_sha,
|
||||||
|
starts=starts,
|
||||||
|
end_seconds=epoch.timeline_end_seconds,
|
||||||
|
)
|
||||||
recording_id = canonical_recording_id(base[0])
|
recording_id = canonical_recording_id(base[0])
|
||||||
self.cache.mkdir(mode=0o700, parents=True, exist_ok=True)
|
self.cache.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
if self.cache.is_symlink() or shutil.disk_usage(self.cache).free < 3 * 1024**3:
|
if self.cache.is_symlink() or shutil.disk_usage(self.cache).free < 3 * 1024**3:
|
||||||
@@ -162,7 +206,12 @@ class PortableReplayService:
|
|||||||
try:
|
try:
|
||||||
recording.set_sinks(rr.FileSink(output, write_footer=True))
|
recording.set_sinks(rr.FileSink(output, write_footer=True))
|
||||||
_log_video(recording, proxy, epoch)
|
_log_video(recording, proxy, epoch)
|
||||||
log_tgs(recording, data, epoch.timeline_end_seconds)
|
if tgs is not None:
|
||||||
|
log_tgs(recording, tgs, epoch.timeline_end_seconds)
|
||||||
|
if semantics is not None:
|
||||||
|
log_semantics(recording, semantics)
|
||||||
|
if objects is not None:
|
||||||
|
log_objects(recording, objects)
|
||||||
recording.flush(timeout_sec=180)
|
recording.flush(timeout_sec=180)
|
||||||
finally:
|
finally:
|
||||||
recording.disconnect()
|
recording.disconnect()
|
||||||
@@ -188,6 +237,191 @@ class PortableReplayService:
|
|||||||
os.replace(staged, self.cache / f"{key}.json")
|
os.replace(staged, self.cache / f"{key}.json")
|
||||||
return artifact
|
return artifact
|
||||||
|
|
||||||
|
def _composition_key(
|
||||||
|
self, run_id: str, result_ids: tuple[str, ...], base_sha: str
|
||||||
|
) -> tuple[str, tuple[dict[str, Any], ...]]:
|
||||||
|
if _COMPOSITION.fullmatch(run_id) is None or _SHA.fullmatch(base_sha) is None:
|
||||||
|
raise PortableReplayError("unsupported composition replay identity")
|
||||||
|
if not result_ids or len(result_ids) != len(set(result_ids)):
|
||||||
|
raise PortableReplayError("composition replay members are invalid")
|
||||||
|
views = tuple(cast(dict[str, Any], self.view.read(result_id)) for result_id in result_ids)
|
||||||
|
source_ids = {view["source_session_id"] for view in views}
|
||||||
|
if len(source_ids) != 1:
|
||||||
|
raise PortableReplayError("composition replay members use different sources")
|
||||||
|
identity = [
|
||||||
|
"portable-composition-rerun-0.36.3-v1",
|
||||||
|
run_id,
|
||||||
|
base_sha,
|
||||||
|
*[f"{view['result_id']}:{view['artifact_manifest_id']}" for view in views],
|
||||||
|
]
|
||||||
|
return hashlib.sha256("\0".join(identity).encode()).hexdigest(), views
|
||||||
|
|
||||||
|
def cached_composition(
|
||||||
|
self, run_id: str, result_ids: tuple[str, ...], base_sha: str
|
||||||
|
) -> CanonicalLabReplayArtifact | None:
|
||||||
|
key, _ = self._composition_key(run_id, result_ids, base_sha)
|
||||||
|
sidecar = self.cache / f"{key}.json"
|
||||||
|
if not sidecar.exists():
|
||||||
|
return None
|
||||||
|
metadata = read_json(sidecar, 8192)
|
||||||
|
digest = metadata.get("sha256")
|
||||||
|
name = metadata.get("file")
|
||||||
|
if (
|
||||||
|
metadata.get("key") != key
|
||||||
|
or not isinstance(digest, str)
|
||||||
|
or _SHA.fullmatch(digest) is None
|
||||||
|
or not isinstance(name, str)
|
||||||
|
or re.fullmatch(r"[a-f0-9]{64}\.replay\.rrd", name) is None
|
||||||
|
):
|
||||||
|
raise PortableReplayError("composition replay cache identity changed")
|
||||||
|
path = self.cache / name
|
||||||
|
if (
|
||||||
|
path.is_symlink()
|
||||||
|
or not path.is_file()
|
||||||
|
or path.stat().st_size != metadata["byte_length"]
|
||||||
|
):
|
||||||
|
raise PortableReplayError("composition replay cache length changed")
|
||||||
|
if not 4 <= metadata["byte_length"] <= 1024 * 1024 * 1024:
|
||||||
|
raise PortableReplayError("composition replay cache exceeds bounds")
|
||||||
|
stamp = stat_identity(path.stat())
|
||||||
|
fingerprint = (path, digest)
|
||||||
|
if self._verified.get(fingerprint) != stamp:
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
if hashlib.file_digest(stream, "sha256").hexdigest() != digest:
|
||||||
|
raise PortableReplayError("composition replay cache digest changed")
|
||||||
|
self._verified[fingerprint] = stamp
|
||||||
|
while len(self._verified) > 32:
|
||||||
|
self._verified.popitem(last=False)
|
||||||
|
return CanonicalLabReplayArtifact(path, metadata["byte_length"], digest)
|
||||||
|
|
||||||
|
def prepare_composition(
|
||||||
|
self, run_id: str, result_ids: tuple[str, ...], base_sha: str
|
||||||
|
) -> CanonicalLabReplayArtifact:
|
||||||
|
with _LOCK:
|
||||||
|
cached = self.cached_composition(run_id, result_ids, base_sha)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
key, views = self._composition_key(run_id, result_ids, base_sha)
|
||||||
|
source_session_id = cast(str, views[0]["source_session_id"])
|
||||||
|
base = self.recording_source(source_session_id)
|
||||||
|
if base is None or base[1] != base_sha:
|
||||||
|
raise PortableReplayError("composition source recording is not ready")
|
||||||
|
recording_id = canonical_recording_id(base[0])
|
||||||
|
epoch: RecordedMediaEpoch | None = None
|
||||||
|
tgs_rows: list[Any] = []
|
||||||
|
semantic_rows: list[Any] = []
|
||||||
|
object_rows: list[Any] = []
|
||||||
|
for view in views:
|
||||||
|
result_id = cast(str, view["result_id"])
|
||||||
|
binding = self.view.sessions.get_lab_instance(result_id)
|
||||||
|
if binding is None or binding.source_session_id != source_session_id:
|
||||||
|
raise PortableReplayError("composition result binding disappeared")
|
||||||
|
source = binding.provenance["source"]
|
||||||
|
bundle_sha = source["bundle_sha256"]
|
||||||
|
if not isinstance(bundle_sha, str) or _SHA.fullmatch(bundle_sha) is None:
|
||||||
|
raise PortableReplayError("composition source bundle identity is invalid")
|
||||||
|
bundle_path = (
|
||||||
|
self.data_dir / PORTABLE_SOURCE_DOCUMENT_DIRECTORY / f"{bundle_sha}.json"
|
||||||
|
)
|
||||||
|
bundle = read_json(bundle_path, 32 * 1024 * 1024)
|
||||||
|
verified_file(bundle_path, bundle_sha, bundle_path.stat().st_size)
|
||||||
|
if bundle["source_session_id"] != source_session_id:
|
||||||
|
raise PortableReplayError("composition member belongs to another session")
|
||||||
|
_, catalog_sha = self.view.sessions.get_session_with_catalog_snapshot(
|
||||||
|
source_session_id
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
catalog_sha != source["catalog_sha256"]
|
||||||
|
or catalog_sha != bundle["source_catalog_sha256"]
|
||||||
|
):
|
||||||
|
raise PortableReplayError("composition source catalog changed")
|
||||||
|
member_epoch = self._camera(source_session_id, bundle)
|
||||||
|
if epoch is None:
|
||||||
|
epoch = member_epoch
|
||||||
|
elif (
|
||||||
|
epoch.timeline_start_seconds != member_epoch.timeline_start_seconds
|
||||||
|
or epoch.timeline_end_seconds != member_epoch.timeline_end_seconds
|
||||||
|
or len(epoch.segments) != len(member_epoch.segments)
|
||||||
|
):
|
||||||
|
raise PortableReplayError("composition member clocks disagree")
|
||||||
|
starts = [member_epoch.timeline_start_seconds] + [
|
||||||
|
member_epoch.timeline_start_seconds + part.end_time_seconds
|
||||||
|
for part in member_epoch.segments[:-1]
|
||||||
|
]
|
||||||
|
if result_id.startswith("m49-tgs-portable-review-"):
|
||||||
|
data = load_tgs_data(view, self.view.artifacts, source_bundle_sha256=bundle_sha)
|
||||||
|
if len(starts) != len(data.rows) or any(
|
||||||
|
abs(start - row["session_seconds"]) > 1e-8
|
||||||
|
for start, row in zip(starts, data.rows, strict=True)
|
||||||
|
):
|
||||||
|
raise PortableReplayError("composition camera and TGS clocks disagree")
|
||||||
|
tgs_rows.append(data)
|
||||||
|
elif result_id.startswith(("ai-layer-rf-detr-", "ai-layer-object-distance-")):
|
||||||
|
object_rows.append(
|
||||||
|
load_object_data(
|
||||||
|
view,
|
||||||
|
self.view.artifacts,
|
||||||
|
source_bundle_sha256=bundle_sha,
|
||||||
|
starts=starts,
|
||||||
|
end_seconds=member_epoch.timeline_end_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
semantic_rows.append(
|
||||||
|
load_semantic_data(
|
||||||
|
view,
|
||||||
|
self.view.artifacts,
|
||||||
|
source_bundle_sha256=bundle_sha,
|
||||||
|
starts=starts,
|
||||||
|
end_seconds=member_epoch.timeline_end_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if epoch is None:
|
||||||
|
raise PortableReplayError("composition has no replay clock")
|
||||||
|
self.cache.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
if self.cache.is_symlink() or shutil.disk_usage(self.cache).free < 3 * 1024**3:
|
||||||
|
raise PortableReplayError("replay cache has insufficient safe space")
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix=".pack-composition-", dir=self.cache
|
||||||
|
) as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
proxy = self._video(epoch, root)
|
||||||
|
output = root / "overlay.rrd"
|
||||||
|
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
|
||||||
|
try:
|
||||||
|
recording.set_sinks(rr.FileSink(output, write_footer=True))
|
||||||
|
_log_video(recording, proxy, epoch)
|
||||||
|
for data in tgs_rows:
|
||||||
|
log_tgs(recording, data, epoch.timeline_end_seconds)
|
||||||
|
for data in semantic_rows:
|
||||||
|
log_semantics(recording, data)
|
||||||
|
for data in object_rows:
|
||||||
|
log_objects(recording, data)
|
||||||
|
recording.flush(timeout_sec=180)
|
||||||
|
finally:
|
||||||
|
recording.disconnect()
|
||||||
|
with output.open("rb") as stream:
|
||||||
|
digest = hashlib.file_digest(stream, "sha256").hexdigest()
|
||||||
|
artifact = canonical_lab_replay(
|
||||||
|
base[0],
|
||||||
|
base_generation_sha256=base_sha,
|
||||||
|
overlay=CanonicalLabOverlayArtifact(output, output.stat().st_size, digest),
|
||||||
|
result_id=run_id,
|
||||||
|
recording_id=recording_id,
|
||||||
|
cache_root=self.cache,
|
||||||
|
)
|
||||||
|
metadata = {
|
||||||
|
"key": key,
|
||||||
|
"file": artifact.path.name,
|
||||||
|
"sha256": artifact.sha256,
|
||||||
|
"byte_length": artifact.byte_length,
|
||||||
|
}
|
||||||
|
staged = self.cache / f".{key}.json"
|
||||||
|
staged.write_text(json.dumps(metadata, sort_keys=True), encoding="utf-8")
|
||||||
|
os.chmod(staged, 0o600)
|
||||||
|
os.replace(staged, self.cache / f"{key}.json")
|
||||||
|
return artifact
|
||||||
|
|
||||||
def _camera(self, session_id: str, bundle: dict[str, Any]) -> RecordedMediaEpoch:
|
def _camera(self, session_id: str, bundle: dict[str, Any]) -> RecordedMediaEpoch:
|
||||||
camera = bundle["camera"]
|
camera = bundle["camera"]
|
||||||
artifact = self.view.sessions.get_recorded_media(session_id, camera["artifact_id"])
|
artifact = self.view.sessions.get_recorded_media(session_id, camera["artifact_id"])
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"""Project sealed LAB V1 masks, one native frame at a time, without inference.
|
||||||
|
|
||||||
|
No archive LAB, model installation, or Worker is consulted. The source camera
|
||||||
|
clock and the published component identities are the only evidence inputs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import tarfile
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import rerun as rr
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from k1link.artifact_gateway import CentralArtifactStore
|
||||||
|
from k1link.laboratory.canonical_rerun_overlay import (
|
||||||
|
_encoded_semantic_png,
|
||||||
|
_localized_semantic_label,
|
||||||
|
_semantic_palette,
|
||||||
|
)
|
||||||
|
from k1link.observatory.portable_tgs_replay import PortableReplayError, read_json, verified_file
|
||||||
|
|
||||||
|
RESULT_SCHEMA = "missioncore.recorded-eomt-ddrnet-review/v2"
|
||||||
|
RENDERER_VERSION = "portable-eomt-ddrnet-camera-rerun-0.36.3-v1"
|
||||||
|
# The target labels belong to this exact admitted EoMT preprocessing profile,
|
||||||
|
# not to the model's raw Cityscapes label order. A new profile needs an adapter.
|
||||||
|
EOMT_PROFILE_SHA = "ea583966bc3409f5cf563cbf4fad05e366907e67187082eb692aff53d9f5d875"
|
||||||
|
EOMT_LABELS = (
|
||||||
|
"outside_valid_fov",
|
||||||
|
"person",
|
||||||
|
"bicycle",
|
||||||
|
"motorcycle",
|
||||||
|
"car",
|
||||||
|
"heavy_vehicle",
|
||||||
|
"building_structure",
|
||||||
|
"paved_road",
|
||||||
|
"sidewalk_curb",
|
||||||
|
"ground_dirt",
|
||||||
|
"grass_low_vegetation",
|
||||||
|
"tree_woody_vegetation",
|
||||||
|
"sky",
|
||||||
|
"static_obstacle",
|
||||||
|
"animal",
|
||||||
|
"other_background",
|
||||||
|
)
|
||||||
|
_PNG_BOUND = 1024 * 1024
|
||||||
|
_MAX_FRAMES = 250_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SemanticReplayData:
|
||||||
|
times: tuple[float, ...]
|
||||||
|
end_seconds: float
|
||||||
|
city_masks: Path | None
|
||||||
|
vegetation_masks: Path | None
|
||||||
|
classes: dict[str, list[dict[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
|
def load_semantic_data(
|
||||||
|
view: dict[str, Any],
|
||||||
|
store: CentralArtifactStore,
|
||||||
|
*,
|
||||||
|
source_bundle_sha256: str,
|
||||||
|
starts: list[float],
|
||||||
|
end_seconds: float,
|
||||||
|
) -> SemanticReplayData:
|
||||||
|
doc = view["result_document"]
|
||||||
|
source = doc["source"]
|
||||||
|
if (
|
||||||
|
doc.get("schema_version")
|
||||||
|
not in {
|
||||||
|
RESULT_SCHEMA,
|
||||||
|
"missioncore.recorded-ai-layer-review/v1",
|
||||||
|
}
|
||||||
|
or doc.get("result_id") != view["result_id"]
|
||||||
|
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("semantic result and source clock disagree")
|
||||||
|
members = {row["role"]: row for row in view["artifacts"]}
|
||||||
|
if len(members) != len(view["artifacts"]):
|
||||||
|
raise PortableReplayError("semantic artifact roles are duplicated")
|
||||||
|
|
||||||
|
def artifact(role: str, media: str) -> Path:
|
||||||
|
member = members.get(role)
|
||||||
|
if member is None:
|
||||||
|
raise PortableReplayError("semantic artifact is missing")
|
||||||
|
if member["media_type"] != media:
|
||||||
|
raise PortableReplayError("semantic artifact media type changed")
|
||||||
|
return verified_file(
|
||||||
|
store.object_path(member["sha256"]),
|
||||||
|
member["sha256"],
|
||||||
|
member["byte_length"],
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = doc["schema_version"]
|
||||||
|
module = doc.get("module")
|
||||||
|
module_id = module.get("module_id") if isinstance(module, dict) else None
|
||||||
|
include_eomt = schema == RESULT_SCHEMA or module_id == "eomt"
|
||||||
|
include_ddrnet = schema == RESULT_SCHEMA or module_id == "ddrnet"
|
||||||
|
if not include_eomt and not include_ddrnet:
|
||||||
|
raise PortableReplayError("semantic AI module is unsupported")
|
||||||
|
|
||||||
|
city_classes: list[dict[str, Any]] = []
|
||||||
|
city_masks: Path | None = None
|
||||||
|
if include_eomt:
|
||||||
|
city = read_json(artifact("eomt-result-document", "application/json"))
|
||||||
|
if schema == RESULT_SCHEMA:
|
||||||
|
bound = doc["components"]["eomt"]
|
||||||
|
component_bound = (
|
||||||
|
bound["result_id"] == city["result_id"]
|
||||||
|
and bound["frames_processed"] == len(starts)
|
||||||
|
and bound["result_document_sha256"] == members["eomt-result-document"]["sha256"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
component_bound = (
|
||||||
|
doc["module"].get("component_result_sha256")
|
||||||
|
== members["eomt-result-document"]["sha256"]
|
||||||
|
)
|
||||||
|
if not component_bound or (
|
||||||
|
city["identity"]["configuration"].get("profile_sha256") != EOMT_PROFILE_SHA
|
||||||
|
or city.get("frames_processed") != len(starts)
|
||||||
|
or city.get("session_id") != view["source_session_id"]
|
||||||
|
or city.get("input_sha256") != source["camera_input_sha256"]
|
||||||
|
or city.get("timestamp_basis") != "session-time-seconds"
|
||||||
|
):
|
||||||
|
raise PortableReplayError("EoMT target profile or source changed")
|
||||||
|
for class_id, label in enumerate(EOMT_LABELS):
|
||||||
|
color = hashlib.sha256(f"mission-core-segment-{class_id}".encode()).digest()
|
||||||
|
city_classes.append(
|
||||||
|
{
|
||||||
|
"class_id": class_id,
|
||||||
|
"label": label,
|
||||||
|
"color_rgb": [64 + color[channel] % 176 for channel in range(3)],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
frames = artifact("eomt-panoptic-frame-metadata", "application/x-ndjson")
|
||||||
|
with frames.open("rb") as stream:
|
||||||
|
for index, timestamp in enumerate(starts):
|
||||||
|
line = stream.readline(_PNG_BOUND + 1)
|
||||||
|
if not line or len(line) > _PNG_BOUND:
|
||||||
|
raise PortableReplayError("semantic frame metadata is truncated or oversized")
|
||||||
|
row = json.loads(line)
|
||||||
|
value = row.get("session_seconds")
|
||||||
|
if (
|
||||||
|
row.get("frame_index") != index
|
||||||
|
or row.get("sequence") != index + 1
|
||||||
|
or type(value) not in (int, float)
|
||||||
|
or not np.isfinite(value)
|
||||||
|
or abs(value - timestamp) > 1e-8
|
||||||
|
):
|
||||||
|
raise PortableReplayError("semantic frame clock disagrees with camera")
|
||||||
|
for category in row["semantic_classes"]:
|
||||||
|
class_id = category.get("id")
|
||||||
|
if (
|
||||||
|
type(class_id) is not int
|
||||||
|
or not 0 < class_id < len(EOMT_LABELS)
|
||||||
|
or category.get("label") != EOMT_LABELS[class_id]
|
||||||
|
):
|
||||||
|
raise PortableReplayError("EoMT metadata taxonomy changed")
|
||||||
|
if stream.read(1):
|
||||||
|
raise PortableReplayError("semantic metadata contains extra frames")
|
||||||
|
city_masks = artifact("eomt-panoptic-mask-archive", "application/gzip")
|
||||||
|
|
||||||
|
vegetation_classes: list[dict[str, Any]] = []
|
||||||
|
vegetation_masks: Path | None = None
|
||||||
|
if include_ddrnet:
|
||||||
|
vegetation = read_json(artifact("ddrnet-result-document", "application/json"))
|
||||||
|
if schema == RESULT_SCHEMA:
|
||||||
|
bound = doc["components"]["ddrnet"]
|
||||||
|
component_bound = (
|
||||||
|
bound["result_id"] == vegetation["result_id"]
|
||||||
|
and bound["frames_processed"] == len(starts)
|
||||||
|
and bound["result_document_sha256"] == members["ddrnet-result-document"]["sha256"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
component_bound = (
|
||||||
|
doc["module"].get("component_result_sha256")
|
||||||
|
== members["ddrnet-result-document"]["sha256"]
|
||||||
|
)
|
||||||
|
semantic = vegetation["video_semantics"]
|
||||||
|
taxonomy = semantic["taxonomy"]
|
||||||
|
vegetation_classes = taxonomy["classes"]
|
||||||
|
archive = semantic["mask_archive"]
|
||||||
|
packaged = members.get("ddrnet-semantic-mask-archive")
|
||||||
|
if (
|
||||||
|
not component_bound
|
||||||
|
or packaged is None
|
||||||
|
or taxonomy.get("schema_version") != "missioncore.lab-v1-vegetation-taxonomy/v1"
|
||||||
|
or not isinstance(vegetation_classes, list)
|
||||||
|
or {row["class_id"] for row in vegetation_classes} != set(range(64))
|
||||||
|
or archive.get("frame_count") != len(starts)
|
||||||
|
or (archive.get("width"), archive.get("height")) != (800, 600)
|
||||||
|
or archive.get("encoding") != "uint8-class-id-png"
|
||||||
|
or any(
|
||||||
|
archive.get(key) != packaged[key] for key in ("sha256", "byte_length", "media_type")
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise PortableReplayError("DDRNet mask taxonomy or archive binding changed")
|
||||||
|
_semantic_palette(cast(list[object], vegetation_classes))
|
||||||
|
for row in vegetation_classes:
|
||||||
|
if not isinstance(row.get("label"), str) or not row["label"]:
|
||||||
|
raise PortableReplayError("DDRNet class label is invalid")
|
||||||
|
vegetation_masks = artifact("ddrnet-semantic-mask-archive", "application/zip")
|
||||||
|
return SemanticReplayData(
|
||||||
|
tuple(starts),
|
||||||
|
end_seconds,
|
||||||
|
city_masks,
|
||||||
|
vegetation_masks,
|
||||||
|
{
|
||||||
|
**({"city": city_classes} if include_eomt else {}),
|
||||||
|
**({"vegetation": vegetation_classes} if include_ddrnet else {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_semantics(recording: rr.RecordingStream, data: SemanticReplayData) -> None:
|
||||||
|
try:
|
||||||
|
_log_semantics(recording, data)
|
||||||
|
except (tarfile.TarError, zipfile.BadZipFile, EOFError) as exc:
|
||||||
|
raise PortableReplayError("semantic mask archive is damaged") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _log_semantics(recording: rr.RecordingStream, data: SemanticReplayData) -> None:
|
||||||
|
"""No whole-route decoded tensor or extracted archive; release each frame."""
|
||||||
|
palettes = {
|
||||||
|
layer: _semantic_palette(cast(list[object], classes))
|
||||||
|
for layer, classes in data.classes.items()
|
||||||
|
}
|
||||||
|
for layer, classes in data.classes.items():
|
||||||
|
recording.log(
|
||||||
|
f"/perception/camera/segmentation/{layer}",
|
||||||
|
rr.AnnotationContext(
|
||||||
|
[
|
||||||
|
rr.ClassDescription(
|
||||||
|
info=rr.AnnotationInfo(
|
||||||
|
id=row["class_id"],
|
||||||
|
label=_localized_semantic_label(row["label"]),
|
||||||
|
color=[*row["color_rgb"], 255],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for row in classes
|
||||||
|
]
|
||||||
|
),
|
||||||
|
static=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def frame(layer: str, index: int, payload: bytes) -> None:
|
||||||
|
with Image.open(io.BytesIO(payload)) as image:
|
||||||
|
if image.size != (800, 600) or image.mode not in ("L", "P") or image.format != "PNG":
|
||||||
|
raise PortableReplayError("semantic mask raster changed")
|
||||||
|
mask = np.asarray(image, dtype=np.uint8)
|
||||||
|
if int(mask.max()) >= len(data.classes[layer]):
|
||||||
|
raise PortableReplayError("semantic mask contains an unknown class")
|
||||||
|
encoded = _encoded_semantic_png(mask, palettes[layer])
|
||||||
|
recording.set_time(
|
||||||
|
"session_time",
|
||||||
|
duration=np.timedelta64(round(data.times[index] * 1e9), "ns"),
|
||||||
|
)
|
||||||
|
recording.log(
|
||||||
|
f"/perception/camera/segmentation/{layer}",
|
||||||
|
rr.EncodedImage(contents=encoded, media_type="image/png", opacity=0.72, draw_order=1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.city_masks is not None:
|
||||||
|
seen: set[int] = set()
|
||||||
|
root_seen = False
|
||||||
|
# Stream gzip once. Members may arrive in archive order rather than frame
|
||||||
|
# order; every log uses its own verified camera timestamp.
|
||||||
|
with tarfile.open(data.city_masks, "r|gz") as archive:
|
||||||
|
for member in archive:
|
||||||
|
if member.isdir() and member.name.rstrip("/") == "semantic-masks":
|
||||||
|
if root_seen or member.size != 0:
|
||||||
|
raise PortableReplayError("EoMT mask archive directory is invalid")
|
||||||
|
root_seen = True
|
||||||
|
cast(Any, archive).members.clear()
|
||||||
|
continue
|
||||||
|
match = re.fullmatch(r"semantic-masks/frame-([0-9]{6})\.png", member.name)
|
||||||
|
if not match or not member.isfile() or not 0 < member.size <= _PNG_BOUND:
|
||||||
|
raise PortableReplayError("EoMT mask archive member is unsafe")
|
||||||
|
index = int(match[1]) - 1
|
||||||
|
if not 0 <= index < len(data.times) or index in seen:
|
||||||
|
raise PortableReplayError(
|
||||||
|
"EoMT mask sequence is duplicated or outside coverage"
|
||||||
|
)
|
||||||
|
source = archive.extractfile(member)
|
||||||
|
if source is None:
|
||||||
|
raise PortableReplayError("EoMT mask is unavailable")
|
||||||
|
with source:
|
||||||
|
payload = source.read(_PNG_BOUND + 1)
|
||||||
|
if len(payload) != member.size:
|
||||||
|
raise PortableReplayError("EoMT mask is truncated")
|
||||||
|
frame("city", index, payload)
|
||||||
|
seen.add(index)
|
||||||
|
# tarfile otherwise retains a TarInfo for every frame even in
|
||||||
|
# streaming mode on Python 3.12. No lookup uses that history.
|
||||||
|
cast(Any, archive).members.clear()
|
||||||
|
if len(seen) != len(data.times):
|
||||||
|
raise PortableReplayError("EoMT mask sequence is incomplete")
|
||||||
|
if data.vegetation_masks is not None:
|
||||||
|
with zipfile.ZipFile(data.vegetation_masks) as masks:
|
||||||
|
names = masks.namelist()
|
||||||
|
expected = {f"masks/frame-{index + 1:06d}.png" for index in range(len(data.times))}
|
||||||
|
if len(names) != len(expected) or set(names) != expected:
|
||||||
|
raise PortableReplayError("DDRNet mask sequence is incomplete or duplicated")
|
||||||
|
for index in range(len(data.times)):
|
||||||
|
info = masks.getinfo(f"masks/frame-{index + 1:06d}.png")
|
||||||
|
if not 0 < info.file_size <= _PNG_BOUND:
|
||||||
|
raise PortableReplayError("DDRNet mask exceeds the frame bound")
|
||||||
|
frame("vegetation", index, masks.read(info))
|
||||||
|
recording.set_time(
|
||||||
|
"session_time",
|
||||||
|
duration=np.timedelta64(round(data.end_seconds * 1e9), "ns"),
|
||||||
|
)
|
||||||
|
recording.log("/perception/camera/segmentation", rr.Clear(recursive=True))
|
||||||
@@ -36,7 +36,15 @@ PORTABLE_LABORATORY_SETUP_CATALOG_SCHEMA: Final = (
|
|||||||
PORTABLE_LAB_V1_SETUP_ID: Final = "lab-v1-eomt-ddrnet-portable-v1"
|
PORTABLE_LAB_V1_SETUP_ID: Final = "lab-v1-eomt-ddrnet-portable-v1"
|
||||||
PORTABLE_LAB_V1_DISPLAY_NAME: Final = "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39"
|
PORTABLE_LAB_V1_DISPLAY_NAME: Final = "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39"
|
||||||
PORTABLE_M49_SETUP_ID: Final = "m49-tgs-portable-v2"
|
PORTABLE_M49_SETUP_ID: Final = "m49-tgs-portable-v2"
|
||||||
PORTABLE_M49_DISPLAY_NAME: Final = "M4.9T5 · TRAVEL TGS · CPU-only, без ML"
|
PORTABLE_M49_DISPLAY_NAME: Final = "TRAVEL TGS"
|
||||||
|
AI_DDRNET_SETUP_ID: Final = "ai-segmentation-ddrnet-v1"
|
||||||
|
AI_EOMT_SETUP_ID: Final = "ai-segmentation-eomt-v1"
|
||||||
|
AI_RF_DETR_SETUP_ID: Final = "ai-detection-rf-detr-v1"
|
||||||
|
AI_OBJECT_DISTANCE_SETUP_ID: Final = "ai-range-object-distance-v1"
|
||||||
|
AI_DDRNET_DISPLAY_NAME: Final = "DDRNet-39 · GOOSE"
|
||||||
|
AI_EOMT_DISPLAY_NAME: Final = "EoMT Large · Cityscapes"
|
||||||
|
AI_RF_DETR_DISPLAY_NAME: Final = "RF-DETR Large"
|
||||||
|
AI_OBJECT_DISTANCE_DISPLAY_NAME: Final = "Дистанция до объектов · K1 LiDAR"
|
||||||
|
|
||||||
_SOURCE_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
_SOURCE_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||||
_OBSERVATION_ONLY_AUTHORITY: Final = {
|
_OBSERVATION_ONLY_AUTHORITY: Final = {
|
||||||
@@ -63,6 +71,38 @@ _SETUP_PRESENTATION: Final = {
|
|||||||
"incompatible": "Запись не соответствует требованиям TRAVEL TGS.",
|
"incompatible": "Запись не соответствует требованиям TRAVEL TGS.",
|
||||||
"executor_unavailable": "Переносимый вычислительный контур M4.9T5 пока недоступен.",
|
"executor_unavailable": "Переносимый вычислительный контур M4.9T5 пока недоступен.",
|
||||||
},
|
},
|
||||||
|
AI_DDRNET_SETUP_ID: {
|
||||||
|
"lab_id": "LAB AI-DDRNET",
|
||||||
|
"display_name": AI_DDRNET_DISPLAY_NAME,
|
||||||
|
"description": "Независимая семантическая сегментация DDRNet записанной K1-сессии.",
|
||||||
|
"compatible": "Запись соответствует требованиям DDRNet.",
|
||||||
|
"incompatible": "Запись не соответствует требованиям DDRNet.",
|
||||||
|
"executor_unavailable": "AI-модуль DDRNet на Worker 006 недоступен.",
|
||||||
|
},
|
||||||
|
AI_EOMT_SETUP_ID: {
|
||||||
|
"lab_id": "LAB AI-EOMT",
|
||||||
|
"display_name": AI_EOMT_DISPLAY_NAME,
|
||||||
|
"description": "Независимая семантическая сегментация EoMT записанной K1-сессии.",
|
||||||
|
"compatible": "Запись соответствует требованиям EoMT.",
|
||||||
|
"incompatible": "Запись не соответствует требованиям EoMT.",
|
||||||
|
"executor_unavailable": "AI-модуль EoMT на Worker 006 недоступен.",
|
||||||
|
},
|
||||||
|
AI_RF_DETR_SETUP_ID: {
|
||||||
|
"lab_id": "LAB AI-RF-DETR",
|
||||||
|
"display_name": AI_RF_DETR_DISPLAY_NAME,
|
||||||
|
"description": "Независимая детекция объектов RF-DETR на записанном видео.",
|
||||||
|
"compatible": "Запись соответствует требованиям RF-DETR.",
|
||||||
|
"incompatible": "Запись не соответствует требованиям RF-DETR.",
|
||||||
|
"executor_unavailable": "AI-модуль RF-DETR на Worker 006 недоступен.",
|
||||||
|
},
|
||||||
|
AI_OBJECT_DISTANCE_SETUP_ID: {
|
||||||
|
"lab_id": "LAB AI-RANGE",
|
||||||
|
"display_name": AI_OBJECT_DISTANCE_DISPLAY_NAME,
|
||||||
|
"description": ("RF-DETR и синхронное облако точек для оценки дистанции до объектов."),
|
||||||
|
"compatible": "Запись содержит видео, облако точек, позу и калибровку.",
|
||||||
|
"incompatible": "Для дистанции не хватает видео или пространственных данных.",
|
||||||
|
"executor_unavailable": "AI-модуль дистанции на Worker 006 недоступен.",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_MODEL_PRESENTATION: Final = {
|
_MODEL_PRESENTATION: Final = {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ from k1link.observatory.m49_portable_result import (
|
|||||||
M49_PORTABLE_RESULT_CONTRACT_SHA256,
|
M49_PORTABLE_RESULT_CONTRACT_SHA256,
|
||||||
validate_m49_portable_result,
|
validate_m49_portable_result,
|
||||||
)
|
)
|
||||||
|
from k1link.observatory.modular_result import (
|
||||||
|
MODULAR_RESULT_CONTRACT_SHA256,
|
||||||
|
validate_modular_result,
|
||||||
|
)
|
||||||
from k1link.observatory.portable_artifact_transport import (
|
from k1link.observatory.portable_artifact_transport import (
|
||||||
PortableArtifactTransportError,
|
PortableArtifactTransportError,
|
||||||
PortableObservatoryArtifactTransport,
|
PortableObservatoryArtifactTransport,
|
||||||
@@ -55,9 +59,7 @@ OBSERVATORY_WORKER_SOURCE_CAS_ROOT_ENV: Final = "MISSIONCORE_OBSERVATORY_WORKER_
|
|||||||
OBSERVATORY_WORKER_RESULT_STAGING_ROOT_ENV: Final = (
|
OBSERVATORY_WORKER_RESULT_STAGING_ROOT_ENV: Final = (
|
||||||
"MISSIONCORE_OBSERVATORY_WORKER_RESULT_STAGING_ROOT"
|
"MISSIONCORE_OBSERVATORY_WORKER_RESULT_STAGING_ROOT"
|
||||||
)
|
)
|
||||||
OBSERVATORY_WORKER_LOCAL_ENABLED_ENV: Final = (
|
OBSERVATORY_WORKER_LOCAL_ENABLED_ENV: Final = "MISSIONCORE_OBSERVATORY_WORKER_LOCAL_ENABLED"
|
||||||
"MISSIONCORE_OBSERVATORY_WORKER_LOCAL_ENABLED"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PortableWorkerIntegrationError(RuntimeError):
|
class PortableWorkerIntegrationError(RuntimeError):
|
||||||
@@ -162,6 +164,10 @@ _BUILTIN_VALIDATOR_REGISTRATIONS: Final = (
|
|||||||
contract_sha256=M49_PORTABLE_RESULT_CONTRACT_SHA256,
|
contract_sha256=M49_PORTABLE_RESULT_CONTRACT_SHA256,
|
||||||
validator=validate_m49_portable_result,
|
validator=validate_m49_portable_result,
|
||||||
),
|
),
|
||||||
|
PortableResultContractValidatorRegistration(
|
||||||
|
contract_sha256=MODULAR_RESULT_CONTRACT_SHA256,
|
||||||
|
validator=validate_modular_result,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
_BUILTIN_VALIDATORS_BY_CONTRACT: Final = {
|
_BUILTIN_VALIDATORS_BY_CONTRACT: Final = {
|
||||||
registration.contract_sha256: registration.validator
|
registration.contract_sha256: registration.validator
|
||||||
@@ -172,26 +178,23 @@ _BUILTIN_VALIDATORS_BY_CONTRACT: Final = {
|
|||||||
def portable_result_validator_registry(
|
def portable_result_validator_registry(
|
||||||
definitions: PortableRunDefinitionRegistry,
|
definitions: PortableRunDefinitionRegistry,
|
||||||
*,
|
*,
|
||||||
registrations: tuple[PortableResultContractValidatorRegistration, ...]
|
registrations: tuple[PortableResultContractValidatorRegistration, ...] | None = None,
|
||||||
| None = None,
|
|
||||||
) -> PortableResultContractValidatorRegistry:
|
) -> PortableResultContractValidatorRegistry:
|
||||||
"""Select server-installed validators by exact result-contract identity."""
|
"""Select server-installed validators by exact result-contract identity."""
|
||||||
|
|
||||||
installed = (
|
installed = _BUILTIN_VALIDATOR_REGISTRATIONS if registrations is None else registrations
|
||||||
_BUILTIN_VALIDATOR_REGISTRATIONS
|
|
||||||
if registrations is None
|
|
||||||
else registrations
|
|
||||||
)
|
|
||||||
by_contract = {
|
by_contract = {
|
||||||
registration.contract_sha256: registration
|
registration.contract_sha256: registration
|
||||||
for registration in PortableResultContractValidatorRegistry(installed).registrations
|
for registration in PortableResultContractValidatorRegistry(installed).registrations
|
||||||
}
|
}
|
||||||
selected = tuple(
|
selected: list[PortableResultContractValidatorRegistration] = []
|
||||||
by_contract[definition.result_contract.contract_sha256]
|
selected_contracts: set[str] = set()
|
||||||
for definition in definitions.definitions
|
for definition in definitions.definitions:
|
||||||
if definition.result_contract.contract_sha256 in by_contract
|
contract_sha256 = definition.result_contract.contract_sha256
|
||||||
)
|
if contract_sha256 in by_contract and contract_sha256 not in selected_contracts:
|
||||||
registry = PortableResultContractValidatorRegistry(selected)
|
selected.append(by_contract[contract_sha256])
|
||||||
|
selected_contracts.add(contract_sha256)
|
||||||
|
registry = PortableResultContractValidatorRegistry(tuple(selected))
|
||||||
for definition in definitions.definitions:
|
for definition in definitions.definitions:
|
||||||
if definition.executor.ready:
|
if definition.executor.ready:
|
||||||
try:
|
try:
|
||||||
@@ -287,8 +290,7 @@ def build_portable_observatory_worker_integration(
|
|||||||
definition.setup_id
|
definition.setup_id
|
||||||
for definition in definitions.definitions
|
for definition in definitions.definitions
|
||||||
if any(
|
if any(
|
||||||
registration.contract_sha256
|
registration.contract_sha256 == definition.result_contract.contract_sha256
|
||||||
== definition.result_contract.contract_sha256
|
|
||||||
for registration in validator_registry.registrations
|
for registration in validator_registry.registrations
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -575,9 +575,7 @@ class ObservatoryRecordedJob:
|
|||||||
self.claim_heartbeat_at_utc,
|
self.claim_heartbeat_at_utc,
|
||||||
)
|
)
|
||||||
if (self.active_claim_token is None) != (self.active_claimant_id is None):
|
if (self.active_claim_token is None) != (self.active_claimant_id is None):
|
||||||
raise ObservatoryRecordedQueueIntegrityError(
|
raise ObservatoryRecordedQueueIntegrityError("recorded-job claim ownership is partial")
|
||||||
"recorded-job claim ownership is partial"
|
|
||||||
)
|
|
||||||
if self.active_claim_token is None:
|
if self.active_claim_token is None:
|
||||||
if any(value is not None for value in lease_values) or self.claim_renewal_count != 0:
|
if any(value is not None for value in lease_values) or self.claim_renewal_count != 0:
|
||||||
raise ObservatoryRecordedQueueIntegrityError(
|
raise ObservatoryRecordedQueueIntegrityError(
|
||||||
@@ -654,9 +652,7 @@ class ObservatoryRecordedJob:
|
|||||||
and self.publication_error is None
|
and self.publication_error is None
|
||||||
and self.published_at_utc is None
|
and self.published_at_utc is None
|
||||||
),
|
),
|
||||||
"pending": (
|
"pending": (self.publication_error is None and self.published_at_utc is None),
|
||||||
self.publication_error is None and self.published_at_utc is None
|
|
||||||
),
|
|
||||||
"failed": (
|
"failed": (
|
||||||
self.publication_attempts >= 1
|
self.publication_attempts >= 1
|
||||||
and self.publication_error is not None
|
and self.publication_error is not None
|
||||||
@@ -673,9 +669,7 @@ class ObservatoryRecordedJob:
|
|||||||
"recorded-job publication receipt is inconsistent"
|
"recorded-job publication receipt is inconsistent"
|
||||||
)
|
)
|
||||||
if self.publication_state != "not-required" and (
|
if self.publication_state != "not-required" and (
|
||||||
self.state != "succeeded"
|
self.state != "succeeded" or self.result_id is None or self.result_sha256 is None
|
||||||
or self.result_id is None
|
|
||||||
or self.result_sha256 is None
|
|
||||||
):
|
):
|
||||||
raise ObservatoryRecordedQueueIntegrityError(
|
raise ObservatoryRecordedQueueIntegrityError(
|
||||||
"recorded-job publication lifecycle has no execution result"
|
"recorded-job publication lifecycle has no execution result"
|
||||||
@@ -854,10 +848,7 @@ class ObservatoryRecordedReconciliationRequest:
|
|||||||
_validate_text(self.reason, "reconciliation reason", max_length=1_000)
|
_validate_text(self.reason, "reconciliation reason", max_length=1_000)
|
||||||
if self.resource_release_attestation.job_id != self.job_id:
|
if self.resource_release_attestation.job_id != self.job_id:
|
||||||
raise ValueError("resource-release attestation is bound to another job")
|
raise ValueError("resource-release attestation is bound to another job")
|
||||||
if (
|
if self.resource_release_attestation.claim_generation != self.expected_claim_generation:
|
||||||
self.resource_release_attestation.claim_generation
|
|
||||||
!= self.expected_claim_generation
|
|
||||||
):
|
|
||||||
raise ValueError("resource-release attestation is bound to another generation")
|
raise ValueError("resource-release attestation is bound to another generation")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -1237,16 +1228,15 @@ class ObservatoryRecordedJobQueue:
|
|||||||
if reject_duplicate_computation:
|
if reject_duplicate_computation:
|
||||||
# This check shares the INSERT transaction: two clients with
|
# This check shares the INSERT transaction: two clients with
|
||||||
# different idempotency keys cannot race into duplicate jobs.
|
# different idempotency keys cannot race into duplicate jobs.
|
||||||
# A sealed result awaiting publication is not a reason to run
|
# One sealed recording/profile version is calculated once after
|
||||||
# the models again. Published-cache validity is a separate gate.
|
# it has started successfully. Failed attempts remain as audit
|
||||||
|
# evidence and may be resubmitted as a fresh queue attempt.
|
||||||
duplicate = connection.execute(
|
duplicate = connection.execute(
|
||||||
"SELECT job_id FROM observatory_recorded_jobs "
|
"SELECT job_id FROM observatory_recorded_jobs "
|
||||||
"WHERE identity_sha256 = ? AND ("
|
"WHERE source_session_id = ? AND setup_id = ? "
|
||||||
"state IN ('accepted', 'queued', 'claimed', 'running', 'paused', "
|
"AND definition_sha256 = ? AND state != 'failed' "
|
||||||
"'preemption-pending', 'reconciliation-required') OR "
|
|
||||||
"(state = 'succeeded' AND publication_state IN ('pending', 'failed'))) "
|
|
||||||
"ORDER BY created_at_utc DESC, job_id DESC LIMIT 1",
|
"ORDER BY created_at_utc DESC, job_id DESC LIMIT 1",
|
||||||
(identity_sha256,),
|
(intent.source_session_id, definition.setup_id, definition.definition_sha256),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ObservatoryRecordedQueueDuplicateError(duplicate["job_id"])
|
raise ObservatoryRecordedQueueDuplicateError(duplicate["job_id"])
|
||||||
@@ -1361,12 +1351,13 @@ class ObservatoryRecordedJobQueue:
|
|||||||
grant_count = connection.execute(
|
grant_count = connection.execute(
|
||||||
"SELECT COUNT(*) FROM observatory_recorded_claim_grants_v3"
|
"SELECT COUNT(*) FROM observatory_recorded_claim_grants_v3"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
job_counts = dict(connection.execute(
|
job_counts = dict(
|
||||||
"SELECT state, COUNT(*) FROM observatory_recorded_jobs GROUP BY state"
|
connection.execute(
|
||||||
).fetchall())
|
"SELECT state, COUNT(*) FROM observatory_recorded_jobs GROUP BY state"
|
||||||
|
).fetchall()
|
||||||
|
)
|
||||||
live_count = connection.execute(
|
live_count = connection.execute(
|
||||||
"SELECT COUNT(*) FROM observatory_live_leases "
|
"SELECT COUNT(*) FROM observatory_live_leases WHERE state IN ('pending', 'active')"
|
||||||
"WHERE state IN ('pending', 'active')"
|
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
return {
|
return {
|
||||||
"schema_version": "missioncore.observatory-claim-readiness/v1",
|
"schema_version": "missioncore.observatory-claim-readiness/v1",
|
||||||
@@ -1622,7 +1613,11 @@ class ObservatoryRecordedJobQueue:
|
|||||||
return self._get_job(connection, job_id)
|
return self._get_job(connection, job_id)
|
||||||
|
|
||||||
def report_progress(
|
def report_progress(
|
||||||
self, job_id: str, *, claim_token: str, claimant_id: str,
|
self,
|
||||||
|
job_id: str,
|
||||||
|
*,
|
||||||
|
claim_token: str,
|
||||||
|
claimant_id: str,
|
||||||
progress: RecordedProgress,
|
progress: RecordedProgress,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Replace one small observation, fenced in the ownership transaction."""
|
"""Replace one small observation, fenced in the ownership transaction."""
|
||||||
@@ -1665,10 +1660,11 @@ class ObservatoryRecordedJobQueue:
|
|||||||
job = self._get_job(connection, job_id)
|
job = self._get_job(connection, job_id)
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
"SELECT snapshot_json, received_at_utc FROM observatory_recorded_progress "
|
"SELECT snapshot_json, received_at_utc FROM observatory_recorded_progress "
|
||||||
"WHERE job_id = ?", (job_id,),
|
"WHERE job_id = ?",
|
||||||
|
(job_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
progress = None if row is None else RecordedProgress.model_validate_json(
|
progress = (
|
||||||
row["snapshot_json"]
|
None if row is None else RecordedProgress.model_validate_json(row["snapshot_json"])
|
||||||
)
|
)
|
||||||
if progress is not None and progress.claim_generation != job.claim_generation:
|
if progress is not None and progress.claim_generation != job.claim_generation:
|
||||||
progress = None
|
progress = None
|
||||||
@@ -1681,10 +1677,15 @@ class ObservatoryRecordedJobQueue:
|
|||||||
"claim_generation": job.claim_generation,
|
"claim_generation": job.claim_generation,
|
||||||
"state": job.state,
|
"state": job.state,
|
||||||
"received_at_utc": None if progress is None else row["received_at_utc"],
|
"received_at_utc": None if progress is None else row["received_at_utc"],
|
||||||
"age_seconds": None if progress is None else max(0.0, (
|
"age_seconds": None
|
||||||
_parse_timestamp(self._timestamp(), "clock")
|
if progress is None
|
||||||
- _parse_timestamp(row["received_at_utc"], "progress receipt")
|
else max(
|
||||||
).total_seconds()),
|
0.0,
|
||||||
|
(
|
||||||
|
_parse_timestamp(self._timestamp(), "clock")
|
||||||
|
- _parse_timestamp(row["received_at_utc"], "progress receipt")
|
||||||
|
).total_seconds(),
|
||||||
|
),
|
||||||
"progress": None if progress is None else progress.model_dump(mode="json"),
|
"progress": None if progress is None else progress.model_dump(mode="json"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1720,10 +1721,7 @@ class ObservatoryRecordedJobQueue:
|
|||||||
with self._transaction() as connection:
|
with self._transaction() as connection:
|
||||||
job = self._get_job(connection, job_id)
|
job = self._get_job(connection, job_id)
|
||||||
self._require_active_claim(job, claim_token, now=self._timestamp())
|
self._require_active_claim(job, claim_token, now=self._timestamp())
|
||||||
if (
|
if job.claim_generation != claim_generation or job.active_claimant_id != claimant_id:
|
||||||
job.claim_generation != claim_generation
|
|
||||||
or job.active_claimant_id != claimant_id
|
|
||||||
):
|
|
||||||
raise ObservatoryRecordedQueueStaleClaimError(
|
raise ObservatoryRecordedQueueStaleClaimError(
|
||||||
"recorded-job claim ownership is stale"
|
"recorded-job claim ownership is stale"
|
||||||
)
|
)
|
||||||
@@ -1756,8 +1754,7 @@ class ObservatoryRecordedJobQueue:
|
|||||||
|
|
||||||
with self._transaction() as connection:
|
with self._transaction() as connection:
|
||||||
existing = connection.execute(
|
existing = connection.execute(
|
||||||
"SELECT * FROM observatory_recorded_reconciliations "
|
"SELECT * FROM observatory_recorded_reconciliations WHERE reconciliation_id = ?",
|
||||||
"WHERE reconciliation_id = ?",
|
|
||||||
(request.reconciliation_id,),
|
(request.reconciliation_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
@@ -2060,9 +2057,7 @@ class ObservatoryRecordedJobQueue:
|
|||||||
with self._transaction() as connection:
|
with self._transaction() as connection:
|
||||||
job = self._get_job(connection, job_id)
|
job = self._get_job(connection, job_id)
|
||||||
if job.state != "succeeded":
|
if job.state != "succeeded":
|
||||||
raise ObservatoryRecordedQueueConflictError(
|
raise ObservatoryRecordedQueueConflictError("recorded execution has not succeeded")
|
||||||
"recorded execution has not succeeded"
|
|
||||||
)
|
|
||||||
if job.publication_state == "published":
|
if job.publication_state == "published":
|
||||||
return job
|
return job
|
||||||
if job.publication_state not in {"pending", "failed"}:
|
if job.publication_state not in {"pending", "failed"}:
|
||||||
@@ -2080,15 +2075,39 @@ class ObservatoryRecordedJobQueue:
|
|||||||
)
|
)
|
||||||
return self._get_job(connection, job_id)
|
return self._get_job(connection, job_id)
|
||||||
|
|
||||||
def pending_publications(self) -> tuple[ObservatoryRecordedJob, ...]:
|
def pending_publications(
|
||||||
"""Return durable outbox entries in deterministic retry order."""
|
self,
|
||||||
|
*,
|
||||||
|
limit: int | None = None,
|
||||||
|
after: tuple[str, str] | None = None,
|
||||||
|
) -> tuple[ObservatoryRecordedJob, ...]:
|
||||||
|
"""Read an outbox page without retaining the entire failed-job history.
|
||||||
|
|
||||||
|
The cursor uses immutable creation identity, not ``updated_at_utc``:
|
||||||
|
attempts and concurrent publication cannot move entries across pages.
|
||||||
|
The no-argument form preserves the existing internal inspection API.
|
||||||
|
"""
|
||||||
|
if limit is not None and (type(limit) is not int or not 1 <= limit <= 100):
|
||||||
|
raise ValueError("publication page limit must be within 1..100")
|
||||||
|
conditions = ["publication_state IN ('pending', 'failed')"]
|
||||||
|
parameters: list[object] = []
|
||||||
|
if after is not None:
|
||||||
|
if not isinstance(after, tuple) or len(after) != 2:
|
||||||
|
raise ValueError("publication cursor is invalid")
|
||||||
|
_validate_timestamp(after[0], "publication cursor timestamp")
|
||||||
|
_validate_pattern(after[1], _JOB_ID, "publication cursor job id")
|
||||||
|
conditions.append("(created_at_utc, job_id) > (?, ?)")
|
||||||
|
parameters.extend(after)
|
||||||
|
query = (
|
||||||
|
"SELECT * FROM observatory_recorded_jobs WHERE "
|
||||||
|
+ " AND ".join(conditions)
|
||||||
|
+ " ORDER BY created_at_utc, job_id"
|
||||||
|
)
|
||||||
|
if limit is not None:
|
||||||
|
query += " LIMIT ?"
|
||||||
|
parameters.append(limit)
|
||||||
with self._read_connection() as connection:
|
with self._read_connection() as connection:
|
||||||
rows = connection.execute(
|
rows = connection.execute(query, parameters).fetchall()
|
||||||
"SELECT * FROM observatory_recorded_jobs "
|
|
||||||
"WHERE publication_state IN ('pending', 'failed') "
|
|
||||||
"ORDER BY created_at_utc, job_id"
|
|
||||||
).fetchall()
|
|
||||||
return tuple(_job_from_row(row) for row in rows)
|
return tuple(_job_from_row(row) for row in rows)
|
||||||
|
|
||||||
def fail(
|
def fail(
|
||||||
@@ -2187,8 +2206,12 @@ class ObservatoryRecordedJobQueue:
|
|||||||
return tuple(_job_from_row(row) for row in rows)
|
return tuple(_job_from_row(row) for row in rows)
|
||||||
|
|
||||||
def published_results(
|
def published_results(
|
||||||
self, *, source_session_id: str, source_catalog_sha256: str,
|
self,
|
||||||
setup_id: str, definition_sha256: str,
|
*,
|
||||||
|
source_session_id: str,
|
||||||
|
source_catalog_sha256: str,
|
||||||
|
setup_id: str,
|
||||||
|
definition_sha256: str,
|
||||||
) -> tuple[ObservatoryRecordedJob, ...]:
|
) -> tuple[ObservatoryRecordedJob, ...]:
|
||||||
"""Exact cache candidates, never inferred from labels or a truncated job page."""
|
"""Exact cache candidates, never inferred from labels or a truncated job page."""
|
||||||
|
|
||||||
@@ -2527,11 +2550,10 @@ class ObservatoryRecordedJobQueue:
|
|||||||
)
|
)
|
||||||
if exact_replay:
|
if exact_replay:
|
||||||
return job
|
return job
|
||||||
if (
|
if job.terminal_claim_token_sha256 != token_sha256 or job.terminal_code in {
|
||||||
job.terminal_claim_token_sha256 != token_sha256
|
"claim-lease-expired",
|
||||||
or job.terminal_code
|
"claim-lease-migration",
|
||||||
in {"claim-lease-expired", "claim-lease-migration"}
|
}:
|
||||||
):
|
|
||||||
raise ObservatoryRecordedQueueStaleClaimError(
|
raise ObservatoryRecordedQueueStaleClaimError(
|
||||||
"recorded-job terminal acknowledgement is stale"
|
"recorded-job terminal acknowledgement is stale"
|
||||||
)
|
)
|
||||||
@@ -2618,9 +2640,7 @@ class ObservatoryRecordedJobQueue:
|
|||||||
or _parse_timestamp(now, "queue timestamp")
|
or _parse_timestamp(now, "queue timestamp")
|
||||||
>= _parse_timestamp(job.claim_expires_at_utc, "claim expiry")
|
>= _parse_timestamp(job.claim_expires_at_utc, "claim expiry")
|
||||||
):
|
):
|
||||||
raise ObservatoryRecordedQueueStaleClaimError(
|
raise ObservatoryRecordedQueueStaleClaimError("recorded-job claim lease expired")
|
||||||
"recorded-job claim lease expired"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _recover_stale_claims(
|
def _recover_stale_claims(
|
||||||
self,
|
self,
|
||||||
@@ -3162,10 +3182,7 @@ def _reconciliation_receipt_from_row(
|
|||||||
evidence_sha256=row["resource_release_evidence_sha256"],
|
evidence_sha256=row["resource_release_evidence_sha256"],
|
||||||
attested_at_utc=row["resource_release_attested_at_utc"],
|
attested_at_utc=row["resource_release_attested_at_utc"],
|
||||||
)
|
)
|
||||||
if (
|
if attestation.attestation_sha256 != row["resource_release_attestation_sha256"]:
|
||||||
attestation.attestation_sha256
|
|
||||||
!= row["resource_release_attestation_sha256"]
|
|
||||||
):
|
|
||||||
raise ObservatoryRecordedQueueIntegrityError(
|
raise ObservatoryRecordedQueueIntegrityError(
|
||||||
"stored resource-release attestation identity changed"
|
"stored resource-release attestation identity changed"
|
||||||
)
|
)
|
||||||
@@ -3433,9 +3450,7 @@ def _validate_claim_lease_seconds(value: object) -> None:
|
|||||||
if (
|
if (
|
||||||
not isinstance(value, int)
|
not isinstance(value, int)
|
||||||
or isinstance(value, bool)
|
or isinstance(value, bool)
|
||||||
or not MIN_RECORDED_CLAIM_LEASE_SECONDS
|
or not MIN_RECORDED_CLAIM_LEASE_SECONDS <= value <= MAX_RECORDED_CLAIM_LEASE_SECONDS
|
||||||
<= value
|
|
||||||
<= MAX_RECORDED_CLAIM_LEASE_SECONDS
|
|
||||||
):
|
):
|
||||||
raise ValueError("recorded-job claim lease duration is invalid")
|
raise ValueError("recorded-job claim lease duration is invalid")
|
||||||
|
|
||||||
|
|||||||
@@ -340,6 +340,20 @@ class RecordedK1SourceAdmissionService:
|
|||||||
prepare_media=False,
|
prepare_media=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def prepare_check(self, source_session_id: str) -> PortableRecordedSourceAdmission:
|
||||||
|
"""Prepare the recorded-media manifest and return a non-persistent check.
|
||||||
|
|
||||||
|
This is the bounded one-click path used when a compatible recording has
|
||||||
|
not previously been opened in the viewer. Source bundle documents are
|
||||||
|
still written only by ``admit`` after the returned identity is fenced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self._prepare(
|
||||||
|
source_session_id,
|
||||||
|
persist=False,
|
||||||
|
prepare_media=True,
|
||||||
|
)
|
||||||
|
|
||||||
def admit(
|
def admit(
|
||||||
self,
|
self,
|
||||||
source_session_id: str,
|
source_session_id: str,
|
||||||
@@ -606,14 +620,10 @@ class RecordedK1SourceAdmissionService:
|
|||||||
catalog_artifact = catalog_artifacts.get(replay_artifact.artifact_id)
|
catalog_artifact = catalog_artifacts.get(replay_artifact.artifact_id)
|
||||||
exact_metadata_member = (
|
exact_metadata_member = (
|
||||||
catalog_artifact is not None
|
catalog_artifact is not None
|
||||||
and replay_artifact.artifact_id
|
and replay_artifact.artifact_id == PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
||||||
== PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
and catalog_artifact.kind == PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
||||||
and catalog_artifact.kind
|
and replay_artifact.media_type == PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
||||||
== PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
and catalog_artifact.media_type == PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
||||||
and replay_artifact.media_type
|
|
||||||
== PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
|
||||||
and catalog_artifact.media_type
|
|
||||||
== PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
|
||||||
)
|
)
|
||||||
digest_matches_catalog = (
|
digest_matches_catalog = (
|
||||||
replay_artifact.expected_sha256 == catalog_artifact.sha256
|
replay_artifact.expected_sha256 == catalog_artifact.sha256
|
||||||
@@ -854,13 +864,10 @@ def _seal_replay_artifact_digests(
|
|||||||
catalog_artifact = catalog_artifacts.get(artifact.artifact_id)
|
catalog_artifact = catalog_artifacts.get(artifact.artifact_id)
|
||||||
if (
|
if (
|
||||||
catalog_artifact is None
|
catalog_artifact is None
|
||||||
or artifact.artifact_id
|
or artifact.artifact_id != PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
||||||
!= PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
or catalog_artifact.kind != PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
||||||
or catalog_artifact.kind
|
|
||||||
!= PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
|
||||||
or artifact.media_type != PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
or artifact.media_type != PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
||||||
or catalog_artifact.media_type
|
or catalog_artifact.media_type != PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
||||||
!= PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
|
||||||
or catalog_artifact.sha256 is not None
|
or catalog_artifact.sha256 is not None
|
||||||
or catalog_artifact.integrity_status not in _SEALED_ARTIFACT_STATES
|
or catalog_artifact.integrity_status not in _SEALED_ARTIFACT_STATES
|
||||||
or artifact.file_byte_length != catalog_artifact.byte_length
|
or artifact.file_byte_length != catalog_artifact.byte_length
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user