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 { createIsolatedRerunHost } from "./rerun/isolatedRerunHost";
|
||||
import { keepRecordedBlueprintSession } from "../core/observation/recordedBlueprintLifecycle";
|
||||
import {
|
||||
RECORDED_RERUN_ORBITAL_EYE,
|
||||
RECORDED_RERUN_PLAN_EYE,
|
||||
type RecordedRerunCameraEye,
|
||||
} from "./rerun/recordedRerunCameraJournal";
|
||||
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import {
|
||||
@@ -138,6 +145,15 @@ export interface RerunViewportProps {
|
||||
|
||||
interface RerunBlueprintChannel {
|
||||
endpointUrl: string;
|
||||
cameraContract?: string | null;
|
||||
configureCameraJournal?: (
|
||||
eye: RecordedRerunCameraEye,
|
||||
spatialViewportStart: number,
|
||||
) => void;
|
||||
getCameraEye?: () => RecordedRerunCameraEye;
|
||||
setCameraViewportStart?: (spatialViewportStart: number) => void;
|
||||
getCurrentTimeNs?: () => number | null;
|
||||
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||
channel: {
|
||||
readonly ready: boolean;
|
||||
send_rrd: (rrdBytes: Uint8Array) => void;
|
||||
@@ -166,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 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 {
|
||||
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_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) {
|
||||
const explicit = explicitSourceUrl.trim();
|
||||
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)
|
||||
) return null;
|
||||
const endpoint = new URL(explicit, `${base.origin}/`);
|
||||
@@ -393,7 +412,11 @@ export async function fetchRecordedBlueprintRrd(
|
||||
followTrajectory = false,
|
||||
semanticLayer,
|
||||
unifiedPerception,
|
||||
unifiedCameraShare = 0.46,
|
||||
planView = false,
|
||||
cameraEye,
|
||||
currentTimeNs,
|
||||
onCameraMaxOrbitalRadius,
|
||||
perceptionLayers = {
|
||||
enabled: false,
|
||||
detections2d: false,
|
||||
@@ -410,7 +433,11 @@ export async function fetchRecordedBlueprintRrd(
|
||||
followTrajectory?: boolean;
|
||||
semanticLayer?: "city" | "vegetation";
|
||||
unifiedPerception?: boolean;
|
||||
unifiedCameraShare?: number;
|
||||
planView?: boolean;
|
||||
cameraEye?: RecordedRerunCameraEye;
|
||||
currentTimeNs?: number | null;
|
||||
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||
perceptionLayers?: RecordedPerceptionLayers;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
@@ -426,10 +453,8 @@ export async function fetchRecordedBlueprintRrd(
|
||||
!RECORDED_BLUEPRINT_PATH.test(endpoint.pathname) ||
|
||||
!Number.isFinite(settings.accumulationSeconds) ||
|
||||
settings.accumulationSeconds < 0 ||
|
||||
settings.accumulationSeconds > 3600 ||
|
||||
!Number.isFinite(settings.pointSize) ||
|
||||
settings.pointSize < 0.1 ||
|
||||
settings.pointSize > 32 ||
|
||||
!["intensity", "height", "distance", "rgb", "class"].includes(settings.colorMode) ||
|
||||
!["turbo", "viridis", "plasma", "grayscale", "custom"].includes(settings.palette) ||
|
||||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
||||
@@ -437,9 +462,21 @@ export async function fetchRecordedBlueprintRrd(
|
||||
![0, 1].includes(viewResetGeneration) ||
|
||||
(semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) ||
|
||||
typeof resolvedUnifiedPerception !== "boolean" ||
|
||||
!Number.isFinite(unifiedCameraShare) ||
|
||||
unifiedCameraShare < 0.1 ||
|
||||
unifiedCameraShare > 0.9 ||
|
||||
typeof planView !== "boolean" ||
|
||||
(cameraEye !== undefined && [
|
||||
...cameraEye.position,
|
||||
...cameraEye.lookTarget,
|
||||
...cameraEye.eyeUp,
|
||||
].some((value) => !Number.isFinite(value))) ||
|
||||
(currentTimeNs !== undefined && currentTimeNs !== null && (
|
||||
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
|
||||
)) ||
|
||||
[
|
||||
perceptionLayers.enabled,
|
||||
perceptionLayers.cameraImage ?? true,
|
||||
perceptionLayers.detections2d,
|
||||
perceptionLayers.segmentation,
|
||||
perceptionLayers.cuboids3d,
|
||||
@@ -474,9 +511,17 @@ export async function fetchRecordedBlueprintRrd(
|
||||
view_reset_generation: viewResetGeneration,
|
||||
follow_trajectory: followTrajectory,
|
||||
unified_perception: resolvedUnifiedPerception,
|
||||
unified_camera_share: unifiedCameraShare,
|
||||
semantic_layer: semanticLayer ?? null,
|
||||
plan_view: planView,
|
||||
eye_position: cameraEye?.position ?? null,
|
||||
eye_look_target: cameraEye?.lookTarget ?? null,
|
||||
eye_up: cameraEye?.eyeUp ?? null,
|
||||
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
|
||||
current_time_ns: currentTimeNs,
|
||||
}),
|
||||
show_detections_2d: perceptionLayers.detections2d,
|
||||
show_camera_image: perceptionLayers.cameraImage ?? true,
|
||||
show_segmentation: perceptionLayers.segmentation,
|
||||
show_cuboids_3d: perceptionLayers.cuboids3d,
|
||||
...(perceptionLayers.costmap === undefined ? {} : {
|
||||
@@ -506,9 +551,34 @@ export async function fetchRecordedBlueprintRrd(
|
||||
) {
|
||||
throw new Error("Invalid recorded blueprint RRD");
|
||||
}
|
||||
const maxOrbitalRadius = Number(
|
||||
response.headers.get("X-MissionCore-Camera-Max-Orbital-Radius"),
|
||||
);
|
||||
if (Number.isFinite(maxOrbitalRadius) && maxOrbitalRadius >= 0.02) {
|
||||
onCameraMaxOrbitalRadius?.(maxOrbitalRadius);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function recordedCameraJournalContract(
|
||||
{
|
||||
activeView,
|
||||
viewResetGeneration,
|
||||
planView,
|
||||
}: {
|
||||
activeView: RecordedRerunView;
|
||||
viewResetGeneration: 0 | 1;
|
||||
planView: boolean;
|
||||
followTrajectory: boolean;
|
||||
},
|
||||
): string {
|
||||
// Following is a tracking property of the current native eye. It must never
|
||||
// initialize the browser journal again: doing so replaces the operator's
|
||||
// current pose with the startup preset immediately before the blueprint is
|
||||
// sent. Plan/3D and explicit reset are the only preset transitions.
|
||||
return [activeView, viewResetGeneration, planView].join(":");
|
||||
}
|
||||
|
||||
export async function fetchRecordedPerceptionRrd(
|
||||
endpointUrl: string,
|
||||
identity: RecordedRerunIdentity,
|
||||
@@ -717,6 +787,7 @@ export function RerunViewport({
|
||||
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
|
||||
const recordedSemanticLayer = recordedProfile?.semanticLayer;
|
||||
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
|
||||
const recordedUnifiedCameraShare = recordedProfile?.unifiedCameraShare ?? 0.46;
|
||||
const recordedPlanView = recordedProfile?.planView ?? false;
|
||||
const recordedPerceptionRetryGeneration =
|
||||
recordedProfile?.perceptionRetryGeneration ?? 0;
|
||||
@@ -762,9 +833,9 @@ export function RerunViewport({
|
||||
: sourceUrl
|
||||
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const recordedPointColorsUrl = sourceUrl
|
||||
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const recordedPointColorsUrl = recordedBlueprintUrl
|
||||
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
|
||||
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
|
||||
const presentationStatus = rerunPresentationStatus(
|
||||
status,
|
||||
presentationGate,
|
||||
@@ -836,6 +907,9 @@ export function RerunViewport({
|
||||
diagnosticLifecycle.verifyBuild();
|
||||
|
||||
let disposed = false;
|
||||
const blueprintOwnerId = crypto.randomUUID().replaceAll("-", "");
|
||||
blueprintSessionIdRef.current = blueprintOwnerId;
|
||||
let releaseBlueprintSession = () => {};
|
||||
let disposeViewer: (() => void) | undefined;
|
||||
let recordedOpenWatchdog: {
|
||||
arm: () => void;
|
||||
@@ -1082,6 +1156,11 @@ export function RerunViewport({
|
||||
);
|
||||
}
|
||||
activeViewerLifecycleRef.current = disposeActiveViewerLifecycle;
|
||||
const restoreAfterPageCache = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) setRetryNonce((nonce) => nonce + 1);
|
||||
};
|
||||
window.addEventListener("pagehide", disposeActiveViewerLifecycle);
|
||||
window.addEventListener("pageshow", restoreAfterPageCache);
|
||||
|
||||
host.replaceChildren();
|
||||
appliedPointColorKeyRef.current = null;
|
||||
@@ -1089,15 +1168,22 @@ export function RerunViewport({
|
||||
setRecordingBufferProgress(null);
|
||||
onStatusChange?.("loading");
|
||||
|
||||
void import("@rerun-io/web-viewer")
|
||||
.then(async ({ WebViewer }) => {
|
||||
const isolatedHost = isRecordedSource ? createIsolatedRerunHost(host) : null;
|
||||
disposeViewer = () => isolatedHost?.dispose();
|
||||
const nativeViewer = isolatedHost?.ready ?? import("@rerun-io/web-viewer")
|
||||
.then(({ WebViewer }) => ({ viewer: new WebViewer(), mount: host }));
|
||||
void nativeViewer
|
||||
.then(async ({ viewer, mount }) => {
|
||||
// React StrictMode intentionally disposes the first effect while the
|
||||
// dynamic import is still pending. Never start that stale viewer (the
|
||||
// vendor API treats a missing host as document.body).
|
||||
if (disposed) return;
|
||||
|
||||
const viewer = new WebViewer();
|
||||
if (disposed) {
|
||||
viewer.stop();
|
||||
isolatedHost?.dispose();
|
||||
return;
|
||||
}
|
||||
disposeViewer = createReentrantViewerDisposer(() => {
|
||||
releaseBlueprintSession();
|
||||
clearRecordingTimers();
|
||||
clearPlaybackRangeTimer();
|
||||
playbackTimeUpdates.cancel();
|
||||
@@ -1155,6 +1241,7 @@ export function RerunViewport({
|
||||
// A partially initialized WASM handle can already be gone after a
|
||||
// startup failure.
|
||||
}
|
||||
isolatedHost?.dispose();
|
||||
host.replaceChildren();
|
||||
});
|
||||
if (isRecordedSource && recordedArtifact) {
|
||||
@@ -1191,6 +1278,13 @@ export function RerunViewport({
|
||||
event.application_id === "nodedc_mission_core_recorded" &&
|
||||
/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(event.recording_id)
|
||||
) {
|
||||
releaseBlueprintSession = keepRecordedBlueprintSession({
|
||||
endpointUrl: recordedBlueprintUrl,
|
||||
origin: window.location.origin,
|
||||
applicationId: event.application_id,
|
||||
recordingId: event.recording_id,
|
||||
ownerId: blueprintOwnerId,
|
||||
});
|
||||
const current = recordedIdentityRef.current;
|
||||
if (
|
||||
current?.applicationId !== event.application_id ||
|
||||
@@ -1500,7 +1594,7 @@ export function RerunViewport({
|
||||
};
|
||||
await viewer.start(
|
||||
rerunViewerInitialSource(resolvedSource),
|
||||
host,
|
||||
mount,
|
||||
viewerOptions,
|
||||
);
|
||||
if (disposed) {
|
||||
@@ -1518,7 +1612,34 @@ export function RerunViewport({
|
||||
|
||||
if (recordedBlueprintUrl) {
|
||||
const channel = viewer.open_channel("missioncore/recorded-blueprint");
|
||||
blueprintChannel = { endpointUrl: recordedBlueprintUrl, channel };
|
||||
blueprintChannel = {
|
||||
endpointUrl: recordedBlueprintUrl,
|
||||
cameraContract: null,
|
||||
configureCameraJournal: (eye, spatialViewportStart) => {
|
||||
if ("configure_camera_journal" in viewer) {
|
||||
viewer.configure_camera_journal(eye, spatialViewportStart);
|
||||
}
|
||||
},
|
||||
getCameraEye: () => "get_camera_eye" in viewer
|
||||
? viewer.get_camera_eye()
|
||||
: RECORDED_RERUN_ORBITAL_EYE,
|
||||
setCameraViewportStart: (spatialViewportStart) => {
|
||||
if ("set_camera_viewport_start" in viewer) {
|
||||
viewer.set_camera_viewport_start(spatialViewportStart);
|
||||
}
|
||||
},
|
||||
getCurrentTimeNs: () => {
|
||||
const currentIdentity = recordedIdentityRef.current;
|
||||
if (!currentIdentity) return null;
|
||||
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
|
||||
},
|
||||
setCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
||||
if ("set_camera_max_orbital_radius" in viewer) {
|
||||
viewer.set_camera_max_orbital_radius(maxOrbitalRadius);
|
||||
}
|
||||
},
|
||||
channel,
|
||||
};
|
||||
blueprintChannelRef.current = blueprintChannel;
|
||||
setBlueprintChannelRevision((revision) => revision + 1);
|
||||
}
|
||||
@@ -1578,6 +1699,8 @@ export function RerunViewport({
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", disposeActiveViewerLifecycle);
|
||||
window.removeEventListener("pageshow", restoreAfterPageCache);
|
||||
if (activeViewerLifecycleRef.current === disposeActiveViewerLifecycle) {
|
||||
activeViewerLifecycleRef.current = null;
|
||||
}
|
||||
@@ -1959,6 +2082,22 @@ export function RerunViewport({
|
||||
!active.channel.ready
|
||||
) return;
|
||||
const abort = new AbortController();
|
||||
const cameraContract = recordedCameraJournalContract(
|
||||
{
|
||||
activeView: recordedView,
|
||||
viewResetGeneration: recordedViewResetGeneration,
|
||||
planView: recordedPlanView,
|
||||
followTrajectory: recordedFollowTrajectory,
|
||||
},
|
||||
);
|
||||
if (active.cameraContract !== cameraContract) {
|
||||
active.configureCameraJournal?.(
|
||||
recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE,
|
||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
||||
);
|
||||
active.cameraContract = cameraContract;
|
||||
}
|
||||
const cameraEye = active.getCameraEye?.();
|
||||
void fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
|
||||
origin: window.location.origin,
|
||||
blueprintSessionId: blueprintSessionIdRef.current,
|
||||
@@ -1969,7 +2108,15 @@ export function RerunViewport({
|
||||
perceptionLayers: recordedPerceptionLayers,
|
||||
semanticLayer: recordedSemanticLayer,
|
||||
unifiedPerception: recordedUnifiedPerception,
|
||||
unifiedCameraShare: recordedUnifiedCameraShare,
|
||||
planView: recordedPlanView,
|
||||
cameraEye,
|
||||
currentTimeNs: active.getCurrentTimeNs?.(),
|
||||
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
||||
if (blueprintChannelRef.current === active) {
|
||||
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
|
||||
}
|
||||
},
|
||||
}).then((payload) => {
|
||||
if (
|
||||
abort.signal.aborted ||
|
||||
@@ -1980,6 +2127,9 @@ export function RerunViewport({
|
||||
return;
|
||||
}
|
||||
active.channel.send_rrd(payload);
|
||||
active.setCameraViewportStart?.(
|
||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
||||
);
|
||||
}).catch(() => {
|
||||
// The recording remains usable with its embedded default blueprint.
|
||||
// A later settings change retries through the same small channel.
|
||||
@@ -1993,8 +2143,10 @@ export function RerunViewport({
|
||||
recordedFollowTrajectory,
|
||||
recordedSemanticLayer,
|
||||
recordedUnifiedPerception,
|
||||
recordedUnifiedCameraShare,
|
||||
recordedPlanView,
|
||||
recordedPerceptionLayers.enabled,
|
||||
recordedPerceptionLayers.cameraImage,
|
||||
recordedPerceptionLayers.detections2d,
|
||||
recordedPerceptionLayers.segmentation,
|
||||
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,
|
||||
spatialLayerControls,
|
||||
spatialLeadingControl,
|
||||
spatialTrailingControl,
|
||||
mediaModeControlsVisible = true,
|
||||
paneToolbarsAlwaysVisible = false,
|
||||
mediaMultiLayer = false,
|
||||
mediaContent,
|
||||
spatialContent,
|
||||
@@ -116,10 +119,14 @@ export function CanonicalRecordedLabReplay<
|
||||
mediaLayerControls?: ReactNode;
|
||||
spatialLayerControls?: ReactNode;
|
||||
spatialLeadingControl?: ReactNode;
|
||||
spatialTrailingControl?: ReactNode;
|
||||
mediaModeControlsVisible?: boolean;
|
||||
/** Keeps renderer-local controls inside their viewport when only one pane is present. */
|
||||
paneToolbarsAlwaysVisible?: boolean;
|
||||
mediaMultiLayer?: boolean;
|
||||
mediaContent?: ReactNode;
|
||||
spatialContent?: ReactNode;
|
||||
/** One upstream Rerun viewer owns both panes and the shared playback clock. */
|
||||
/** One upstream Rerun viewer owns both panes; this controlled split owns their shared geometry. */
|
||||
unifiedContent?: ReactNode;
|
||||
emptyMessage: string;
|
||||
deckOverlays?: ReactNode;
|
||||
@@ -162,14 +169,14 @@ export function CanonicalRecordedLabReplay<
|
||||
aria-label={mediaAriaLabel}
|
||||
hidden={mediaMode === "none"}
|
||||
>
|
||||
{splitView ? (
|
||||
{splitView || paneToolbarsAlwaysVisible ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="media"
|
||||
data-multi-semantic={mediaMultiLayer ? "true" : undefined}
|
||||
>
|
||||
{mediaLayerControls}
|
||||
{mediaModeControls}
|
||||
{mediaModeControlsVisible ? mediaModeControls : null}
|
||||
</div>
|
||||
) : null}
|
||||
{mediaContent}
|
||||
@@ -181,7 +188,7 @@ export function CanonicalRecordedLabReplay<
|
||||
data-pane="spatial"
|
||||
aria-label={spatialAriaLabel}
|
||||
>
|
||||
{splitView ? (
|
||||
{splitView || paneToolbarsAlwaysVisible ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-toolbar"
|
||||
data-pane-toolbar="spatial"
|
||||
@@ -190,6 +197,7 @@ export function CanonicalRecordedLabReplay<
|
||||
<div className="m4-replay-threat-visual__spatial-toolbar-end">
|
||||
{spatialLayerControls}
|
||||
{spatialModeControls}
|
||||
{spatialTrailingControl}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -216,7 +224,7 @@ export function CanonicalRecordedLabReplay<
|
||||
expanded={expanded}
|
||||
onModeChange={onMediaModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
modeControlsVisible={!splitView}
|
||||
modeControlsVisible={!splitView && !paneToolbarsAlwaysVisible}
|
||||
actions={actions}
|
||||
overlay={overlay}
|
||||
transport={transport}
|
||||
@@ -241,7 +249,7 @@ export function CanonicalRecordedLabReplay<
|
||||
orientation={splitOrientation}
|
||||
minPrimarySize={splitView ? 24 : 0}
|
||||
minSecondarySize={splitView ? 24 : 0}
|
||||
resizable={splitView && !unifiedContent}
|
||||
resizable={splitView}
|
||||
separatorLabel="Изменить размер видео/камеры и 3D/плана"
|
||||
/>
|
||||
{mediaMode === "none" && spatialMode === "none" ? (
|
||||
|
||||
@@ -4,14 +4,20 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Checker,
|
||||
ControlRow,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
IconButton,
|
||||
Inspector,
|
||||
InspectorSelectField,
|
||||
RangeControl,
|
||||
ToastStack,
|
||||
Window,
|
||||
type ToastItem,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../ObservationTimeline";
|
||||
@@ -26,18 +32,24 @@ import {
|
||||
CanonicalRecordedLabReplay,
|
||||
useCanonicalRecordedLabReplayState,
|
||||
} from "../laboratory/CanonicalRecordedLabReplay";
|
||||
import {
|
||||
type CanonicalLabReplayDescriptor,
|
||||
} from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||
import { 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 SpatialLayer = "source" | "local" | "tgs" | "semantic";
|
||||
type SemanticLayer = "city" | "vegetation";
|
||||
|
||||
interface CanonicalReplayLaunch {
|
||||
base: ObservationSessionReplayLaunch;
|
||||
@@ -45,7 +57,80 @@ interface CanonicalReplayLaunch {
|
||||
}
|
||||
|
||||
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({
|
||||
resultId,
|
||||
@@ -53,15 +138,22 @@ export function CanonicalResultRerunReplay({
|
||||
initialPlaybackStartSeconds,
|
||||
resolveReplay,
|
||||
semantics = false,
|
||||
detections = false,
|
||||
costmap = false,
|
||||
label = "Сохранённый результат · синхронизированный повтор",
|
||||
moduleIds = [],
|
||||
viewerLayers = [],
|
||||
label = "Сохранённый результат",
|
||||
}: {
|
||||
resultId: string;
|
||||
sessionId: string;
|
||||
initialPlaybackStartSeconds?: number;
|
||||
resolveReplay: (resultId: string, launch: ObservationSessionReplayLaunch, options: { signal: AbortSignal }) => Promise<CanonicalLabReplayDescriptor>;
|
||||
semantics?: boolean;
|
||||
detections?: boolean;
|
||||
costmap?: boolean;
|
||||
portable?: boolean;
|
||||
moduleIds?: readonly string[];
|
||||
viewerLayers?: readonly AIViewerLayer[];
|
||||
label?: string;
|
||||
}) {
|
||||
const {
|
||||
@@ -75,96 +167,106 @@ export function CanonicalResultRerunReplay({
|
||||
onSplitPrimarySizeChange,
|
||||
onExpandedChange,
|
||||
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
|
||||
initialMediaMode: "video",
|
||||
initialMediaMode: "camera",
|
||||
initialSpatialMode: "3d",
|
||||
});
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||
const [showSemantics, setShowSemantics] = useState(true);
|
||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>(costmap ? "tgs" : "source");
|
||||
// Give the portable composition its own initial native view identity.
|
||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(costmap ? 1 : 0);
|
||||
const layerIds = useMemo(
|
||||
() => new Set(viewerLayers.map((layer) => layer.layerId)),
|
||||
[viewerLayers],
|
||||
);
|
||||
const hasProjectedLayers = viewerLayers.length > 0;
|
||||
const hasDDRNet = layerIds.has("camera.ddrnet")
|
||||
|| (!hasProjectedLayers && semantics && moduleIds.includes("ddrnet"));
|
||||
const hasEoMT = layerIds.has("camera.eomt")
|
||||
|| (!hasProjectedLayers && semantics && moduleIds.includes("eomt"));
|
||||
const hasFallbackSemantics = semantics && !hasDDRNet && !hasEoMT;
|
||||
const hasDetections = layerIds.has("camera.detections") || (!hasProjectedLayers && detections);
|
||||
const hasTgs = layerIds.has("spatial.tgs") || (!hasProjectedLayers && costmap);
|
||||
const legacyUnclassified = !hasProjectedLayers
|
||||
&& moduleIds.length === 0
|
||||
&& !semantics
|
||||
&& !detections
|
||||
&& !costmap;
|
||||
const hasCameraPane = layerIds.has("camera.source")
|
||||
|| (!hasProjectedLayers && (legacyUnclassified || semantics || detections));
|
||||
const hasSourcePoints = layerIds.has("spatial.source-points")
|
||||
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
|
||||
const hasLocalSlam = layerIds.has("spatial.local-slam")
|
||||
|| (!hasProjectedLayers && (legacyUnclassified || costmap || moduleIds.includes("object-distance")));
|
||||
const hasSpatialPane = hasSourcePoints || hasLocalSlam || hasTgs;
|
||||
const splitView = hasCameraPane && hasSpatialPane;
|
||||
|
||||
const [showCamera, setShowCamera] = useState(true);
|
||||
const [showDDRNet, setShowDDRNet] = useState(hasDDRNet || hasFallbackSemantics);
|
||||
const [showEoMT, setShowEoMT] = useState(hasEoMT);
|
||||
const [showDetections, setShowDetections] = useState(hasDetections);
|
||||
const [showSourcePoints, setShowSourcePoints] = useState(hasSourcePoints);
|
||||
const [showLocalSlam, setShowLocalSlam] = useState(hasLocalSlam);
|
||||
const [showTgs, setShowTgs] = useState(hasTgs);
|
||||
const [followTarget, setFollowTarget] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [sceneDraft, setSceneDraft] = useState<SceneSettings>(() => loadLabSceneSettings(resultId));
|
||||
const [blueprintSplitPrimarySize, setBlueprintSplitPrimarySize] = useState(
|
||||
RERUN_UNIFIED_CAMERA_SHARE_PERCENT,
|
||||
);
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(hasTgs ? 1 : 0);
|
||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] =
|
||||
useState<RerunPlaybackController | null>(null);
|
||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
|
||||
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||
const viewerFrameRef = useRef<HTMLDivElement>(null);
|
||||
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
|
||||
const sceneDraftRef = useRef(sceneDraft);
|
||||
const previousSplitViewRef = useRef(splitView);
|
||||
const trackedLaunchSha256Ref = useRef<string | null>(null);
|
||||
|
||||
const stopNativeSplitTracking = useCallback(() => {
|
||||
nativeSplitTrackingCleanupRef.current?.();
|
||||
nativeSplitTrackingCleanupRef.current = null;
|
||||
}, []);
|
||||
|
||||
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!splitView
|
||||
|| event.button !== 0
|
||||
|| !(event.target instanceof HTMLCanvasElement)
|
||||
) return;
|
||||
const frame = viewerFrameRef.current;
|
||||
if (!frame) return;
|
||||
const bounds = frame.getBoundingClientRect();
|
||||
if (bounds.width <= 0) return;
|
||||
const dividerX = bounds.left
|
||||
+ bounds.width * nativeSplitPercentRef.current / 100;
|
||||
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
|
||||
|
||||
stopNativeSplitTracking();
|
||||
const pointerId = event.pointerId;
|
||||
const update = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return;
|
||||
const currentBounds = frame.getBoundingClientRect();
|
||||
if (currentBounds.width <= 0) return;
|
||||
const next = Math.min(90, Math.max(
|
||||
10,
|
||||
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
|
||||
));
|
||||
nativeSplitPercentRef.current = next;
|
||||
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
|
||||
};
|
||||
const stop = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return;
|
||||
stopNativeSplitTracking();
|
||||
};
|
||||
window.addEventListener("pointermove", update, true);
|
||||
window.addEventListener("pointerup", stop, true);
|
||||
window.addEventListener("pointercancel", stop, true);
|
||||
nativeSplitTrackingCleanupRef.current = () => {
|
||||
window.removeEventListener("pointermove", update, true);
|
||||
window.removeEventListener("pointerup", stop, true);
|
||||
window.removeEventListener("pointercancel", stop, true);
|
||||
};
|
||||
}, [splitView, stopNativeSplitTracking]);
|
||||
|
||||
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!splitView) stopNativeSplitTracking();
|
||||
const launchSha256 = launch?.replay.sha256 ?? null;
|
||||
if (
|
||||
trackedLaunchSha256Ref.current !== launchSha256
|
||||
|| (splitView && !previousSplitViewRef.current)
|
||||
) {
|
||||
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
|
||||
if (trackedLaunchSha256Ref.current !== launchSha256 || (splitView && !previousSplitViewRef.current)) {
|
||||
onSplitPrimarySizeChange(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||
setBlueprintSplitPrimarySize(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||
}
|
||||
trackedLaunchSha256Ref.current = launchSha256;
|
||||
previousSplitViewRef.current = splitView;
|
||||
const cameraPanePercent = mediaMode === null
|
||||
? 0
|
||||
: splitView
|
||||
? nativeSplitPercentRef.current
|
||||
: 100;
|
||||
viewerFrameRef.current?.style.setProperty(
|
||||
"--canonical-rerun-camera-pane",
|
||||
`${cameraPanePercent}%`,
|
||||
);
|
||||
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
|
||||
}, [launch?.replay.sha256, onSplitPrimarySizeChange, splitView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!splitView) return;
|
||||
const timer = window.setTimeout(() => setBlueprintSplitPrimarySize(splitPrimarySize), 75);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [splitPrimarySize, splitView]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
`${LAB_SCENE_SETTINGS_STORAGE_PREFIX}${resultId}`,
|
||||
JSON.stringify({
|
||||
pointSize: sceneDraft.pointSize,
|
||||
accumulationSeconds: sceneDraft.accumulationSeconds,
|
||||
colorMode: sceneDraft.colorMode,
|
||||
palette: sceneDraft.palette,
|
||||
showGrid: sceneDraft.showGrid,
|
||||
showLabels: sceneDraft.showLabels,
|
||||
showCameraFrustums: sceneDraft.showCameraFrustums,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// The visual remains usable when the browser refuses local persistence.
|
||||
}
|
||||
}, [resultId, sceneDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchLabViewProfile(resultId, controller.signal).then((settings) => {
|
||||
if (!controller.signal.aborted && settings) {
|
||||
sceneDraftRef.current = settings;
|
||||
setSceneDraft(settings);
|
||||
}
|
||||
}).catch(() => {
|
||||
// The per-browser copy remains a usable fallback while the local service recovers.
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -179,16 +281,12 @@ export function CanonicalResultRerunReplay({
|
||||
onUpdate: () => undefined,
|
||||
}).then(async (value) => ({
|
||||
base: value,
|
||||
replay: await resolveReplay(resultId, value, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
replay: await resolveReplay(resultId, value, { signal: controller.signal }),
|
||||
})).then((value) => {
|
||||
if (!controller.signal.aborted) setLaunch(value);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setLaunchError(
|
||||
caught instanceof Error ? caught.message : "Сохранённая запись недоступна.",
|
||||
);
|
||||
setLaunchError(caught instanceof Error ? caught.message : "Сохранённая запись недоступна.");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
@@ -198,17 +296,15 @@ export function CanonicalResultRerunReplay({
|
||||
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
|
||||
const presentationState = launchError || viewerStatus === "error"
|
||||
? "error"
|
||||
: presentationReady
|
||||
? "ready"
|
||||
: "loading";
|
||||
const sceneSettings = useMemo(() => ({
|
||||
...defaultSceneSettings,
|
||||
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
|
||||
showPoints: spatialMode !== null,
|
||||
showTrajectory: spatialMode !== null,
|
||||
showGrid: spatialMode !== null,
|
||||
pointSize: 3.8,
|
||||
}), [spatialLayer, spatialMode]);
|
||||
: presentationReady ? "ready" : "loading";
|
||||
const sceneSettings = useMemo<SceneSettings>(() => ({
|
||||
...sceneDraft,
|
||||
projection: spatialMode === "plan" ? "2d" : "3d",
|
||||
showPoints: spatialMode !== null && (showSourcePoints || showLocalSlam),
|
||||
showTrajectory: spatialMode !== null && showLocalSlam,
|
||||
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
|
||||
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
|
||||
const semanticVisible = showDDRNet || showEoMT;
|
||||
const profile = launch ? recordedSessionRerunProfile({
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
artifact: {
|
||||
@@ -222,88 +318,99 @@ export function CanonicalResultRerunReplay({
|
||||
presentationGate: "ready",
|
||||
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
|
||||
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
|
||||
?? ((launch.base.mediaSources[0]?.timelineStartSeconds ?? launch.base.timelineStartSeconds)
|
||||
+ (costmap ? 0.000001 : 0)),
|
||||
view: mediaMode !== null ? "perception" : "spatial",
|
||||
+ (hasTgs ? 0.000001 : 0)),
|
||||
view: hasCameraPane && !hasSpatialPane ? "perception" : "spatial",
|
||||
viewResetGeneration,
|
||||
followTrajectory: true,
|
||||
semanticLayer,
|
||||
followTrajectory: followTarget,
|
||||
semanticLayer: showEoMT ? "city" : "vegetation",
|
||||
unifiedPerception: splitView,
|
||||
unifiedCameraShare: blueprintSplitPrimarySize / 100,
|
||||
planView: spatialMode === "plan",
|
||||
perceptionLayers: {
|
||||
enabled: mediaMode !== null,
|
||||
detections2d: semantics && mediaMode === "video",
|
||||
segmentation: semantics && mediaMode === "video" && showSemantics,
|
||||
enabled: true,
|
||||
cameraImage: showCamera,
|
||||
detections2d: hasDetections && showDetections,
|
||||
segmentation: semantics && semanticVisible,
|
||||
cuboids3d: false,
|
||||
...(costmap ? { costmap: spatialMode !== null && spatialLayer === "tgs" } : {}),
|
||||
costmap: hasTgs && showTgs,
|
||||
},
|
||||
perceptionRetryGeneration: 0,
|
||||
lockPerceptionCameraInteraction: mediaMode !== null,
|
||||
lockPerceptionCameraInteraction: false,
|
||||
}) : null;
|
||||
|
||||
const mediaLayerControls = semantics ? (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои камеры и видео"
|
||||
>
|
||||
<Button
|
||||
size="dense"
|
||||
shape="pill"
|
||||
variant={showSemantics ? "primary" : "secondary"}
|
||||
aria-pressed={showSemantics}
|
||||
onClick={() => setShowSemantics((visible) => !visible)}
|
||||
>
|
||||
СЕМАНТИКА
|
||||
</Button>
|
||||
<SegmentedControl
|
||||
value={semanticLayer}
|
||||
items={[
|
||||
{ value: "city", label: "ГОРОД · EoMT" },
|
||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
||||
]}
|
||||
label="Источник семантики"
|
||||
size="dense"
|
||||
onChange={(value) => {
|
||||
setSemanticLayer(value);
|
||||
setShowSemantics(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : undefined;
|
||||
const spatialLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Пространственные слои результата"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={spatialLayer}
|
||||
items={[
|
||||
{ value: "source", label: "ИСХ. ТОЧКИ" },
|
||||
{ value: "local", label: "ЛОК. SLAM" },
|
||||
{ value: "tgs", label: "TGS", disabled: !costmap },
|
||||
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
|
||||
]}
|
||||
label="Пространственные слои"
|
||||
size="dense"
|
||||
onChange={setSpatialLayer}
|
||||
/>
|
||||
const toggle = (caption: string, active: boolean, onClick: () => void) => (
|
||||
<Button key={caption} size="dense" shape="pill" variant={active ? "primary" : "secondary"}
|
||||
aria-pressed={active} onClick={onClick}>
|
||||
{caption}
|
||||
</Button>
|
||||
);
|
||||
const openSlamSettings = useCallback(() => {
|
||||
if (expanded) onExpandedChange(false);
|
||||
setSettingsOpen(true);
|
||||
}, [expanded, onExpandedChange]);
|
||||
const patchSceneDraft = useCallback((patch: Partial<SceneSettings>) => {
|
||||
const next = { ...sceneDraftRef.current, ...patch };
|
||||
sceneDraftRef.current = next;
|
||||
setSceneDraft(next);
|
||||
}, []);
|
||||
const closeSlamSettings = useCallback(() => {
|
||||
const next = normalizeLabSceneSettings(sceneDraftRef.current);
|
||||
sceneDraftRef.current = next;
|
||||
setSceneDraft(next);
|
||||
setSettingsOpen(false);
|
||||
void saveLabViewProfile(resultId, next).then((settings) => {
|
||||
sceneDraftRef.current = settings;
|
||||
setSceneDraft(settings);
|
||||
setToasts((current) => current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST));
|
||||
}).catch(() => {
|
||||
setToasts((current) => [
|
||||
...current.filter(({ id }) => id !== LAB_VIEW_PROFILE_ERROR_TOAST),
|
||||
{
|
||||
id: LAB_VIEW_PROFILE_ERROR_TOAST,
|
||||
tone: "error",
|
||||
title: "Настройки LAB не сохранились",
|
||||
description: "Изменения остались в текущем просмотре. Откройте настройки и повторите.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
}, [resultId]);
|
||||
const mediaLayerControls = (
|
||||
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои камеры">
|
||||
{toggle("КАМЕРА", showCamera, () => setShowCamera((value) => !value))}
|
||||
{hasEoMT ? toggle("EOMT", showEoMT, () => setShowEoMT((value) => !value)) : null}
|
||||
{hasDDRNet || hasFallbackSemantics
|
||||
? toggle("DDRNET", showDDRNet, () => setShowDDRNet((value) => !value)) : null}
|
||||
{hasDetections
|
||||
? toggle("РАМКИ", showDetections, () => setShowDetections((value) => !value)) : null}
|
||||
</div>
|
||||
);
|
||||
const spatialLayerControls = (
|
||||
<div className="m4-replay-threat-visual__pane-layer-controls" role="group" aria-label="Слои облака точек">
|
||||
{hasSourcePoints
|
||||
? toggle("ИСХ. ТОЧКИ", showSourcePoints, () => setShowSourcePoints((value) => !value)) : null}
|
||||
{hasLocalSlam ? (
|
||||
<div className="m4-replay-threat-visual__layer-with-settings">
|
||||
{toggle("ЛОК. SLAM", showLocalSlam, () => setShowLocalSlam((value) => !value))}
|
||||
<IconButton label="Настройки облака SLAM" onClick={openSlamSettings}>
|
||||
<Icon name="settings" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : null}
|
||||
{hasTgs ? toggle("TGS", showTgs, () => setShowTgs((value) => !value)) : null}
|
||||
</div>
|
||||
);
|
||||
const followTargetControl = toggle(
|
||||
"СЛЕДОВАТЬ",
|
||||
followTarget,
|
||||
() => setFollowTarget((value) => !value),
|
||||
);
|
||||
const resetSpatialView = (
|
||||
<Button
|
||||
size="dense"
|
||||
variant="ghost"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
aria-label="Сбросить положение 3D камеры"
|
||||
title="Сбросить положение 3D камеры"
|
||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
|
||||
>
|
||||
</Button>
|
||||
<IconButton label="Сбросить положение видов"
|
||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}>
|
||||
<Icon name="refresh" size={16} />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
const transport = presentationReady && playback && playbackController ? (
|
||||
@@ -322,65 +429,39 @@ export function CanonicalResultRerunReplay({
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="canonical-vegetation-rerun-replay"
|
||||
data-presentation-state={presentationState}
|
||||
aria-busy={presentationState === "loading"}
|
||||
>
|
||||
<div className="canonical-vegetation-rerun-replay" data-presentation-state={presentationState}
|
||||
aria-busy={presentationState === "loading"}>
|
||||
<CanonicalRecordedLabReplay
|
||||
label={label}
|
||||
mediaMode={mediaMode ?? "none"}
|
||||
mediaModes={[
|
||||
{ value: "video", label: "ВИДЕО" },
|
||||
{ value: "camera", label: "КАМЕРА" },
|
||||
]}
|
||||
spatialMode={spatialMode ?? "none"}
|
||||
spatialModes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "ПЛАН" },
|
||||
]}
|
||||
mediaMode={hasCameraPane ? mediaMode ?? "camera" : "none"}
|
||||
mediaModes={[{ value: "camera", label: "КАМЕРА" }]}
|
||||
spatialMode={hasSpatialPane ? spatialMode ?? "3d" : "none"}
|
||||
spatialModes={[{ value: "3d", label: "3D" }, { value: "plan", label: "ПЛАН" }]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitOrientation}
|
||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||
splitOrientation={splitView ? "vertical" : splitOrientation}
|
||||
mediaAriaLabel="Камера и рассчитанные слои"
|
||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialLeadingControl={resetSpatialView}
|
||||
spatialTrailingControl={followTargetControl}
|
||||
mediaModeControlsVisible={false}
|
||||
paneToolbarsAlwaysVisible
|
||||
mediaMultiLayer
|
||||
actions={resetSpatialView}
|
||||
unifiedContent={profile ? (
|
||||
<div
|
||||
ref={viewerFrameRef}
|
||||
className="canonical-vegetation-rerun-replay__viewport-lock"
|
||||
data-split-view={splitView ? "true" : undefined}
|
||||
style={{
|
||||
"--canonical-rerun-camera-pane": `${
|
||||
mediaMode === null
|
||||
? 0
|
||||
: splitView
|
||||
? nativeSplitPercentRef.current
|
||||
: 100
|
||||
}%`,
|
||||
} as CSSProperties}
|
||||
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
|
||||
>
|
||||
<RerunViewport
|
||||
profile={profile}
|
||||
sceneSettings={sceneSettings}
|
||||
onStatusChange={setViewerStatus}
|
||||
onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController}
|
||||
/>
|
||||
<div className="canonical-vegetation-rerun-replay__viewport-lock"
|
||||
data-split-view={splitView ? "true" : undefined}>
|
||||
<RerunViewport profile={profile} sceneSettings={sceneSettings}
|
||||
onStatusChange={setViewerStatus} onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController} />
|
||||
</div>
|
||||
) : launchError ? (
|
||||
<div className="l3-visual-audit__state" role="alert">
|
||||
{launchError}
|
||||
</div>
|
||||
) : (
|
||||
<div aria-hidden="true" />
|
||||
)}
|
||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
||||
<div className="l3-visual-audit__state" role="alert">{launchError}</div>
|
||||
) : <div aria-hidden="true" />}
|
||||
emptyMessage="Слои результата недоступны."
|
||||
deckOverlays={presentationState === "loading" ? (
|
||||
<div className="canonical-vegetation-rerun-replay__loading">
|
||||
<ActivityIndicator label="Загружаем синхронизированную запись" />
|
||||
@@ -392,6 +473,52 @@ export function CanonicalResultRerunReplay({
|
||||
onExpandedChange={onExpandedChange}
|
||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
/>
|
||||
<Window open={settingsOpen} title="Отображение" subtitle="Параметры облака SLAM"
|
||||
placement="end" size="sm" onClose={closeSlamSettings}>
|
||||
<Inspector defaultOpen={["points"]} sections={[
|
||||
{
|
||||
id: "points",
|
||||
label: "Облако точек",
|
||||
description: "Размер и способ окрашивания",
|
||||
content: <>
|
||||
<RangeControl label="Размер точки" min={0.1} max={10} step={0.1}
|
||||
exactValueBounds={{ min: 0.1 }}
|
||||
value={sceneDraft.pointSize} formatValue={(value) => `${value.toFixed(1)} пкс`}
|
||||
onChange={(pointSize) => patchSceneDraft({ pointSize })} />
|
||||
<InspectorSelectField label="Атрибут цвета" value={sceneDraft.colorMode}
|
||||
options={[...COLOR_OPTIONS]}
|
||||
onChange={(colorMode) => patchSceneDraft({ colorMode })} />
|
||||
<InspectorSelectField label="Палитра" value={sceneDraft.palette}
|
||||
options={[...PALETTE_OPTIONS]}
|
||||
onChange={(palette) => patchSceneDraft({ palette })} />
|
||||
</>,
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
label: "Накопление и время",
|
||||
description: "История облака и траектория",
|
||||
content: <RangeControl label="Накопление" min={0} max={500} step={1}
|
||||
exactValueBounds={{ min: 0 }}
|
||||
value={sceneDraft.accumulationSeconds} formatValue={(value) => `${value} с`}
|
||||
onChange={(accumulationSeconds) => patchSceneDraft({ accumulationSeconds })} />,
|
||||
},
|
||||
{
|
||||
id: "environment",
|
||||
label: "Окружение сцены",
|
||||
description: "Сетка, подписи и камеры",
|
||||
content: <>
|
||||
<ControlRow label="Сетка"><Checker checked={sceneDraft.showGrid} label="Показывать"
|
||||
onChange={(showGrid) => patchSceneDraft({ showGrid })} /></ControlRow>
|
||||
<ControlRow label="Подписи"><Checker checked={sceneDraft.showLabels} label="Показывать"
|
||||
onChange={(showLabels) => patchSceneDraft({ showLabels })} /></ControlRow>
|
||||
<ControlRow label="Камеры"><Checker checked={sceneDraft.showCameraFrustums} label="Показывать"
|
||||
onChange={(showCameraFrustums) => patchSceneDraft({ showCameraFrustums })} /></ControlRow>
|
||||
</>,
|
||||
},
|
||||
]} />
|
||||
</Window>
|
||||
<ToastStack items={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,51 @@
|
||||
import { GlassSurface, StatusBadge } from "@nodedc/ui-react";
|
||||
import { GlassSurface } from "@nodedc/ui-react";
|
||||
import { CanonicalResultRerunReplay } from "./CanonicalResultRerunReplay";
|
||||
import { resolveCanonicalLabReplay } from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { ObservatoryPortableResultReview } from "../../core/observatory/recordedRun";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
|
||||
function resolvePortableReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||
function resolvePortableTgsReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||
options: { signal: AbortSignal }) {
|
||||
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-tgs" });
|
||||
}
|
||||
|
||||
export function PortableResultReplay({ review }: { review: ObservatoryPortableResultReview }) {
|
||||
const tgs = review.resultDocument.schema_version === "missioncore.recorded-tgs-costmap-review/v2";
|
||||
if (!tgs) return (
|
||||
function resolvePortableSemanticReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||
options: { signal: AbortSignal }) {
|
||||
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-semantic" });
|
||||
}
|
||||
|
||||
function resolvePortableObjectReplay(resultId: string, launch: ObservationSessionReplayLaunch,
|
||||
options: { signal: AbortSignal }) {
|
||||
return resolveCanonicalLabReplay(resultId, launch, { ...options, sourceKind: "portable-objects" });
|
||||
}
|
||||
|
||||
export function PortableResultReplay({
|
||||
review,
|
||||
label,
|
||||
}: {
|
||||
review: ObservatoryPortableResultReview;
|
||||
label?: string;
|
||||
}) {
|
||||
const schema = review.resultDocument.schema_version;
|
||||
const tgs = schema === "missioncore.recorded-tgs-costmap-review/v2";
|
||||
const module = review.resultDocument.module;
|
||||
const moduleId = module && typeof module === "object" && "module_id" in module
|
||||
? module.module_id : undefined;
|
||||
const semantics = schema === "missioncore.recorded-eomt-ddrnet-review/v2"
|
||||
|| (schema === "missioncore.recorded-ai-layer-review/v1"
|
||||
&& (moduleId === "ddrnet" || moduleId === "eomt"));
|
||||
const objects = schema === "missioncore.recorded-ai-layer-review/v1"
|
||||
&& (moduleId === "rf-detr" || moduleId === "object-distance");
|
||||
if (!tgs && !semantics && !objects) return (
|
||||
<GlassSurface className="observatory-replay-state" padding="lg">
|
||||
<div><StatusBadge tone="success">Проверено</StatusBadge>
|
||||
<h3>{review.resultKind}</h3><p>Связанных артефактов: {review.artifacts.length}</p>
|
||||
<details><summary>Документ результата</summary>
|
||||
<pre>{JSON.stringify(review.resultDocument, null, 2)}</pre></details>
|
||||
</div>
|
||||
<p role="alert">Сохранённый результат этого профиля пока не поддерживает визуальный разбор.</p>
|
||||
</GlassSurface>
|
||||
);
|
||||
return <>
|
||||
<p>Камера, исходное облако и TGS · общая шкала времени. Зелёный — опора,
|
||||
красный — занятое пространство, жёлтый — отклонённые точки.
|
||||
Отсутствие ячеек не означает свободный путь. Сегментация и детекция в этот профиль не входят.</p>
|
||||
<CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
|
||||
sessionId={review.sourceSessionId} costmap resolveReplay={resolvePortableReplay} />
|
||||
</>;
|
||||
return <CanonicalResultRerunReplay key={review.resultId} resultId={review.resultId}
|
||||
sessionId={review.sourceSessionId} portable costmap={tgs} semantics={semantics}
|
||||
detections={objects}
|
||||
moduleIds={typeof moduleId === "string" ? [moduleId] : []}
|
||||
label={label}
|
||||
resolveReplay={tgs ? resolvePortableTgsReplay
|
||||
: objects ? resolvePortableObjectReplay : resolvePortableSemanticReplay} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
FieldFrame,
|
||||
Select,
|
||||
StatusBadge,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
aiModuleIdForSetup,
|
||||
fetchAIModuleCatalog,
|
||||
fetchAICompositionRuns,
|
||||
fetchAICompositionJobs,
|
||||
saveAIComposition,
|
||||
type AICompositionReceipt,
|
||||
type AICompositionRun,
|
||||
type AIGroupId,
|
||||
type AIModuleCatalog,
|
||||
} from "../../core/observatory/aiComposition";
|
||||
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
|
||||
|
||||
const groups: readonly { id: AIGroupId; label: string; description: string }[] = [
|
||||
{ id: "segmentation", label: "Сегментация", description: "Пиксельные маски сцены" },
|
||||
{ id: "detection", label: "Объекты", description: "Рамки и классы объектов" },
|
||||
{ id: "geometry", label: "Облако точек / TGS", description: "Опорная поверхность и карта проходимости" },
|
||||
{ id: "range", label: "Дистанция", description: "Расстояния до объектов" },
|
||||
{ id: "motion", label: "Движение", description: "Треки и динамика объектов" },
|
||||
{ id: "policy", label: "Политика", description: "Теневые правила интерпретации" },
|
||||
];
|
||||
|
||||
export function AIConfigurationWindow({
|
||||
open, sourceSessionId, sourceLabel, onClose, onCalculated,
|
||||
}: {
|
||||
readonly open: boolean;
|
||||
readonly sourceSessionId: string;
|
||||
readonly sourceLabel: string;
|
||||
readonly onClose: () => void;
|
||||
readonly onCalculated: (receipt: AICompositionReceipt) => void;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<AIModuleCatalog | null>(null);
|
||||
const [existingJobs, setExistingJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
|
||||
const [existingRuns, setExistingRuns] = useState<readonly AICompositionRun[]>([]);
|
||||
const [selected, setSelected] = useState<Partial<Record<AIGroupId, string>>>({});
|
||||
const [state, setState] = useState<"idle" | "loading" | "ready" | "saving" | "error">("idle");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const request = new AbortController();
|
||||
setState("loading");
|
||||
setMessage(null);
|
||||
void Promise.all([
|
||||
fetchAIModuleCatalog(request.signal),
|
||||
fetchAICompositionJobs(sourceSessionId, request.signal),
|
||||
fetchAICompositionRuns(sourceSessionId, request.signal),
|
||||
]).then(([value, jobs, runs]) => {
|
||||
if (request.signal.aborted) return;
|
||||
setCatalog(value);
|
||||
setExistingJobs(jobs);
|
||||
setExistingRuns(runs);
|
||||
setSelected({});
|
||||
setState("ready");
|
||||
}).catch((error: unknown) => {
|
||||
if (request.signal.aborted) return;
|
||||
setMessage(error instanceof Error ? error.message : "Каталог AI-модулей недоступен.");
|
||||
setState("error");
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [open, sourceSessionId]);
|
||||
|
||||
const existingByModule = useMemo(() => {
|
||||
const result = new Map<string, ObservatoryRecordedJob>();
|
||||
for (const job of existingJobs) {
|
||||
if (job.state === "failed" || job.publication.state === "failed") continue;
|
||||
const moduleId = aiModuleIdForSetup(job.setupId);
|
||||
if (moduleId && !result.has(moduleId)) result.set(moduleId, job);
|
||||
}
|
||||
return result;
|
||||
}, [existingJobs]);
|
||||
|
||||
const selections = useMemo(() => groups.flatMap(({ id }) => {
|
||||
const moduleId = selected[id];
|
||||
const module = catalog?.groups.find((item) => item.group === id)?.modules.find(
|
||||
(item) => item.moduleId === moduleId,
|
||||
);
|
||||
return module ? [{ group: id, module }] : [];
|
||||
}), [catalog, selected]);
|
||||
|
||||
const selectedModuleIds = useMemo(
|
||||
() => selections.map(({ module }) => module.moduleId).sort(),
|
||||
[selections],
|
||||
);
|
||||
const matchingRun = useMemo(() => existingRuns.find((run) => (
|
||||
run.state !== "failed"
|
||||
&& [...run.moduleIds].sort().join("\0") === selectedModuleIds.join("\0")
|
||||
)), [existingRuns, selectedModuleIds]);
|
||||
const configurationAlreadyExists = selections.length > 0 && matchingRun !== undefined;
|
||||
const configurationIsRunning = matchingRun?.state === "running";
|
||||
|
||||
function selectModule(group: AIGroupId, moduleId: string): void {
|
||||
if (!catalog) return;
|
||||
setSelected((current) => {
|
||||
const next = { ...current, [group]: moduleId === "none" ? undefined : moduleId };
|
||||
const modules = catalog.groups.flatMap((item) => item.modules);
|
||||
const selectedIds = new Set(Object.values(next).filter((value): value is string => Boolean(value)));
|
||||
if (moduleId !== "none") {
|
||||
const module = modules.find((item) => item.moduleId === moduleId);
|
||||
for (const requirement of module?.requires ?? []) {
|
||||
if (requirement.startsWith("source.")) continue;
|
||||
if (modules.some((item) => selectedIds.has(item.moduleId) && item.provides.includes(requirement))) {
|
||||
continue;
|
||||
}
|
||||
const providers = modules.filter((item) => item.provides.includes(requirement));
|
||||
if (providers.length === 1) {
|
||||
const providerGroup = catalog.groups.find((item) => item.modules.some(
|
||||
(candidate) => candidate.moduleId === providers[0]?.moduleId,
|
||||
))?.group;
|
||||
if (providerGroup) {
|
||||
next[providerGroup] = providers[0]?.moduleId;
|
||||
selectedIds.add(providers[0]?.moduleId ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
const capabilities = new Set(modules.filter((item) => Object.values(next).includes(item.moduleId))
|
||||
.flatMap((item) => item.provides));
|
||||
for (const item of modules.filter((candidate) => Object.values(next).includes(candidate.moduleId))) {
|
||||
if (item.requires.some((requirement) => !requirement.startsWith("source.") && !capabilities.has(requirement))) {
|
||||
const dependentGroup = catalog.groups.find((candidate) => candidate.modules.some(
|
||||
(module) => module.moduleId === item.moduleId,
|
||||
))?.group;
|
||||
if (dependentGroup) {
|
||||
next[dependentGroup] = undefined;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setMessage(null);
|
||||
if (state === "error") setState("ready");
|
||||
}
|
||||
|
||||
async function calculate(): Promise<void> {
|
||||
if (selections.length === 0 || state === "saving") return;
|
||||
if (configurationAlreadyExists) {
|
||||
setMessage("Эта конфигурация уже рассчитана. Выберите другую конфигурацию.");
|
||||
return;
|
||||
}
|
||||
setState("saving");
|
||||
setMessage(null);
|
||||
try {
|
||||
const receipt = await saveAIComposition(sourceSessionId, selections);
|
||||
onCalculated(receipt);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Не удалось запустить расчёт.");
|
||||
setState("error");
|
||||
}
|
||||
}
|
||||
|
||||
return <Window
|
||||
open={open}
|
||||
className="observatory-ai-config-window"
|
||||
title="Сконфигурировать AI-слой"
|
||||
subtitle={sourceLabel}
|
||||
size="lg"
|
||||
closeOnBackdrop={state !== "saving"}
|
||||
closeOnEscape={state !== "saving"}
|
||||
onClose={onClose}
|
||||
footer={<WindowFooterActions>
|
||||
{configurationAlreadyExists ? (
|
||||
<span className="observatory-ai-config__duplicate" role="status">
|
||||
{configurationIsRunning
|
||||
? "Текущая конфигурация уже рассчитывается."
|
||||
: "Текущая конфигурация уже рассчитана."}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
className={state === "saving" ? "observatory-ai-config__calculate--saving" : undefined}
|
||||
disabled={state !== "ready" || selections.length === 0 || configurationAlreadyExists}
|
||||
aria-busy={state === "saving"}
|
||||
onClick={() => void calculate()}
|
||||
>
|
||||
{state === "saving" ? "Отправляем на Worker…" : "Рассчитать"}
|
||||
</Button>
|
||||
</WindowFooterActions>}
|
||||
>
|
||||
<div className="observatory-ai-config" aria-busy={state === "loading" || state === "saving"}>
|
||||
<p className="observatory-ai-config__lead">
|
||||
Выберите модуль в каждом нужном слое. Зависимые блоки подключаются явно в этой конфигурации.
|
||||
</p>
|
||||
{state === "loading" ? <ActivityIndicator label="Загружаем AI-модули" /> : null}
|
||||
{catalog ? groups.map(({ id, label, description }) => {
|
||||
const modules = catalog.groups.find((item) => item.group === id)?.modules ?? [];
|
||||
const selectedModule = modules.find((item) => item.moduleId === selected[id]);
|
||||
const selectedExisting = selectedModule
|
||||
? existingByModule.get(selectedModule.moduleId)
|
||||
: undefined;
|
||||
const allCalculated = modules.length > 0 && modules.every(
|
||||
(module) => existingByModule.has(module.moduleId),
|
||||
);
|
||||
return <section className="observatory-ai-module-group" key={id}>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{label}</strong>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
<StatusBadge tone={selectedModule ? "accent" : "neutral"}>
|
||||
{selectedExisting
|
||||
? selectedExisting.state === "succeeded" ? "Уже рассчитано" : "В расчёте"
|
||||
: selectedModule?.label ?? (allCalculated
|
||||
? "Результат доступен"
|
||||
: modules.length ? "Не используется" : "Не установлен")}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="observatory-ai-module-group__body">
|
||||
<FieldFrame
|
||||
label={`Модуль: ${label}`}
|
||||
description={modules.length
|
||||
? allCalculated
|
||||
? "Готовый результат можно включить в новую конфигурацию без повторного расчёта."
|
||||
: id === "segmentation"
|
||||
? "DDRNet и EoMT — независимые альтернативы; выберите одну модель."
|
||||
: id === "range"
|
||||
? "Дистанция использует рамки детектора и синхронное облако точек."
|
||||
: id === "geometry"
|
||||
? "TGS обрабатывает LiDAR независимо от сегментации и детектора."
|
||||
: "Связи с другими блоками определяются входами выбранного модуля."
|
||||
: "Для этого слоя на Worker 006 пока нет установленного модуля."}
|
||||
>
|
||||
<Select
|
||||
label={`Выбрать модуль: ${label}`}
|
||||
value={selected[id] ?? "none"}
|
||||
options={[{ value: "none", label: "Не использовать" }, ...modules.map((module) => ({
|
||||
value: module.moduleId,
|
||||
label: module.label,
|
||||
description: existingByModule.has(module.moduleId)
|
||||
? "Готовый результат будет использован без повторного расчёта"
|
||||
: module.dockerName,
|
||||
}))]}
|
||||
disabled={modules.length === 0 || state === "saving"}
|
||||
onChange={(value) => selectModule(id, value)}
|
||||
/>
|
||||
</FieldFrame>
|
||||
</div>
|
||||
</section>;
|
||||
}) : null}
|
||||
{message ? <p className="observatory-ai-config__error" role="alert">{message}</p> : null}
|
||||
</div>
|
||||
</Window>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { WebViewer } from "@rerun-io/web-viewer";
|
||||
|
||||
import type { RerunFrameWindow } from "./recordedRerunProtocol";
|
||||
import { createRecordedRerunOwner } from "./recordedRerunOwner";
|
||||
|
||||
// This entry is loaded only in the owned iframe. Upstream code is unmodified;
|
||||
// destroying the frame also ends its timers, WASM, video and WebGPU lifetime.
|
||||
const mount = document.getElementById("viewer");
|
||||
if (!mount) throw new Error("Recorded viewer mount is missing");
|
||||
(window as RerunFrameWindow).missionCoreRerun = createRecordedRerunOwner(() => new WebViewer(), mount);
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createRecordedRerunFacade, type RecordedRerunViewer } from "./recordedRerunFacade";
|
||||
import type { RerunFrameWindow } from "./recordedRerunProtocol";
|
||||
import { relayIsolatedRerunInput } from "./isolatedRerunInput";
|
||||
|
||||
/** Own the complete recorded-viewer realm, not just its canvas or SDK handle. */
|
||||
export function createIsolatedRerunHost(host: HTMLElement) {
|
||||
let frame: HTMLIFrameElement | null = host.ownerDocument.createElement("iframe");
|
||||
frame.title = "Синхронизированная запись";
|
||||
frame.className = "rerun-viewport__runtime";
|
||||
frame.src = "/rerun-runtime.html";
|
||||
let disposed = false;
|
||||
let releaseFacade: (() => void) | null = null;
|
||||
let releaseInput: (() => void) | null = null;
|
||||
let frameWindow: RerunFrameWindow | null = null;
|
||||
let rejectReady: ((reason: Error) => void) | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
frame!.onload = null;
|
||||
frame!.onerror = null;
|
||||
releaseInput?.();
|
||||
releaseInput = null;
|
||||
try {
|
||||
releaseFacade?.();
|
||||
} catch {
|
||||
// Realm teardown is required even if a partial native start failed.
|
||||
} finally {
|
||||
releaseFacade = null;
|
||||
frameWindow = null;
|
||||
frame!.remove();
|
||||
frame = null;
|
||||
rejectReady?.(new Error("Recorded viewer was disposed"));
|
||||
rejectReady = null;
|
||||
}
|
||||
};
|
||||
|
||||
const ready = new Promise<{ viewer: RecordedRerunViewer; mount: HTMLElement }>((resolve, reject) => {
|
||||
rejectReady = reject;
|
||||
frame!.onload = () => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
frameWindow = frame!.contentWindow as RerunFrameWindow | null;
|
||||
const api = frameWindow?.missionCoreRerun;
|
||||
if (!api || api.version !== 2) throw new Error("Recorded viewer host is unavailable");
|
||||
const owner = createRecordedRerunFacade((request, bytes) => {
|
||||
const current = frameWindow?.missionCoreRerun;
|
||||
if (!current) throw new Error("Recorded viewer was disposed");
|
||||
return current.invoke(request, bytes);
|
||||
});
|
||||
releaseFacade = owner.dispose;
|
||||
api.connect(owner.notify);
|
||||
releaseInput = relayIsolatedRerunInput(host, frameWindow, frame);
|
||||
clearTimeout(timer);
|
||||
rejectReady = null;
|
||||
frame!.onload = null;
|
||||
frame!.onerror = null;
|
||||
resolve({ viewer: owner.facade, mount: host });
|
||||
} catch (error) {
|
||||
reject(new Error(String(error)));
|
||||
dispose();
|
||||
}
|
||||
};
|
||||
frame!.onerror = () => {
|
||||
reject(new Error("Recorded viewer host failed to load"));
|
||||
dispose();
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error("Recorded viewer host timed out"));
|
||||
dispose();
|
||||
}, 30_000);
|
||||
});
|
||||
host.append(frame);
|
||||
return { ready, dispose };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Relay Escape from the isolated viewer realm to the owning application. */
|
||||
export function relayIsolatedRerunInput(
|
||||
host: HTMLElement,
|
||||
frameWindow: Window | null,
|
||||
_frame: HTMLIFrameElement | null,
|
||||
) {
|
||||
const parent = host.ownerDocument.defaultView;
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented || !parent) return;
|
||||
host.dispatchEvent(new parent.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
};
|
||||
frameWindow?.addEventListener("keydown", escape);
|
||||
return () => {
|
||||
frameWindow?.removeEventListener("keydown", escape);
|
||||
frameWindow = null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
export type RecordedRerunCameraEye = {
|
||||
readonly position: readonly [number, number, number];
|
||||
readonly lookTarget: readonly [number, number, number];
|
||||
readonly eyeUp: readonly [number, number, number];
|
||||
};
|
||||
|
||||
type Vector3 = [number, number, number];
|
||||
type DragMode = "rotate" | "pan" | "roll";
|
||||
|
||||
const ROTATION_RADIANS_PER_POINT = 0.004;
|
||||
const RERUN_WEB_LINE_SCROLL_POINTS = 8;
|
||||
const RERUN_ORBITAL_SCROLL_DIVISOR = 200;
|
||||
const RERUN_WEB_PINCH_SCROLL_DIVISOR = 100;
|
||||
const RERUN_MIN_ORBIT_DISTANCE = 0.02;
|
||||
const DOM_WHEEL_DELTA_LINE = 1;
|
||||
const DOM_WHEEL_DELTA_PAGE = 2;
|
||||
|
||||
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
|
||||
position: [16, -16, 18],
|
||||
lookTarget: [0, 0, 0],
|
||||
eyeUp: [0, 0, 1],
|
||||
};
|
||||
|
||||
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
|
||||
position: [0, 0, 30],
|
||||
lookTarget: [0, 0, 0],
|
||||
eyeUp: [0, 1, 0],
|
||||
};
|
||||
|
||||
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
|
||||
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
|
||||
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const cross = (a: Vector3, b: Vector3): Vector3 => [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
const length = (value: Vector3) => Math.hypot(...value);
|
||||
const normalize = (value: Vector3, fallback: Vector3): Vector3 => {
|
||||
const magnitude = length(value);
|
||||
return magnitude > Number.EPSILON ? scale(value, 1 / magnitude) : fallback;
|
||||
};
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
|
||||
const rotateAroundAxis = (value: Vector3, rawAxis: Vector3, angle: number): Vector3 => {
|
||||
const axis = normalize(rawAxis, [0, 0, 1]);
|
||||
const cosine = Math.cos(angle);
|
||||
const sine = Math.sin(angle);
|
||||
return add(
|
||||
add(scale(value, cosine), scale(cross(axis, value), sine)),
|
||||
scale(axis, dot(axis, value) * (1 - cosine)),
|
||||
);
|
||||
};
|
||||
|
||||
function cloneEye(value: RecordedRerunCameraEye): {
|
||||
position: Vector3;
|
||||
lookTarget: Vector3;
|
||||
eyeUp: Vector3;
|
||||
} {
|
||||
return {
|
||||
position: vector(value.position),
|
||||
lookTarget: vector(value.lookTarget),
|
||||
eyeUp: vector(value.eyeUp),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
|
||||
* renderer. Rerun writes operator navigation into its active blueprint clone;
|
||||
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
|
||||
* here lets a later layer blueprint activate with the exact operator pose.
|
||||
*/
|
||||
export function createRecordedRerunCameraJournal(
|
||||
canvas: HTMLCanvasElement,
|
||||
scope: Window & typeof globalThis,
|
||||
) {
|
||||
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
|
||||
let spatialViewportStart = 0;
|
||||
let pointerId: number | null = null;
|
||||
let dragMode: DragMode | null = null;
|
||||
let previousPointer: readonly [number, number] | null = null;
|
||||
let latestPointer: readonly [number, number] | null = null;
|
||||
let maxOrbitalRadius = 1.0e17;
|
||||
|
||||
const isSpatialPoint = (event: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
|
||||
};
|
||||
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
|
||||
const usableUp = () => {
|
||||
const fwd = forward();
|
||||
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
|
||||
? [1, 0, 0]
|
||||
: cross(fwd, [0, 0, 1]);
|
||||
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
|
||||
const candidate = normalize(eye.eyeUp, fallback);
|
||||
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
|
||||
};
|
||||
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
|
||||
|
||||
const rotate = (deltaX: number, deltaY: number) => {
|
||||
const radius = orbitRadius();
|
||||
const up = usableUp();
|
||||
let fwd = forward();
|
||||
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
|
||||
fwd = normalize(
|
||||
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
|
||||
fwd,
|
||||
);
|
||||
const right = normalize(cross(fwd, up), [1, 0, 0]);
|
||||
const maxPitch = 0.99 * Math.PI / 2;
|
||||
const nextPitch = clamp(
|
||||
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
|
||||
-maxPitch,
|
||||
maxPitch,
|
||||
);
|
||||
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
|
||||
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
|
||||
};
|
||||
|
||||
const pan = (deltaX: number, deltaY: number) => {
|
||||
const fwd = forward();
|
||||
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
|
||||
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
|
||||
const speed = 0.001 * orbitRadius();
|
||||
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
|
||||
eye.position = add(eye.position, translation);
|
||||
eye.lookTarget = add(eye.lookTarget, translation);
|
||||
};
|
||||
|
||||
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const left = rect.left + rect.width * spatialViewportStart;
|
||||
const centerX = left + (rect.right - left) / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const relativeX = event.clientX - centerX;
|
||||
const relativeY = event.clientY - centerY;
|
||||
const divisor = relativeX * relativeX + relativeY * relativeY;
|
||||
if (divisor <= Number.EPSILON) return;
|
||||
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
|
||||
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
|
||||
};
|
||||
|
||||
const pointerDown = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (!isSpatialPoint(event)) return;
|
||||
pointerId = event.pointerId;
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
|
||||
};
|
||||
const pointerMove = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
|
||||
const deltaX = event.clientX - previousPointer[0];
|
||||
const deltaY = event.clientY - previousPointer[1];
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
if (dragMode === "rotate") rotate(deltaX, deltaY);
|
||||
else if (dragMode === "pan") pan(deltaX, deltaY);
|
||||
else roll(event, deltaX, deltaY);
|
||||
};
|
||||
const pointerEnd = (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
pointerMove(event);
|
||||
pointerId = null;
|
||||
previousPointer = null;
|
||||
dragMode = null;
|
||||
};
|
||||
const wheel = (event: WheelEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
|
||||
? event.clientX
|
||||
: latestPointer?.[0];
|
||||
if (wheelX === undefined || rect.width <= 0) return;
|
||||
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
|
||||
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
|
||||
? RERUN_WEB_LINE_SCROLL_POINTS
|
||||
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
|
||||
? rect.height
|
||||
: 1;
|
||||
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
|
||||
// eframe negates the DOM delta before Rerun applies
|
||||
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
|
||||
// exponent products collapse to the raw accumulated DOM delta below.
|
||||
const divisor = event.ctrlKey
|
||||
? RERUN_WEB_PINCH_SCROLL_DIVISOR
|
||||
: RERUN_ORBITAL_SCROLL_DIVISOR;
|
||||
const radiusFactor = Math.exp(scrollPoints / divisor);
|
||||
const offset = subtract(eye.position, eye.lookTarget);
|
||||
const radius = length(offset);
|
||||
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
|
||||
// the scene-derived cap is allowed to stay there and is never snapped in.
|
||||
const nextRadius = clamp(
|
||||
radius * radiusFactor,
|
||||
RERUN_MIN_ORBIT_DISTANCE,
|
||||
Math.max(radius, maxOrbitalRadius),
|
||||
);
|
||||
eye.position = add(
|
||||
eye.lookTarget,
|
||||
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
|
||||
);
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", pointerDown, true);
|
||||
scope.addEventListener("pointermove", pointerMove, true);
|
||||
scope.addEventListener("pointerup", pointerEnd, true);
|
||||
scope.addEventListener("pointercancel", pointerEnd, true);
|
||||
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
|
||||
|
||||
return {
|
||||
current(): RecordedRerunCameraEye {
|
||||
return {
|
||||
position: [...eye.position],
|
||||
lookTarget: [...eye.lookTarget],
|
||||
eyeUp: [...eye.eyeUp],
|
||||
};
|
||||
},
|
||||
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
|
||||
eye = cloneEye(nextEye);
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
pointerId = null;
|
||||
dragMode = null;
|
||||
previousPointer = null;
|
||||
},
|
||||
setSpatialViewportStart(nextSpatialViewportStart: number) {
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
},
|
||||
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
|
||||
if (
|
||||
Number.isFinite(nextMaxOrbitalRadius) &&
|
||||
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
|
||||
) {
|
||||
maxOrbitalRadius = nextMaxOrbitalRadius;
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
canvas.removeEventListener("pointerdown", pointerDown, true);
|
||||
scope.removeEventListener("pointermove", pointerMove, true);
|
||||
scope.removeEventListener("pointerup", pointerEnd, true);
|
||||
scope.removeEventListener("pointercancel", pointerEnd, true);
|
||||
scope.removeEventListener("wheel", wheel, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||
import type { RerunInvoke } from "./recordedRerunProtocol";
|
||||
import type { RecordedRerunCameraEye } from "./recordedRerunCameraJournal";
|
||||
|
||||
type NativeChannel = ReturnType<WebViewer["open_channel"]>;
|
||||
type Channel = Pick<NativeChannel, "ready" | "send_rrd" | "close">;
|
||||
export type RecordedRerunViewer = Pick<WebViewer,
|
||||
"start" | "stop" | "ready" | "on" | "open" | "close" | "override_panel_state" |
|
||||
"get_active_recording_id" | "get_active_timeline" | "get_current_time" |
|
||||
"get_playing" | "get_time_range" | "set_active_timeline" |
|
||||
"set_current_time" | "set_playing"
|
||||
> & {
|
||||
open_channel: (name?: string) => Channel;
|
||||
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
|
||||
get_camera_eye: () => RecordedRerunCameraEye;
|
||||
set_camera_viewport_start: (spatialViewportStart: number) => void;
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
|
||||
};
|
||||
|
||||
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
|
||||
export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
|
||||
let nextId = 0;
|
||||
const callbacks = new Map<number, (...args: unknown[]) => void>();
|
||||
const starts = new Map<number, { resolve: () => void; reject: (error: Error) => void }>();
|
||||
const command = (method: string, args: unknown[] = [], id?: number, bytes?: Uint8Array) => {
|
||||
if (!invoke) throw new Error("Recorded viewer was disposed");
|
||||
const response = JSON.parse(invoke(JSON.stringify({ method, args, id }), bytes));
|
||||
if (response.error) throw new Error(response.error);
|
||||
return response.result;
|
||||
};
|
||||
const dispose = () => {
|
||||
callbacks.clear();
|
||||
for (const pending of starts.values()) pending.reject(new Error("Recorded viewer was disposed"));
|
||||
starts.clear();
|
||||
try { if (invoke) command("stop"); } finally { invoke = null; }
|
||||
};
|
||||
const notify = (message: string) => {
|
||||
if (!invoke) return;
|
||||
const event = JSON.parse(message);
|
||||
if (event.type === "event") callbacks.get(event.id)?.(...event.values);
|
||||
else {
|
||||
const pending = starts.get(event.id);
|
||||
starts.delete(event.id);
|
||||
if (event.type === "started") pending?.resolve();
|
||||
else pending?.reject(new Error(event.message));
|
||||
}
|
||||
};
|
||||
const facade: RecordedRerunViewer = {
|
||||
get ready() { return invoke ? command("ready") : false; },
|
||||
start(source, _parent, options) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const id = ++nextId;
|
||||
starts.set(id, { resolve, reject });
|
||||
try { command("start", [source, options], id); }
|
||||
catch (error) { starts.delete(id); reject(error); }
|
||||
});
|
||||
},
|
||||
stop: dispose,
|
||||
on: ((event: string, callback: (...args: unknown[]) => void) => {
|
||||
const id = ++nextId;
|
||||
callbacks.set(id, callback);
|
||||
try { command("on", [event], id); }
|
||||
catch (error) { callbacks.delete(id); throw error; }
|
||||
return () => {
|
||||
if (callbacks.delete(id) && invoke) command("off", [], id);
|
||||
};
|
||||
}) as WebViewer["on"],
|
||||
open_channel(name) {
|
||||
const id = ++nextId;
|
||||
command("channel-open", name === undefined ? [] : [name], id);
|
||||
let closed = false;
|
||||
return {
|
||||
get ready() { return !closed && invoke ? command("channel-ready", [], id) : false; },
|
||||
send_rrd(bytes) { if (!closed && invoke) command("channel-send", [], id, bytes); },
|
||||
close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (invoke) command("channel-close", [], id);
|
||||
},
|
||||
};
|
||||
},
|
||||
open: (...args) => command("open", args),
|
||||
close: (...args) => { if (invoke) command("close", args); },
|
||||
override_panel_state: (...args) => command("override_panel_state", args),
|
||||
get_active_recording_id: () => command("get_active_recording_id"),
|
||||
get_active_timeline: (...args) => command("get_active_timeline", args),
|
||||
get_current_time: (...args) => command("get_current_time", args),
|
||||
get_playing: (...args) => command("get_playing", args),
|
||||
get_time_range: (...args) => command("get_time_range", args),
|
||||
set_active_timeline: (...args) => command("set_active_timeline", args),
|
||||
set_current_time: (...args) => command("set_current_time", args),
|
||||
set_playing: (...args) => command("set_playing", args),
|
||||
configure_camera_journal: (eye, spatialViewportStart) => command(
|
||||
"configure-camera-journal", [eye, spatialViewportStart],
|
||||
),
|
||||
get_camera_eye: () => command("get-camera-eye"),
|
||||
set_camera_viewport_start: (spatialViewportStart) => command(
|
||||
"set-camera-viewport-start", [spatialViewportStart],
|
||||
),
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius) => command(
|
||||
"set-camera-max-orbital-radius", [maxOrbitalRadius],
|
||||
),
|
||||
};
|
||||
return { facade, dispose, notify };
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
|
||||
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
|
||||
|
||||
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
|
||||
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
|
||||
let native: WebViewer | null = null;
|
||||
let notify: RerunNotify | null = null;
|
||||
const subscriptions = new Map<number, () => void>();
|
||||
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
|
||||
let cameraJournal: ReturnType<typeof createRecordedRerunCameraJournal> | null = null;
|
||||
const send = (message: object) => notify?.(JSON.stringify(message));
|
||||
const stop = () => {
|
||||
notify = null;
|
||||
for (const unsubscribe of subscriptions.values()) {
|
||||
try { unsubscribe(); } catch { /* Continue partial-start cleanup. */ }
|
||||
}
|
||||
subscriptions.clear();
|
||||
for (const channel of channels.values()) {
|
||||
try { channel.close(); } catch { /* Other channels must still close. */ }
|
||||
}
|
||||
channels.clear();
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = null;
|
||||
const current = native;
|
||||
native = null;
|
||||
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
|
||||
};
|
||||
const required = () => {
|
||||
if (!native) throw new Error("Recorded viewer was disposed");
|
||||
return native;
|
||||
};
|
||||
const start = async (id: number, args: [string | string[] | null, Parameters<WebViewer["start"]>[2]]) => {
|
||||
const starting = required();
|
||||
try {
|
||||
await starting.start(args[0], mount, args[1]);
|
||||
if (native === starting && starting.canvas) {
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = createRecordedRerunCameraJournal(
|
||||
starting.canvas,
|
||||
window as Window & typeof globalThis,
|
||||
);
|
||||
}
|
||||
if (native === starting) send({ type: "started", id });
|
||||
} catch (error) {
|
||||
if (native === starting) send({ type: "start-failed", id, message: String(error) });
|
||||
} finally {
|
||||
if (native !== starting) {
|
||||
try { starting.stop(); } catch { /* Late completion after disposal. */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
version: 2,
|
||||
connect(callback) {
|
||||
if (native) throw new Error("Recorded viewer already has an owner");
|
||||
native = create();
|
||||
notify = callback;
|
||||
},
|
||||
invoke(request, bytes) {
|
||||
try {
|
||||
const { method, args = [], id } = JSON.parse(request);
|
||||
let result: unknown;
|
||||
switch (method) {
|
||||
case "stop": stop(); break;
|
||||
case "start": required(); void start(id, args); break;
|
||||
case "ready": result = native?.ready ?? false; break;
|
||||
case "on": {
|
||||
const subscribe = required().on as (event: string, callback: (...values: unknown[]) => void) => () => void;
|
||||
subscriptions.set(id, subscribe.call(required(), args[0], (...values) => send({ type: "event", id, values })));
|
||||
break;
|
||||
}
|
||||
case "off":
|
||||
try { subscriptions.get(id)?.(); } finally { subscriptions.delete(id); }
|
||||
break;
|
||||
case "channel-open": {
|
||||
const name = args[0] as string | undefined;
|
||||
channels.set(id, required().open_channel(name));
|
||||
break;
|
||||
}
|
||||
case "channel-ready": result = channels.get(id)?.ready ?? false; break;
|
||||
case "channel-send": if (bytes) {
|
||||
channels.get(id)?.send_rrd(bytes);
|
||||
} break;
|
||||
case "channel-close":
|
||||
try { channels.get(id)?.close(); } finally {
|
||||
channels.delete(id);
|
||||
}
|
||||
break;
|
||||
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
|
||||
case "get-camera-eye": result = cameraJournal?.current(); break;
|
||||
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
|
||||
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
|
||||
case "open": required().open(args[0]); break;
|
||||
case "close": required().close(args[0]); break;
|
||||
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
|
||||
case "get_active_recording_id": result = required().get_active_recording_id(); break;
|
||||
case "get_active_timeline": result = required().get_active_timeline(args[0]); break;
|
||||
case "get_current_time": result = required().get_current_time(args[0], args[1]); break;
|
||||
case "get_playing": result = required().get_playing(args[0]); break;
|
||||
case "get_time_range": result = required().get_time_range(args[0], args[1]); break;
|
||||
case "set_active_timeline": required().set_active_timeline(args[0], args[1]); break;
|
||||
case "set_current_time": required().set_current_time(args[0], args[1], args[2]); break;
|
||||
case "set_playing": required().set_playing(args[0], args[1]); break;
|
||||
default: throw new Error("Unsupported recorded viewer command");
|
||||
}
|
||||
return JSON.stringify({ result });
|
||||
} catch (error) {
|
||||
// A native Error also has a foreign prototype. Return text, never throw
|
||||
// that object into a parent Promise/callback retained after disposal.
|
||||
return JSON.stringify({ error: String(error) });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Only parent-owned inputs and primitive strings cross the recorded realm.
|
||||
* An SDK instance, Promise, DOM node or native callback never becomes a value
|
||||
* retained by React. This is not a second playback clock.
|
||||
*/
|
||||
export type RerunInvoke = (request: string, bytes?: Uint8Array) => string;
|
||||
export type RerunNotify = (message: string) => void;
|
||||
export interface RerunFrameApi {
|
||||
version: 2;
|
||||
connect: (notify: RerunNotify) => void;
|
||||
invoke: RerunInvoke;
|
||||
}
|
||||
export type RerunFrameWindow = Window & { missionCoreRerun?: RerunFrameApi };
|
||||
@@ -28,19 +28,25 @@ export async function resolveCanonicalLabReplay(
|
||||
origin?: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
sourceKind?: "legacy-vegetation" | "portable-tgs";
|
||||
sourceKind?: "legacy-vegetation" | "portable-tgs" | "portable-semantic" | "portable-objects";
|
||||
} = {},
|
||||
): Promise<CanonicalLabReplayDescriptor> {
|
||||
const base = new URL(origin);
|
||||
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)
|
||||
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||
) {
|
||||
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/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/canonical-replay.rrd`;
|
||||
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
||||
@@ -76,3 +82,53 @@ export async function resolveCanonicalLabReplay(
|
||||
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveAICompositionReplay(
|
||||
runId: string,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
{
|
||||
origin = window.location.origin,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
origin?: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
} = {},
|
||||
): Promise<CanonicalLabReplayDescriptor> {
|
||||
const base = new URL(origin);
|
||||
if (
|
||||
!/^ai-composition-[a-f0-9]{64}$/.test(runId)
|
||||
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|
||||
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||
) {
|
||||
throw new Error("Составной replay LAB имеет небезопасный descriptor.");
|
||||
}
|
||||
const sourceUrl = `/api/v1/observatory/ai-composition-runs/${runId}/replays/${launch.sha256}/recording.rrd`;
|
||||
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
||||
const response = await fetcher(descriptorUrl.href, {
|
||||
method: "HEAD",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/vnd.rerun.rrd" },
|
||||
signal,
|
||||
});
|
||||
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
|
||||
const byteLength = Number(response.headers.get("Content-Length"));
|
||||
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
|
||||
if (
|
||||
response.status !== 200 || contentType !== "application/vnd.rerun.rrd"
|
||||
|| response.headers.get("X-Rerun-Format") !== "RRF2" || !sha256
|
||||
|| !Number.isSafeInteger(byteLength) || byteLength < 4
|
||||
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
|
||||
) {
|
||||
throw new Error("Составной replay LAB не прошёл проверку.");
|
||||
}
|
||||
return {
|
||||
sourceUrl,
|
||||
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
|
||||
byteLength,
|
||||
sha256,
|
||||
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Small lease for the server's ephemeral blueprint, never for the recording. */
|
||||
export function keepRecordedBlueprintSession({
|
||||
endpointUrl,
|
||||
origin,
|
||||
applicationId,
|
||||
recordingId,
|
||||
ownerId,
|
||||
fetcher = fetch,
|
||||
schedule = (callback: () => void) => window.setInterval(callback, 30_000),
|
||||
cancel = (handle: number) => window.clearInterval(handle),
|
||||
}: {
|
||||
endpointUrl: string;
|
||||
origin: string;
|
||||
applicationId: string;
|
||||
recordingId: string;
|
||||
ownerId: string;
|
||||
fetcher?: typeof fetch;
|
||||
schedule?: (callback: () => void) => number;
|
||||
cancel?: (handle: number) => void;
|
||||
}): () => void {
|
||||
const endpoint = new URL(endpointUrl, origin);
|
||||
if (endpoint.origin !== origin || endpoint.search || endpoint.hash ||
|
||||
!/^\/api\/v1\/observation-sessions\/[A-Za-z0-9._:-]+\/blueprint\.rrd$/.test(endpoint.pathname)) {
|
||||
throw new Error("Unsafe recorded blueprint lifecycle endpoint");
|
||||
}
|
||||
endpoint.pathname = endpoint.pathname.replace(/blueprint\.rrd$/, "blueprint-lifecycle");
|
||||
const identity = { application_id: applicationId, recording_id: recordingId,
|
||||
blueprint_session_id: ownerId };
|
||||
let closed = false;
|
||||
let pending: AbortController | null = null;
|
||||
const renew = () => {
|
||||
if (closed) return;
|
||||
pending?.abort();
|
||||
pending = new AbortController();
|
||||
void fetcher(endpoint.href, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...identity, action: "renew" }), signal: pending.signal,
|
||||
}).catch(() => { /* The TTL cleans up after a lost browser/network. */ });
|
||||
};
|
||||
const timer = schedule(renew);
|
||||
return () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
cancel(timer);
|
||||
pending?.abort();
|
||||
pending = null;
|
||||
void fetcher(endpoint.href, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...identity, action: "release" }),
|
||||
keepalive: true, signal: AbortSignal.timeout(5_000),
|
||||
}).catch(() => { /* Terminal release is best-effort; the server also has TTL. */ });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
decodeObservationSessionCatalog,
|
||||
ObservationSessionApiError,
|
||||
ObservationSessionContractError,
|
||||
type ObservationSessionCatalog,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionScope,
|
||||
} from "./sessionArchive";
|
||||
|
||||
const PAGE_SCHEMA = "missioncore.observation-session-page/v1";
|
||||
const CURSOR = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
||||
|
||||
export interface ObservationSessionCatalogPage extends ObservationSessionCatalog {
|
||||
readonly nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** Opt-in paging leaves the legacy catalog response and consumers unchanged. */
|
||||
export async function fetchObservationSessionCatalogPage({
|
||||
scope,
|
||||
limit = 100,
|
||||
cursor = null,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
scope: ObservationSessionScope;
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
}): Promise<ObservationSessionCatalogPage> {
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 100
|
||||
|| (cursor !== null && !CURSOR.test(cursor))) {
|
||||
throw new ObservationSessionContractError("Некорректная страница каталога сессий.");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const query = new URLSearchParams({ limit: String(limit), scope });
|
||||
if (scope === "laboratory") query.set("lab_contract", "v3");
|
||||
query.set("pagination", "cursor-v1");
|
||||
if (cursor !== null) query.set("cursor", cursor);
|
||||
const response = await fetcher(`/api/v1/observation-sessions?${query}`, {
|
||||
method: "GET", headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new ObservationSessionApiError(
|
||||
`Не удалось загрузить страницу каталога сессий (HTTP ${response.status}).`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new ObservationSessionContractError("Некорректный ответ каталога сессий.");
|
||||
}
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
throw new ObservationSessionContractError("Страница каталога должна быть объектом.");
|
||||
}
|
||||
const page = body as Record<string, unknown>;
|
||||
if (Object.keys(page).length !== 3
|
||||
|| page.schema_version !== PAGE_SCHEMA
|
||||
|| !Object.hasOwn(page, "items")
|
||||
|| !(page.next_cursor === null
|
||||
|| (typeof page.next_cursor === "string" && CURSOR.test(page.next_cursor)))) {
|
||||
throw new ObservationSessionContractError("Нарушен контракт страницы каталога сессий.");
|
||||
}
|
||||
const catalog = decodeObservationSessionCatalog({ items: page.items });
|
||||
if (catalog.items.length > limit) {
|
||||
throw new ObservationSessionContractError("Страница каталога превысила запрошенный размер.");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
return { ...catalog, nextCursor: page.next_cursor };
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface RerunPlaybackController {
|
||||
|
||||
export interface RecordedPerceptionLayers {
|
||||
enabled: boolean;
|
||||
cameraImage?: boolean;
|
||||
detections2d: boolean;
|
||||
segmentation: boolean;
|
||||
cuboids3d: boolean;
|
||||
@@ -76,6 +77,8 @@ export interface RecordedSessionRerunProfile {
|
||||
semanticLayer?: "city" | "vegetation";
|
||||
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
|
||||
unifiedPerception?: boolean;
|
||||
/** Controlled camera-column share for the native horizontal Rerun container. */
|
||||
unifiedCameraShare?: number;
|
||||
/** Requests the canonical top-down eye without changing the world coordinate frame. */
|
||||
planView?: boolean;
|
||||
perceptionRetryGeneration: number;
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import {
|
||||
decodeObservatoryRecordedJob,
|
||||
type ObservatoryRecordedJob,
|
||||
} from "./recordedJobs";
|
||||
|
||||
const COMPOSITION_SCHEMA = "missioncore.observatory-ai-composition/v1";
|
||||
const CATALOG_SCHEMA = "missioncore.observatory-ai-module-catalog/v1";
|
||||
const RECEIPT_SCHEMA = "missioncore.observatory-ai-composition-receipt/v3";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type AIGroupId = "segmentation" | "detection" | "geometry" | "range" | "motion" | "policy";
|
||||
|
||||
export interface AIModule {
|
||||
readonly moduleId: string;
|
||||
readonly moduleSha256: string;
|
||||
readonly label: string;
|
||||
readonly dockerName: string;
|
||||
readonly requires: readonly string[];
|
||||
readonly provides: readonly string[];
|
||||
readonly defaults: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface AIModuleGroup {
|
||||
readonly group: AIGroupId;
|
||||
readonly modules: readonly AIModule[];
|
||||
}
|
||||
|
||||
export interface AIModuleCatalog {
|
||||
readonly groups: readonly AIModuleGroup[];
|
||||
}
|
||||
|
||||
export interface AICompositionReceipt {
|
||||
readonly compositionSha256: string;
|
||||
readonly created: boolean;
|
||||
readonly dispatchReady: boolean;
|
||||
readonly dispatchReason: string;
|
||||
readonly setupIds: readonly string[];
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
readonly run: AICompositionRun;
|
||||
}
|
||||
|
||||
export interface AIViewerLayer {
|
||||
readonly layerId: string;
|
||||
readonly paneId: "camera" | "spatial";
|
||||
readonly label: string;
|
||||
readonly control: "toggle" | "toggle-with-settings";
|
||||
readonly order: number;
|
||||
}
|
||||
|
||||
export interface AICompositionPresentation {
|
||||
readonly configurationLabel: string;
|
||||
readonly modules: readonly { readonly moduleId: string; readonly label: string }[];
|
||||
readonly viewerLayers: readonly AIViewerLayer[];
|
||||
}
|
||||
|
||||
export interface AICompositionRun {
|
||||
readonly runId: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly compositionSha256: string;
|
||||
readonly moduleIds: readonly string[];
|
||||
readonly setupIds: readonly string[];
|
||||
readonly jobIds: readonly string[];
|
||||
readonly resultIds: readonly string[];
|
||||
readonly createdAtUtc: string;
|
||||
readonly state: "running" | "ready" | "failed";
|
||||
readonly configurationLabel: string;
|
||||
readonly displayName: string | null;
|
||||
readonly presentation: AICompositionPresentation;
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
}
|
||||
|
||||
export const AI_MODULE_SETUP_IDS: Readonly<Record<string, string>> = Object.freeze({
|
||||
ddrnet: "ai-segmentation-ddrnet-v1",
|
||||
eomt: "ai-segmentation-eomt-v1",
|
||||
tgs: "m49-tgs-portable-v2",
|
||||
"rf-detr": "ai-detection-rf-detr-v1",
|
||||
"object-distance": "ai-range-object-distance-v1",
|
||||
});
|
||||
|
||||
export function aiModuleIdForSetup(setupId: string): string | null {
|
||||
return Object.entries(AI_MODULE_SETUP_IDS).find(([, value]) => value === setupId)?.[0] ?? null;
|
||||
}
|
||||
|
||||
export async function fetchAIModuleCatalog(signal?: AbortSignal): Promise<AIModuleCatalog> {
|
||||
const response = await fetch("/api/v1/observatory/ai-module-catalog", {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "каталог AI-модулей");
|
||||
exactKeys(root, ["authority", "groups", "schema_version"]);
|
||||
if (root.schema_version !== CATALOG_SCHEMA) throw new Error("Версия каталога AI-модулей изменилась.");
|
||||
const known = new Set<AIGroupId>(["segmentation", "detection", "geometry", "range", "motion", "policy"]);
|
||||
const groups = array(root.groups).map((value): AIModuleGroup => {
|
||||
const group = record(value, "группа AI-модулей");
|
||||
exactKeys(group, ["group", "modules"]);
|
||||
if (typeof group.group !== "string" || !known.has(group.group as AIGroupId)) {
|
||||
throw new Error("Каталог вернул неизвестную группу AI-модулей.");
|
||||
}
|
||||
return { group: group.group as AIGroupId, modules: array(group.modules).map(moduleOf) };
|
||||
});
|
||||
if (new Set(groups.map((group) => group.group)).size !== groups.length) {
|
||||
throw new Error("Каталог AI-модулей содержит повторяющиеся группы.");
|
||||
}
|
||||
return { groups };
|
||||
}
|
||||
|
||||
export async function saveAIComposition(
|
||||
sourceSessionId: string,
|
||||
selections: readonly { group: AIGroupId; module: AIModule }[],
|
||||
): Promise<AICompositionReceipt> {
|
||||
const response = await fetch("/api/v1/observatory/ai-compositions", {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: COMPOSITION_SCHEMA,
|
||||
source_session_id: sourceSessionId,
|
||||
idempotency_key: createIdempotencyKey(),
|
||||
selections: selections.map(({ group, module }) => ({
|
||||
group, module_id: module.moduleId, module_sha256: module.moduleSha256,
|
||||
parameters: module.defaults,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "конфигурация AI-слоя");
|
||||
exactKeys(root, ["composition", "composition_sha256", "created", "dispatch", "run", "schema_version", "source_session_id"]);
|
||||
if (root.schema_version !== RECEIPT_SCHEMA || root.source_session_id !== sourceSessionId) {
|
||||
throw new Error("Сервер вернул конфигурацию для другой записи.");
|
||||
}
|
||||
const digest = text(root.composition_sha256);
|
||||
if (!SHA256.test(digest)) throw new Error("Сервер вернул некорректную идентичность композиции.");
|
||||
const dispatch = record(root.dispatch, "готовность композиции");
|
||||
exactKeys(dispatch, ["jobs", "ready", "reason", "setup_ids"]);
|
||||
if (dispatch.ready !== true) {
|
||||
throw new Error(text(dispatch.reason));
|
||||
}
|
||||
const setupIds = array(dispatch.setup_ids).map(text);
|
||||
const jobs = array(dispatch.jobs).map(decodeObservatoryRecordedJob);
|
||||
if (!jobs.length || jobs.length !== setupIds.length
|
||||
|| jobs.some((job, index) => job.setupId !== setupIds[index])) {
|
||||
throw new Error("Сервер вернул неполную очередь композиции.");
|
||||
}
|
||||
const run = compositionRunOf(root.run);
|
||||
return {
|
||||
compositionSha256: digest,
|
||||
created: boolean(dispatch && root.created),
|
||||
dispatchReady: boolean(dispatch.ready),
|
||||
dispatchReason: text(dispatch.reason),
|
||||
setupIds,
|
||||
jobs,
|
||||
run,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchAICompositionRuns(
|
||||
sourceSessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<readonly AICompositionRun[]> {
|
||||
const query = new URLSearchParams({ source_session_id: sourceSessionId });
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs?${query}`, {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "запуски композиций");
|
||||
exactKeys(root, ["items", "schema_version"]);
|
||||
if (root.schema_version !== "missioncore.observatory-ai-composition-run-list/v1") {
|
||||
throw new Error("Версия запусков AI-композиций изменилась.");
|
||||
}
|
||||
return array(root.items).map(compositionRunOf);
|
||||
}
|
||||
|
||||
export async function renameAICompositionRunProjection(
|
||||
runId: string,
|
||||
displayName: string,
|
||||
): Promise<string> {
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
|
||||
throw new Error("Некорректная идентичность результата AI inference.");
|
||||
}
|
||||
const normalized = displayName.trim();
|
||||
if (!normalized || normalized.length > 160) {
|
||||
throw new Error("Название результата должно содержать от 1 до 160 символов.");
|
||||
}
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: "missioncore.observatory-ai-composition-run-rename/v1",
|
||||
display_name: normalized,
|
||||
}),
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const row = record(body, "результат AI inference");
|
||||
exactKeys(row, ["display_name", "run_id", "schema_version"]);
|
||||
if (
|
||||
row.schema_version !== "missioncore.observatory-ai-composition-run-projection/v1"
|
||||
|| row.run_id !== runId
|
||||
|| row.display_name !== normalized
|
||||
) throw new Error("Сервер не подтвердил переименование результата AI inference.");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function deleteAICompositionRunProjection(runId: string): Promise<void> {
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId)) {
|
||||
throw new Error("Некорректная идентичность результата AI inference.");
|
||||
}
|
||||
const response = await fetch(`/api/v1/observatory/ai-composition-runs/${encodeURIComponent(runId)}`, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok || response.status !== 204) {
|
||||
throw apiError(await bodyOf(response), response.status);
|
||||
}
|
||||
}
|
||||
|
||||
function compositionRunOf(value: unknown): AICompositionRun {
|
||||
const row = record(value, "запуск AI-композиции");
|
||||
const expected = [
|
||||
"composition_sha256", "configuration_label", "created_at_utc", "display_name", "job_ids", "jobs",
|
||||
"module_ids", "presentation", "result_ids", "run_id", "schema_version", "setup_ids",
|
||||
"source_session_id", "state",
|
||||
];
|
||||
// The receipt embeds the immutable run before its live state projection.
|
||||
const receiptShape = [
|
||||
"composition_sha256", "created_at_utc", "job_ids", "module_ids", "presentation",
|
||||
"run_id", "schema_version", "setup_ids", "source_session_id",
|
||||
];
|
||||
const keys = Object.keys(row);
|
||||
const projected = keys.length === expected.length;
|
||||
exactKeys(row, projected ? expected : receiptShape);
|
||||
if (row.schema_version !== "missioncore.observatory-ai-composition-run/v1") {
|
||||
throw new Error("Версия запуска AI-композиции изменилась.");
|
||||
}
|
||||
const sourceSessionId = text(row.source_session_id);
|
||||
const compositionSha256 = text(row.composition_sha256);
|
||||
const runId = text(row.run_id);
|
||||
if (!/^ai-composition-[a-f0-9]{64}$/.test(runId) || !SHA256.test(compositionSha256)) {
|
||||
throw new Error("Некорректная идентичность запуска AI-композиции.");
|
||||
}
|
||||
const presentation = presentationOf(row.presentation);
|
||||
const state = projected ? text(row.state) : "running";
|
||||
if (!new Set(["running", "ready", "failed"]).has(state)) {
|
||||
throw new Error("Неизвестное состояние запуска AI-композиции.");
|
||||
}
|
||||
return {
|
||||
runId,
|
||||
sourceSessionId,
|
||||
compositionSha256,
|
||||
moduleIds: array(row.module_ids).map(text),
|
||||
setupIds: array(row.setup_ids).map(text),
|
||||
jobIds: array(row.job_ids).map(text),
|
||||
resultIds: projected ? array(row.result_ids).map(text) : [],
|
||||
createdAtUtc: text(row.created_at_utc),
|
||||
state: state as AICompositionRun["state"],
|
||||
configurationLabel: projected ? text(row.configuration_label) : presentation.configurationLabel,
|
||||
displayName: projected
|
||||
? row.display_name === null ? null : text(row.display_name)
|
||||
: null,
|
||||
presentation,
|
||||
jobs: projected ? array(row.jobs).map(decodeObservatoryRecordedJob) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function presentationOf(value: unknown): AICompositionPresentation {
|
||||
const row = record(value, "проекция AI-композиции");
|
||||
exactKeys(row, ["configuration_label", "modules", "schema_version", "viewer_layers"]);
|
||||
if (row.schema_version !== "missioncore.observatory-presentation-projection/v1") {
|
||||
throw new Error("Версия проекции AI-композиции изменилась.");
|
||||
}
|
||||
const modules = array(row.modules).map((value) => {
|
||||
const module = record(value, "модуль проекции");
|
||||
exactKeys(module, ["label", "module_id"]);
|
||||
return { moduleId: text(module.module_id), label: text(module.label) };
|
||||
});
|
||||
const viewerLayers = array(row.viewer_layers).map((value): AIViewerLayer => {
|
||||
const layer = record(value, "слой viewer");
|
||||
exactKeys(layer, ["control", "label", "layer_id", "order", "pane_id"]);
|
||||
const paneId = text(layer.pane_id);
|
||||
const control = text(layer.control);
|
||||
if (!new Set(["camera", "spatial"]).has(paneId)
|
||||
|| !new Set(["toggle", "toggle-with-settings"]).has(control)
|
||||
|| typeof layer.order !== "number") {
|
||||
throw new Error("Некорректная проекция слоя viewer.");
|
||||
}
|
||||
return {
|
||||
layerId: text(layer.layer_id), paneId: paneId as AIViewerLayer["paneId"],
|
||||
label: text(layer.label), control: control as AIViewerLayer["control"], order: layer.order,
|
||||
};
|
||||
});
|
||||
return { configurationLabel: text(row.configuration_label), modules, viewerLayers };
|
||||
}
|
||||
|
||||
export async function fetchAICompositionJobs(
|
||||
sourceSessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<readonly ObservatoryRecordedJob[]> {
|
||||
const query = new URLSearchParams({ source_session_id: sourceSessionId });
|
||||
const response = await fetch(`/api/v1/observatory/ai-runs?${query}`, {
|
||||
headers: { Accept: "application/json" }, signal,
|
||||
});
|
||||
const body = await bodyOf(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const root = record(body, "очередь AI-слоёв");
|
||||
exactKeys(root, ["authority", "items", "schema_version"]);
|
||||
if (root.schema_version !== "missioncore.observatory-recorded-job-list/v1") {
|
||||
throw new Error("Версия очереди AI-слоёв изменилась.");
|
||||
}
|
||||
return array(root.items).map(decodeObservatoryRecordedJob);
|
||||
}
|
||||
|
||||
function moduleOf(value: unknown): AIModule {
|
||||
const row = record(value, "AI-модуль");
|
||||
exactKeys(row, ["defaults", "docker_name", "label", "module_id", "module_sha256", "parameter_choices", "provides", "requires"]);
|
||||
const digest = text(row.module_sha256);
|
||||
if (!SHA256.test(digest)) throw new Error("Каталог вернул некорректную версию AI-модуля.");
|
||||
return {
|
||||
moduleId: text(row.module_id), moduleSha256: digest, label: text(row.label),
|
||||
dockerName: text(row.docker_name), requires: array(row.requires).map(text),
|
||||
provides: array(row.provides).map(text), defaults: record(row.defaults, "параметры AI-модуля"),
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Некорректный ${label}.`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function array(value: unknown): unknown[] { if (!Array.isArray(value)) throw new Error("Ожидался список."); return value; }
|
||||
function text(value: unknown): string { if (typeof value !== "string" || !value) throw new Error("Ожидался текст."); return value; }
|
||||
function boolean(value: unknown): boolean { if (typeof value !== "boolean") throw new Error("Ожидалось логическое значение."); return value; }
|
||||
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
|
||||
if (Object.keys(value).sort().join() !== [...expected].sort().join()) throw new Error("Контракт AI-слоя изменился.");
|
||||
}
|
||||
async function bodyOf(response: Response): Promise<unknown> { const value = await response.text(); try { return JSON.parse(value); } catch { return value; } }
|
||||
function apiError(body: unknown, status: number): Error {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body) ? (body as Record<string, unknown>).detail : null;
|
||||
return new Error(typeof detail === "string" ? detail : `Observatory API вернул HTTP ${status}.`);
|
||||
}
|
||||
|
||||
function createIdempotencyKey(): string {
|
||||
const entropy = typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `ai-layer:${entropy}`;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
fetchObservationSessionCatalog,
|
||||
type ObservationLabInstance,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionSummary,
|
||||
} from "../observation/sessionArchive";
|
||||
import { fetchObservationSessionCatalogPage } from "../observation/sessionCatalogPage";
|
||||
import {
|
||||
observatoryRecordedRunBinding,
|
||||
type ObservatoryRecordedRunBinding,
|
||||
@@ -164,19 +164,23 @@ function boundedLimit(limit: number): number {
|
||||
|
||||
function evidenceFromSession(
|
||||
session: ObservationSessionSummary,
|
||||
): ObservatoryEvidence {
|
||||
): ObservatoryEvidence | null {
|
||||
if (!session.lab) {
|
||||
throw new ObservatoryCatalogContractError(
|
||||
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
|
||||
);
|
||||
}
|
||||
// Historical LABs retain their archive and replay adapters. Observatory is
|
||||
// the portable-profile product; a familiar LAB label is not admission.
|
||||
if (session.lab.replayCapability?.kind !== "portable-result-review") return null;
|
||||
const recordedRun = observatoryRecordedRunBinding(session.id, session.lab);
|
||||
return {
|
||||
sessionId: session.id,
|
||||
label: session.label,
|
||||
status: session.status,
|
||||
publishedAtUtc: session.lab.publishedAtUtc,
|
||||
lab: session.lab,
|
||||
recordedRun: observatoryRecordedRunBinding(session.id, session.lab),
|
||||
recordedRun,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,11 +212,11 @@ export function buildObservatoryCatalog(
|
||||
const unresolvedEvidence: ObservatoryEvidence[] = [];
|
||||
for (const laboratorySession of laboratorySessions) {
|
||||
const evidence = evidenceFromSession(laboratorySession);
|
||||
if (!evidence) continue;
|
||||
const sourceId = evidence.lab.sourceSessionId;
|
||||
if (!sources.has(sourceId)) {
|
||||
// Both API projections are bounded newest-first windows without a
|
||||
// cursor. Absence from the source window is not proof of a broken
|
||||
// relationship and must not be presented as an integrity fault.
|
||||
// A bounded traversal may stop before this source. Absence alone is
|
||||
// not proof of a broken relationship or permission to remove data.
|
||||
unresolvedEvidence.push(evidence);
|
||||
continue;
|
||||
}
|
||||
@@ -236,7 +240,8 @@ export function buildObservatoryCatalog(
|
||||
window: {
|
||||
limit: safeLimit,
|
||||
sourceCount: sourceSessions.length,
|
||||
laboratoryCount: laboratorySessions.length,
|
||||
laboratoryCount: [...linked.values()].reduce((count, items) => count + items.length, 0)
|
||||
+ unresolvedEvidence.length,
|
||||
sourceLimitReached: sourceSessions.length >= safeLimit,
|
||||
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
|
||||
},
|
||||
@@ -253,9 +258,64 @@ export async function fetchObservatoryCatalog({
|
||||
fetcher?: ObservationSessionFetch;
|
||||
} = {}): Promise<ObservatoryCatalog> {
|
||||
const safeLimit = boundedLimit(limit);
|
||||
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
|
||||
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
|
||||
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
|
||||
]);
|
||||
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
|
||||
const request = new AbortController();
|
||||
const abort = () => request.abort(signal?.reason);
|
||||
if (signal?.aborted) abort();
|
||||
else signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
const [sources, laboratories] = await Promise.all([
|
||||
readCatalogPages("source", safeLimit, fetcher, request.signal),
|
||||
readCatalogPages("laboratory", safeLimit, fetcher, request.signal),
|
||||
]);
|
||||
const catalog = buildObservatoryCatalog(sources.items, laboratories.items, safeLimit);
|
||||
return {
|
||||
...catalog,
|
||||
window: {
|
||||
...catalog.window,
|
||||
sourceLimitReached: sources.hasMore,
|
||||
laboratoryLimitReached: laboratories.hasMore,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
request.abort();
|
||||
signal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata only, never recordings or result artifacts. The cap is an explicit
|
||||
// partial window, not silent completeness or an unbounded background crawler.
|
||||
const MAX_CATALOG_PAGES = 256;
|
||||
|
||||
async function readCatalogPages(
|
||||
scope: "source" | "laboratory",
|
||||
limit: number,
|
||||
fetcher: ObservationSessionFetch,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ items: ObservationSessionSummary[]; hasMore: boolean }> {
|
||||
const items: ObservationSessionSummary[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
const cursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
for (let index = 0; index < MAX_CATALOG_PAGES; index += 1) {
|
||||
const page = await fetchObservationSessionCatalogPage({ scope, limit, cursor, signal, fetcher });
|
||||
for (const item of page.items) {
|
||||
if (seenIds.has(item.id)) {
|
||||
throw new ObservatoryCatalogContractError("Каталог повторил сессию между страницами.");
|
||||
}
|
||||
seenIds.add(item.id);
|
||||
// Do not accumulate legacy provenance while walking the shared catalog.
|
||||
if (scope === "source" || item.lab?.replayCapability?.kind === "portable-result-review") {
|
||||
items.push(item);
|
||||
} else if (!item.lab) {
|
||||
throw new ObservatoryCatalogContractError("LAB-каталог вернул исходную сессию.");
|
||||
}
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
if (cursor === null) return { items, hasMore: false };
|
||||
if (cursors.has(cursor)) {
|
||||
throw new ObservatoryCatalogContractError("Каталог повторил курсор страницы.");
|
||||
}
|
||||
cursors.add(cursor);
|
||||
}
|
||||
return { items, hasMore: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { SceneSettings } from "../../sceneSettings";
|
||||
|
||||
const PROFILE_SCHEMA = "missioncore.observatory-lab-view-profile/v1";
|
||||
|
||||
export async function fetchLabViewProfile(
|
||||
resultId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SceneSettings | null> {
|
||||
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) throw new Error("Профиль отображения LAB не загрузился.");
|
||||
return decodeProfile(body, resultId);
|
||||
}
|
||||
|
||||
export async function saveLabViewProfile(
|
||||
resultId: string,
|
||||
settings: SceneSettings,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SceneSettings> {
|
||||
const response = await fetch(`/api/v1/observatory/lab-view-profiles/${encodeURIComponent(resultId)}`, {
|
||||
method: "PUT",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: PROFILE_SCHEMA,
|
||||
result_id: resultId,
|
||||
scene_settings: {
|
||||
point_size: settings.pointSize,
|
||||
accumulation_seconds: settings.accumulationSeconds,
|
||||
color_mode: settings.colorMode,
|
||||
palette: settings.palette,
|
||||
show_grid: settings.showGrid,
|
||||
show_labels: settings.showLabels,
|
||||
show_camera_frustums: settings.showCameraFrustums,
|
||||
},
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const body: unknown = await response.json();
|
||||
if (!response.ok) throw new Error("Настройки LAB не сохранились. Повторите закрытие окна.");
|
||||
return decodeProfile(body, resultId);
|
||||
}
|
||||
|
||||
function decodeProfile(value: unknown, expectedResultId: string): SceneSettings {
|
||||
const row = record(value);
|
||||
exactKeys(row, ["result_id", "scene_settings", "schema_version", "updated_at_utc"]);
|
||||
if (row.schema_version !== PROFILE_SCHEMA || row.result_id !== expectedResultId) {
|
||||
throw new Error("Сервер вернул профиль другой LAB.");
|
||||
}
|
||||
const settings = record(row.scene_settings);
|
||||
exactKeys(settings, [
|
||||
"accumulation_seconds", "color_mode", "palette", "point_size",
|
||||
"show_camera_frustums", "show_grid", "show_labels",
|
||||
]);
|
||||
const pointSize = finite(settings.point_size);
|
||||
const accumulationSeconds = finite(settings.accumulation_seconds);
|
||||
const colorMode = settings.color_mode;
|
||||
const palette = settings.palette;
|
||||
if (
|
||||
pointSize < 0.1
|
||||
|| accumulationSeconds < 0
|
||||
|| !new Set(["intensity", "height", "distance", "rgb", "class"]).has(String(colorMode))
|
||||
|| !new Set(["turbo", "viridis", "plasma", "grayscale"]).has(String(palette))
|
||||
|| typeof settings.show_grid !== "boolean"
|
||||
|| typeof settings.show_labels !== "boolean"
|
||||
|| typeof settings.show_camera_frustums !== "boolean"
|
||||
) throw new Error("Сервер вернул повреждённый профиль LAB.");
|
||||
return {
|
||||
projection: "3d",
|
||||
pointSize,
|
||||
colorMode: colorMode as SceneSettings["colorMode"],
|
||||
palette: palette as SceneSettings["palette"],
|
||||
customColor: "#35d7c1",
|
||||
accumulationSeconds,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showGrid: settings.show_grid,
|
||||
showLabels: settings.show_labels,
|
||||
showCameraFrustums: settings.show_camera_frustums,
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("Некорректный профиль отображения LAB.");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function finite(value: unknown): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw new Error("Некорректное числовое значение профиля LAB.");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: string[]): void {
|
||||
if (Object.keys(value).sort().join() !== [...expected].sort().join()) {
|
||||
throw new Error("Контракт профиля отображения LAB изменился.");
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export async function fetchObservatoryRecordedJobs(
|
||||
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
|
||||
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
|
||||
observationAuthority(row.authority);
|
||||
return array(row.items, "items").map(decodeJob).filter((job) => (
|
||||
return array(row.items, "items").map(decodeObservatoryRecordedJob).filter((job) => (
|
||||
job.sourceSessionId === sourceSessionId && job.setupId === setupId
|
||||
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
|
||||
));
|
||||
@@ -125,7 +125,7 @@ export async function submitObservatoryRecordedJob(
|
||||
});
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const job = decodeJob(body);
|
||||
const job = decodeObservatoryRecordedJob(body);
|
||||
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|
||||
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
|
||||
throw new ObservatoryRecordedJobContractError(
|
||||
@@ -156,7 +156,7 @@ export async function retryObservatoryRecordedJobPublication(
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const job = decodeJob(body);
|
||||
const job = decodeObservatoryRecordedJob(body);
|
||||
if (job.jobId !== jobId) {
|
||||
throw new ObservatoryRecordedJobContractError(
|
||||
"Повтор публикации вернул другой расчёт.",
|
||||
@@ -165,7 +165,7 @@ export async function retryObservatoryRecordedJobPublication(
|
||||
return job;
|
||||
}
|
||||
|
||||
function decodeJob(value: unknown): ObservatoryRecordedJob {
|
||||
export function decodeObservatoryRecordedJob(value: unknown): ObservatoryRecordedJob {
|
||||
const row = record(value, "расчёт");
|
||||
exactKeys(row, [
|
||||
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface RecordedProgressView {
|
||||
readonly completed: number;
|
||||
readonly total: number | null;
|
||||
readonly ageSeconds: number;
|
||||
readonly elapsedSeconds: number;
|
||||
readonly phaseElapsedSeconds: number;
|
||||
}
|
||||
|
||||
export async function fetchRecordedProgress(
|
||||
@@ -71,7 +73,8 @@ export function decodeRecordedProgress(
|
||||
const completed = integer(progress.completed);
|
||||
const total = progress.total === null ? null : integer(progress.total, 1);
|
||||
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("Некорректные счётчики прогресса.");
|
||||
}
|
||||
integer(progress.phase_index);
|
||||
@@ -79,6 +82,7 @@ export function decodeRecordedProgress(
|
||||
jobId: job.jobId, claimGeneration: integer(progress.claim_generation, 1),
|
||||
sequence: integer(progress.sequence, 1), phase: progress.phase as Phase,
|
||||
unit: progress.unit as Unit, completed, total, ageSeconds: finite(row.age_seconds),
|
||||
elapsedSeconds: elapsed, phaseElapsedSeconds: phaseElapsed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +91,8 @@ export function recordedProgressLabel(
|
||||
): string {
|
||||
if (job?.publication.state === "pending") return "Сохраняем результат";
|
||||
if (!job || job.state === "accepted" || job.state === "queued") return "Ожидаем расчёт";
|
||||
if (job.state === "failed") return "Ошибка расчёта";
|
||||
if (job.state === "succeeded") return "Расчёт завершён";
|
||||
if (job.state === "paused" || job.state === "preemption-pending") return "Расчёт приостановлен";
|
||||
if (job.state === "reconciliation-required") return "Проверяем состояние расчёта";
|
||||
if (!progress || progress.jobId !== job.jobId
|
||||
|
||||
@@ -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",
|
||||
root: "polygon",
|
||||
label: "Обсерватория",
|
||||
title: "Проверка компьютерного зрения",
|
||||
title: "AI inference",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
||||
description: "Сессии и квалификация без доступа к управлению.",
|
||||
icon: "eye",
|
||||
kind: "observatory",
|
||||
groups: [],
|
||||
|
||||
@@ -89,16 +89,6 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__viewport-lock
|
||||
.rerun-viewport__camera-lock {
|
||||
width: var(--canonical-rerun-camera-pane, 100%);
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__viewport-lock[data-split-view="true"]
|
||||
.rerun-viewport__camera-lock {
|
||||
width: calc(var(--canonical-rerun-camera-pane, 46%) - 0.75rem);
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__loading {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
@@ -155,29 +145,16 @@
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
align-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"]
|
||||
> .m4-replay-threat-visual__pane-layer-controls {
|
||||
flex: 1 0 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"][data-multi-semantic="true"]
|
||||
> .m4-replay-threat-visual__pane-mode-controls {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]:has(
|
||||
.m4-replay-threat-visual__review-controls
|
||||
) .m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
||||
@@ -185,12 +162,17 @@
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"] {
|
||||
right: 3.8rem;
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="spatial"],
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
|
||||
.m4-replay-threat-visual__deck:not([data-split="true"])
|
||||
.m4-replay-threat-visual__pane-toolbar[data-pane-toolbar="media"] {
|
||||
/* The two viewer-wide actions stay at the deck's right edge. Keep their
|
||||
complete hit area out of this viewport-owned toolbar at every split. */
|
||||
right: 7rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-toolbar > *,
|
||||
@@ -199,18 +181,15 @@
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__spatial-toolbar-end {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-width: none;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__spatial-toolbar-end::-webkit-scrollbar {
|
||||
display: none;
|
||||
.m4-replay-threat-visual__pane-toolbar > .m4-replay-threat-visual__pane-layer-controls,
|
||||
.m4-replay-threat-visual__spatial-toolbar-end > .m4-replay-threat-visual__pane-layer-controls {
|
||||
/* A viewport toolbar is one adaptive flex sequence. `display: contents`
|
||||
keeps the semantic layer group while allowing each control to move to the
|
||||
next row only when the viewport itself runs out of width. */
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__pane-mode-controls {
|
||||
@@ -259,7 +238,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--nodedc-glass-outline);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-floating-surface);
|
||||
padding: 0.42rem 0.58rem;
|
||||
@@ -280,6 +260,15 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"]
|
||||
.laboratory-evidence-viewer__controls {
|
||||
right: 0.6rem;
|
||||
left: auto;
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer[data-mode-controls="content"] .laboratory-evidence-viewer__controls > .nodedc-icon-button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -304,6 +293,30 @@
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-with-settings {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 0.18rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
padding: 0.12rem;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__layer-with-settings > .nodedc-icon-button:focus-visible {
|
||||
background: var(--nodedc-focus-surface);
|
||||
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__review-controls,
|
||||
.m4-replay-threat-visual__review-controls > *,
|
||||
.m4-replay-threat-visual__single-pane-controls,
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.observatory-lead,
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice,
|
||||
@@ -21,18 +20,15 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.observatory-lead,
|
||||
.observatory-notice {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.observatory-lead > div,
|
||||
.observatory-catalog-bar__copy,
|
||||
.observatory-evidence header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-lead h2,
|
||||
.observatory-catalog-bar h3,
|
||||
.observatory-session-summary h3,
|
||||
.observatory-evidence h3 {
|
||||
@@ -47,7 +43,6 @@
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.observatory-lead p,
|
||||
.observatory-state p,
|
||||
.observatory-evidence-empty p {
|
||||
max-width: 52rem;
|
||||
@@ -197,6 +192,50 @@
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.observatory-evidence__ordering {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort {
|
||||
display: grid;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort:hover,
|
||||
.observatory-evidence__sort:focus-visible {
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
color: var(--nodedc-text-primary);
|
||||
}
|
||||
|
||||
.observatory-evidence__sort svg {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
transition: transform 160ms ease-out;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort path {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.observatory-evidence__sort[data-direction="oldest"] svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.observatory-evidence-list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
@@ -241,6 +280,14 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy strong {
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
@@ -251,6 +298,28 @@
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-evidence-card--progress {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__progress {
|
||||
height: 0.28rem;
|
||||
margin: 0.18rem 0 0.08rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgb(var(--nodedc-accent-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
transition: width 180ms ease-out;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__actions,
|
||||
.observatory-replay__header,
|
||||
.observatory-replay-state,
|
||||
@@ -282,6 +351,7 @@
|
||||
.observatory-replay {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
font-family: var(--nodedc-font-family);
|
||||
}
|
||||
|
||||
.observatory-replay__header {
|
||||
@@ -293,10 +363,37 @@
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.observatory-replay__header h3 {
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.observatory-replay__header p,
|
||||
.observatory-replay-state p {
|
||||
.observatory-replay-state p,
|
||||
.observatory-replay__description {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-family: var(--nodedc-font-family);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
font-weight: var(--nodedc-font-weight-regular);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.observatory-replay__description {
|
||||
margin-right: 0.25rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.observatory-replay .m4-replay-threat-visual__buffering {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-replay .laboratory-evidence-viewer {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.observatory-evidence-empty {
|
||||
@@ -369,7 +466,6 @@
|
||||
}
|
||||
|
||||
@container observatory-workspace (max-width: 920px) {
|
||||
.observatory-lead,
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice {
|
||||
@@ -399,3 +495,146 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
.observatory-ai-config {
|
||||
display: grid;
|
||||
gap: var(--nodedc-space-4);
|
||||
}
|
||||
|
||||
.observatory-ai-config__lead,
|
||||
.observatory-ai-config__error {
|
||||
margin: 0;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.observatory-ai-config__error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
.observatory-ai-config__duplicate {
|
||||
margin-right: auto;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@keyframes observatory-ai-calculate-pulse {
|
||||
0%,
|
||||
100% {
|
||||
background-color: var(--nodedc-primary-action-bg);
|
||||
}
|
||||
|
||||
50% {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--nodedc-primary-action-bg) 88%,
|
||||
var(--nodedc-canvas-soft)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.observatory-ai-config__calculate--saving:disabled {
|
||||
animation: observatory-ai-calculate-pulse 1.8s ease-in-out infinite;
|
||||
cursor: progress;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.observatory-ai-config-window {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: var(--nodedc-shadow-window);
|
||||
}
|
||||
|
||||
.observatory-ai-config-window::before,
|
||||
.observatory-ai-config-window::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group {
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--nodedc-space-3);
|
||||
padding: var(--nodedc-space-3) var(--nodedc-space-4);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header .nodedc-status {
|
||||
min-width: 0;
|
||||
max-width: 55%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header span {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-ai-module-group__body {
|
||||
padding: var(--nodedc-space-4);
|
||||
border: 0;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group__body .nodedc-select-anchor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.observatory-ai-config-window .nodedc-select-trigger,
|
||||
.observatory-ai-config-window .nodedc-window__close {
|
||||
border: 0;
|
||||
outline: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.observatory-ai-config-window .nodedc-select-trigger:focus-visible,
|
||||
.observatory-ai-config-window .nodedc-window__close:focus-visible {
|
||||
background-color: var(--nodedc-focus-surface);
|
||||
box-shadow:
|
||||
var(--nodedc-glass-control-shadow),
|
||||
inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.observatory-ai-config-window .nodedc-window__title {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.observatory-ai-module-group > header .nodedc-status {
|
||||
max-width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.observatory-ai-config__calculate--saving:disabled {
|
||||
animation: none;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--nodedc-primary-action-bg) 94%,
|
||||
var(--nodedc-canvas-soft)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,13 @@
|
||||
height: calc(100% + var(--rerun-native-chrome-height));
|
||||
}
|
||||
|
||||
.rerun-viewport__runtime {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.rerun-viewport__canvas canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||
import { AICompositionReplay } from "../../components/laboratory/AICompositionReplay";
|
||||
import { PortableResultReplay } from "../../components/laboratory/PortableResultReplay";
|
||||
import { AIConfigurationWindow } from "../../components/observatory/AIConfigurationWindow";
|
||||
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
|
||||
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
|
||||
import {
|
||||
@@ -32,9 +34,14 @@ import {
|
||||
renameObservatoryLabProjection,
|
||||
} from "../../core/observatory/catalogMutations";
|
||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
|
||||
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
|
||||
import { recordedProgressLabel } from "../../core/observatory/recordedProgress";
|
||||
import { useAICompositionJobs } from "../../core/observatory/useAICompositionJobs";
|
||||
import {
|
||||
deleteAICompositionRunProjection,
|
||||
renameAICompositionRunProjection,
|
||||
type AICompositionRun,
|
||||
} from "../../core/observatory/aiComposition";
|
||||
import type { ObservatoryRecordedJob } from "../../core/observatory/recordedJobs";
|
||||
import type { RecordedProgressView } from "../../core/observatory/recordedProgress";
|
||||
import type { WorkspaceDefinition } from "../../productModel";
|
||||
|
||||
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
||||
@@ -53,8 +60,31 @@ type ObservatoryMutationReconciliation =
|
||||
readonly sessionId: string;
|
||||
};
|
||||
|
||||
type EvidenceSortDirection = "newest" | "oldest";
|
||||
|
||||
type ObservatoryEvidenceRow =
|
||||
| {
|
||||
readonly kind: "composition";
|
||||
readonly id: string;
|
||||
readonly timestamp: string;
|
||||
readonly run: AICompositionRun;
|
||||
}
|
||||
| {
|
||||
readonly kind: "job";
|
||||
readonly id: string;
|
||||
readonly timestamp: string;
|
||||
readonly job: ObservatoryRecordedJob;
|
||||
}
|
||||
| {
|
||||
readonly kind: "evidence";
|
||||
readonly id: string;
|
||||
readonly timestamp: string;
|
||||
readonly evidence: ObservatoryEvidence;
|
||||
};
|
||||
|
||||
type ObservatoryReplayState =
|
||||
| { readonly kind: "closed" }
|
||||
| { readonly kind: "composition"; readonly run: AICompositionRun }
|
||||
| {
|
||||
readonly kind: "loading";
|
||||
readonly binding: ObservatoryRecordedRunBinding;
|
||||
@@ -114,15 +144,198 @@ function formatDuration(seconds: number): string {
|
||||
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function compositionEventTimestamp(run: AICompositionRun): string {
|
||||
if (run.state === "running" || run.jobs.length === 0) return run.createdAtUtc;
|
||||
return run.jobs.reduce((latest, job) => (
|
||||
Date.parse(job.updatedAtUtc) > Date.parse(latest) ? job.updatedAtUtc : latest
|
||||
), run.createdAtUtc);
|
||||
}
|
||||
|
||||
function compositionProducedEvidence(
|
||||
run: AICompositionRun,
|
||||
evidence: ObservatoryEvidence,
|
||||
): boolean {
|
||||
if (!run.resultIds.includes(evidence.sessionId)) return false;
|
||||
const publishedAt = Date.parse(evidence.publishedAtUtc);
|
||||
const compositionCreatedAt = Date.parse(run.createdAtUtc);
|
||||
return Number.isFinite(publishedAt) && Number.isFinite(compositionCreatedAt)
|
||||
&& publishedAt >= compositionCreatedAt;
|
||||
}
|
||||
|
||||
function mutationErrorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Не удалось изменить лабораторный результат в Обсерватории.";
|
||||
}
|
||||
|
||||
function evidenceResultSubtitle(evidence: ObservatoryEvidence): string {
|
||||
const profileName = evidence.lab.calculationProfile?.displayName;
|
||||
return profileName ? `${evidence.label} · ${profileName}` : evidence.label;
|
||||
function evidenceConfigurationLabel(
|
||||
evidence: ObservatoryEvidence,
|
||||
moduleLabelsBySetup: Readonly<Record<string, string>>,
|
||||
jobs: readonly ObservatoryRecordedJob[],
|
||||
): string {
|
||||
const setupId = evidence.lab.calculationProfile?.setupId
|
||||
?? jobs.find((job) => job.resultId === evidence.sessionId)?.setupId;
|
||||
const profileName = setupId
|
||||
? moduleLabelsBySetup[setupId] ?? evidence.lab.calculationProfile?.displayName
|
||||
: evidence.lab.calculationProfile?.displayName;
|
||||
return profileName ?? evidence.label;
|
||||
}
|
||||
|
||||
function aiJobTone(job: ObservatoryRecordedJob): "accent" | "success" | "danger" | "warning" {
|
||||
if (job.state === "failed" || job.publication.state === "failed") return "danger";
|
||||
if (job.state === "paused" || job.state === "preemption-pending") return "warning";
|
||||
if (job.state === "succeeded") return "success";
|
||||
return "accent";
|
||||
}
|
||||
|
||||
const AI_PROGRESS_PHASE_RANGES = {
|
||||
"source-transfer": [0, 5],
|
||||
"source-preparation": [5, 25],
|
||||
computing: [25, 90],
|
||||
"result-assembly": [90, 95],
|
||||
"result-transfer": [95, 99],
|
||||
} as const;
|
||||
|
||||
function aiJobProgressPercent(
|
||||
job: ObservatoryRecordedJob,
|
||||
progress: RecordedProgressView | undefined,
|
||||
): number {
|
||||
if (job.state === "succeeded") return 100;
|
||||
if (!progress) return 0;
|
||||
const [start, end] = AI_PROGRESS_PHASE_RANGES[progress.phase];
|
||||
if (progress.total === null || progress.total === 0) return start;
|
||||
const phaseFraction = Math.max(0, Math.min(1, progress.completed / progress.total));
|
||||
return start + ((end - start) * phaseFraction);
|
||||
}
|
||||
|
||||
function aiJobStage(
|
||||
job: ObservatoryRecordedJob,
|
||||
progress: RecordedProgressView | undefined,
|
||||
): string {
|
||||
if (job.state === "failed" || job.publication.state === "failed") return "Ошибка";
|
||||
if (job.state === "accepted" || job.state === "queued" || job.state === "claimed" || !progress) {
|
||||
return "Инициализация";
|
||||
}
|
||||
if (progress.phase === "source-transfer" || progress.phase === "source-preparation") {
|
||||
return "Передача на сервер";
|
||||
}
|
||||
return "Просчёт";
|
||||
}
|
||||
|
||||
function computingStageDetail(
|
||||
job: ObservatoryRecordedJob,
|
||||
progress: RecordedProgressView,
|
||||
recordingDurationSeconds: number,
|
||||
): string {
|
||||
if (job.setupId === "ai-range-object-distance-v1") {
|
||||
if (progress.completed === 0) {
|
||||
return "Подготавливаем камеру и LiDAR";
|
||||
}
|
||||
if (progress.completed === 1) {
|
||||
return "Собираем видеоряд для модели";
|
||||
}
|
||||
if (progress.completed === 2) {
|
||||
return "RF-DETR обрабатывает запись · измеренная скорость Worker 006: 17–18 FPS";
|
||||
}
|
||||
if (progress.completed === 3) return "Сопоставляем рамки с LiDAR · осталось несколько минут";
|
||||
return "Собираем и проверяем результат";
|
||||
}
|
||||
if (job.setupId === "ai-segmentation-ddrnet-v1") {
|
||||
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
|
||||
if (progress.completed === 1) return "Собираем видеоряд для модели";
|
||||
if (progress.completed === 2) {
|
||||
return `DDRNet обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
|
||||
}
|
||||
return "Собираем и проверяем результат";
|
||||
}
|
||||
if (job.setupId === "ai-segmentation-eomt-v1") {
|
||||
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
|
||||
if (progress.completed === 1) return "Собираем видеоряд для модели";
|
||||
if (progress.completed === 2) {
|
||||
return `EoMT обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
|
||||
}
|
||||
return "Собираем и проверяем результат";
|
||||
}
|
||||
if (job.setupId === "ai-detection-rf-detr-v1") {
|
||||
if (progress.completed === 0) return "Подготавливаем входные данные · обычно 5–10 минут";
|
||||
if (progress.completed === 1) return "Собираем видеоряд для модели";
|
||||
if (progress.completed === 2) {
|
||||
return `RF-DETR обрабатывает запись · ориентир ${formatDuration(recordingDurationSeconds)}`;
|
||||
}
|
||||
return "Собираем и проверяем результат";
|
||||
}
|
||||
return "Обрабатываем запись выбранным модулем";
|
||||
}
|
||||
|
||||
function aiJobStageDetail(
|
||||
job: ObservatoryRecordedJob,
|
||||
progress: RecordedProgressView | undefined,
|
||||
recordingDurationSeconds: number,
|
||||
): string {
|
||||
if (job.state === "failed") {
|
||||
return "Не удалось запустить модель. Конфигурацию можно отправить повторно";
|
||||
}
|
||||
if (job.publication.state === "failed") {
|
||||
return "Результат рассчитан, но не сохранён в Обсерватории";
|
||||
}
|
||||
if (job.state === "succeeded") {
|
||||
if (job.publication.state === "pending") return "Завершаем сохранение результата";
|
||||
if (job.publication.state === "not-required") return "Публикация результата не требуется";
|
||||
return "Результат сохранён в Обсерватории";
|
||||
}
|
||||
if (!progress) {
|
||||
return "Готовим конфигурацию и место в очереди";
|
||||
}
|
||||
if (progress.phase === "source-transfer") {
|
||||
if (progress.total !== null && progress.total > 0) {
|
||||
return `${progress.completed.toLocaleString("ru-RU")} / ${progress.total.toLocaleString("ru-RU")} файлов`;
|
||||
}
|
||||
return "Копируем исходную запись на Worker 006";
|
||||
}
|
||||
if (progress.phase === "source-preparation") return "Проверяем целостность переданных данных";
|
||||
if (progress.phase === "computing") {
|
||||
return computingStageDetail(job, progress, recordingDurationSeconds);
|
||||
}
|
||||
return "Собираем, передаём и сохраняем результат";
|
||||
}
|
||||
|
||||
function presentAIJob(job: ObservatoryRecordedJob): boolean {
|
||||
if (job.state === "failed") return true;
|
||||
if (job.publication.state === "pending" || job.publication.state === "failed") return true;
|
||||
return job.state !== "succeeded";
|
||||
}
|
||||
|
||||
function presentLatestAIJobs(
|
||||
jobs: readonly ObservatoryRecordedJob[],
|
||||
): readonly ObservatoryRecordedJob[] {
|
||||
const seenSetups = new Set<string>();
|
||||
return jobs.filter((job) => {
|
||||
if (seenSetups.has(job.setupId)) return false;
|
||||
seenSetups.add(job.setupId);
|
||||
return presentAIJob(job);
|
||||
});
|
||||
}
|
||||
|
||||
function compositionProgressPercent(
|
||||
run: AICompositionRun,
|
||||
progress: Readonly<Record<string, RecordedProgressView>>,
|
||||
): number {
|
||||
if (run.state === "ready") return 100;
|
||||
if (run.jobs.length === 0) return 0;
|
||||
return run.jobs.reduce(
|
||||
(total, job) => total + aiJobProgressPercent(job, progress[job.jobId]),
|
||||
0,
|
||||
) / run.jobs.length;
|
||||
}
|
||||
|
||||
function compositionStage(
|
||||
run: AICompositionRun,
|
||||
progress: Readonly<Record<string, RecordedProgressView>>,
|
||||
): string {
|
||||
if (run.state === "ready") return "Расчёт завершён";
|
||||
if (run.state === "failed") return "Ошибка расчёта";
|
||||
const current = run.jobs.find((job) => job.state !== "succeeded") ?? run.jobs[0];
|
||||
return current ? aiJobStage(current, progress[current.jobId]) : "Инициализация";
|
||||
}
|
||||
|
||||
export function ObservatoryWorkspace({
|
||||
@@ -132,22 +345,21 @@ export function ObservatoryWorkspace({
|
||||
}) {
|
||||
const controller = useObservatoryCatalog();
|
||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
|
||||
const recordedJobsController = useObservatoryRecordedJobs(
|
||||
selectedSessionId,
|
||||
setupController.selectedSetupId,
|
||||
setupController.selectedSetup?.runDefinition?.definitionSha256 ?? "",
|
||||
);
|
||||
const [aiConfigurationOpen, setAIConfigurationOpen] = useState(false);
|
||||
const [evidenceSortDirection, setEvidenceSortDirection] = useState<EvidenceSortDirection>("newest");
|
||||
const aiJobsController = useAICompositionJobs(selectedSessionId);
|
||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
||||
const [compositionRenameTarget, setCompositionRenameTarget] = useState<AICompositionRun | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
|
||||
const [compositionDeleteTarget, setCompositionDeleteTarget] = useState<AICompositionRun | null>(null);
|
||||
const [mutationPending, setMutationPending] = useState<"rename" | "delete" | null>(null);
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [mutationReconciliation, setMutationReconciliation] = useState<
|
||||
ObservatoryMutationReconciliation | null
|
||||
>(null);
|
||||
const overviewRef = useRef<HTMLElement | null>(null);
|
||||
const overviewRef = useRef<HTMLDivElement | null>(null);
|
||||
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
|
||||
const refreshedPublishedJobRef = useRef<string | null>(null);
|
||||
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
|
||||
@@ -165,6 +377,7 @@ export function ObservatoryWorkspace({
|
||||
useEffect(() => {
|
||||
if (items.some((item) => item.source.id === selectedSessionId)) return;
|
||||
closeReplay();
|
||||
setAIConfigurationOpen(false);
|
||||
setSelectedSessionId(items[0]?.source.id ?? "");
|
||||
}, [closeReplay, items, selectedSessionId]);
|
||||
|
||||
@@ -173,62 +386,54 @@ export function ObservatoryWorkspace({
|
||||
const selectedSession = items.find(
|
||||
(item) => item.source.id === selectedSessionId,
|
||||
) ?? null;
|
||||
const presentedEvidence = selectedSession?.evidence ?? [];
|
||||
const presentedCompositionRuns = aiJobsController.runs;
|
||||
const presentedEvidence = (selectedSession?.evidence ?? []).filter(
|
||||
(evidence) => !presentedCompositionRuns.some(
|
||||
(run) => compositionProducedEvidence(run, evidence),
|
||||
),
|
||||
);
|
||||
const options = useMemo(() => items.map(({ source, evidence }) => ({
|
||||
value: source.id,
|
||||
label: source.label,
|
||||
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
|
||||
})), [items]);
|
||||
const setupOptions = useMemo(() => (
|
||||
setupController.selectableSetups.map((setup) => ({
|
||||
value: setup.setupId,
|
||||
label: setup.displayName,
|
||||
description: setup.description,
|
||||
}))
|
||||
), [setupController.selectableSetups]);
|
||||
const preflightCandidate = setupController.preflight.kind === "ready"
|
||||
? setupController.preflight.value
|
||||
: null;
|
||||
const runPreflight = preflightCandidate
|
||||
&& preflightCandidate.sourceSessionId === selectedSession?.source.id
|
||||
&& preflightCandidate.setupId === setupController.selectedSetup?.setupId
|
||||
&& preflightCandidate.definitionSha256
|
||||
=== setupController.selectedSetup?.runDefinition?.definitionSha256
|
||||
? preflightCandidate
|
||||
: null;
|
||||
const queueSubmissionAllowed = Boolean(
|
||||
setupController.selectedSetup?.runDefinition
|
||||
&& setupController.selectedSetup.compatibility.compatible
|
||||
&& runPreflight?.outcome === "queueable"
|
||||
&& runPreflight.submissionAllowed,
|
||||
const compositionJobIds = useMemo(() => new Set(
|
||||
presentedCompositionRuns.flatMap((run) => run.jobIds),
|
||||
), [presentedCompositionRuns]);
|
||||
const presentedAIJobs = presentLatestAIJobs(aiJobsController.jobs).filter(
|
||||
(job) => !compositionJobIds.has(job.jobId),
|
||||
);
|
||||
const presentedJob = recordedJobsController.activeJob ?? recordedJobsController.latestJob;
|
||||
const publicationFailed = presentedJob?.publication.state === "failed";
|
||||
const calculationPending = recordedJobsController.activeJob !== null
|
||||
|| recordedJobsController.publicationPending
|
||||
|| recordedJobsController.state === "submitting"
|
||||
|| recordedJobsController.state === "retrying-publication";
|
||||
const showCalculate = setupController.selectedSetup !== null
|
||||
&& !calculationPending && !publicationFailed;
|
||||
const canSubmitRecordedJob = queueSubmissionAllowed
|
||||
&& setupController.state === "ready"
|
||||
&& recordedJobsController.state === "ready"
|
||||
&& !calculationPending && !publicationFailed;
|
||||
const queueStatusError = setupController.preflight.kind === "error"
|
||||
? setupController.preflight.message
|
||||
: recordedJobsController.error
|
||||
?? (publicationFailed
|
||||
? presentedJob?.publication.error ?? "Не удалось опубликовать результат."
|
||||
: recordedJobsController.computationFailed
|
||||
? "Не удалось выполнить расчёт. Можно повторить запуск."
|
||||
: runPreflight?.outcome === "blocked"
|
||||
? runPreflight.checks.filter((check) => check.outcome === "fail")
|
||||
.map((check) => check.message).join(" ") || "Запуск профиля недоступен."
|
||||
: null);
|
||||
const presentedRows = useMemo<readonly ObservatoryEvidenceRow[]>(() => {
|
||||
const rows: ObservatoryEvidenceRow[] = [
|
||||
...presentedCompositionRuns.map((run): ObservatoryEvidenceRow => ({
|
||||
kind: "composition",
|
||||
id: run.runId,
|
||||
timestamp: compositionEventTimestamp(run),
|
||||
run,
|
||||
})),
|
||||
...presentedAIJobs.map((job): ObservatoryEvidenceRow => ({
|
||||
kind: "job",
|
||||
id: job.jobId,
|
||||
timestamp: job.updatedAtUtc,
|
||||
job,
|
||||
})),
|
||||
...presentedEvidence.map((evidence): ObservatoryEvidenceRow => ({
|
||||
kind: "evidence",
|
||||
id: evidence.sessionId,
|
||||
timestamp: evidence.publishedAtUtc,
|
||||
evidence,
|
||||
})),
|
||||
];
|
||||
const direction = evidenceSortDirection === "newest" ? -1 : 1;
|
||||
return rows.sort((left, right) => (
|
||||
direction * (Date.parse(left.timestamp) - Date.parse(right.timestamp))
|
||||
|| left.id.localeCompare(right.id)
|
||||
));
|
||||
}, [evidenceSortDirection, presentedAIJobs, presentedCompositionRuns, presentedEvidence]);
|
||||
const initialLoading = !controller.catalog
|
||||
&& ["idle", "loading"].includes(controller.state);
|
||||
const unavailable = !controller.catalog && controller.state === "error";
|
||||
const replayEvidenceId = replay.kind === "closed"
|
||||
const replayEvidenceId = replay.kind === "closed" || replay.kind === "composition"
|
||||
? null
|
||||
: replay.binding.evidenceSessionId;
|
||||
const replayEvidence = replayEvidenceId === null
|
||||
@@ -238,14 +443,13 @@ export function ObservatoryWorkspace({
|
||||
) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const publishedJob = recordedJobsController.jobs.find(
|
||||
const publishedJob = aiJobsController.jobs.find(
|
||||
(job) => job.publication.state === "published" && job.resultId !== null,
|
||||
);
|
||||
if (!publishedJob || refreshedPublishedJobRef.current === publishedJob.jobId) return;
|
||||
refreshedPublishedJobRef.current = publishedJob.jobId;
|
||||
void controller.refresh();
|
||||
setupController.refresh();
|
||||
}, [controller.refresh, recordedJobsController.jobs, setupController.refresh]);
|
||||
}, [aiJobsController.jobs, controller.refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -276,8 +480,7 @@ export function ObservatoryWorkspace({
|
||||
setDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
if (mutationReconciliation.kind === "delete") setupController.refresh();
|
||||
}, [controller.catalog, mutationPending, mutationReconciliation, setupController.refresh]);
|
||||
}, [controller.catalog, mutationPending, mutationReconciliation]);
|
||||
|
||||
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
||||
const attempt = replayCoordinatorRef.current.begin();
|
||||
@@ -307,6 +510,8 @@ export function ObservatoryWorkspace({
|
||||
|
||||
const openRename = useCallback((evidence: ObservatoryEvidence) => {
|
||||
if (!evidence.recordedRun) return;
|
||||
setCompositionRenameTarget(null);
|
||||
setCompositionDeleteTarget(null);
|
||||
setDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
@@ -316,13 +521,52 @@ export function ObservatoryWorkspace({
|
||||
|
||||
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
|
||||
if (!evidence.recordedRun) return;
|
||||
setCompositionRenameTarget(null);
|
||||
setCompositionDeleteTarget(null);
|
||||
setRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
setDeleteTarget(evidence);
|
||||
}, []);
|
||||
|
||||
const openCompositionRename = useCallback((run: AICompositionRun) => {
|
||||
setRenameTarget(null);
|
||||
setDeleteTarget(null);
|
||||
setCompositionDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
setRenameValue(run.displayName ?? run.presentation.configurationLabel);
|
||||
setCompositionRenameTarget(run);
|
||||
}, []);
|
||||
|
||||
const openCompositionDelete = useCallback((run: AICompositionRun) => {
|
||||
setRenameTarget(null);
|
||||
setDeleteTarget(null);
|
||||
setCompositionRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
setCompositionDeleteTarget(run);
|
||||
}, []);
|
||||
|
||||
const submitRename = useCallback(async () => {
|
||||
if (compositionRenameTarget) {
|
||||
if (mutationPending !== null) return;
|
||||
setMutationPending("rename");
|
||||
setMutationError(null);
|
||||
try {
|
||||
await renameAICompositionRunProjection(
|
||||
compositionRenameTarget.runId,
|
||||
renameValue,
|
||||
);
|
||||
setCompositionRenameTarget(null);
|
||||
aiJobsController.refresh();
|
||||
} catch (error) {
|
||||
setMutationError(mutationErrorMessage(error));
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!renameTarget?.recordedRun || mutationPending !== null) return;
|
||||
const requestedDisplayName = renameValue.trim();
|
||||
const reconciliation: ObservatoryMutationReconciliation = {
|
||||
@@ -356,9 +600,27 @@ export function ObservatoryWorkspace({
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
}, [controller, mutationPending, renameTarget, renameValue]);
|
||||
}, [aiJobsController, compositionRenameTarget, controller, mutationPending, renameTarget, renameValue]);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (compositionDeleteTarget) {
|
||||
if (mutationPending !== null) return;
|
||||
setMutationPending("delete");
|
||||
setMutationError(null);
|
||||
try {
|
||||
if (replay.kind === "composition" && replay.run.runId === compositionDeleteTarget.runId) {
|
||||
flushSync(() => closeReplay());
|
||||
}
|
||||
await deleteAICompositionRunProjection(compositionDeleteTarget.runId);
|
||||
setCompositionDeleteTarget(null);
|
||||
aiJobsController.refresh();
|
||||
} catch (error) {
|
||||
setMutationError(mutationErrorMessage(error));
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
|
||||
const reconciliation: ObservatoryMutationReconciliation = {
|
||||
kind: "delete",
|
||||
@@ -370,6 +632,7 @@ export function ObservatoryWorkspace({
|
||||
try {
|
||||
if (
|
||||
replay.kind !== "closed"
|
||||
&& replay.kind !== "composition"
|
||||
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
|
||||
) {
|
||||
flushSync(() => {
|
||||
@@ -378,7 +641,6 @@ export function ObservatoryWorkspace({
|
||||
}
|
||||
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
|
||||
controller.applyEvidenceDeletion(deleteTarget.sessionId);
|
||||
setupController.refresh();
|
||||
setDeleteTarget(null);
|
||||
setMutationReconciliation(null);
|
||||
void controller.refresh();
|
||||
@@ -399,25 +661,16 @@ export function ObservatoryWorkspace({
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
}, [closeReplay, controller, deleteTarget, mutationPending, replay, setupController.refresh]);
|
||||
}, [aiJobsController, closeReplay, compositionDeleteTarget, controller, deleteTarget, mutationPending, replay]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overviewRef}
|
||||
className="observatory-workspace"
|
||||
data-workspace-id={definition.id}
|
||||
data-observatory-authority="observation-only"
|
||||
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
|
||||
data-observatory-viewer={replay.kind === "ready" || replay.kind === "composition" ? "attached" : "detached"}
|
||||
>
|
||||
<section ref={overviewRef} className="observatory-lead">
|
||||
<div>
|
||||
<span className="section-eyebrow">{definition.eyebrow}</span>
|
||||
<h2>{definition.title}</h2>
|
||||
<p>
|
||||
Записанные источники и строго связанные результаты без запуска тяжёлого
|
||||
визуализатора и без доступа к управлению аппаратом.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<GlassSurface className="observatory-catalog-bar" padding="sm">
|
||||
<div className="observatory-catalog-bar__copy">
|
||||
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
|
||||
@@ -436,83 +689,23 @@ export function ObservatoryWorkspace({
|
||||
menuWidth={460}
|
||||
onChange={selectSession}
|
||||
/>
|
||||
<Select
|
||||
label="Выбрать сетап лаборатории"
|
||||
value={setupController.selectedSetupId}
|
||||
options={setupOptions}
|
||||
disabled={!selectedSessionId || setupOptions.length === 0}
|
||||
searchable
|
||||
searchPlaceholder="Поиск по сетапам"
|
||||
emptyLabel={setupController.state === "error" ? "Каталог профилей недоступен" : "Нет профилей для расчёта"}
|
||||
minMenuWidth={360}
|
||||
menuWidth={500}
|
||||
onChange={setupController.selectSetup}
|
||||
/>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
controller.state === "loading"
|
||||
|| controller.state === "refreshing"
|
||||
|| setupController.state === "loading"
|
||||
|| setupController.state === "refreshing"
|
||||
}
|
||||
icon={<Icon name="refresh" />}
|
||||
onClick={() => {
|
||||
void controller.refresh();
|
||||
setupController.refresh();
|
||||
recordedJobsController.refresh();
|
||||
}}
|
||||
variant="primary"
|
||||
disabled={!selectedSession}
|
||||
onClick={() => setAIConfigurationOpen(true)}
|
||||
>
|
||||
Обновить
|
||||
Сконфигурировать AI-слой
|
||||
</Button>
|
||||
<div className="observatory-catalog-bar__run" aria-busy={calculationPending}>
|
||||
{calculationPending ? (
|
||||
<>
|
||||
<ActivityIndicator size="compact" />
|
||||
<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>
|
||||
</GlassSurface>
|
||||
|
||||
{queueStatusError ? (
|
||||
{aiJobsController.error ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||
<span className="observatory-notice__copy">{queueStatusError}</span>
|
||||
{publicationFailed ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={recordedJobsController.state === "retrying-publication"}
|
||||
onClick={() => void recordedJobsController.retryPublication()}
|
||||
>
|
||||
Повторить публикацию
|
||||
</Button>
|
||||
) : null}
|
||||
<span className="observatory-notice__copy">{aiJobsController.error}</span>
|
||||
<Button size="compact" variant="ghost" onClick={aiJobsController.refresh}>
|
||||
Повторить
|
||||
</Button>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
@@ -524,13 +717,6 @@ export function ObservatoryWorkspace({
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{setupController.error ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||
<span className="observatory-notice__copy">{setupController.error}</span>
|
||||
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{controller.catalog
|
||||
&& (controller.catalog.window.sourceLimitReached
|
||||
|| controller.catalog.window.laboratoryLimitReached) ? (
|
||||
@@ -616,23 +802,150 @@ export function ObservatoryWorkspace({
|
||||
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
|
||||
<h3>Лабораторные доказательства</h3>
|
||||
</div>
|
||||
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
|
||||
{selectedSession.evidence.length}
|
||||
</StatusBadge>
|
||||
<div className="observatory-evidence__ordering">
|
||||
<StatusBadge tone={presentedRows.length > 0 ? "accent" : "neutral"}>
|
||||
{presentedRows.length}
|
||||
</StatusBadge>
|
||||
<button
|
||||
type="button"
|
||||
className="observatory-evidence__sort"
|
||||
data-direction={evidenceSortDirection}
|
||||
aria-label={evidenceSortDirection === "newest"
|
||||
? "Показать сначала старые результаты"
|
||||
: "Показать сначала новые результаты"}
|
||||
title={evidenceSortDirection === "newest"
|
||||
? "Сначала новые"
|
||||
: "Сначала старые"}
|
||||
onClick={() => setEvidenceSortDirection((current) => (
|
||||
current === "newest" ? "oldest" : "newest"
|
||||
))}
|
||||
>
|
||||
<svg viewBox="0 0 18 18" aria-hidden="true">
|
||||
<path d="M3 5h12M5 9h10M7 13h8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{selectedSession.evidence.length > 0 ? (
|
||||
{presentedRows.length > 0 ? (
|
||||
<ol className="observatory-evidence-list">
|
||||
{presentedEvidence.map((evidence) => (
|
||||
{presentedRows.map((row) => {
|
||||
if (row.kind === "composition") {
|
||||
const { run } = row;
|
||||
const failed = run.state === "failed";
|
||||
const active = run.state === "running";
|
||||
const progressPercent = compositionProgressPercent(run, aiJobsController.progress);
|
||||
return (
|
||||
<li key={run.runId}>
|
||||
<div className={`observatory-evidence-card${active
|
||||
? " observatory-evidence-card--progress" : ""}`}
|
||||
aria-busy={active}>
|
||||
<span className="observatory-evidence-card__icon" aria-hidden="true">
|
||||
{failed ? <Icon name="alert" size={16} />
|
||||
: run.state === "ready" ? <Icon name="clipboard" size={18} />
|
||||
: <ActivityIndicator size="compact" />}
|
||||
</span>
|
||||
<div className="observatory-evidence-card__copy">
|
||||
<strong>{run.displayName ?? run.presentation.configurationLabel}</strong>
|
||||
{active ? (
|
||||
<div className="observatory-evidence-card__progress" aria-hidden="true">
|
||||
<span style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
<small>{run.state === "ready"
|
||||
? formatTimestamp(row.timestamp)
|
||||
: `${compositionStage(run, aiJobsController.progress)} · ${formatTimestamp(row.timestamp)}`}</small>
|
||||
</div>
|
||||
{active ? (
|
||||
<StatusBadge tone="accent">{Math.round(progressPercent)}%</StatusBadge>
|
||||
) : failed ? <StatusBadge tone="danger">Ошибка</StatusBadge> : null}
|
||||
<div className="observatory-evidence-card__actions">
|
||||
{run.state === "ready" ? (
|
||||
<>
|
||||
<IconButton
|
||||
label={`Удалить ${run.displayName ?? run.presentation.configurationLabel} из Обсерватории`}
|
||||
onClick={() => openCompositionDelete(run)}
|
||||
>
|
||||
<Icon name="trash" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={`Переименовать ${run.displayName ?? run.presentation.configurationLabel}`}
|
||||
onClick={() => openCompositionRename(run)}
|
||||
>
|
||||
<Icon name="edit" size={16} />
|
||||
</IconButton>
|
||||
<IconButton label={`Открыть визуальный разбор: ${run.configurationLabel}`}
|
||||
onClick={() => setReplay({ kind: "composition", run })}>
|
||||
<Icon name="eye" size={16} />
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (row.kind === "job") {
|
||||
const { job } = row;
|
||||
const progress = aiJobsController.progress[job.jobId];
|
||||
const progressPercent = aiJobProgressPercent(job, progress);
|
||||
const moduleLabel = aiJobsController.moduleLabelsBySetup[job.setupId] ?? job.setupId;
|
||||
const failed = job.state === "failed" || job.publication.state === "failed";
|
||||
const active = !failed && (
|
||||
job.state !== "succeeded" || job.publication.state === "pending"
|
||||
);
|
||||
return (
|
||||
<li key={job.jobId}>
|
||||
<div
|
||||
className={`observatory-evidence-card${active
|
||||
? " observatory-evidence-card--progress" : ""}`}
|
||||
aria-busy={active}
|
||||
>
|
||||
<span className="observatory-evidence-card__icon" aria-hidden="true">
|
||||
{failed
|
||||
? <Icon name="alert" size={16} />
|
||||
: job.state === "succeeded"
|
||||
? <Icon name="check" size={16} />
|
||||
: <ActivityIndicator size="compact" />}
|
||||
</span>
|
||||
<div className="observatory-evidence-card__copy">
|
||||
<strong>{moduleLabel}</strong>
|
||||
{active ? (
|
||||
<div className="observatory-evidence-card__progress" aria-hidden="true">
|
||||
<span style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
<small>
|
||||
{aiJobStage(job, progress)} · {aiJobStageDetail(
|
||||
job,
|
||||
progress,
|
||||
selectedSession.source.durationSeconds,
|
||||
)}{progress && !failed && job.state !== "succeeded"
|
||||
? ` · прошло ${formatDuration(progress.elapsedSeconds)}`
|
||||
: ""} · {formatTimestamp(row.timestamp)}
|
||||
</small>
|
||||
</div>
|
||||
{active ? (
|
||||
<StatusBadge tone={aiJobTone(job)}>{Math.round(progressPercent)}%</StatusBadge>
|
||||
) : failed ? <StatusBadge tone="danger">Ошибка</StatusBadge> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const { evidence } = row;
|
||||
return (
|
||||
<li key={evidence.sessionId}>
|
||||
<div className="observatory-evidence-card">
|
||||
<span className="observatory-evidence-card__icon" aria-hidden="true">
|
||||
<Icon name="clipboard" size={18} />
|
||||
</span>
|
||||
<div className="observatory-evidence-card__copy">
|
||||
<strong>{evidence.lab.labId}</strong>
|
||||
<span>{evidenceResultSubtitle(evidence)}</span>
|
||||
<strong>{evidenceConfigurationLabel(
|
||||
evidence,
|
||||
aiJobsController.moduleLabelsBySetup,
|
||||
aiJobsController.jobs,
|
||||
)}</strong>
|
||||
<small>
|
||||
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
|
||||
{formatTimestamp(row.timestamp)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="observatory-evidence-card__actions">
|
||||
@@ -668,7 +981,8 @@ export function ObservatoryWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="observatory-evidence-empty">
|
||||
@@ -711,6 +1025,20 @@ export function ObservatoryWorkspace({
|
||||
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
) : replay.kind === "composition" ? (
|
||||
<section className="observatory-replay" aria-label="Визуальный разбор конфигурации">
|
||||
<header className="observatory-replay__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">СОХРАНЁННАЯ КОНФИГУРАЦИЯ</span>
|
||||
<h3>{replay.run.configurationLabel}</h3>
|
||||
</div>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="close" size={14} />}
|
||||
onClick={returnToOverview}>
|
||||
Закрыть разбор
|
||||
</Button>
|
||||
</header>
|
||||
<AICompositionReplay run={replay.run} />
|
||||
</section>
|
||||
) : replay.kind === "ready" ? (
|
||||
<section className="observatory-replay" aria-label="Визуальный разбор результата">
|
||||
<header className="observatory-replay__header">
|
||||
@@ -718,12 +1046,13 @@ export function ObservatoryWorkspace({
|
||||
<span className="section-eyebrow">
|
||||
СОХРАНЁННЫЙ РЕЗУЛЬТАТ / ЗАПИСАННАЯ СЕССИЯ
|
||||
</span>
|
||||
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
|
||||
<p>
|
||||
{replay.review.kind === "canonical-recorded-rerun"
|
||||
? "Записанный маршрут синхронизирован по общей временной шкале."
|
||||
: "Просмотр сохранённых данных результата без повторного расчёта."}
|
||||
</p>
|
||||
<h3>{replayEvidence && selectedSession
|
||||
? evidenceConfigurationLabel(
|
||||
replayEvidence,
|
||||
aiJobsController.moduleLabelsBySetup,
|
||||
aiJobsController.jobs,
|
||||
)
|
||||
: replay.binding.resultId}</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
@@ -740,7 +1069,14 @@ export function ObservatoryWorkspace({
|
||||
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>
|
||||
) : null}
|
||||
@@ -756,7 +1092,7 @@ export function ObservatoryWorkspace({
|
||||
) : null}
|
||||
|
||||
<Window
|
||||
open={renameTarget !== null}
|
||||
open={renameTarget !== null || compositionRenameTarget !== null}
|
||||
title="Переименовать лабораторный результат"
|
||||
subtitle="Меняется только отображаемое название в Обсерватории"
|
||||
size="sm"
|
||||
@@ -765,6 +1101,7 @@ export function ObservatoryWorkspace({
|
||||
onClose={() => {
|
||||
if (mutationPending === "rename") return;
|
||||
setRenameTarget(null);
|
||||
setCompositionRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}}
|
||||
@@ -774,6 +1111,7 @@ export function ObservatoryWorkspace({
|
||||
disabled={mutationPending === "rename"}
|
||||
onClick={() => {
|
||||
setRenameTarget(null);
|
||||
setCompositionRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}}
|
||||
@@ -808,19 +1146,26 @@ export function ObservatoryWorkspace({
|
||||
disabled={mutationPending === "rename"}
|
||||
onChange={(event) => setRenameValue(event.currentTarget.value)}
|
||||
/>
|
||||
{mutationError && renameTarget ? (
|
||||
{mutationError && (renameTarget || compositionRenameTarget) ? (
|
||||
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Window>
|
||||
<AIConfigurationWindow
|
||||
open={aiConfigurationOpen && selectedSession !== null}
|
||||
sourceSessionId={selectedSession?.source.id ?? ""}
|
||||
sourceLabel={selectedSession?.source.label ?? ""}
|
||||
onClose={() => setAIConfigurationOpen(false)}
|
||||
onCalculated={() => aiJobsController.refresh()}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
open={deleteTarget !== null}
|
||||
open={deleteTarget !== null || compositionDeleteTarget !== null}
|
||||
title="Удалить результат из Обсерватории?"
|
||||
description={deleteTarget ? (
|
||||
description={deleteTarget || compositionDeleteTarget ? (
|
||||
<div className="observatory-delete-confirmation">
|
||||
<p>
|
||||
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
|
||||
Будут удалены только каталожная проекция <strong>{deleteTarget?.label ?? compositionDeleteTarget?.displayName ?? compositionDeleteTarget?.presentation.configurationLabel}</strong>
|
||||
{" "}и её отображаемое название в Обсерватории.
|
||||
</p>
|
||||
<p>
|
||||
@@ -838,6 +1183,7 @@ export function ObservatoryWorkspace({
|
||||
onClose={() => {
|
||||
if (mutationPending === "delete") return;
|
||||
setDeleteTarget(null);
|
||||
setCompositionDeleteTarget(null);
|
||||
setMutationError(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-viewer={replay\.kind === "ready" \? "attached" : "detached"}/,
|
||||
/data-observatory-viewer={replay\.kind === "ready" \|\| replay\.kind === "composition" \? "attached" : "detached"}/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
`${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.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.match(sharedReplay, /useState<0 \| 1>\(costmap \? 1 : 0\)/);
|
||||
assert.match(sharedReplay, /costmap \? 0\.000001 : 0/);
|
||||
assert.match(sharedReplay, /useState<0 \| 1>\(hasTgs \? 1 : 0\)/);
|
||||
assert.match(sharedReplay, /hasTgs \? 0\.000001 : 0/);
|
||||
for (const adapter of ["CanonicalVegetationRerunReplay", "PortableResultReplay"]) {
|
||||
const source = await read(`components/laboratory/${adapter}.tsx`);
|
||||
assert.match(source, /<CanonicalResultRerunReplay/);
|
||||
|
||||
@@ -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 resultId = `m49-tgs-portable-review-${"c".repeat(64)}`;
|
||||
const resultId = `${prefix}-${"c".repeat(64)}`;
|
||||
const launch = { sourceUrl: "/api/v1/observation-sessions/source/recording.rrd",
|
||||
viewerSourceUrl: `/api/v1/observation-sessions/source/recording.rrd?generation=${base}`,
|
||||
sha256: base };
|
||||
@@ -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",
|
||||
"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);
|
||||
assert.equal(replay.viewerSourceUrl, `${replay.sourceUrl}?generation=${generation}`);
|
||||
assert.equal(replay.blueprintSourceUrl, "/api/v1/observation-sessions/source/blueprint.rrd");
|
||||
await assert.rejects(resolveCanonicalLabReplay(`lab-v1-vegetation-shadow-${"c".repeat(64)}`, launch, options));
|
||||
await assert.rejects(resolveCanonicalLabReplay(resultId, launch, {
|
||||
...options, sourceKind: sourceKind === "portable-tgs" ? "portable-semantic" : "portable-tgs",
|
||||
}));
|
||||
await assert.rejects(resolveCanonicalLabReplay(resultId, { ...launch, sha256: "d".repeat(64) }, options));
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server, fetchLabViewProfile, saveLabViewProfile, normalizeLabSceneSettings;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent",
|
||||
server: { middlewareMode: true } });
|
||||
({ fetchLabViewProfile, saveLabViewProfile } = await server.ssrLoadModule(
|
||||
"/src/core/observatory/labViewProfile.ts"));
|
||||
({ normalizeLabSceneSettings } = await server.ssrLoadModule(
|
||||
"/src/components/laboratory/CanonicalResultRerunReplay.tsx"));
|
||||
});
|
||||
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
const serverProfile = {
|
||||
schema_version: "missioncore.observatory-lab-view-profile/v1",
|
||||
result_id: "result-1",
|
||||
scene_settings: {
|
||||
point_size: 4.7,
|
||||
accumulation_seconds: 8,
|
||||
color_mode: "height",
|
||||
palette: "viridis",
|
||||
show_grid: false,
|
||||
show_labels: true,
|
||||
show_camera_frustums: false,
|
||||
},
|
||||
updated_at_utc: "2026-09-04T09:30:00.000Z",
|
||||
};
|
||||
|
||||
test("LAB view profile round-trips through the result-scoped server endpoint", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
requests.push({ url, init });
|
||||
return new Response(JSON.stringify(serverProfile), {
|
||||
status: 200, headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const settings = await fetchLabViewProfile("result-1");
|
||||
assert.equal(settings.pointSize, 4.7);
|
||||
assert.equal(settings.palette, "viridis");
|
||||
const saved = await saveLabViewProfile("result-1", settings);
|
||||
assert.equal(saved.accumulationSeconds, 8);
|
||||
assert.equal(requests[0].url, "/api/v1/observatory/lab-view-profiles/result-1");
|
||||
assert.equal(requests[1].init.method, "PUT");
|
||||
assert.deepEqual(JSON.parse(requests[1].init.body).scene_settings, serverProfile.scene_settings);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("LAB view profile fails closed on a foreign result identity", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({
|
||||
...serverProfile, result_id: "foreign-result",
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
try {
|
||||
await assert.rejects(fetchLabViewProfile("result-1"), /другой LAB/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("LAB scene settings normalize typed values before persistence", () => {
|
||||
assert.deepEqual(
|
||||
normalizeLabSceneSettings({
|
||||
pointSize: 28,
|
||||
accumulationSeconds: 305,
|
||||
colorMode: "height",
|
||||
palette: "viridis",
|
||||
customColor: "#ffffff",
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showLabels: true,
|
||||
showCameraFrustums: false,
|
||||
projection: "3d",
|
||||
}),
|
||||
{
|
||||
pointSize: 28,
|
||||
accumulationSeconds: 305,
|
||||
colorMode: "height",
|
||||
palette: "viridis",
|
||||
customColor: "#ffffff",
|
||||
showGrid: false,
|
||||
showPoints: true,
|
||||
showTrajectory: true,
|
||||
showLabels: true,
|
||||
showCameraFrustums: false,
|
||||
projection: "3d",
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -886,7 +886,7 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(canonical, /primary=\{mediaPane\}/);
|
||||
assert.match(canonical, /secondary=\{spatialPane/);
|
||||
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
|
||||
assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/);
|
||||
assert.match(canonical, /resizable=\{splitView\}/);
|
||||
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
||||
assert.match(canonical, /secondaryMode=\{\{/);
|
||||
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
||||
|
||||
@@ -40,6 +40,7 @@ let formatAccumulationDuration;
|
||||
let resolveRerunSourceUrl;
|
||||
let resolveRecordedBlueprintUrl;
|
||||
let fetchRecordedBlueprintRrd;
|
||||
let recordedCameraJournalContract;
|
||||
let resolveRecordedPerceptionUrl;
|
||||
let fetchRecordedPerceptionRrd;
|
||||
let resolveRecordedPerceptionViewerSourceUrl;
|
||||
@@ -110,6 +111,7 @@ before(async () => {
|
||||
resolveRerunSourceUrl,
|
||||
resolveRecordedBlueprintUrl,
|
||||
fetchRecordedBlueprintRrd,
|
||||
recordedCameraJournalContract,
|
||||
resolveRecordedPerceptionUrl,
|
||||
fetchRecordedPerceptionRrd,
|
||||
resolveRecordedPerceptionViewerSourceUrl,
|
||||
@@ -118,7 +120,7 @@ before(async () => {
|
||||
fetchRecordedPointColorsRrd,
|
||||
recordedPointColorKey,
|
||||
isRecordedPlaybackFullyBuffered,
|
||||
} = await server.ssrLoadModule(
|
||||
} = await server.ssrLoadModule(
|
||||
"/src/components/RerunViewport.tsx",
|
||||
));
|
||||
({ recordedObservationSources } = await server.ssrLoadModule(
|
||||
@@ -129,6 +131,25 @@ before(async () => {
|
||||
));
|
||||
});
|
||||
|
||||
test("follow toggles retain the current recorded camera journal", () => {
|
||||
const before = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: false,
|
||||
});
|
||||
const afterFollowToggle = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 0, planView: false, followTrajectory: true,
|
||||
});
|
||||
const afterPlanToggle = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 0, planView: true, followTrajectory: true,
|
||||
});
|
||||
const afterReset = recordedCameraJournalContract({
|
||||
activeView: "spatial", viewResetGeneration: 1, planView: false, followTrajectory: false,
|
||||
});
|
||||
|
||||
assert.equal(afterFollowToggle, before);
|
||||
assert.notEqual(afterPlanToggle, before);
|
||||
assert.notEqual(afterReset, before);
|
||||
});
|
||||
|
||||
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||
const bounds = { width: 1280, height: 720 };
|
||||
const left = initialObservationWindowRect(0, 2, bounds);
|
||||
@@ -444,6 +465,7 @@ test("recorded replay becomes ready only after the complete declared timeline is
|
||||
|
||||
test("recorded blueprint fetch is bounded, strict and sends only display settings", async () => {
|
||||
const calls = [];
|
||||
const cameraLimits = [];
|
||||
const payload = Uint8Array.from([0x52, 0x52, 0x46, 0x32, 0x01]);
|
||||
const result = await fetchRecordedBlueprintRrd(
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
@@ -464,6 +486,9 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
activeView: "perception3d",
|
||||
viewResetGeneration: 1,
|
||||
followTrajectory: true,
|
||||
currentTimeNs: 39_215_263_458,
|
||||
onCameraMaxOrbitalRadius: value => cameraLimits.push(value),
|
||||
unifiedCameraShare: 0.73,
|
||||
perceptionLayers: {
|
||||
enabled: true,
|
||||
detections2d: true,
|
||||
@@ -474,7 +499,10 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
calls.push({ input: String(input), init, body: JSON.parse(String(init.body)) });
|
||||
return new Response(payload, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/vnd.rerun.rrd" },
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"X-MissionCore-Camera-Max-Orbital-Radius": "686.024231",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -497,13 +525,20 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
active_view: "perception3d",
|
||||
view_reset_generation: 1,
|
||||
follow_trajectory: true,
|
||||
current_time_ns: 39_215_263_458,
|
||||
unified_perception: true,
|
||||
unified_camera_share: 0.73,
|
||||
semantic_layer: null,
|
||||
plan_view: false,
|
||||
eye_position: null,
|
||||
eye_look_target: null,
|
||||
eye_up: null,
|
||||
show_camera_image: true,
|
||||
show_detections_2d: true,
|
||||
show_segmentation: false,
|
||||
show_cuboids_3d: true,
|
||||
});
|
||||
assert.deepEqual(cameraLimits, [686.024231]);
|
||||
|
||||
await fetchRecordedBlueprintRrd(
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
@@ -537,6 +572,7 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
},
|
||||
);
|
||||
assert.equal(calls[1].body.unified_perception, false);
|
||||
assert.equal(calls[1].body.unified_camera_share, 0.46);
|
||||
assert.equal(calls[1].body.show_cuboids_3d, true);
|
||||
|
||||
await assert.rejects(
|
||||
|
||||
@@ -63,14 +63,31 @@ function evidence(id, sourceSessionId, publishedAtUtc) {
|
||||
labId: `LAB-${id}`,
|
||||
sourceSessionId,
|
||||
resultKind: "recorded-evidence",
|
||||
resultId: `result-${id}`,
|
||||
resultId: id,
|
||||
sourceResultId: null,
|
||||
configSha256: "a".repeat(64),
|
||||
runCreatedAtUtc: publishedAtUtc,
|
||||
publishedAtUtc,
|
||||
replayCapability: null,
|
||||
replayCapability: {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
|
||||
kind: "portable-result-review", viewerProfile: "portable-result",
|
||||
timeline: "result-defined", activation: "explicit", commandsEnabled: false,
|
||||
},
|
||||
calculationProfile: null,
|
||||
provenance: { verdict: "must-not-be-inferred" },
|
||||
provenance: {
|
||||
schema_version: "missioncore.observatory-portable-result-publication/v1",
|
||||
authority: { commands_enabled: false },
|
||||
calculation_profile: null, calculation_profile_sha256: "1".repeat(64),
|
||||
job: {}, method: {}, storage: {},
|
||||
source: { session_id: sourceSessionId },
|
||||
run_definition: { definition_sha256: "a".repeat(64) },
|
||||
result_package: { manifest_sha256: "b".repeat(64), artifact_manifest_id: "c".repeat(64) },
|
||||
replay_capability: {
|
||||
schema_version: "missioncore.observation-lab-replay-capability/v2",
|
||||
kind: "portable-result-review", viewer_profile: "portable-result",
|
||||
timeline: "result-defined", activation: "explicit", commands_enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -154,7 +171,9 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
|
||||
const calls = [];
|
||||
const fetcher = async (input, init) => {
|
||||
calls.push({ input: String(input), method: init?.method });
|
||||
return new Response(JSON.stringify({ items: [] }), {
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observation-session-page/v1", items: [], next_cursor: null,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -175,8 +194,8 @@ test("Observatory fetches disjoint read-only source and laboratory projections",
|
||||
assert.deepEqual(
|
||||
calls.map(({ input }) => input).sort(),
|
||||
[
|
||||
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3",
|
||||
"/api/v1/observation-sessions?limit=50&scope=source",
|
||||
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3&pagination=cursor-v1",
|
||||
"/api/v1/observation-sessions?limit=50&scope=source&pagination=cursor-v1",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
|
||||
@@ -203,7 +222,7 @@ test("Observatory exposes bounded-window uncertainty without inventing a broken
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory projects a typed canonical run only through its exact sourceSessionId", () => {
|
||||
test("historical canonical replay stays out of Observatory without modifying its archive record", () => {
|
||||
const canonicalResultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
const canonical = evidence(
|
||||
canonicalResultId,
|
||||
@@ -227,20 +246,144 @@ test("Observatory projects a typed canonical run only through its exact sourceSe
|
||||
commandsEnabled: false,
|
||||
},
|
||||
};
|
||||
const before = structuredClone(canonical);
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[source("20260828T130511Z_viewer_live", "2026-08-28T13:05:11Z")],
|
||||
[canonical],
|
||||
);
|
||||
|
||||
assert.deepEqual(catalog.items[0].evidence[0].recordedRun, {
|
||||
kind: "canonical-recorded-rerun",
|
||||
evidenceSessionId: canonicalResultId,
|
||||
sourceSessionId: "20260828T130511Z_viewer_live",
|
||||
resultId: canonicalResultId,
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
assert.deepEqual(catalog.items[0].evidence, []);
|
||||
assert.equal(catalog.window.laboratoryCount, 0);
|
||||
assert.deepEqual(canonical, before);
|
||||
});
|
||||
|
||||
test("only admitted portable evidence is visible regardless of LAB label, age or installed version", () => {
|
||||
const legacy = evidence("legacy", "source", "2026-09-03T13:00:00Z");
|
||||
legacy.lab.labId = "LAB M4.9T5";
|
||||
legacy.lab.replayCapability = null;
|
||||
const historical = evidence("old-portable", "source", "2026-07-20T10:00:00Z");
|
||||
historical.lab.labId = "LAB E24"; // Labels must never become an allow/deny list.
|
||||
const current = evidence("new-portable", "source", "2026-09-03T12:00:00Z");
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[source("source", "2026-07-19T10:00:00Z")], [legacy, historical, current],
|
||||
);
|
||||
assert.deepEqual(catalog.items[0].evidence.map((item) => item.sessionId), ["new-portable", "old-portable"]);
|
||||
assert.equal(catalog.window.laboratoryCount, 2);
|
||||
assert.equal(findObservatoryEvidence(catalog, "legacy"), null);
|
||||
});
|
||||
|
||||
test("a malformed portable binding errors instead of silently disappearing as legacy", () => {
|
||||
const broken = evidence("broken", "source", "2026-09-03T12:00:00Z");
|
||||
broken.lab.provenance.source.session_id = "another-source";
|
||||
assert.throws(() => buildObservatoryCatalog(
|
||||
[source("source", "2026-07-19T10:00:00Z")], [broken],
|
||||
), /identity/);
|
||||
});
|
||||
|
||||
function wire(item) {
|
||||
const lab = item.lab;
|
||||
return {
|
||||
id: item.id, label: item.label, started_at_utc: item.startedAtUtc,
|
||||
completed_at_utc: item.completedAtUtc, status: item.status,
|
||||
modalities: item.modalities, duration_seconds: item.durationSeconds, replayable: item.replayable,
|
||||
...(lab ? { lab: {
|
||||
lab_id: "LAB M4.9T5", source_session_id: lab.sourceSessionId, result_kind: lab.resultKind,
|
||||
result_id: lab.resultId, source_result_id: lab.sourceResultId, config_sha256: lab.configSha256,
|
||||
run_created_at_utc: lab.runCreatedAtUtc, published_at_utc: lab.publishedAtUtc,
|
||||
provenance: lab.provenance, replay_capability: lab.replayCapability ? lab.provenance.replay_capability : null,
|
||||
calculation_profile: null,
|
||||
} } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function pageResponse(items, nextCursor = null) {
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observation-session-page/v1",
|
||||
items: items.map(wire), next_cursor: nextCursor,
|
||||
}), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
test("500 sources and later-page results are searchable metadata, with no archive or viewer requests", async () => {
|
||||
const sources = Array.from({ length: 500 }, (_, index) => source(`source-${index}`, "2026-08-29T10:00:00Z"));
|
||||
const labs = Array.from({ length: 101 }, (_, index) => {
|
||||
const item = evidence(`legacy-${index}`, sources[0].id, "2026-08-29T11:00:00Z");
|
||||
item.lab.replayCapability = null;
|
||||
return item;
|
||||
});
|
||||
labs.push(evidence("portable-after-legacy", sources[499].id, "2026-08-29T12:00:00Z"));
|
||||
const calls = [];
|
||||
const catalog = await fetchObservatoryCatalog({ fetcher: async (input, init) => {
|
||||
const url = new URL(String(input), "http://localhost");
|
||||
calls.push(url);
|
||||
assert.equal(init.method, "GET");
|
||||
assert.equal(url.pathname, "/api/v1/observation-sessions");
|
||||
const rows = url.searchParams.get("scope") === "source" ? sources : labs;
|
||||
const cursor = url.searchParams.get("cursor");
|
||||
const start = cursor === null ? 0 : rows.findIndex((item) => item.id === cursor) + 1;
|
||||
const page = rows.slice(start, start + 100);
|
||||
return pageResponse(page, start + page.length < rows.length ? page.at(-1).id : null);
|
||||
} });
|
||||
assert.equal(calls.length, 7);
|
||||
assert.equal(catalog.items.length, 500);
|
||||
assert.equal(catalog.window.laboratoryCount, 1);
|
||||
assert.equal(catalog.window.sourceLimitReached, false);
|
||||
assert.equal(catalog.window.laboratoryLimitReached, false);
|
||||
assert.equal(catalog.items.find((item) => item.source.id === "source-499").evidence[0].sessionId, "portable-after-legacy");
|
||||
assert.deepEqual(catalog.unresolvedEvidence, []);
|
||||
});
|
||||
|
||||
test("exactly one full page with no cursor is complete, not a guessed truncated window", async () => {
|
||||
const catalog = await fetchObservatoryCatalog({ limit: 1, fetcher: async (url) =>
|
||||
String(url).includes("scope=source")
|
||||
? pageResponse([source("source", "2026-08-29T10:00:00Z")])
|
||||
: pageResponse([evidence("result", "source", "2026-08-29T11:00:00Z")]),
|
||||
});
|
||||
assert.equal(catalog.window.sourceLimitReached, false);
|
||||
assert.equal(catalog.window.laboratoryLimitReached, false);
|
||||
});
|
||||
|
||||
test("paging rejects repeated records, cursor cycles, unsafe cursors and unversioned responses", async () => {
|
||||
for (const failure of ["duplicate", "cycle", "unsafe", "unversioned", "too-large"]) {
|
||||
let index = 0;
|
||||
await assert.rejects(fetchObservatoryCatalog({ limit: 1, fetcher: async (url) => {
|
||||
if (String(url).includes("scope=laboratory")) return pageResponse([]);
|
||||
index += 1;
|
||||
if (failure === "unversioned") return new Response(JSON.stringify({ items: [] }));
|
||||
if (failure === "unsafe") return pageResponse([], "../../private");
|
||||
const id = failure === "duplicate" ? "same" : `source-${index}`;
|
||||
const rows = [source(id, "2026-08-29T10:00:00Z")];
|
||||
if (failure === "too-large") rows.push(source("extra", "2026-08-29T10:00:00Z"));
|
||||
return pageResponse(rows, failure === "cycle" ? "cycle" : id);
|
||||
} }));
|
||||
assert.ok(index <= 2);
|
||||
}
|
||||
});
|
||||
|
||||
test("the traversal cap is explicit and cannot confirm absence of a result", async () => {
|
||||
let count = 0;
|
||||
const catalog = await fetchObservatoryCatalog({ limit: 1, fetcher: async (url) => {
|
||||
if (String(url).includes("scope=source")) return pageResponse([]);
|
||||
count += 1;
|
||||
return pageResponse([], `cursor-${count}`);
|
||||
} });
|
||||
assert.equal(count, 256);
|
||||
assert.equal(catalog.window.laboratoryLimitReached, true);
|
||||
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(catalog, "missing"), false);
|
||||
});
|
||||
|
||||
test("failure aborts the sibling traversal and an already aborted caller starts no requests", async () => {
|
||||
const signals = [];
|
||||
await assert.rejects(fetchObservatoryCatalog({ fetcher: async (url, init) => {
|
||||
signals.push(init.signal);
|
||||
if (String(url).includes("scope=source")) return new Response("unavailable", { status: 503 });
|
||||
return pageResponse([]);
|
||||
} }), /503/);
|
||||
assert.ok(signals.every((signal) => signal.aborted));
|
||||
const request = new AbortController();
|
||||
request.abort();
|
||||
await assert.rejects(fetchObservatoryCatalog({ signal: request.signal, fetcher: async () => {
|
||||
assert.fail("aborted catalog cannot start a request");
|
||||
} }), { name: "AbortError" });
|
||||
});
|
||||
|
||||
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
|
||||
|
||||
@@ -68,6 +68,10 @@ test("missing and stale telemetry show no fabricated advancement", () => {
|
||||
assert.equal(label(job, null), "Ожидаем данные расчёта");
|
||||
assert.equal(label(job, decode({ ...view(), age_seconds: 30 }, job)), "Ожидаем обновление прогресса");
|
||||
});
|
||||
test("terminal jobs keep their real state when progress telemetry is absent", () => {
|
||||
assert.equal(label({ ...job, state: "failed" }, null), "Ошибка расчёта");
|
||||
assert.equal(label({ ...job, state: "succeeded" }, null), "Расчёт завершён");
|
||||
});
|
||||
test("progress is a read-only request bound to the active job", async () => {
|
||||
const calls = [];
|
||||
const progress = await fetchProgress(job, { fetcher: async (path, init) => {
|
||||
|
||||
@@ -35,9 +35,9 @@ test("Observatory is the third independent Polygon workspace", () => {
|
||||
id: "observatory",
|
||||
root: "polygon",
|
||||
label: "Обсерватория",
|
||||
title: "Проверка компьютерного зрения",
|
||||
title: "AI inference",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
||||
description: "Сессии и квалификация без доступа к управлению.",
|
||||
icon: "eye",
|
||||
kind: "observatory",
|
||||
groups: [],
|
||||
@@ -73,9 +73,15 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
||||
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
|
||||
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
|
||||
assert.match(workspace, /useObservatoryCatalog/);
|
||||
assert.doesNotMatch(workspace, /observatory-lead/);
|
||||
assert.doesNotMatch(workspace, /Расчёт записанных маршрутов выбранными профилями/);
|
||||
assert.match(workspace, /Связанных результатов нет/);
|
||||
assert.match(workspace, /не является выводом о качестве/);
|
||||
assert.match(workspace, /presentedEvidence = selectedSession\?\.evidence \?\? \[\]/);
|
||||
assert.match(workspace, /function compositionProducedEvidence\(/);
|
||||
assert.match(workspace, /publishedAt >= compositionCreatedAt/);
|
||||
assert.match(workspace, /const presentedCompositionRuns = aiJobsController\.runs/);
|
||||
assert.match(workspace, /presentedEvidence = \(selectedSession\?\.evidence \?\? \[\]\)\.filter/);
|
||||
assert.match(workspace, /!presentedCompositionRuns\.some\([\s\S]*compositionProducedEvidence\(run, evidence\)/);
|
||||
assert.doesNotMatch(workspace, /MAX_PRESENTED_EVIDENCE|\.evidence\.slice\(/);
|
||||
assert.match(workspace, /Полнота исторических/);
|
||||
assert.match(workspace, /вне текущего загруженного среза/);
|
||||
@@ -86,12 +92,13 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
||||
assert.match(workspace, /observatory-evidence-card__copy/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/function evidenceResultSubtitle[\s\S]*calculationProfile\?\.displayName[\s\S]*`\$\{evidence\.label\} · \$\{profileName\}`[\s\S]*: evidence\.label/,
|
||||
/function evidenceConfigurationLabel[\s\S]*calculationProfile\?\.displayName[\s\S]*return profileName \?\? evidence\.label/,
|
||||
);
|
||||
assert.match(
|
||||
workspace,
|
||||
/<span>\{evidenceResultSubtitle\(evidence\)\}<\/span>/,
|
||||
/<strong>\{evidenceConfigurationLabel\([\s\S]*evidence,[\s\S]*aiJobsController\.moduleLabelsBySetup,[\s\S]*aiJobsController\.jobs,[\s\S]*\)\}<\/strong>/,
|
||||
);
|
||||
assert.doesNotMatch(workspace, /evidenceConfigurationLabel\([^)]*,\s*selectedSession\.source\.label/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
|
||||
@@ -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, /<PortableResultReplay review=\{replay\.review\}/);
|
||||
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.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
|
||||
assert.doesNotMatch(workspace, /evidence\.lab\.resultKind|без запуска тяжёлого/);
|
||||
assert.match(workspace, /Проверяем точную связь результата/);
|
||||
assert.match(workspace, /role="alert"/);
|
||||
assert.match(workspace, /Повторить/);
|
||||
@@ -112,7 +125,10 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
|
||||
workspace,
|
||||
/const returnToOverview[\s\S]*closeReplay\(\);[\s\S]*scrollIntoView\(\{ block: "start" \}\)/,
|
||||
);
|
||||
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/<h3>\{replayEvidence && selectedSession[\s\S]*evidenceConfigurationLabel\([\s\S]*replayEvidence,[\s\S]*aiJobsController\.moduleLabelsBySetup,[\s\S]*aiJobsController\.jobs,[\s\S]*\)[\s\S]*replay\.binding\.resultId\}<\/h3>/,
|
||||
);
|
||||
assert.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/);
|
||||
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
||||
assert.match(workspace, /data-observatory-authority="observation-only"/);
|
||||
@@ -186,6 +202,10 @@ test("Observatory rename and delete use admitted projection mutations and canoni
|
||||
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
|
||||
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
|
||||
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
|
||||
assert.match(workspace, /renameAICompositionRunProjection/);
|
||||
assert.match(workspace, /deleteAICompositionRunProjection/);
|
||||
assert.match(workspace, /openCompositionRename\(run\)/);
|
||||
assert.match(workspace, /openCompositionDelete\(run\)/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
|
||||
@@ -196,9 +216,12 @@ test("Observatory rename and delete use admitted projection mutations and canoni
|
||||
);
|
||||
|
||||
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
|
||||
const deleteFlowEnd = workspace.indexOf("}, [closeReplay, controller", deleteFlowStart);
|
||||
const deleteFlowEnd = workspace.indexOf("}, [aiJobsController, closeReplay", deleteFlowStart);
|
||||
assert.ok(deleteFlowStart >= 0 && deleteFlowEnd > deleteFlowStart);
|
||||
const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
|
||||
const wholeDeleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
|
||||
const evidenceFlowStart = wholeDeleteFlow.indexOf("if (!deleteTarget?.recordedRun");
|
||||
assert.ok(evidenceFlowStart >= 0);
|
||||
const deleteFlow = wholeDeleteFlow.slice(evidenceFlowStart);
|
||||
const teardown = deleteFlow.indexOf("closeReplay();");
|
||||
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
|
||||
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
|
||||
@@ -219,77 +242,128 @@ test("Observatory rename and delete use admitted projection mutations and canoni
|
||||
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
|
||||
});
|
||||
|
||||
test("Observatory keeps one compact selector axis without the obsolete setup detail", async () => {
|
||||
const [workspace, styles, setupHook, jobsHook] = await Promise.all([
|
||||
test("Observatory configures independent AI modules and shows queued evidence progress", async () => {
|
||||
const [workspace, styles, configWindow, jobsHook, sharedReplay] = await Promise.all([
|
||||
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||
read("styles/observatory.css"),
|
||||
read("core/observatory/useObservatoryLaboratorySetups.ts"),
|
||||
read("core/observatory/useObservatoryRecordedJobs.ts"),
|
||||
read("components/observatory/AIConfigurationWindow.tsx"),
|
||||
read("core/observatory/useAICompositionJobs.ts"),
|
||||
read("components/laboratory/CanonicalResultRerunReplay.tsx"),
|
||||
]);
|
||||
|
||||
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
|
||||
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
|
||||
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
|
||||
assert.match(workspace, /setupController\.error \? \(/);
|
||||
assert.match(workspace, />\s*Сконфигурировать AI-слой\s*<\/Button>/);
|
||||
assert.doesNotMatch(workspace, /label="Выбрать сетап лаборатории"/);
|
||||
assert.doesNotMatch(workspace, />\s*Обновить\s*<\/Button>/);
|
||||
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
|
||||
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
|
||||
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
|
||||
assert.match(workspace, /useObservatoryRecordedJobs/);
|
||||
assert.doesNotMatch(workspace, /useObservatoryRecordedJobs|useObservatoryLaboratorySetups/);
|
||||
assert.match(configWindow, /title="Сконфигурировать AI-слой"/);
|
||||
assert.match(configWindow, /label: "Сегментация"/);
|
||||
assert.match(configWindow, /Зависимые блоки подключаются явно/);
|
||||
assert.match(configWindow, /label: "Облако точек \/ TGS"/);
|
||||
assert.match(configWindow, /label: "Дистанция"/);
|
||||
assert.match(configWindow, /Дистанция использует рамки детектора и синхронное облако точек/);
|
||||
assert.match(configWindow, /TGS обрабатывает LiDAR независимо от сегментации и детектора/);
|
||||
assert.match(configWindow, /providers\.length === 1/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/runPreflight\?\.outcome === "queueable"[\s\S]*runPreflight\.submissionAllowed/,
|
||||
configWindow,
|
||||
/<WindowFooterActions>[\s\S]*"Отправляем на Worker…" : "Рассчитать"/,
|
||||
);
|
||||
assert.match(configWindow, /className=\{state === "saving" \? "observatory-ai-config__calculate--saving"/);
|
||||
assert.match(configWindow, /aria-busy=\{state === "saving"\}/);
|
||||
assert.match(styles, /@keyframes observatory-ai-calculate-pulse/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/showCalculate \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
|
||||
styles,
|
||||
/\.observatory-ai-config__calculate--saving:disabled \{[\s\S]*animation: observatory-ai-calculate-pulse 1\.8s ease-in-out infinite;[\s\S]*opacity: 1;/,
|
||||
);
|
||||
assert.doesNotMatch(workspace, /recordedJobStatus|presentedJobStatus|Расчёт завершён|Результат опубликован|Вычислено ·/);
|
||||
assert.match(workspace, /setupController\.selectableSetups\.map/);
|
||||
assert.match(workspace, /showCalculate = setupController\.selectedSetup !== null/);
|
||||
assert.match(workspace, /disabled=\{!canSubmitRecordedJob\}/);
|
||||
assert.match(workspace, /aria-busy=\{calculationPending\}/);
|
||||
const runBar = workspace.slice(workspace.indexOf('<div className="observatory-catalog-bar__run"'), workspace.indexOf("{queueStatusError ?"));
|
||||
assert.doesNotMatch(runBar, /StatusBadge/);
|
||||
assert.match(setupHook, /fetchObservatoryPortableLaboratorySetups/);
|
||||
assert.doesNotMatch(setupHook, /fetchObservatoryLaboratorySetups|mergeSetupCatalogs|legacyResult/);
|
||||
assert.match(setupHook, /selectableObservatorySetups\(next\)/);
|
||||
assert.match(setupHook, /selectable\[0\]\?\.setupId \?\? ""/);
|
||||
assert.match(setupHook, /selectedSetupId, sourceSessionId, selectedDefinitionSha256/);
|
||||
assert.match(workspace, /useAICompositionJobs\(selectedSessionId\)/);
|
||||
assert.match(workspace, /observatory-evidence-card--progress/);
|
||||
assert.match(workspace, /const active = run\.state === "running"/);
|
||||
assert.match(workspace, /\{active \? \([\s\S]*observatory-evidence-card__progress/);
|
||||
assert.match(workspace, /run\.state === "ready" \? <Icon name="clipboard"/);
|
||||
assert.match(workspace, /function aiJobStage\(/);
|
||||
assert.match(workspace, /return "Инициализация"/);
|
||||
assert.match(workspace, /return "Передача на сервер"/);
|
||||
assert.match(workspace, /return "Просчёт"/);
|
||||
assert.match(workspace, /Собираем видеоряд для модели/);
|
||||
assert.match(workspace, /измеренная скорость Worker 006: 17–18 FPS/);
|
||||
assert.doesNotMatch(workspace, /phaseElapsedSeconds \/ progress\.completed/);
|
||||
assert.match(workspace, /AI_PROGRESS_PHASE_RANGES/);
|
||||
assert.match(workspace, /"source-preparation": \[5, 25\]/);
|
||||
assert.match(workspace, /computing: \[25, 90\]/);
|
||||
assert.match(workspace, /Подготавливаем камеру и LiDAR/);
|
||||
assert.match(workspace, /прошло.*formatDuration\(progress\.elapsedSeconds\)/s);
|
||||
assert.match(workspace, /Не удалось запустить модель/);
|
||||
assert.doesNotMatch(workspace, /job\.terminalMessage|job\.publication\.error/);
|
||||
assert.doesNotMatch(workspace, /function aiModuleLabel\(/);
|
||||
assert.match(workspace, /aiJobsController\.moduleLabelsBySetup\[job\.setupId\] \?\? job\.setupId/);
|
||||
assert.match(jobsHook, /fetchAIModuleCatalog\(request\.signal\)/);
|
||||
assert.match(jobsHook, /AI_MODULE_SETUP_IDS\[module\.moduleId\]/);
|
||||
assert.match(jobsHook, /run\.presentation\.modules\.flatMap/);
|
||||
assert.match(jobsHook, /Object\.fromEntries\(\[\.\.\.catalogLabels, \.\.\.projectedLabels\]\)/);
|
||||
assert.match(workspace, /<strong>\{moduleLabel\}<\/strong>/);
|
||||
assert.match(workspace, /<strong>\{run\.displayName \?\? run\.presentation\.configurationLabel\}<\/strong>/);
|
||||
assert.doesNotMatch(workspace, /<strong>\{selectedSession\.source\.label\} ·/);
|
||||
assert.match(workspace, /type EvidenceSortDirection = "newest" \| "oldest"/);
|
||||
assert.match(workspace, /const presentedRows = useMemo<readonly ObservatoryEvidenceRow\[]>/);
|
||||
assert.match(workspace, /direction \* \(Date\.parse\(left\.timestamp\) - Date\.parse\(right\.timestamp\)\)/);
|
||||
assert.match(workspace, /className="observatory-evidence__sort"/);
|
||||
assert.match(workspace, /data-direction=\{evidenceSortDirection\}/);
|
||||
assert.match(workspace, /formatTimestamp\(row\.timestamp\)/);
|
||||
assert.match(styles, /\.observatory-evidence-card\s*\{[\s\S]*grid-template-columns:\s*auto minmax\(0, 1fr\) auto auto/);
|
||||
assert.match(styles, /\.observatory-evidence__sort\s*\{[\s\S]*border:\s*0;[\s\S]*outline:\s*0/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
|
||||
styles,
|
||||
/\.observatory-evidence-card__copy strong \{[\s\S]*font-size: var\(--nodedc-font-size-sm\);/,
|
||||
);
|
||||
assert.match(sharedReplay, /LAB_SCENE_SETTINGS_STORAGE_PREFIX/);
|
||||
assert.match(sharedReplay, /window\.localStorage\.getItem/);
|
||||
assert.match(sharedReplay, /window\.localStorage\.setItem/);
|
||||
assert.match(sharedReplay, /unifiedCameraShare: blueprintSplitPrimarySize \/ 100/);
|
||||
assert.match(sharedReplay, /setSettingsOpen\(false\);[\s\S]*void saveLabViewProfile/);
|
||||
assert.match(
|
||||
jobsHook,
|
||||
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
|
||||
sharedReplay,
|
||||
/showPoints: spatialMode !== null && \(showSourcePoints \|\| showLocalSlam\)/,
|
||||
);
|
||||
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
|
||||
assert.match(jobsHook, /JSON\.stringify\(\[sourceSessionId, setupId, definitionSha256\]\)/);
|
||||
assert.match(jobsHook, /snapshot\.selectionKey === selectionKey \? snapshot : null/);
|
||||
assert.doesNotMatch(jobsHook, /observatory-ui:[^`]*sourceSessionId|\.slice\(0, 160\)/);
|
||||
assert.match(workspace, /recordedJobsController\.refresh\(\)/);
|
||||
assert.match(jobsHook, /POLL_INTERVAL_MS = 1_500/);
|
||||
assert.match(sharedReplay, /showTrajectory: spatialMode !== null && showLocalSlam/);
|
||||
assert.match(
|
||||
sharedReplay,
|
||||
/accumulationSeconds: showLocalSlam \? sceneDraft\.accumulationSeconds : 0/,
|
||||
);
|
||||
assert.match(workspace, /<AICompositionReplay run=\{replay\.run\} \/>/);
|
||||
assert.match(workspace, /function presentAIJob\(job: ObservatoryRecordedJob\)/);
|
||||
assert.match(workspace, /if \(job\.state === "failed"\) return true/);
|
||||
assert.match(workspace, /return job\.state !== "succeeded"/);
|
||||
assert.match(workspace, /function presentLatestAIJobs/);
|
||||
assert.match(workspace, /seenSetups\.has\(job\.setupId\)/);
|
||||
assert.match(configWindow, /fetchAICompositionJobs\(sourceSessionId, request\.signal\)/);
|
||||
assert.match(
|
||||
configWindow,
|
||||
/if \(job\.state === "failed" \|\| job\.publication\.state === "failed"\) continue;/,
|
||||
);
|
||||
assert.match(configWindow, /Текущая конфигурация уже рассчитана/);
|
||||
assert.match(configWindow, /configurationAlreadyExists/);
|
||||
assert.match(configWindow, /Готовый результат будет использован без повторного расчёта/);
|
||||
assert.doesNotMatch(configWindow, /disabled: existingByModule\.has\(module\.moduleId\)/);
|
||||
assert.doesNotMatch(configWindow, /selections\.some\(\(\{ module \}\) => existingByModule/);
|
||||
assert.match(jobsHook, /const OPEN = new Set/);
|
||||
assert.match(jobsHook, /1_500/);
|
||||
assert.match(jobsHook, /globalThis\.setTimeout/);
|
||||
assert.doesNotMatch(jobsHook, /setInterval/);
|
||||
assert.match(workspace, /job\.publication\.state === "published"/);
|
||||
assert.match(workspace, /void controller\.refresh\(\);[\s\S]*setupController\.refresh\(\);/);
|
||||
assert.match(workspace, /onCalculated=\{\(\) => aiJobsController\.refresh\(\)\}/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.observatory-catalog-bar__controls \{[\s\S]*min-width: 0;[\s\S]*flex: 1 1 auto;[\s\S]*flex-wrap: nowrap;[\s\S]*justify-content: flex-end;/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.observatory-catalog-bar__run \{[\s\S]*display: flex;[\s\S]*align-items: center;/,
|
||||
);
|
||||
assert.match(styles, /\.observatory-evidence-card__progress \{/);
|
||||
assert.match(styles, /\.observatory-ai-module-group > header \{/);
|
||||
assert.match(styles, /\.observatory-ai-module-group \{[\s\S]*border: 0;[\s\S]*outline: 0;/);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar,[\s\S]*\.observatory-catalog-bar__controls,[\s\S]*flex-direction: column;/,
|
||||
);
|
||||
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
|
||||
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
|
||||
assert.match(setupHook, /preflightRequest\.current !== request/);
|
||||
assert.match(
|
||||
setupHook,
|
||||
/state !== "ready" \|\| !selectedSetup \|\| preflight\.kind !== "idle"[\s\S]*void check\(\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server, createRecordedRerunCameraJournal, initialEye;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent",
|
||||
server: { middlewareMode: true } });
|
||||
const module = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunCameraJournal.ts");
|
||||
createRecordedRerunCameraJournal = module.createRecordedRerunCameraJournal;
|
||||
initialEye = module.RECORDED_RERUN_ORBITAL_EYE;
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, init = {}) { this.type = type; Object.assign(this, init); }
|
||||
}
|
||||
|
||||
class FakeTarget {
|
||||
listeners = new Map();
|
||||
emitted = [];
|
||||
addEventListener(type, listener) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push(listener); this.listeners.set(type, listeners);
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener));
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
this.emitted.push(event);
|
||||
for (const listener of this.listeners.get(event.type) ?? []) listener(event);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const radius = (eye) => Math.hypot(
|
||||
eye.position[0] - eye.lookTarget[0],
|
||||
eye.position[1] - eye.lookTarget[1],
|
||||
eye.position[2] - eye.lookTarget[2],
|
||||
);
|
||||
|
||||
test("recorded orbital eye tracks only 3D viewport navigation", () => {
|
||||
const scope = new FakeTarget();
|
||||
const timers = [];
|
||||
Object.assign(scope, {
|
||||
WheelEvent: FakeEvent,
|
||||
requestAnimationFrame(callback) { callback(); return 1; },
|
||||
setTimeout(callback) { timers.push(callback); return timers.length; },
|
||||
});
|
||||
const canvas = new FakeTarget();
|
||||
Object.assign(canvas, {
|
||||
clientHeight: 200,
|
||||
isConnected: true,
|
||||
getBoundingClientRect: () => ({
|
||||
left: 100, right: 500, top: 50, bottom: 250, width: 400, height: 200,
|
||||
}),
|
||||
});
|
||||
const journal = createRecordedRerunCameraJournal(canvas, scope);
|
||||
journal.configure(initialEye, 0.46);
|
||||
const pointer = (type, x, y, buttons) => new FakeEvent(type, {
|
||||
clientX: x, clientY: y, button: 0, buttons, pointerId: 7, pointerType: "mouse",
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
});
|
||||
|
||||
canvas.dispatchEvent(pointer("pointerdown", 200, 100, 1));
|
||||
scope.dispatchEvent(pointer("pointermove", 240, 120, 1));
|
||||
scope.dispatchEvent(pointer("pointerup", 240, 120, 0));
|
||||
assert.deepEqual(journal.current(), initialEye);
|
||||
|
||||
canvas.dispatchEvent(pointer("pointerdown", 380, 100, 1));
|
||||
scope.dispatchEvent(pointer("pointermove", 420, 120, 1));
|
||||
scope.dispatchEvent(pointer("pointerup", 420, 120, 0));
|
||||
const rotated = journal.current();
|
||||
assert.notDeepEqual(rotated.position, initialEye.position);
|
||||
assert.ok(Math.abs(radius(rotated) - radius(initialEye)) < 1e-9);
|
||||
|
||||
journal.setMaxOrbitalRadius(40);
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 380, clientY: 130, deltaX: 0, deltaY: 2_000, deltaMode: 0,
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
}));
|
||||
assert.ok(Math.abs(radius(journal.current()) - 40) < 1e-9);
|
||||
|
||||
journal.configure(rotated, 0.46);
|
||||
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 0, clientY: 0, deltaX: 0, deltaY: -20, deltaMode: 0,
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
}));
|
||||
const zoomed = journal.current();
|
||||
assert.ok(Math.abs(radius(zoomed) - radius(rotated) * Math.exp(-20 / 200)) < 1e-9);
|
||||
|
||||
const snapshot = journal.current();
|
||||
journal.setSpatialViewportStart(0.8);
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 380, clientY: 130, deltaX: 0, deltaY: -20, deltaMode: 0,
|
||||
}));
|
||||
assert.deepEqual(journal.current(), snapshot);
|
||||
journal.dispose();
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server, createIsolatedRerunHost, keepRecordedBlueprintSession, createRecordedRerunFacade, createRecordedRerunOwner, relayIsolatedRerunInput;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent",
|
||||
server: { middlewareMode: true } });
|
||||
({ createIsolatedRerunHost } = await server.ssrLoadModule(
|
||||
"/src/components/rerun/isolatedRerunHost.ts"));
|
||||
({ keepRecordedBlueprintSession } = await server.ssrLoadModule(
|
||||
"/src/core/observation/recordedBlueprintLifecycle.ts"));
|
||||
({ createRecordedRerunFacade } = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunFacade.ts"));
|
||||
({ createRecordedRerunOwner } = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunOwner.ts"));
|
||||
({ relayIsolatedRerunInput } = await server.ssrLoadModule(
|
||||
"/src/components/rerun/isolatedRerunInput.ts"));
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
function fixture(t, { stopFails = false } = {}) {
|
||||
const timers = new Map();
|
||||
let timerId = 0, creates = 0, stops = 0, removals = 0;
|
||||
t.mock.method(globalThis, "setTimeout", (fn) => {
|
||||
timers.set(++timerId, fn); return timerId;
|
||||
});
|
||||
t.mock.method(globalThis, "clearTimeout", (id) => timers.delete(id));
|
||||
const listeners = new Map();
|
||||
const viewer = { stop() { stops++; if (stopFails) throw new Error("partial start"); } };
|
||||
const mount = {};
|
||||
const frame = {
|
||||
contentWindow: {
|
||||
missionCoreRerun: createRecordedRerunOwner(() => { creates++; return viewer; }, mount),
|
||||
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||
removeEventListener: (name) => listeners.delete(name),
|
||||
},
|
||||
remove() { removals++; },
|
||||
};
|
||||
const host = { dataset: {}, ownerDocument: { createElement(name) {
|
||||
assert.equal(name, "iframe"); return frame;
|
||||
} }, append(child) { assert.equal(child, frame); } };
|
||||
return { host, frame, viewer, mount, timers, listeners,
|
||||
counts: () => ({ creates, stops, removals }) };
|
||||
}
|
||||
|
||||
function bridge(native, mount = {}) {
|
||||
const child = createRecordedRerunOwner(() => native, mount);
|
||||
const parent = createRecordedRerunFacade((request, bytes) => {
|
||||
assert.equal(typeof request, "string");
|
||||
const result = child.invoke(request, bytes);
|
||||
assert.equal(typeof result, "string");
|
||||
return result;
|
||||
});
|
||||
child.connect(message => {
|
||||
assert.equal(typeof message, "string");
|
||||
parent.notify(message);
|
||||
});
|
||||
return parent;
|
||||
}
|
||||
|
||||
test("recorded realm terminates on close even if upstream stop throws", async (t) => {
|
||||
const f = fixture(t, { stopFails: true });
|
||||
const scope = createIsolatedRerunHost(f.host);
|
||||
assert.equal(f.frame.src, "/rerun-runtime.html");
|
||||
f.frame.onload();
|
||||
const ready = await scope.ready;
|
||||
assert.notEqual(ready.viewer, f.viewer);
|
||||
assert.equal(ready.mount, f.host);
|
||||
assert.equal(f.timers.size, 0);
|
||||
assert.equal(f.listeners.size, 1);
|
||||
scope.dispose(); scope.dispose();
|
||||
assert.deepEqual(f.counts(), { creates: 1, stops: 1, removals: 1 });
|
||||
assert.equal(f.listeners.size, 0);
|
||||
assert.equal(f.frame.onload, null);
|
||||
assert.equal(ready.viewer.ready, false);
|
||||
});
|
||||
|
||||
test("native frame input relays Escape and leaves pointer ownership inside Rerun", () => {
|
||||
const listeners = new Map(), events = [];
|
||||
class ParentEvent { constructor(type, init) { this.type = type; Object.assign(this, init); } }
|
||||
const host = { dataset: {}, ownerDocument: { defaultView: {
|
||||
KeyboardEvent: ParentEvent, PointerEvent: ParentEvent,
|
||||
} }, dispatchEvent: event => events.push(event) };
|
||||
const child = {
|
||||
addEventListener: (name, fn) => listeners.set(name, fn),
|
||||
removeEventListener: name => listeners.delete(name),
|
||||
};
|
||||
const release = relayIsolatedRerunInput(host, child, null);
|
||||
assert.equal(listeners.has("pointerdown"), false);
|
||||
listeners.get("keydown")({ key: "Escape", defaultPrevented: false });
|
||||
assert.equal(events[0].key, "Escape");
|
||||
release(); release();
|
||||
assert.equal(listeners.size, 0); assert.equal(events.length, 1);
|
||||
});
|
||||
|
||||
test("disposed facade, channel and unsubscribe reject native use and copy event values", async () => {
|
||||
let eventCallback, unsubscribed = 0, sends = 0, starts = 0;
|
||||
const range = { min: 1, max: 2 }, mount = {};
|
||||
const native = {
|
||||
ready: true, stop() {},
|
||||
async start(source, parent) { assert.equal(parent, mount); starts++; },
|
||||
get_time_range: () => range,
|
||||
on(event, callback) { eventCallback = callback; return () => { unsubscribed++; }; },
|
||||
open_channel: () => ({ ready: true, send_rrd() { sends++; }, close() {} }),
|
||||
};
|
||||
const { facade, dispose } = bridge(native, mount);
|
||||
await facade.start(null, {}, null);
|
||||
assert.equal(starts, 1);
|
||||
let event;
|
||||
const unsubscribe = facade.on("time_update", value => { event = value; });
|
||||
eventCallback(range);
|
||||
assert.deepEqual(event, range); assert.notEqual(event, range);
|
||||
assert.notEqual(facade.get_time_range(), range);
|
||||
const channel = facade.open_channel();
|
||||
channel.send_rrd(new Uint8Array());
|
||||
dispose(); dispose(); unsubscribe();
|
||||
assert.equal(unsubscribed, 1);
|
||||
assert.equal(channel.ready, false);
|
||||
channel.send_rrd(new Uint8Array());
|
||||
eventCallback({ min: 3, max: 4 });
|
||||
assert.deepEqual(event, range);
|
||||
assert.equal(sends, 1);
|
||||
await assert.rejects(facade.start(null, {}, null), /disposed/);
|
||||
});
|
||||
|
||||
test("close before frame load cancels startup and cannot reopen", async (t) => {
|
||||
const f = fixture(t);
|
||||
const scope = createIsolatedRerunHost(f.host);
|
||||
const lateLoad = f.frame.onload;
|
||||
const rejected = assert.rejects(scope.ready, /disposed/);
|
||||
scope.dispose(); lateLoad();
|
||||
await rejected;
|
||||
assert.deepEqual(f.counts(), { creates: 0, stops: 0, removals: 1 });
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test("closing during SDK startup also releases a late successful start", async () => {
|
||||
let finish, stops = 0;
|
||||
const owner = bridge({
|
||||
start: () => new Promise(resolve => { finish = resolve; }),
|
||||
stop() { stops++; },
|
||||
}, {});
|
||||
const pending = owner.facade.start(null, {}, null);
|
||||
const rejected = assert.rejects(pending, /disposed/);
|
||||
owner.dispose();
|
||||
assert.equal(stops, 1);
|
||||
finish();
|
||||
await rejected;
|
||||
await Promise.resolve();
|
||||
assert.equal(stops, 2);
|
||||
assert.equal(owner.facade.ready, false);
|
||||
});
|
||||
|
||||
test("late failed startup cannot revive a disposed parent and still finishes cleanup", async () => {
|
||||
let fail, stops = 0;
|
||||
const owner = bridge({
|
||||
start: () => new Promise((_resolve, reject) => { fail = reject; }),
|
||||
stop() { stops++; if (stops > 1) throw new Error("already gone"); },
|
||||
}, {});
|
||||
const pending = owner.facade.start(null, {}, null);
|
||||
const rejected = assert.rejects(pending, /disposed/);
|
||||
owner.dispose();
|
||||
fail(new Error("startup failed"));
|
||||
await rejected;
|
||||
await Promise.resolve();
|
||||
assert.equal(stops, 2);
|
||||
assert.equal(owner.facade.ready, false);
|
||||
});
|
||||
|
||||
test("primitive bridge preserves all public clock/control arguments and errors", async () => {
|
||||
const calls = [];
|
||||
const methods = {
|
||||
open: [["one.rrd", "two.rrd"]], close: ["one.rrd"],
|
||||
override_panel_state: ["time", "hidden"],
|
||||
get_active_recording_id: [], get_active_timeline: ["recording"],
|
||||
get_current_time: ["recording", "session_time"], get_playing: ["recording"],
|
||||
get_time_range: ["recording", "session_time"],
|
||||
set_active_timeline: ["recording", "session_time"],
|
||||
set_current_time: ["recording", "session_time", 123.456],
|
||||
set_playing: ["recording", false],
|
||||
};
|
||||
const native = { stop() {}, start: async () => { throw new Error("native startup failure"); } };
|
||||
for (const name of Object.keys(methods)) native[name] = (...args) => { calls.push([name, args]); return 1; };
|
||||
const { facade, dispose } = bridge(native);
|
||||
for (const [name, args] of Object.entries(methods)) facade[name](...args);
|
||||
assert.deepEqual(calls, Object.entries(methods));
|
||||
await assert.rejects(facade.start(null, {}, null), /native startup failure/);
|
||||
dispose();
|
||||
});
|
||||
|
||||
test("sync native failures become parent-owned errors and optional channel names stay undefined", () => {
|
||||
const nativeError = new Error("native failure");
|
||||
let channelName = "unobserved";
|
||||
const { facade, dispose } = bridge({
|
||||
stop() {},
|
||||
open() { throw nativeError; },
|
||||
open_channel(name) { channelName = name; return { close() {} }; },
|
||||
});
|
||||
assert.throws(() => facade.open("rrd"), error => error !== nativeError && /native failure/.test(error.message));
|
||||
facade.open_channel();
|
||||
assert.equal(channelName, undefined);
|
||||
dispose();
|
||||
});
|
||||
|
||||
test("dispose closes every auxiliary channel even if one channel fails", () => {
|
||||
const closed = [];
|
||||
let stops = 0;
|
||||
const owner = bridge({
|
||||
stop() { stops++; },
|
||||
open_channel: name => ({ close() {
|
||||
closed.push(name);
|
||||
if (name === "first") throw new Error("already gone");
|
||||
} }),
|
||||
}, {});
|
||||
const first = owner.facade.open_channel("first");
|
||||
const second = owner.facade.open_channel("second");
|
||||
owner.dispose(); owner.dispose(); first.close(); second.close();
|
||||
assert.deepEqual(closed, ["first", "second"]);
|
||||
assert.equal(stops, 1);
|
||||
});
|
||||
|
||||
test("host load timeout removes the frame and clears its timer", async (t) => {
|
||||
const f = fixture(t);
|
||||
const scope = createIsolatedRerunHost(f.host);
|
||||
const rejected = assert.rejects(scope.ready, /timed out/);
|
||||
[...f.timers.values()][0]();
|
||||
await rejected;
|
||||
assert.equal(f.counts().removals, 1);
|
||||
assert.equal(f.timers.size, 0);
|
||||
});
|
||||
|
||||
test("blueprint termination cancels renewal and sends one bounded keepalive release", async () => {
|
||||
let tick, canceled = 0;
|
||||
const requests = [];
|
||||
const release = keepRecordedBlueprintSession({
|
||||
endpointUrl: "/api/v1/observation-sessions/source/blueprint.rrd",
|
||||
origin: "http://core.test", applicationId: "nodedc_mission_core_recorded",
|
||||
recordingId: "recording", ownerId: "a".repeat(32),
|
||||
fetcher: async (url, init) => { requests.push({ url, ...init }); return {}; },
|
||||
schedule(fn) { tick = fn; return 1; }, cancel() { canceled++; },
|
||||
});
|
||||
tick();
|
||||
const renewal = requests[0];
|
||||
release(); release(); tick();
|
||||
assert.equal(canceled, 1);
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(renewal.signal.aborted, true);
|
||||
assert.equal(requests[1].keepalive, true);
|
||||
assert.equal(requests[1].url, "http://core.test/api/v1/observation-sessions/source/blueprint-lifecycle");
|
||||
assert.deepEqual(JSON.parse(requests[1].body), {
|
||||
action: "release", application_id: "nodedc_mission_core_recorded",
|
||||
recording_id: "recording", blueprint_session_id: "a".repeat(32),
|
||||
});
|
||||
});
|
||||
|
||||
test("blueprint lease refuses a foreign endpoint", () => {
|
||||
assert.throws(() => keepRecordedBlueprintSession({ endpointUrl: "https://other.test/blueprint.rrd",
|
||||
origin: "http://core.test", applicationId: "app", recordingId: "recording", ownerId: "owner",
|
||||
}), /Unsafe/);
|
||||
});
|
||||
@@ -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", () => {
|
||||
const source = `/api/v1/observatory/portable-results/m49-tgs-portable-review-${"c".repeat(64)}/replays/${"d".repeat(64)}/recording.rrd`;
|
||||
for (const prefix of [
|
||||
"m49-tgs-portable-review",
|
||||
"lab-v1-eomt-ddrnet",
|
||||
"ai-layer-ddrnet",
|
||||
"ai-layer-eomt",
|
||||
"ai-layer-rf-detr",
|
||||
"ai-layer-object-distance",
|
||||
]) test(`${prefix} replay uses recorded admission and the shared source blueprint`, () => {
|
||||
const source = `/api/v1/observatory/portable-results/${prefix}-${"c".repeat(64)}/replays/${"d".repeat(64)}/recording.rrd`;
|
||||
const blueprint = "/api/v1/observation-sessions/source/blueprint.rrd";
|
||||
assert.equal(isRecordedRrdSource(source), true);
|
||||
assert.equal(isRecordedRrdSource(source.replace("/replays/", "/untrusted/")), false);
|
||||
|
||||
@@ -97,7 +97,7 @@ test("M4 keeps independent semantic controls in media and spatial panes", async
|
||||
assert.match(source, /aria-label="Слои 3D и плана"/);
|
||||
assert.match(canonical, /data-pane-mode="media"/);
|
||||
assert.match(canonical, /data-pane-mode="spatial"/);
|
||||
assert.match(canonical, /modeControlsVisible=\{!splitView\}/);
|
||||
assert.match(canonical, /modeControlsVisible=\{!splitView && !paneToolbarsAlwaysVisible\}/);
|
||||
assert.match(source, /semanticOverlay=\{mediaMode === "video" \? semanticOverlay : undefined\}/);
|
||||
});
|
||||
|
||||
|
||||
@@ -497,14 +497,17 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
||||
assert.match(rerunSource, /resolveReplay\(resultId, value/);
|
||||
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
|
||||
assert.match(rerunSource, /unifiedPerception: splitView/);
|
||||
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
|
||||
assert.match(rerunSource, /!splitView[\s\S]*event\.button !== 0/);
|
||||
assert.match(rerunSource, /if \(!splitView\) stopNativeSplitTracking\(\)/);
|
||||
assert.match(rerunSource, /event\.target instanceof HTMLCanvasElement/);
|
||||
assert.match(rerunSource, /--canonical-rerun-camera-pane/);
|
||||
assert.match(rerunSource, /onPointerDownCapture=\{splitView \? trackNativeSplit : undefined\}/);
|
||||
assert.match(rerunSource, /lockPerceptionCameraInteraction: false/);
|
||||
assert.match(rerunSource, /&& !semantics[\s\S]*&& !detections[\s\S]*&& !costmap/);
|
||||
assert.doesNotMatch(rerunSource, /trackNativeSplit|rerunNativeCursor|rerunNativeInput/);
|
||||
assert.match(canonicalSource, /resizable=\{splitView\}/);
|
||||
assert.match(rerunSource, /unifiedCameraShare: blueprintSplitPrimarySize \/ 100/);
|
||||
assert.match(rerunSource, /data-split-view=\{splitView \? "true" : undefined\}/);
|
||||
assert.match(rerunSource, /mediaMode === null[\s\S]*\? 0[\s\S]*: splitView[\s\S]*\? nativeSplitPercentRef\.current[\s\S]*: 100/);
|
||||
assert.match(rerunSource, /max=\{500\}/);
|
||||
assert.match(rerunSource, /exactValueBounds=\{\{ min: 0 \}\}/);
|
||||
assert.match(replayStyles, /m4-replay-threat-visual__pane-toolbar \{[\s\S]*flex-wrap: wrap;/);
|
||||
assert.match(replayStyles, /m4-replay-threat-visual__spatial-toolbar-end \{[\s\S]*display: contents;/);
|
||||
assert.match(rerunSource, /<ToastStack items=\{toasts\}/);
|
||||
assert.match(rerunSource, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
|
||||
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
|
||||
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
|
||||
@@ -517,22 +520,14 @@ test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival r
|
||||
replayStyles,
|
||||
/canonical-vegetation-rerun-replay__timeline \.observation-timeline__playback \{[\s\S]*grid-template-columns: auto auto minmax\(0, 1fr\) auto;/,
|
||||
);
|
||||
assert.match(
|
||||
replayStyles,
|
||||
/canonical-vegetation-rerun-replay__viewport-lock[\s\S]*rerun-viewport__camera-lock \{[\s\S]*width: var\(--canonical-rerun-camera-pane, 100%\);/,
|
||||
);
|
||||
assert.match(
|
||||
replayStyles,
|
||||
/canonical-vegetation-rerun-replay__viewport-lock\[data-split-view="true"\][\s\S]*width: calc\(var\(--canonical-rerun-camera-pane, 46%\) - 0\.75rem\);/,
|
||||
);
|
||||
assert.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
|
||||
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
||||
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: !costmap/);
|
||||
assert.match(rerunSource, /hasTgs \? toggle\("TGS", showTgs/);
|
||||
assert.match(canonicalSource, /primary=\{mediaPane\}/);
|
||||
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
||||
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
||||
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
||||
assert.match(canonicalSource, /resizable=\{splitView && !unifiedContent\}/);
|
||||
assert.match(canonicalSource, /resizable=\{splitView\}/);
|
||||
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
|
||||
assert.match(resultSource, /linkedTgsResultId/);
|
||||
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
||||
|
||||
@@ -91,6 +91,12 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
build: {
|
||||
target: "esnext",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
app: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||
rerun: fileURLToPath(new URL("./rerun-runtime.html", import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
|
||||
@@ -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
|
||||
meanings that are not yet stable enough for Platform Ontology.
|
||||
|
||||
These mechanisms are sufficient before A3. A new RDF/graph store, ontology
|
||||
service, or parallel entity catalog would introduce duplicated identity and
|
||||
migration work without a current query or integration consumer.
|
||||
These mechanisms were sufficient before A3. Observatory modular compositions
|
||||
crossed the gate on 2026-09-04: planning, Worker dispatch, LAB publication and
|
||||
replay presentation require the same module/composition/result/layer relations,
|
||||
and typed single-result traversal lost the TGS member of a composed run. The
|
||||
bounded local package is defined by
|
||||
`docs/domain-model/observatory-domain-ontology-v1.md`. It keeps identity in the
|
||||
existing stores and has no Platform Ontology runtime dependency.
|
||||
|
||||
A formal local runtime ontology is introduced only when:
|
||||
|
||||
@@ -239,8 +243,10 @@ A formal local runtime ontology is introduced only when:
|
||||
- the graph answers named queries used by the product or automation;
|
||||
- promotion or synchronization with NODE.DC Platform Ontology is defined.
|
||||
|
||||
Until those conditions hold, new stable meanings extend the versioned local
|
||||
vocabulary and executable contracts. They do not create a second runtime model.
|
||||
Other domains continue to extend the versioned local vocabulary and executable
|
||||
contracts until they independently satisfy these conditions. The admitted
|
||||
Observatory package projects existing store identities and cannot become a
|
||||
second authority for them.
|
||||
|
||||
## Automated boundary gate
|
||||
|
||||
|
||||
@@ -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: четыре этапа — записанные лаборатории → переносимые профили → борт
|
||||
|
||||
## Актуальный маршрут — 2026-09-03
|
||||
|
||||
Сверено с кодом `bee8552`, установкой `54c8d82`, исправлением reindex `bd947b4`
|
||||
и последними решениями владельца. M4.9T5 ×004TREE рассчитан через UI, опубликован
|
||||
и повторно открыт как документ из кэша после перезапуска. Визуальный replay ещё
|
||||
не реализован для этого portable-результата. Разделы до журнала инкрементов — текущий
|
||||
и последними решениями владельца. Проверки и сборка текущего инкремента поверх
|
||||
`eff60e4` завершены; возврат к основному маршруту зафиксирован ниже. M4.9T5 ×004TREE рассчитан через UI, опубликован
|
||||
и повторно открыт из кэша после перезапуска. Общий camera/TGS replay реализован;
|
||||
подробная визуальная и memory-lifecycle приёмка ещё открыта. Разделы до журнала инкрементов — текущий
|
||||
маршрут; исторические «следующий шаг» и «этап открыт/закрыт» ниже не команды.
|
||||
|
||||
### Цель и результат
|
||||
@@ -29,9 +209,11 @@ PASS через Wi-Fi/LTE не является условием готовно
|
||||
рассчитаны → выбор пуст, «Рассчитать» нет, «Обновить» остаётся. Плашек завершения
|
||||
нет; во время работы общий индикатор показывает настоящую фазу и доступные счётчики.
|
||||
Внутри первичного decode/передачи архива детальные счётчики ещё отсутствуют.
|
||||
Лимит первых 6 карточек снят,
|
||||
но пагинация сотен записей ещё не доказана.
|
||||
- **Последняя приёмка:** 726 frontend tests, 128 focused progress/queue/API/
|
||||
Обсерватория теперь показывает только identity-bound portable результаты;
|
||||
исторические LAB остаются в Legacy. Каталог дочитывает cursor pages по100
|
||||
metadata entries (до256 страниц на scope с явным partial flag), поиск видит
|
||||
все загруженные источники. Fixture500 sources и real20-page proof PASS.
|
||||
- **Предыдущая функциональная приёмка:** 726 frontend tests, 128 focused progress/queue/API/
|
||||
transport tests, отдельно40 runtime/wiring и3 installer tests, typecheck/build,
|
||||
Ruff/mypy и browser QA. Для найденного при рестарте cache-дефекта — ещё89
|
||||
session/publication и72 admission/cache/queue/API tests. Эти проходы пересекаются,
|
||||
@@ -47,12 +229,49 @@ PASS через Wi-Fi/LTE не является условием готовно
|
||||
- **Текущие составы:** M4.9T5 — CPU TGS; LAB V1 — последовательные EoMT и DDRNet.
|
||||
Ни один не равен полному будущему AI-профилю рига. Архивные overlays в одном
|
||||
viewer не доказывают, что один Docker вычислил все слои.
|
||||
- **Последний viewer-инкремент:** primitive-only owner v2 и передача Escape/pointer
|
||||
из iframe для существующих внешних controls. 740 frontend/64 focused backend
|
||||
tests, полный typecheck/build PASS, новая сборка обслуживается на8000.
|
||||
Разделитель native viewer снова передаёт координаты внешним панелям; проверено
|
||||
контрактным тестом, реальная visual acceptance этой версии ещё не выполнена.
|
||||
- **Входы Worker, новый инкремент2A:** общий source CAS двух агентов установлен;
|
||||
другая job/generation/конфигурация переиспользует exact cached bytes, частичная
|
||||
camera-cache требует только отсутствующие members. M49 проверяет готовый
|
||||
LiDAR pack до raw decode; прежний producer/identity сохранён.111 focused tests,
|
||||
Ruff/mypy и реальный cross-agent synthetic proof PASS, моделей не запускали.
|
||||
Это не кэш viewer (он остаётся на Core) и не устранение cold whole-input barrier.
|
||||
- **Последний продуктовый инкремент2D:** Legacy separation и pagination активны
|
||||
на8000 (`app-9nK8VtbK.js`).747 frontend/41 backend tests, typecheck/build,
|
||||
Ruff/mypy PASS. Browser catalog/search/normal/expanded/Refresh/dropdown Escape
|
||||
принят; тяжёлый replay не открывался. Legacy API до/после побайтно неизменён.
|
||||
- **Освобождение памяти:** найден остаток записанного viewer после закрытия.
|
||||
Добавлены disposable upstream realm, размыкающий ссылки facade и backend
|
||||
release/TTL. Предыдущий browser proof подтвердил освобождение GPU, но прежний
|
||||
facade оставил829 MB renderer после закрытия (112 MB до). Для v2 реальный
|
||||
open/close ещё не проверен: browser auto-review отклонил тяжёлое открытие при
|
||||
pressure2; запрошено отдельное разрешение. Это не блокирует код/контракты
|
||||
оставшихся частей этапа2 и не считается доказанным исправлением memory issue.
|
||||
Активные данные/качество не урезаются, дисковый cache не удаляется.
|
||||
- **Крупный проход saved review/recovery:** общий viewer подключён к сохранённым
|
||||
EoMT/DDRNet masks LAB V1 через новый строгий adapter; M49 cache identity сохранена.
|
||||
Outbox больше не застревает за префиксом exhausted/backoff rows. Краткие
|
||||
heartbeat transport failures повторяются в пределах аренды с тем же sequence;
|
||||
новый control layer установлен в оба агента.749 frontend/166 backend tests,
|
||||
typecheck/build/Ruff/mypy PASS; Core `app-w6onjKPq.js` healthy на8000.
|
||||
- **Реальный LAB V1 ×004TREE пока FAIL:** новая job
|
||||
`observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` потеряла lease на шаге EoMT.
|
||||
После независимой проверки освобождения ресурсов штатно reconciled→failed;
|
||||
история сохранена, очередь11 failed/2 succeeded,0 live leases. Новый LAB не
|
||||
опубликован. Отдельно подтверждён устаревший EoMT disk floor350GiB сверх
|
||||
рабочего набора: при текущих397.47GB free admission не пройдёт. Перед повтором
|
||||
нужна новая sealed resource policy, не очистка данных/не покупка памяти.
|
||||
[Полный отчёт и точные границы доказательств](../experiments/perception/OBSERVATORY_SAVED_REVIEW_RECOVERY_2026-09-03.md).
|
||||
- **Есть инженерный прототип полного графа:** DDRNet/RF-DETR/geometry/distance/
|
||||
motion/TGS/costmap/policy-shadow и короткие потоковые proofs. Не пишем заново.
|
||||
Самостоятельный продуктовый образ и полный recorded-run этого состава не приняты.
|
||||
- **Не закрыто:** полный recorded-analysis текущими
|
||||
профилями, синхронный сохранённый visual replay, матрица двух профилей/записей, полный поиск/
|
||||
пагинация каталога, standalone и фактический перенос на борт.
|
||||
профилями, синхронный сохранённый visual replay, матрица двух профилей/записей,
|
||||
оставшиеся recovery cases, standalone и фактический перенос на борт.
|
||||
|
||||
Источники: [ADR 0050](adr/0050-recorded-observatory-first.md),
|
||||
[cache/UI evidence](../experiments/perception/OBSERVATORY_PUBLISHED_CACHE_2026-09-03.md),
|
||||
@@ -62,6 +281,8 @@ Desktop final-status — операторская сводка и история
|
||||
сохранение выполненной работы, не неподтверждённые сведения о runtime.
|
||||
Текущее исполнение и точные declarations:
|
||||
[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 реализован и активирован.
|
||||
Новый M49-run использовал прежний профиль и полностью выполнил его расписание.
|
||||
Это не закрывает 2A: целиковый input barrier и `_capture_arrays()` до replay-cache
|
||||
lookup ещё сохранены. Progress observation не является новым режимом исполнения.
|
||||
Это не закрывает2A: целиковый input barrier ещё сохранён. Новый preparation adapter
|
||||
проверяет exact LiDAR-cache до вызова прежнего producer, общий source CAS уже
|
||||
установлен в оба агента. На warm input не повторяются raw decode и передача
|
||||
готовых members, но cold materialization по-прежнему ждёт весь вход. Progress
|
||||
observation и warm reuse не являются новым режимом исполнения.
|
||||
Evidence: [source reuse](../experiments/perception/OBSERVATORY_SOURCE_REUSE_2026-09-03.md).
|
||||
|
||||
- Сохранить source timestamps и causal ordering. Медленный Worker притормаживает
|
||||
подачу, а не выбрасывает обязательные кадры ради 1×. Учитывается каждый вход/
|
||||
@@ -133,32 +358,45 @@ timeline. Это приёмка CPU TGS, не полного detector/segmentati
|
||||
точный cache hit, исчезновение M49 из выбора и открытие документа после рестарта.
|
||||
Найден и исправлен no-op reindex: служебная переиндексация больше не меняет
|
||||
source identity; реальное изменение по-прежнему требует нового расчёта.
|
||||
**Осталось в2B:** portable review сейчас JSON, не проигрыватель. Подключить
|
||||
typed result/frame reader и общий `CanonicalRecordedLabReplay`, ленивое чтение
|
||||
сохранённых TGS/costmap и точную камеру/временную привязку. Проверить coverage,
|
||||
seek/play/pause, normal/expanded/restore/Escape и повторное открытие без job.
|
||||
Сначала использовать уже sealed пакет; не считать заново и не подмешивать
|
||||
архивные E47/M49 semantics. Название «полный маршрут и воспроизведение» само
|
||||
по себе не доказывает доступность replay или полноту всей исходной сессии.
|
||||
Общий `CanonicalRecordedLabReplay` уже получает сохранённый camera/source/TGS
|
||||
RRD с Core; JSON не является единственным review. **Осталось в2B:** сохранение
|
||||
ручного ракурса при blueprint activation, подробная визуальная оценка и
|
||||
подтверждённое освобождение памяти закрытого viewer. Использовать уже sealed
|
||||
пакет, не считать заново и не подмешивать архивные E47/M49 semantics. Название
|
||||
«полный маршрут» не доказывает покрытия всех исходных timestamps.
|
||||
|
||||
**2C. LAB V1 и другая запись.** Выполнить LAB V1 × 004TREE, затем оба профиля
|
||||
× RAVNOVES00, строго последовательно. Медленная EoMT не возвращает нас к гонке
|
||||
за сетевым FPS. Не менять незаметно состав, cadence или разрешение ради PASS.
|
||||
RAVNOVES01 — отрицательный случай до подтверждённой аттестации.
|
||||
|
||||
**Попытка2026-09-03:** LAB V1 ×004TREE запущен штатно, но после transfer и prepare
|
||||
не завершил EoMT: lease expired, результата нет. Heartbeat retry исправлен и
|
||||
установлен, exact-job resources освобождены, failed receipt сохранён. Перед
|
||||
следующим расчётом изменить350GiB disk floor через новую sealed версию с
|
||||
проверяемым бюджетом рабочего набора; предусмотреть дешёвый preflight до долгих
|
||||
asset checks и bounded diagnostics до cleanup. Не считать этот canary успешным.
|
||||
|
||||
**2D. Повторное использование, сбои и большой каталог.**
|
||||
|
||||
- Refresh, повторный вход и перезапуск открывают тот же результат без новой job.
|
||||
Все текущие профили рассчитаны → выбор пуст, «Рассчитать» нет; новая версия → новый расчёт,
|
||||
старая остаётся ниже. Проверить transient disconnect, compute failure,
|
||||
publication retry, missing/corrupt artifacts и отсутствие дубликатов.
|
||||
- Исторические результаты получают допуск к review только при достаточном
|
||||
evidence, без подделки provenance. Если binding отсутствует — исправить
|
||||
проверяемую проекцию либо выполнить новый расчёт, не объявлять legacy новым.
|
||||
- Довести постраничный поиск записей и результатов. Нельзя спрятать профиль
|
||||
- **Реализовано:** Обсерватория не показывает Legacy/canonical архивные LAB;
|
||||
они остаются в существующем архиве без миграции/удаления. Portable результаты
|
||||
прошлых версий сохраняются при точном binding, независимо от имён и даты.
|
||||
- **Реализовано и проверено:** постраничный каталог и поиск по загруженным
|
||||
источникам. Нельзя спрятать профиль
|
||||
из-за cache hit, соответствующий результат которого нельзя найти/открыть
|
||||
из-за окна каталога. Сотни metadata entries проверяются функциональными
|
||||
fixtures, не нагрузкой на Mac и не загрузкой всех видео в RAM.
|
||||
[Evidence и границы traversal](../experiments/perception/OBSERVATORY_CATALOG_BOUNDARY_2026-09-03.md).
|
||||
- **Новый recovery proof:** keyset outbox проходит мимо exhausted/backoff rows;
|
||||
interruption до publish/после publish до ACK восстанавливает тот же результат
|
||||
без compute. Transient heartbeat/lost ACK повторяет тот же sequence, истёкшая
|
||||
аренда/чужая identity/HTTP401/403/409 не принимаются. Это contract tests и
|
||||
установленный control layer, не реальная long-outage qualification.
|
||||
|
||||
**Приёмка:** матрица 2 профиля × 2 совместимые записи, полные обязательные выходы,
|
||||
честный прогресс, published package, сохранённый просмотр без compute после
|
||||
@@ -218,12 +456,16 @@ watchdog, stop/slow при пропаже данных, моторы и авто
|
||||
- [x] 2026-09-03 — этап 1: queue/cache/identity/selector реализованы и локально проверены.
|
||||
- [x] 2026-09-03 — история сохранена, completed-status UI удалён.
|
||||
- [x] Этап 2A, часть progress — реальные bounded Worker → backend → UI snapshots.
|
||||
- [x] Этап 2A, warm source reuse — shared CAS и LiDAR early lookup установлены на Worker;111 tests и cross-agent synthetic proof.
|
||||
- [ ] Этап 2A, остаток — versioned recorded-analysis и устранение полного input barrier.
|
||||
- [x] Этап 2B, compute/publish/cache — M4.9 ×004TREE завершён; документ повторно открыт после рестарта.
|
||||
- [x] Найденный reindex-дефект exact cache исправлен; исходная связь восстановлена по точному SHA proof.
|
||||
- [x] Этап 2B, saved replay — camera/source cloud/TGS в одном native Rerun, выдача с Core после рестарта без compute.
|
||||
- [ ] Этап 2B, visual acceptance — сохранение ручного ракурса и подробная визуальная оценка; не подменять работающим проигрывателем.
|
||||
- [ ] Этап 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 — самостоятельный полный профиль и сравнение конфигураций.
|
||||
- [ ] Этап 4 — кандидат, затем фактическая бортовая квалификация.
|
||||
- [x] 2026-09-03 — план и Desktop-сводка синхронизируются с фактическим исполнением.
|
||||
@@ -236,12 +478,40 @@ Core хранит/выдаёт 193,622,178-byte replay; Worker не нужен
|
||||
blueprint clone activation. Не объявлять stable source ID сохранением eye;
|
||||
выбирать только поддержанный native-механизм либо отдельно согласованный upgrade,
|
||||
не патчить WASM и не создавать второй renderer. Подробная визуальная приёмка открыта.
|
||||
Первым действием после нормализации памяти Mac повторить browser first-open для
|
||||
последней правки initial cursor +1мкс; до этого не писать PASS начальной сцены.
|
||||
Автотесты/build проходят; последняя визуальная проверка остановлена resource gate.
|
||||
Отдельно закрыть остаток2A версионированным incremental input;
|
||||
не объявлять прежний whole-recording materializer потоковым.
|
||||
Не начинать снова с claim/v3, memory repair, селектора или сетевого canary.
|
||||
Начальный cursor +1мкс показал camera/TGS в промежуточной проверке, но в финальном
|
||||
expanded first-frame камера была пустой до playback; устойчивость не принята.
|
||||
Прежний facade функционально работает, GPU715→375 MB освобождается,
|
||||
но renderer112→829 MB после закрытия. Follow-up с `vmmap`: один цикл834→215 MB
|
||||
за35с с исчезновением крупных VM областей, повторный812→824 MB за91с.
|
||||
Это не принято как стабильное исправление memory issue; retained root не доказан.
|
||||
В коде введён primitive-only owner v2: SDK handles/Promise/errors остаются в iframe,
|
||||
parent получает JSON-строки, late start и все каналы завершаются независимо.
|
||||
После решения владельца вернуться к основной работе завершены последовательно
|
||||
полный typecheck,740 frontend tests,64 focused backend tests и build. Дополнительно
|
||||
устранена потеря pointer/Escape при iframe boundary для существующих controls;
|
||||
16 focused tests включают перевод координат native divider и cleanup listeners.
|
||||
На8000 `app-DHVEqf5x.js`, backend89747 не перезапускался. Build15.14с,
|
||||
max RSS1994817536 bytes; OOM нет. Swap за интервал финальной сборки вырос
|
||||
1934.69→2173.69MiB: это не доказательство отсутствия resource pressure.
|
||||
Browser auto-review отдельно отклонил тяжёлый M49 viewer при pressure2; запрос
|
||||
владельцу отправлен, обхода не было, проверочная вкладка закрыта. Этот конкретный
|
||||
visual gate открыт, но он не останавливает работу над input/catalog/contracts.
|
||||
Подробности: [memory lifecycle](../experiments/perception/OBSERVATORY_MEMORY_LIFECYCLE_2026-09-03.md).
|
||||
|
||||
**Непосредственно дальше — основной этап2:** перед повтором LAB V1 исправить
|
||||
ресурсную политику диска новой sealed версией и сохранение диагностики отказа;
|
||||
не повторять известный350GiB admission FAIL. Затем закончить2C. Также открыты
|
||||
versioned recorded-analysis и
|
||||
incremental input2A, завершение saved-viewer2B, затем последовательная матрица
|
||||
LAB V1/вторая запись2C и recovery/постраничный каталог2D. Подготовку2A и metadata
|
||||
fixtures2D можно продолжать независимо от разрешения на тяжёлую browser-проверку.
|
||||
Прежний `_capture_arrays()` остаётся только в cold producer path нового adapter;
|
||||
early warm lookup и общий Worker source CAS проверены и установлены. Не называть
|
||||
их новым incremental execution. Producer SHA pack неизменен; новый input path
|
||||
должен сохранять старые exact-cache результаты. Memory lifecycle остаётся
|
||||
критерием завершения операции, не отдельной
|
||||
бесконечной оптимизацией или условием переписать план. Не повторять claim/v3,
|
||||
ремонт селектора, Docker VM или сетевой canary. Лабы в целом ещё не закрыты.
|
||||
|
||||
## Карта продолжения, проверка и восстановление
|
||||
|
||||
@@ -268,6 +538,9 @@ visual entities требуют отдельного решения. Провер
|
||||
- 2026-09-02, владелец: recorded-first вместо сетевого realtime-first; старые
|
||||
FAIL сохраняются, лаборатории не зависят от LTE/борта.
|
||||
- 2026-09-03, владелец: результат внизу вместо completion button/status — реализовано.
|
||||
- 2026-09-03, владелец: активной операции разрешён необходимый расход памяти;
|
||||
завершённая обязана освободить временные ресурсы. Не заменять cleanup снижением
|
||||
качества/новыми жёсткими RAM caps. Little Snitch не трогать; файлы результатов сохранить.
|
||||
- 2026-09-03, актуализация: этап 1 — реализованная основа, сквозной proof явно
|
||||
в этапе 2. Это уточнение границы приёмки, не приписывание успешного end-to-end run.
|
||||
- Desktop17/20, утверждение об отсутствии online prototype и безусловный1×
|
||||
|
||||
@@ -110,3 +110,48 @@ not a realtime inference or navigation claim.
|
||||
- The migration does not improve DDRNet quality, prove terrain traversability
|
||||
or grant navigation/actuation authority. Those remain separate model and
|
||||
safety acceptance questions.
|
||||
|
||||
## Resource ownership addendum — 2026-09-03
|
||||
|
||||
An active calculation/view may allocate what its admitted workload needs. On
|
||||
termination its ephemeral resources must become releasable; lowering evidence
|
||||
quality or deleting durable results is not a substitute for lifecycle cleanup.
|
||||
|
||||
Recorded viewers run the same unmodified upstream SDK in a disposable,
|
||||
same-origin iframe inside the existing viewer surface. A small synchronous
|
||||
facade exposes only the public methods already used by the product adapter.
|
||||
The revision-2 boundary returns primitive JSON strings, including events and
|
||||
errors; the parent parses them in its own realm. Native handles, DOM nodes,
|
||||
channels, unsubscribe closures and SDK startup Promises stay inside the iframe.
|
||||
Parent-owned RRD bytes enter a synchronous channel call, without transfer or
|
||||
changing the payload/quality. Parent startup Promises are settled by primitive
|
||||
notifications and rejected on disposal. Every channel is closed independently;
|
||||
a late SDK startup completion is stopped again. Teardown severs references and
|
||||
removes the iframe. There is still exactly one native renderer and clock,
|
||||
no new playback UI, SDK patch or second backend. The live source path stays
|
||||
direct upstream. Page-cache restoration starts a fresh viewer lifecycle.
|
||||
|
||||
The iframe relays only Escape and primitive pointer fields to parent-owned
|
||||
events for the existing outer controls. Coordinates are translated by the
|
||||
iframe bounds, including the native-chrome crop. Native Rerun still owns its
|
||||
canvas gestures and divider; nothing is cancelled or sent back to the canvas.
|
||||
The parent tracks the same divider for control alignment, not a second layout
|
||||
or renderer. Teardown removes every relay listener and foreign frame reference.
|
||||
|
||||
The backend's ephemeral blueprint store has an exact viewport owner, explicit
|
||||
release, idle expiry (300 seconds; reaped every 30 seconds), and shutdown cleanup.
|
||||
An open owner renews every 30 seconds. Release is serialized with rendering;
|
||||
late updates for that released identity are rejected. Expiry never stops an
|
||||
active render, a recording, a Worker profile or a vehicle. Durable files are not
|
||||
removed. The pre-existing 32-entry cache bound is unchanged; no new active data
|
||||
or image-quality cap is introduced.
|
||||
|
||||
DOM disappearance is not memory acceptance. Compare process footprints before,
|
||||
during and after ordinary sequential open/close cycles without forced GC or
|
||||
page reload. See the dated memory-lifecycle report for measurements and limits.
|
||||
Revision 2 and the input relay passed full typecheck, 740 frontend tests,
|
||||
64 focused backend tests and a production build, served on canonical 8000.
|
||||
Real browser acceptance of this revision remains pending: auto-review rejected
|
||||
opening the heavy saved viewer under elevated pressure; explicit owner approval
|
||||
was requested. This does not gate unrelated input/catalog contract work, nor
|
||||
does a successful build prove that the recorded memory retention is fixed.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# ADR 0050 — Recorded Observatory first; portable profiles then onboard
|
||||
|
||||
> 2026-09-03: [ADR 0051](0051-modular-observatory-profiles.md) now defines
|
||||
> modular Docker composition and grouped single-provider settings. Its UI and
|
||||
> packaging rules supersede the finite whole-profile selector/one-image target
|
||||
> below. Recorded-first, exact cache, preserved evidence and safety remain.
|
||||
|
||||
Date: 2026-09-02. Decision accepted by the owner; implementation in progress.
|
||||
This supersedes ADR 0049's realtime-first product gate and execution order,
|
||||
not its live-stream integrity, ownership or safety contracts.
|
||||
@@ -182,6 +187,29 @@ See [real-run and restart evidence](../../experiments/perception/OBSERVATORY_REC
|
||||
|
||||
## Boundaries
|
||||
|
||||
Owner clarification, 2026-09-03: Observatory's result list includes only admitted
|
||||
portable-profile publications, not every historical LAB linked to a source.
|
||||
Legacy contours, canonical archive projectors and stored evidence remain
|
||||
unchanged. Filter by executable replay/publication binding, never by LAB label,
|
||||
date, model name or equality to the currently installed definition. Older valid
|
||||
portable versions remain reviewable. Versioned opt-in cursor pages feed the
|
||||
existing source search; legacy catalog consumers retain their previous response.
|
||||
See [catalog boundary evidence](../../experiments/perception/OBSERVATORY_CATALOG_BOUNDARY_2026-09-03.md).
|
||||
|
||||
Source-reuse increment (2026-09-03): both current control agents now share a
|
||||
Worker-local source CAS. Every generation still obtains its own authenticated,
|
||||
claim-bound manifest and checks exact digest/length before reusing bytes. Missing
|
||||
camera members are fetched individually instead of retransferring a partly cached
|
||||
epoch. Job/output/profile identities and model packages are unchanged. Cached
|
||||
visual results remain served by Core, not this Worker input cache.
|
||||
|
||||
M49 now selects an exact current-producer LiDAR v2 pack before raw decoding;
|
||||
the legacy producer file and its identity hash remain unchanged. Full source
|
||||
hash and strict NPZ integrity validation still occur on reuse. Cold whole-source
|
||||
materialization, versioned recorded-analysis and full workflow acceptance remain
|
||||
open. Shared-cache synthetic Worker proof is not a model run or a throughput
|
||||
measurement. See [source-reuse evidence](../../experiments/perception/OBSERVATORY_SOURCE_REUSE_2026-09-03.md).
|
||||
|
||||
No Synology deployment, hardware actuation, motor integration, new capture,
|
||||
silent model substitutions or deletion of recordings/results. A Docker image's
|
||||
portability does not promise that a CUDA build runs unchanged on Apple Silicon.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# ADR 0051 — Modular AI containers and composed Observatory profiles
|
||||
|
||||
Date: 2026-09-03. Owner-approved target; the first local modular vertical slice is installed.
|
||||
|
||||
## Decision and supersession
|
||||
|
||||
A reusable module is an independently packaged Docker image for a model or a
|
||||
cohesive algorithmic function. A profile is an immutable, validated composition
|
||||
of module versions, parameters, input/output connections and execution policy.
|
||||
A LAB run applies that composition to an exact recording snapshot. A new
|
||||
combination does not require building another image containing all models.
|
||||
|
||||
This replaces the earlier **one full profile = one image/container** packaging
|
||||
requirement in the historical plan. It keeps ADR 0050's recorded-first purpose,
|
||||
ADR 0048's generic installed-package boundary and the stream contracts from
|
||||
ADR 0049. Several containers are not intrinsically a defect. Per-LAB application
|
||||
forks, hidden host dependencies and duplicated data preparation remain defects.
|
||||
|
||||
The future onboard host is CUDA-capable, architecture/runtime compatible
|
||||
hardware. It is explicitly **not Mac Mini**. Exact CPU architecture, GPU,
|
||||
driver and inference-runtime compatibility still need target qualification;
|
||||
CUDA availability alone is not a promise that every existing image will run.
|
||||
|
||||
New owned module images/containers use the `ndc-` namespace, readable model or
|
||||
function names, explicit versions and ownership labels. A composition has its
|
||||
own readable LAB label; module/composition/job identities are linked in runtime
|
||||
metadata. A reused module need not be renamed to match every LAB that uses it.
|
||||
|
||||
## Operator composition
|
||||
|
||||
Each recording exposes AI-layer settings inside the existing Observatory.
|
||||
Use functional groups with a single-choice Select, not a checklist of arbitrary
|
||||
containers. Segmentation admits **at most one** provider: DDRNet or EoMT, never
|
||||
both in the same new LAB. Optional groups may offer None if the resulting graph
|
||||
still satisfies its declared outputs. A geometry-only or segmentation-only LAB
|
||||
is valid and must not claim the omitted capabilities.
|
||||
|
||||
Candidate groups are segmentation, object detection, LiDAR/TGS geometry and
|
||||
costmap, object/range association, motion and policy-shadow. Their final physical
|
||||
module boundaries follow existing code ownership and measured data exchange,
|
||||
not a mandatory container per mathematical operation. Multiple capabilities
|
||||
provided by one module do not instantiate that module multiple times.
|
||||
|
||||
The server validates provider cardinality, dependencies, source capabilities,
|
||||
calibration/time frames, output contracts and installed Worker versions. A client
|
||||
cannot supply executable argv, image names, host paths or arbitrary resource
|
||||
settings. Invalid dependencies are explained, never silently supplemented by
|
||||
old LAB overlays or an unselected second segmenter.
|
||||
|
||||
An exact existing published composition is opened, not recomputed. The settings
|
||||
remain editable so the operator can create another composition. There is no
|
||||
Calculate action for a complete exact cache hit, no “calculation complete”
|
||||
button/badge, and no duplicate LAB. The grouped selectors keep already-used
|
||||
providers: completion belongs to the whole composition, not to an individual
|
||||
dropdown option. This supersedes the old finite-profile selector grammar, not
|
||||
its exact-cache and idempotency guarantees.
|
||||
|
||||
## Execution, identity and caches
|
||||
|
||||
- Keep one recorded owner on Worker006 and serialize heavy GPU execution.
|
||||
Selecting several modules does not authorize concurrent GPU model jobs.
|
||||
- Share source delivery/preparation locally on Worker. Intermediate image/point
|
||||
buffers do not round-trip through Core between modules. Do not reintroduce
|
||||
whole-recording upload/decode as a permanent streaming-start prerequisite.
|
||||
- Keep a common timestamped, bounded I/O contract with explicit sensor gaps,
|
||||
coordinate frames, unavailable outputs and causal temporal state. Recorded
|
||||
analysis may be slower than acquisition; live has separate freshness gates.
|
||||
- Freeze graph topology, module image/weights/code/config identities, input
|
||||
identities, preprocessing, calibration, cadence, precision and state policy.
|
||||
Canonical ordering of the selection UI is not part of the semantic identity.
|
||||
- Reuse a node only for exact validated inputs and producer identity. A changed
|
||||
detector invalidates its dependent range/motion results, not an independent
|
||||
segmentation result. Stateful reuse also binds initialization and history.
|
||||
- Final LAB/cache publication remains on Core. Worker source/preparation caches
|
||||
are not the final viewer store. Failed publication retries the sealed result,
|
||||
not inference. A partially cached graph is not a complete ready LAB.
|
||||
- Record reused versus computed nodes and provenance. Cache playback or cached
|
||||
composition assembly is never reported as measured onboard processing FPS.
|
||||
|
||||
Onboard modules are long-lived within a mission; do not start a container or
|
||||
reload weights per frame. Resident memory, local transport, bounded queues and
|
||||
end-to-end latency require a joint benchmark. An optional later co-location of
|
||||
hot nodes is a measured deployment optimization, not a reason to remove logical
|
||||
modularity. No automatic actuation or navigation acceptance is introduced.
|
||||
|
||||
## Current implementation and migration
|
||||
|
||||
Current M49 runs CPU TGS in its specialized agent. Current LAB V1 is an installed
|
||||
fixed stack: prepare → EoMT → DDRNet → assemble, with host-bound model/runtime
|
||||
assets. Neither is the new modular composition system. Keep the successful M49
|
||||
publication and exact existing releases; retain old dual-segmentation results
|
||||
as historical comparisons, without admitting that combination in new profiles.
|
||||
|
||||
Reuse `InstalledLabPackage`, its generic launcher, queue/claim/recovery, source
|
||||
CAS, verified publication and common viewer. Extend the versioned contracts for
|
||||
compositions and per-node reuse. Do not redesign the queue or create another
|
||||
agent per model. Source CAS alone does not implement intermediate-result reuse.
|
||||
|
||||
The owner authorized Worker cleanup. Classify every exact container/image/cache
|
||||
against running services, pinned packages, model assets, rollback and retained
|
||||
evidence before deleting. Stopped instances can be retired independently of
|
||||
images and volumes. Preserve raw recordings, published LABs, Legacy views,
|
||||
required source/model caches and one usable rollback; no global Docker prune.
|
||||
|
||||
The accepted EoMT disk floor is now **250 GiB plus its working-set estimate**.
|
||||
Source and narrow tests have changed; installed EoMT still has 350 GiB until a
|
||||
new sealed image/release is activated. Never patch a historical digest in place.
|
||||
|
||||
## Acceptance and route
|
||||
|
||||
Done means: select one provider per group; compute two distinct compositions on
|
||||
compatible recordings with exact cache reuse; view their complete bound outputs
|
||||
from Core; recover publication without compute; prove exclusive GPU scheduling
|
||||
and cleanup; cold-start the selected module distribution without developer
|
||||
checkout or unlisted caches. Onboard qualification remains future work.
|
||||
|
||||
The historical combined route remains documented in
|
||||
[the four-stage ExecPlan](../OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
|
||||
|
||||
## Local implementation record — 2026-09-03
|
||||
|
||||
The first implemented slice replaces the Observatory's fixed profile selector
|
||||
and separate refresh/calculate controls with one always-available
|
||||
`Сконфигурировать AI-слой` action for the selected source. Its canonical Window
|
||||
contains functional groups, one provider Select per group, the close action and
|
||||
one `Рассчитать` action. The Window uses background, spacing and typography for
|
||||
grouping; it has no group outlines, header divider lines or modal rim.
|
||||
|
||||
DDRNet and EoMT are independent alternatives in the Segmentation group. They
|
||||
share only the model-neutral `camera-source` preparation step and neither model
|
||||
consumes the other model's result. The installed module repositories and exact
|
||||
local image identities are:
|
||||
|
||||
- `ndc/mission-core-ai-module-camera-source` —
|
||||
`da926459aee0a841bbdfaf80a0eb5fbead354c56794d1f3384eeba66d0a49e00`;
|
||||
- `ndc/mission-core-ai-module-ddrnet` —
|
||||
`489fc7d1157fd0f1cd1d82e06a15737b7b2aaaf72b2ddb2aca2992b91a97488e`;
|
||||
- `ndc/mission-core-ai-module-eomt` —
|
||||
`5b770178e4a5c8fbe8f8ddab3b83a598973dbe103b669851110b11a0918ab846`;
|
||||
- `ndc/mission-core-ai-module-rf-detr` —
|
||||
`2b8b44be8e9ee4060aa6997fc4c025ad7120f37ecd720a9e59b5e02ac6c90f66`;
|
||||
- `ndc/mission-core-ai-module-object-distance` —
|
||||
`69d68f64981b41e5bcce1e642433e3d466b88c51942a5ba3aec9180d8ed04263`;
|
||||
- `ndc/mission-core-ai-module-tgs` —
|
||||
`f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3`.
|
||||
|
||||
The executable graph also contains `ai-detection-rf-detr-v1` for image-space
|
||||
boxes and `ai-range-object-distance-v1` for box-to-LiDAR association. The range
|
||||
package executes RF-DETR as its explicit dependency, then uses synchronized
|
||||
LiDAR, pose and calibration to estimate range. TGS consumes LiDAR, pose and its
|
||||
own history independently. Selecting TGS and range therefore produces two
|
||||
independent durable jobs; segmentation does not feed either of them. The common
|
||||
Worker agent launches package steps with job-specific container names under
|
||||
`ndc-mission-core-ai-module-*`, serializes GPU work, publishes a
|
||||
`missioncore.recorded-ai-layer-review/v1` result and leaves the historical
|
||||
combined LAB V1 and Legacy contour intact.
|
||||
|
||||
Submission creates a durable Observatory job immediately. The source-bound
|
||||
evidence area shows its selected configuration, current phase, measured
|
||||
progress and an estimated remaining time. A published result moves into the
|
||||
existing evidence list and uses the same open, rename and delete actions as
|
||||
the other portable LAB results.
|
||||
|
||||
The active Worker 006 directory release is `20260903-v5`, release SHA-256
|
||||
`a87c82adfa1058f14f850f488ace38e5c7c9d9e2145d4678a81b52f2a1aad573`.
|
||||
Its common agent image SHA-256 is
|
||||
`d6f2ef1a3f38ebc8503b62296ca9def10472a60a41ffc5edd123df565520dec8`.
|
||||
The DDRNet, EoMT, RF-DETR and object-distance definition SHA-256 values are
|
||||
respectively
|
||||
`b04206a8472588fee22e0282228e51b7817c80f9739180e834b704733d7aaf76`,
|
||||
`6d13402883e8fa79a8ea97b15e81e704d3c0f9dac5d94445e5e53c18dcebe98f`,
|
||||
`a89f51a66a070deb50f4904f596545cdcd7289df7e42b8511aec9547dedd52e8`
|
||||
and `cc56e72fdfb38565a0b402c90a91917821463d980f5855e4cdf4d8b9f262a94c`.
|
||||
The standalone detector and composite range package have distinct resource
|
||||
profile identities, so their otherwise shared RF-DETR model manifest cannot
|
||||
collapse into one executor registration. All four package executor identities
|
||||
are unique. DDRNet, EoMT, RF-DETR and object-distance pass live source,
|
||||
executor, sealed-definition and durable-queue preflight for
|
||||
`20260828T130511Z_viewer_live`; the published M49 TGS result is an exact cache
|
||||
hit. No new full-recording RF-DETR or object-distance inference was run as part
|
||||
of this installation acceptance.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Missing Core Observatory domain ontology v1
|
||||
|
||||
Status: local runtime canonical. Owner: NODE.DC Mission Core.
|
||||
|
||||
The Observatory now has four independent consumers of the same relationships:
|
||||
composition validation, Worker job dispatch, LAB/result projection and replay
|
||||
controls. The previous typed-contract-only approach lost the relation between a
|
||||
multi-module submission and its member results. This satisfies the local
|
||||
ontology admission gate in `docs/18_APPLICATION_COMPONENT_ARCHITECTURE.md`.
|
||||
|
||||
The executable source is `config/observatory-domain-ontology.json`. It defines
|
||||
transport and equipment units, time-bounded equipment mounts, capture profiles,
|
||||
recorded sessions, module versions, immutable compositions, append-only
|
||||
composition runs, container images, worker nodes, jobs, portable results, LAB
|
||||
projections, viewer panes and viewer layers. These are peer subjects with independent identities and
|
||||
lifecycles. A per-result LAB view profile owns the operator's mutable SLAM
|
||||
display choices. The entities follow the NODE.DC Platform ontology architecture without making
|
||||
Platform a dependency. Its named queries are recording capture context,
|
||||
configuration label, member results, viewer layers and ready state.
|
||||
|
||||
A recorded session binds to the transport, equipment units, equipment mounts
|
||||
and exact capture profile that produced it. A composition run points to that
|
||||
session and never substitutes its own identity for transport or equipment. This
|
||||
keeps future vehicle and sensor changes separate from model configuration and
|
||||
from the published evidence.
|
||||
|
||||
A module version is implemented by an immutable container image. Installation
|
||||
of that image on a worker and execution of a recorded job on a worker are
|
||||
separate relationships, so Worker 006 is not encoded inside the module identity.
|
||||
The replay layer remains a capability of the module version and can therefore be
|
||||
derived through the module-to-image relationship without coupling UI controls to
|
||||
a Docker runtime instance.
|
||||
|
||||
One saved composition is opened by one Rerun viewer and one shared recording
|
||||
clock. Camera, LiDAR, depth and future sensor views are panes inside that viewer's
|
||||
blueprint. Every pane owns its layer controls; the overlay follows the pane bounds
|
||||
when a separator moves. A second Rerun viewer is reserved for a genuinely
|
||||
independent recording or clock, because duplicating a viewer per pane would also
|
||||
duplicate the recording transport, memory and synchronization work.
|
||||
|
||||
Pane construction is projected from module capabilities. DDRNet and EoMT create
|
||||
only the camera pane with their independent segmentation layer. RF-DETR creates
|
||||
the camera pane with object frames. TRAVEL TGS creates only the spatial pane with
|
||||
source points, local SLAM and TGS. Object distance composes the camera detections
|
||||
and spatial LiDAR panes it needs. Combining modules takes the ordered union of
|
||||
these pane layers, so a configuration cannot invent an unrelated camera or point
|
||||
cloud viewport.
|
||||
|
||||
Operator SLAM display settings belong to the published LAB result identity. They
|
||||
are mutable presentation state, stored atomically in the Mission Core runtime,
|
||||
and are loaded again after browser storage is cleared. They do not modify the
|
||||
immutable calculation result, module configuration or recorded evidence.
|
||||
|
||||
Identity and lifecycle remain owned by the existing stores. The ontology does
|
||||
not replace their contracts and does not contain executable commands, mutable
|
||||
container instances, paths or resource grants. Image digests and worker
|
||||
identities remain facts supplied by their owning registries. The ontology
|
||||
projects their exact identities into one shared relationship model.
|
||||
|
||||
Version 1 uses additive migration for existing single-module results. A recorded
|
||||
composition run may reference an already published exact module result; this is
|
||||
recorded as reuse, not a new inference. Failed attempts remain audit evidence and
|
||||
do not satisfy the ready-state query.
|
||||
|
||||
There is no runtime dependency or write path to NODE.DC Platform Ontology. The
|
||||
Platform repository supplied the package architecture only. Synchronization is
|
||||
`none` until a meaning becomes stable across products and receives a separate
|
||||
owner-approved migration.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Observatory: граница с Legacy и постраничный каталог
|
||||
|
||||
Session: `observatory-catalog-cleanup-tpit2O`, 2026-09-03.
|
||||
Изменение поверх `eff60e4`; прежние незакоммиченные viewer/source-reuse правки сохранены.
|
||||
|
||||
## Решение и результат
|
||||
|
||||
По уточнению владельца Обсерватория показывает результаты переносимых профилей,
|
||||
а не весь архив исследований, связанный с выбранной исходной записью.
|
||||
Legacy «Лабораторные контуры» и их данные остаются нетронутыми. Разделение —
|
||||
типизированная проекция, не удаление LAB, не миграция исходников и не список
|
||||
запрещённых имён E24/E25/E26.
|
||||
|
||||
В текущем каталоге RAVNOVES004TREE теперь показывает один опубликованный M4.9T5
|
||||
от3 сентября. Старый M49 без replay capability и исторический canonical LAB V1
|
||||
не попадают в Обсерваторию. У RAVNOVES00 вместо14 исторических карточек —0.
|
||||
Нерассчитанные portable profiles по-прежнему доступны для расчёта. Существующие
|
||||
версии полноценных portable результатов не скрываются по возрасту или несовпадению
|
||||
с текущей установленной версией профиля.
|
||||
|
||||
## Реализация
|
||||
|
||||
- `core/observatory/catalog.ts`: принимает только `portable-result-review` через
|
||||
существующий строгий `observatoryRecordedRunBinding`: source/result/definition
|
||||
identity и publication provenance. Неверная portable identity вызывает ошибку,
|
||||
не переименовывается в Legacy и не скрывается как успешная очистка.
|
||||
- Старые canonical/experimental adapters и их endpoints не удалены. Их наличие
|
||||
в коде не означает включение в новый продуктовый список. Общий session catalog
|
||||
продолжает возвращать исторические записи своему прежнему потребителю.
|
||||
- Backend opt-in `pagination=cursor-v1` добавляет versioned page schema и
|
||||
`next_cursor` из существующей keyset pagination `SessionStore`. Без opt-in
|
||||
форма ответа остаётся ровно прежней: `items`. `scope`, `lab_contract` и
|
||||
правила публикации/скрытия проекций не менялись.
|
||||
- `core/observation/sessionCatalogPage.ts`: отдельный строгий page adapter.
|
||||
Observatory дочитывает страницы по100 metadata entries, проверяет уникальность
|
||||
записей, повторные/небезопасные cursors, версию/размер ответа, HTTP errors и
|
||||
отмену. При ошибке отменяется второй обход; существующий hook сохраняет
|
||||
последний успешный каталог и показывает ошибку обновления.
|
||||
- Предел обхода —256 страниц на scope (по умолчанию до25600 строк). Достижение
|
||||
предела оставляет явный partial-window флаг; отсутствие записи в таком срезе
|
||||
не подтверждает удаление. Исторические provenance не накапливаются при обходе.
|
||||
Список хранит только метаданные, не видео, облака или result artifacts.
|
||||
- Текущий поиск `Select` работает по всем загруженным источникам. Отдельного
|
||||
server-side текстового поиска нет. Это keyset traversal, не транзакционный
|
||||
снимок всего каталога: новая запись во время обхода появляется при следующем
|
||||
Refresh; удалённый cursor вызывает ошибку/повтор, не выдуманную полноту.
|
||||
- Из карточек убран внутренний `result_kind`, пояснение над списком описывает
|
||||
операторскую задачу. Новых controls, визуальных паттернов, CSS или per-LAB
|
||||
branches нет; скилл Mission Core UI сохранил канонические controls и разделение
|
||||
`core → workspace`.
|
||||
|
||||
## Проверки
|
||||
|
||||
-24 focused frontend/architecture tests PASS; полный последовательный frontend
|
||||
suite747 PASS; full typecheck PASS. Backend41 tests PASS; Ruff/mypy PASS.
|
||||
- Fixture500 источников и102 LAB entries проверяет связь portable результата
|
||||
со source499 после первой сотни legacy rows. Отдельно: точная полная страница
|
||||
без ложного limit flag, дубли/cursor cycle, abort, небезопасный cursor,
|
||||
неизвестная версия, превышение страницы и явный предел обхода.
|
||||
- Backend102 sources +102 LABs: старый ответ неизменён; страницы100+2, без
|
||||
потери/дубликатов; scope-invalid cursor404, неизвестная pagination422.
|
||||
- На действующем8000 production adapter с `limit=1` сделал20 GET:3 источника,
|
||||
17 исходных LAB rows,1 admitted result,0 unresolved, оба has-more false.
|
||||
UTC13:24:52.491–13:24:52.921; monotonic `performance.now()`
|
||||
130.567750–559.498167ms в одном Node-процессе. Это metadata proof, не FPS.
|
||||
- Browser: normal/expanded composition, поиск и выбор RAVNOVES00/004TREE,
|
||||
обе нерассчитанные версии у00, один текущий M49 у004TREE, Refresh и Escape
|
||||
dropdown проверены. Вне dropdown Escape не изменил modeless окно; закрытие
|
||||
полного viewer и memory lifecycle в этом проходе не проверялись.
|
||||
- Legacy API до и после установки совпал побайтно. Worker queue сохранила
|
||||
10failed/2succeeded,0 live leases и1 v3 grant. Новых model jobs —0.
|
||||
|
||||
## Установка и эксплуатация
|
||||
|
||||
Сборка `app-9nK8VtbK.js` обслуживается единственным Core на8000, PID1425 после
|
||||
контролируемого перезапуска idle-сервиса;8765 пуст. Предыдущая frontend-сборка
|
||||
сохранена в private evidence `previous-dist`, а не удалена. Backend declarations,
|
||||
порты и LaunchAgent не изменены.
|
||||
|
||||
Первый build с искусственным V8 heap limit1024MiB завершился heap-limit OOM
|
||||
до установки; прежний UI оставался рабочим. Последовательный build с2048MiB
|
||||
прошёл за10.84с, maximum RSS2253422592 bytes. Это ограничение build-процесса,
|
||||
не доказательство нехватки всей RAM Mac. Swap2133.69→2301.06MiB после сборки,
|
||||
pressure2; временные build/test процессы завершены. Heavy replay не запускался,
|
||||
прежний browser gate не обходился. Little Snitch, чужие приложения, Docker
|
||||
limits, GPU и модельные контейнеры не менялись. Оставлена одна лёгкая вкладка
|
||||
Обсерватории для владельца, без viewer/inference.
|
||||
|
||||
## Остаток маршрута
|
||||
|
||||
Выполнена часть2D: product/Legacy separation и постраничный metadata catalog.
|
||||
Это не закрытие всего этапа2. Далее остаются2A — versioned recorded-analysis и
|
||||
cold incremental input;2B — синхронный replay/ручной ракурс/ресурсный lifecycle;
|
||||
2C — последовательные LAB V1×004TREE и оба профиля×RAVNOVES00;2D — оставшиеся
|
||||
recovery/negative cases. Standalone полного графа3 и реальный борт4 отдельно.
|
||||
Ops-карточка в этом инкременте не обновлялась.
|
||||
|
||||
## Evidence
|
||||
|
||||
Private root: `.runtime/observatory-catalog-cleanup-tpit2O`. API metadata,
|
||||
screenshots и runtime output не добавлены в Git.
|
||||
|
||||
| Артефакт | SHA-256 |
|
||||
| --- | --- |
|
||||
| `legacy-before.json`, `legacy-after.json` | `4b5a19d83569c598e9936f1a65ef445d65eccc4bdc2731823bbbdebbf746cabc` |
|
||||
| `live-pagination-proof.json` | `1a5c65b04b34f2f797c927f3e29094b399f9c51c717786a1975344e6cc975dcf` |
|
||||
| `frontend-tests.log` | `674745c5a008cdb39a5d5a0b360ddff304da855dca2ba899ca2bd341a415dee0` |
|
||||
| `backend-tests.log` | `d8b26fab3621b44e79ae5555ccd2de5447fb1efac9c7c2737b97bf8563e7e2e1` |
|
||||
| `build-2g.log` | `f1b7d14266cee959bc69b271cffa17a4d1483c2af7330301fbc7261a01519a7f` |
|
||||
| `worker-readiness-after.json` | `26db0da5261bae8b1b85cb06c93f633dcaa9d732a543c6fca24326a6831a7758` |
|
||||
| `normal.png` | `c45fe93e56a1edaf6580bcbef5848d09a34e5bdc84903103567a58e47191d5db` |
|
||||
| `rav00-results.png` | `63cfbbbc6e89b2bb17ddd68c83dddb89a235e8d9eb54709955a70139dd2c76f2` |
|
||||
| `rav004-results.png` | `28520a65a459b6736697cfd82527df9ba2eb3ebacfd1888a70f914b1e4f43809` |
|
||||
@@ -0,0 +1,118 @@
|
||||
# Observatory: возврат к основному лабораторному сценарию
|
||||
|
||||
Дата: 2026-09-03. Session: `observatory-mainline-FZrkOa`.
|
||||
Checkout: `codex/m5-1-observatory`, инкремент поверх `eff60e4`.
|
||||
Handoff snapshot: UTC `2026-09-03T12:22:38.948Z`, Node `process.hrtime.bigint()`
|
||||
`2222283739491791` ns. Catalog snapshot имеет отдельные UTC/monotonic в evidence;
|
||||
часы разных механизмов не вычитаются друг из друга. Для тестов/build сохранено
|
||||
измерение elapsed через `/usr/bin/time -l`, не per-stage trace приложения.
|
||||
|
||||
## Решение и граница
|
||||
|
||||
Владелец потребовал вернуться к основному плану и не превращать общую занятость
|
||||
RAM Mac в бессрочный блокер. Активные процессы могут потреблять нужную память;
|
||||
после завершения временные ресурсы освобождаются. Это правило не отменяет
|
||||
зафиксированный дефект закрытого viewer и не означает, что лабы уже приняты.
|
||||
|
||||
Только локальный Core8000, без Worker/model runs, внешнего deployment, изменений
|
||||
Little Snitch/пользовательских служб/Docker limits, удаления записей/cache.
|
||||
UI skill сохранил один renderer/clock, прежние controls и последовательные gates.
|
||||
|
||||
## Объективный статус четырёх этапов
|
||||
|
||||
| Этап | Что доказано | Что остаётся |
|
||||
| --- | --- | --- |
|
||||
| 1. Основа | Admission, очередь/claim, защита от дублей, exact cache/version/selector | Не повторять уже выполненный ремонт |
|
||||
| 2. Полные лабы | M49×004TREE рассчитан, sealed/published, документ/cache пережили restart; общий saved replay реализован | Versioned recorded-analysis/input, visual acceptance, LAB V1 и вторая запись, recovery и большой каталог |
|
||||
| 3. Полный Docker-профиль | Существует инженерный граф DDRNet/RF-DETR/LiDAR/distance/motion/TGS/costmap/policy-shadow | Самостоятельный image/cold start, полный run и сравнение конфигураций |
|
||||
| 4. Борт | Цель определена | Реальное целевое оборудование и отдельная квалификация; сейчас не переносим |
|
||||
|
||||
**Лабы целиком не закрыты.** M4.9T5 сейчас CPU TGS; LAB V1 — последовательная
|
||||
EoMT+DDRNet segmentation, а не готовый полный профиль рига. Старые FPS и
|
||||
красивые overlays не доказывают целый вычислительный граф в одном Docker.
|
||||
|
||||
## Сверка незакрытого кода
|
||||
|
||||
- `core/observatory/catalog.ts:fetchObservatoryCatalog` запрашивает два окна
|
||||
по100 metadata entries (source/laboratory), не продолжает cursor. API
|
||||
`/api/v1/observation-sessions` cursor уже принимает. Нужно довести постраничные
|
||||
source/result связи и поиск, не грузить записи целиком и не скрывать результаты.
|
||||
- `compute/lidar_replay.py:build_lidar_replay_pack_v2` вызывает `_capture_arrays()`
|
||||
до cache lookup. Это не инкрементальный вход. Source/logical/producer hashes
|
||||
участвуют в identity, поэтому новый путь требует версионирования и сохранения
|
||||
старых exact-cache результатов, не механической перестановки строк.
|
||||
- `viewer/recorded.py:_RecordedBlueprintStream` активирует clone при обновлении
|
||||
слоёв; сохранение ручного eye не решено. Нужен поддержанный upstream-механизм,
|
||||
не локальный патч SDK/WASM и не второй renderer. Initial camera frame и
|
||||
полная visual coverage тоже ещё не приняты.
|
||||
|
||||
## Выполненный инкремент
|
||||
|
||||
Накопленный primitive-only owner v2 проверен и собран. Дополнительно исправлена
|
||||
потеря input между native iframe и существующими внешними controls: Escape и
|
||||
pointer fields копируются в parent-owned events с переводом координат через
|
||||
bounds iframe. Слежение за native divider снова получает события. Исходный
|
||||
canvas не получает синтетических действий; native gestures не отменяются.
|
||||
Все5 listeners удаляются при dispose. Это минимальная поддержка прежней
|
||||
композиции, а не новый UI control или новый clock.
|
||||
|
||||
## Итоговая последовательная проверка
|
||||
|
||||
| Проверка | Результат | Elapsed | Maximum RSS bytes |
|
||||
| --- | --- | ---: | ---: |
|
||||
| Lifecycle/input + architecture | 16 PASS | — | — |
|
||||
| Полный TypeScript | PASS | 4.87с | 904871936 |
|
||||
| Полный frontend suite | 740 PASS | 21.73с | 264798208 |
|
||||
| Lifecycle/session API/RRD backend | 64 PASS | 3.81с | 190988288 |
|
||||
| Production build | PASS | 15.14с | 1994817536 |
|
||||
|
||||
Первые739 frontend tests и первая сборка сохранены отдельно; после input relay
|
||||
повторены итоговые gates740. Число16 входит в740, суммы не уникальные тесты.
|
||||
RSS относится к измерению команды, не всей физической памяти Mac. В backend
|
||||
сохранилось deprecation warning Starlette/httpx; в build — warning больших chunks.
|
||||
|
||||
Serving: `app-DHVEqf5x.js`, runtime `rerun-Dy4Vuq0s.js`, upstream SDK0.36.3 без
|
||||
патча; WASM `re_viewer_bg-BO4B44yr.wasm` 50428810 bytes. `/`, `/rerun-runtime.html`
|
||||
и `/api/health` отвечают успешно, PID89747 на127.0.0.1:8000;8765 пуст.
|
||||
Нового backend restart не было.
|
||||
|
||||
## Browser и ресурсы: точная граница доказательства
|
||||
|
||||
Каталог нового v2 был открыт обычным путём LAB→Обсерватория до input relay patch;
|
||||
renderer92266=136MB, shared GPU72641=408MB, Core89747=164MB. Это не heavy replay.
|
||||
Auto-review отклонил открытие готового M49 при pressure2 из-за риска прежнего
|
||||
остаточного потребления. Запрошено явное разрешение на короткий просмотр;
|
||||
нет ответа на момент этой сводки. Обхода не было. Следовательно, normal/expanded/
|
||||
Escape/divider и open/close именно последней сборки **не приняты в браузере**.
|
||||
Временная проверочная вкладка закрыта; canonical Core оставлен работающим.
|
||||
|
||||
OOM не произошло. До финальной сборки swap1934.69MiB, после2173.69MiB,
|
||||
pressure2. Нельзя назвать RAM неограниченной или заявить memory-fix PASS по
|
||||
успешному build. Docker — только три durable telemetry containers примерно
|
||||
56/292/13MiB. Посторонние приложения и VM limits не менялись.
|
||||
|
||||
## Продолжение и приёмка
|
||||
|
||||
Продолжать оставшийся2A — versioned полный анализ/incremental input; при
|
||||
разрешённом просмотре завершить2B. Далее LAB V1×004TREE, оба профиля×RAVNOVES00,
|
||||
затем recovery/negative cases и пагинация. Подготовка metadata fixtures2D не
|
||||
зависит от heavy browser gate. Старые данные/результаты остаются immutable.
|
||||
Отдельный memory gate не подменяет эти задачи. Этап3 следует за принятым
|
||||
лабораторным циклом; борт, моторы и сетевой realtime PASS сейчас не требуются.
|
||||
|
||||
## Evidence manifest
|
||||
|
||||
Private directory: `.runtime/observatory-mainline-FZrkOa`.
|
||||
Исходные записи/скриншоты/секреты в Git не добавлялись.
|
||||
|
||||
| Файл | SHA-256 |
|
||||
| --- | --- |
|
||||
| `input-bridge-tests.log` | `b007e29a4b828b0982347dee267c0498424048abd7441efa0a1abc6ccdc54592` |
|
||||
| `input-bridge-typecheck.log` | `7c66ca38e202d5c34eaf2509723281e558aadb8fd8716ca38095d31cac877098` |
|
||||
| `input-bridge-full-tests.log` | `e8a103bb84f7412706f29661ca32a63c3b9f8a34b4e9d83a1a1bf468a009242e` |
|
||||
| `input-bridge-build.log` | `a437b38a1d6873707193e742c8719942b0842034adbe6168e5e507771b13226c` |
|
||||
| `backend-tests.log` | `3ccd6d31b1de48f0506557ab5cb83324121fdd80ca143a2d72a5dc7080bfed56` |
|
||||
| `01-catalog.txt` | `f226d322bd9749245a6d436806829b015038fa829ccb96979544ccb7d0518066` |
|
||||
|
||||
Ops ранее не ответил на direct read; карточка в этом инкременте не менялась.
|
||||
Актуализированы локальный ExecPlan и Desktop final-status.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Mission Core: освобождение ресурсов после просмотра — 2026-09-03
|
||||
|
||||
## Решение и граница
|
||||
|
||||
По уточнению владельца активная операция использует необходимую ей память.
|
||||
После завершения/закрытия/отмены/ошибки освобождаются её временные буферы,
|
||||
визуализатор, каналы, таймеры и серверные сессии. Новые ограничения качества,
|
||||
разрешения, числа точек или размера активной записи не вводятся. Дисковые
|
||||
записи, результаты и replay-cache сохраняются. Little Snitch, пользовательские
|
||||
приложения и настройки Docker VM не менялись.
|
||||
|
||||
Это исправление найденного lifecycle записанного просмотра, не приёмка памяти
|
||||
всех операций Core/Worker. Холодную подготовку, расчёт полного профиля,
|
||||
публикацию и live receiver нужно проверять отдельно по стадиям и PID.
|
||||
|
||||
## Доказанная исходная проблема
|
||||
|
||||
На `eff60e4` сохранённый M49 RRD размером 193622178 bytes открывался без compute.
|
||||
Renderer: 111 MB до → 1104 MB открыто → 567 MB спустя 74 секунды после закрытия.
|
||||
Reload снижал его до 133 MB. GPU-процесс: 372 → 838 → 825 → 363 MB.
|
||||
Core оставался около 168–170 MB; Docker VM — 2613 MB.
|
||||
|
||||
В upstream 0.36.3 `stop()` уже вызывает native destroy/free/deinit, но цикл
|
||||
`check_for_panic` продолжает планировать таймер. Наличие этого дефекта установлено
|
||||
по коду; он не доказан как единственный владелец всего остатка памяти. По ADR0045
|
||||
SDK не патчится. В backend также отсутствовало освобождение blueprint-сессии
|
||||
при закрытии UI: ограничение 32 владельцами само по себе не завершало lifecycle.
|
||||
|
||||
## Реализация
|
||||
|
||||
- `components/rerun/`: disposable iframe и фасад публичного upstream API.
|
||||
Уничтожение контекста с очисткой ссылок, включая подписки и каналы, даже при
|
||||
ошибке SDK stop. Ни родительская Promise, ни сохранённый callback не должны
|
||||
держать foreign DOM/SDK object после dispose. Один native clock сохраняется.
|
||||
- `RerunViewport`: cleanup на unmount/pagehide; новое owner-id при повторном
|
||||
запуске; восстановление из browser page cache через новый lifecycle.
|
||||
Активный live path не переведён в iframe.
|
||||
- `recordedBlueprintLifecycle`: renewal раз в 30с и bounded keepalive release.
|
||||
- `recorded_blueprint_lifecycle.py`: explicit release, idle TTL 300с,
|
||||
render/release exclusion, временный запрет запоздалого повторного открытия
|
||||
released owner, очистка failed render и shutdown. Reaper раз в 30с.
|
||||
Потеря renewal удаляет только восстанавливаемый ephemeral blueprint.
|
||||
- `session_api`: точный owner lifecycle endpoint; released update получает 410.
|
||||
Освобождение работает и после удаления исходника, не требует materialization.
|
||||
|
||||
## Проверки кода
|
||||
|
||||
- 64 focused backend tests (lifecycle, session API, RRD), Ruff PASS; mypy новых
|
||||
registry/recorded модулей PASS.
|
||||
- Финальный typecheck, 734 frontend tests и production build PASS; тяжёлые
|
||||
проверки строго последовательны. Сохранилось обычное предупреждение Vite о
|
||||
больших chunks; SDK не обновлялся.
|
||||
- Fixtures: stop throws, close before load, late load, timeout, повторный dispose,
|
||||
сохранённый facade/channel/unsubscribe после dispose, копирование event/range,
|
||||
отмена renewal и keepalive release; expired owner, reset, LRU, shutdown,
|
||||
release во время render и fence позднего запроса.
|
||||
- Канонический backend перезапущен один раз, PID 89747, только 8000. После
|
||||
холодного старта `/api/health`, `/` и `/rerun-runtime.html` отвечают 200.
|
||||
|
||||
## Промежуточная browser-проверка: одного iframe недостаточно
|
||||
|
||||
До добавления фасада два штатных открытия того же сохранённого M49 дали:
|
||||
|
||||
| Фаза | Renderer 89791, MB | GPU 72641, MB | Core 89747, MB |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Каталог до открытия | 124 | 423 | 152 |
|
||||
| Первый просмотр, playback | 993 | 743 | 164 |
|
||||
| Сразу после закрытия | 805 | 401 | 164 |
|
||||
| Через 89 секунд | 810 | 369 | 163 |
|
||||
| Повторное открытие | 1016 | 719 | 166 |
|
||||
| Повторное закрытие | 790 | 410 | 166 |
|
||||
|
||||
GPU освобождён; устойчивый возврат renderer к baseline **не доказан**.
|
||||
Нельзя выдать отсутствие удвоения расхода за полное исправление. Поэтому после
|
||||
этой проверки добавлено размыкание всех ссылок через facade.
|
||||
|
||||
В этой промежуточной версии проверены камера/TGS на начальном cursor, play/pause
|
||||
(39.215 → 49.899с), переключение SOURCE, normal/expanded и Escape из iframe.
|
||||
После закрытия iframe/review = 0, lifecycle POST = 204. Самая ранняя +1мкс
|
||||
граница теперь показала изображение камеры и TGS, но исходный неудобный ракурс
|
||||
и его сброс при blueprint activation не исправлялись этим инкрементом.
|
||||
|
||||
Mac pressure = 1 и swap = 2013.75 MiB без роста в обоих циклах. Docker VM
|
||||
оставалась 2616 MB. GPU-процесс общий; footprint процессов нельзя складывать
|
||||
как точную оценку физической RAM приложения. Значения MB округляет `footprint`.
|
||||
|
||||
## Финальный facade: функциональный PASS, memory acceptance FAIL
|
||||
|
||||
После нормализации pressure до1 выполнен один контрольный цикл финальной сборки
|
||||
`app-C54IKeub.js`, тот же immutable RRD, без вычисления профиля:
|
||||
|
||||
| Фаза | Renderer 90898, MB | GPU 72641, MB | Core 89747, MB |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Каталог до открытия | 112 | 365 | 162 |
|
||||
| Открыто, пауза | 943 | 680 | 164 |
|
||||
| После короткого playback | 1014 | 715 | 164 |
|
||||
| После закрытия | 819 | 411 | 164 |
|
||||
| Через 62с после предыдущего замера | 829 | 375 | 164 |
|
||||
|
||||
Facade **не устранил остаточный footprint renderer**. Браузерный memory issue
|
||||
не закрыт и итог не выдаётся за возврат RAM к baseline. Явные GPU/server cleanup
|
||||
и прекращение подписок реализованы; какой native/allocator/GC ресурс держит
|
||||
оставшуюся память, этим опытом не доказано. Не называть весь остаток живой
|
||||
утечкой без retained-object/native evidence, но и не называть его безвредным кэшем.
|
||||
|
||||
Функционально проверены normal/expanded, Escape из iframe, play/pause
|
||||
39.215→56.757с, изображения камеры и TGS после playback, iframe/review0 после
|
||||
закрытия, server lifecycle204; browser error logs пусты. На начальном expanded
|
||||
кадре камера оказалась пустой, после play появилась: устойчивость initial frame
|
||||
остаётся визуальным ограничением, несмотря на успешный промежуточный first-open.
|
||||
Проверки bfcache и аварийного старта — fixtures, не реальные fault injection.
|
||||
|
||||
В финальном closed-idle snapshot pressure стал2; swap не вырос (2013.75→2005.75
|
||||
MiB), Docker VM2616 MB. Проверочная вкладка закрыта. Новые тяжёлые циклы
|
||||
остановлены resource gate `mission-core-product-ui`. Следующий шаг — локализация
|
||||
удерживаемой native/WASM памяти либо подтверждённый механизм завершения её
|
||||
владельца; не ещё один такой же open/close и не принудительный GC/reload как
|
||||
продуктовое «лечение». Холодный compute и остальные операции остаются отдельно.
|
||||
|
||||
## Follow-up: карта памяти и primitive-only owner, 2026-09-03
|
||||
|
||||
До изменения boundary повторно проверена прежняя сборка `app-C54IKeub.js`
|
||||
на том же immutable M49, с `vmmap -w` в каждой фазе. Это новая диагностика
|
||||
типов/адресов памяти, а не доказательство исправления по исчезновению canvas.
|
||||
|
||||
| Фаза, UTC | Renderer91040, MB | GPU72641, MB | Core89747, MB |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Каталог, 11:41:10 | 119 | 381 | 161 |
|
||||
| Открыто, 11:41:24 | 914 | — | — |
|
||||
| Закрыто, 11:41:58 | 834 | 420 | 162 |
|
||||
| Через35с, 11:42:33 | 215 | 379 | 162 |
|
||||
| Повторно открыто | 941 | 717 | 165 |
|
||||
| Повторно закрыто, 11:44:10 | 812 | 415 | 165 |
|
||||
| Через91с, 11:45:41 | 824 | 376 | 163 |
|
||||
|
||||
В первом цикле крупные writable области `1f160…/1f161…` исчезли: вместо них
|
||||
`vmmap` показывает невыделенный 8-GiB адресный резерв с0 resident/dirty/swap.
|
||||
То есть память действительно возвращалась, не просто уходила в swap. Но
|
||||
повторный цикл не воспроизвёл этот результат: оставалось543MB untagged memory,
|
||||
в основном writable/swapped, а не executable code. Это исключает объяснение
|
||||
всего остатка исключительно скомпилированным WASM-кодом, но **не определяет
|
||||
конкретный retained-object root**. Общий memory gate остаётся FAIL.
|
||||
`vmmap` предупреждает, что не может разобрать внутреннюю PartitionAlloc zone;
|
||||
карта VM не заменяет анализ живых объектов. В этой серии сохранён UTC, но
|
||||
per-phase monotonic timestamp не был записан; интервал35/91с — разница UTC.
|
||||
|
||||
Новая реализация boundary v2 оставляет native SDK, каналы, подписки, Error
|
||||
объекты и startup Promise целиком внутри iframe. Родитель получает JSON-строки
|
||||
и создаёт собственные plain objects/Promise; getter/clock остаётся native,
|
||||
дополнительного polling/clock/renderer нет. Это устраняет сам путь передачи
|
||||
foreign SDK objects, не выдавая его за доказанную единственную причину остатка.
|
||||
Межоконные ссылки и prototype chains действительно могут удерживать удалённое
|
||||
окно: [разбор Chrome](https://web.dev/articles/detached-window-memory-leaks).
|
||||
Исходящие native ошибки также превращаются в текст. Каждый auxiliary channel
|
||||
закрывается даже при ошибке соседнего. Dispose немедленно отклоняет pending
|
||||
parent start; позднее завершение SDK start повторно вызывает публичный stop.
|
||||
|
||||
Проверено на v2:15 focused tests (включая architecture), focused strict TypeScript
|
||||
для пяти runtime-модулей PASS. Проверены все используемые аргументы native
|
||||
clock/control API, copied events/ranges, primitive errors, close-before-load,
|
||||
late success/failure и независимая очистка каналов. Это synthetic lifecycle
|
||||
coverage, **не heap/footprint acceptance**.
|
||||
|
||||
На завершении предыдущего прохода production build, полный frontend suite/typecheck и новый browser A/B **не
|
||||
запускались**: после закрытия тестовой вкладки pressure устойчиво2, swap
|
||||
1941.75→1917.75MiB не растёт. Temporary viewer закрыт, других наших временных
|
||||
тяжёлых процессов нет. По `mission-core-product-ui` heavy QA приостановлена;
|
||||
чужие приложения, Little Snitch и Docker VM не менялись. На8000 остаётся
|
||||
предыдущая рабочая сборка, backend89747 healthy. Следующий шаг после pressure1:
|
||||
полный typecheck/tests/build последовательно, затем real normal/expanded/Escape
|
||||
и bounded memory check v2 с повторным открытием, без forced GC/reload.
|
||||
При повторном FAIL нельзя объявлять boundary исправлением всей memory issue.
|
||||
|
||||
Ops instructions read завершился60s timeout; карточка не изменялась.
|
||||
|
||||
## Возврат к основному сценарию: v2 собран, browser gate отдельно открыт
|
||||
|
||||
По прямому решению владельца продолжены необходимые последовательные проверки,
|
||||
без повторного расследования всей памяти Mac. Обнаружен и исправлен ещё один
|
||||
эффект iframe: canvas pointer events не доходили до родительского слежения за
|
||||
native divider, поэтому внешние controls теряли выравнивание при его перетаскивании.
|
||||
Новый relay передаёт только primitive input fields, переводит координаты через
|
||||
iframe bounds, сохраняет Escape и удаляет все5 listeners при dispose. Native
|
||||
canvas/gestures остаются у upstream; качество, слои и clock не меняются.
|
||||
|
||||
Итоговые проверки:16 focused tests,740 frontend tests,64 focused backend tests,
|
||||
полный typecheck и production build PASS. Build15.14с, maximum RSS1994817536 bytes
|
||||
(около1.86GiB), а не требование выделить приложению столько памяти постоянно.
|
||||
Новая сборка `app-DHVEqf5x.js` обслуживается на8000 без нового backend restart.
|
||||
На первом проходе каталога renderer136MB, shared GPU408MB, Core164MB.
|
||||
|
||||
**Browser acceptance v2 не выполнена.** Auto-review отдельно запретил тяжёлое
|
||||
открытие готового M49 при pressure2 с учётом прежнего остаточного footprint.
|
||||
Запрошено явное разрешение на один короткий просмотр; действие не обходилось,
|
||||
проверочная вкладка закрыта. Сохранённый viewer/Worker inference не запускались.
|
||||
Все сборки/тесты завершились без OOM, однако swap в интервале финальной сборки
|
||||
вырос1934.69→2173.69MiB. Нельзя заявить отсутствие memory pressure или приписать
|
||||
этот системный прирост исключительно Core. Никакие чужие сервисы не остановлены.
|
||||
|
||||
Memory issue остаётся открытым критерием завершения операции. Он не блокирует
|
||||
оставшиеся input/catalog/contracts этапа2 и не заменяет основной маршрут:
|
||||
полный расчёт → сохранённый просмотр → матрица профилей/записей → recovery.
|
||||
Проверки и план: [mainline reconciliation](OBSERVATORY_MAINLINE_RECONCILIATION_2026-09-03.md).
|
||||
|
||||
## Evidence
|
||||
|
||||
Private numeric evidence: `.runtime/observatory-memory-9Xq3IH` (до правки),
|
||||
`.runtime/observatory-memory-fix-V6D9D8` (текущая проверка и test/build logs).
|
||||
Follow-up: `.runtime/observatory-memory-owner-UGy0AS` (VM maps, footprints,
|
||||
focused v2 tests/types; старый browser build отмечен отдельно).
|
||||
Текущий проход: `.runtime/observatory-mainline-FZrkOa`; hashes и версии сборки
|
||||
зафиксированы в mainline reconciliation. Полный browser memory A/B v2 там отсутствует.
|
||||
В Git нет содержимого записи, heap dump или скриншотов исходных данных.
|
||||
Ops direct MCP не ответил на чтение instructions/projects; запись отчёта в
|
||||
MISSIONCOR-72 не выполнена и не заявляется успешной. Локальные документы —
|
||||
фактический handoff до восстановления Ops.
|
||||
@@ -0,0 +1,245 @@
|
||||
# Observatory modular handoff — 2026-09-03
|
||||
|
||||
## 1. Objective and architecture stage
|
||||
|
||||
Owner approved the transition from monolithic full-profile packaging to reusable
|
||||
Docker modules and immutable compositions. This pass audits CURRENT, records
|
||||
TARGET, retires inspected obsolete stopped instances and prepares a self-contained
|
||||
Desktop handoff. It does not implement the new composer or build/install modules.
|
||||
|
||||
Decision: [ADR0051](../../docs/adr/0051-modular-observatory-profiles.md).
|
||||
Sole current route: the new upper section of
|
||||
[ExecPlan](../../docs/OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
|
||||
Earlier routes and numerical evidence remain historical, not current commands.
|
||||
|
||||
Ops report created through direct Tasker MCP, MISSION CORE card73,
|
||||
`a6e5bea8-5441-44ca-957f-1c117b1decf3`, at `2026-09-03T16:07:07.955933+00:00`.
|
||||
Its12 titled blocks separate completed audit from open modular implementation;
|
||||
the pre-existing saved-replay card72 was not overwritten.
|
||||
|
||||
## 2. Decision question and hypothesis
|
||||
|
||||
Can Observatory expose functional provider groups, reuse model/function images
|
||||
across compositions, keep exact cached LABs and later move the selected composition
|
||||
to a compatible CUDA onboard host? This is the accepted architecture direction,
|
||||
not yet an experimentally qualified performance claim. Mac Mini is explicitly
|
||||
not the onboard target. Avoid per-LAB micro-apps and one image containing every
|
||||
possible model combination.
|
||||
|
||||
The UI uses one canonical Select per group. DDRNet and EoMT are alternatives in
|
||||
new LABs, never two selected segmenters. A module may provide several capabilities
|
||||
without being instantiated several times. Existing dual-segment LAB V1 remains
|
||||
legacy compatibility. Exact completed compositions open their existing LAB;
|
||||
there is no Calculate action or completion badge for that exact configuration.
|
||||
|
||||
## 3. Immutable evidence and bounds
|
||||
|
||||
Session: `observatory-modular-handoff-mHGULT`.
|
||||
Repository: `NODEDC_MISSION_CORE_m5_observatory`, branch
|
||||
`codex/m5-1-observatory`, HEAD `eff60e4`, with pre-existing dirty changes retained.
|
||||
Private evidence directory:
|
||||
`.runtime/observatory-modular-handoff-mHGULT` in that checkout; excluded from Git.
|
||||
No recording contents, credentials, raw logs or model weights enter this report.
|
||||
|
||||
Read-only Worker inventory before: UTC `2026-09-03T15:45:51.756951+00:00`, local
|
||||
monotonic start1116.805708333, duration2.502617583s. After: UTC
|
||||
`2026-09-03T15:57:28.620618+00:00`, local monotonic start1815.378697625,
|
||||
duration0.801071041s. Monotonic values are within the local process/host timeline,
|
||||
not synchronized Worker timestamps.
|
||||
|
||||
| Artifact | SHA256 |
|
||||
| --- | --- |
|
||||
| worker-inventory-before.json | a7f91a21797c9814d441629b59b5eab1307b2976ac84ecde7c23daef8632ba20 |
|
||||
| worker-inventory-after.json | 5463f35a3c2b5134d499c495d93d77f7350438038a7a3010031c6842d417fbef |
|
||||
| stopped-container-archive.json | b1c6d3fbc6ae3fcd9a5a34d1d24616433f7f643e7c8233eab287e88747ed5188 |
|
||||
| cleanup-receipt.json | 58af029462c7fc73472ab1aae29be89f90c1412b2d4e35bfe23e3fb37c6b1030 |
|
||||
| Canonical ordered job identity tuples before/after | 69cae43ed43fac04d6443350e39a5f83f0e64f9c7ed58e6889f7320d2b80cc9f |
|
||||
| Original Desktop document before handoff prepend/rename | 7a0e57ba24eb95c95cd86aae55e24ae933de73746ecb299ce9542f6e8d2228c9 |
|
||||
|
||||
## 4. Method, models, algorithms and identities
|
||||
|
||||
Read source package/runner, worker queue/transport/cache, publication/replay and
|
||||
current Observatory selector; compare installed Docker declarations via the
|
||||
current agent's Docker socket over the existing SSH target `mission-gpu`.
|
||||
Inventory excludes Env/credentials. Inspect stopped writable-layer diffs and
|
||||
mounts before deletion; archive logs privately, not in the normal Git tree.
|
||||
|
||||
Current installed LAB V1 package:
|
||||
`1bc84be07634ff69ac7459a2c39dc8fc9bcee33f72985dfa2c6088bb78976d8e`.
|
||||
Definition:`269d71a24b4e63cff54e01273f9d4b35fc6cdd72bc6fadec206169ae0777e6ac`.
|
||||
Release:`667858623962cd6d9849a8985b803f59e429916b5c56f76a6fc6c80c0c54526b`.
|
||||
|
||||
| Fixed step | Pinned image SHA256 | GPU request | Memory limit |
|
||||
| --- | --- | --- | --- |
|
||||
| prepare | 5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373 | none | 4GiB |
|
||||
| EoMT | adba3dc8c97b161ba261ec44fca9ebe1680f117d1bcb1481440172cc3331a174 | one | 24GiB |
|
||||
| DDRNet | e6c986100613ec804f0e0076ca8695abf43ff88ef9d5d85f6857e0b41db74051 | one | 16GiB |
|
||||
| assemble | 5ad7d95baac63af13812cb693d492add4e806a333aba8e60edb2ea1aba754373 | none | 8GiB |
|
||||
|
||||
Memory limits in this table are container host-RAM limits, not GPU VRAM quotas.
|
||||
The fixed sequence uses mounted code/model/runtime assets. EoMT and DDRNet have
|
||||
different Python/Torch environments; retain their separation during packaging.
|
||||
This is not yet an operator-editable graph or standalone module distribution.
|
||||
|
||||
M49 CPU TGS runs inside its specialized agent with source preparation and a
|
||||
compiled executor subprocess. Preserve executor image
|
||||
`f9278ab21aa65045be993dd19bffc25f49955e19598893ac78cc4761ca63ecf3` and current assets.
|
||||
Also retain full-graph engineering prototype image `986dbe712699…`
|
||||
(`ndc-k1-perception-ddrnet39-rfdetr-tgs:stage1-joint-20260901`). Its presence is
|
||||
not proof of a complete accepted recorded LAB or autonomy.
|
||||
|
||||
## 5. Worker/runtime topology and resource policy
|
||||
|
||||
Current agents, preserved and running before/after:
|
||||
|
||||
- `ndc-observatory-installed-lab-worker-agent`, ID
|
||||
`be85dc40147af3eda18803b913598d733c31f07ff587ededae5a58a0ad76f21e`, image
|
||||
`bfdc94ae51906cf327901951f3e03e5f9d3a2d9692593f93e16170235adbcf7a`.
|
||||
- `ndc-observatory-m49-worker-agent`, ID
|
||||
`d58b3872d2ab399ca3566ab88080da1000988eee22c9c7bab7ccc8f2eac63796`, image
|
||||
`e545a47a7ea2318fe946d0c3170f9b53b9a640ba7b0b98f226ad73a2e50f7650`.
|
||||
|
||||
Latest stopped rollback pair retained:
|
||||
`a01b0fc675a01f52f7312d669d8b0e4147ab175baeca6396ec58f7cea4bd6029` and
|
||||
`c51bd8d95a85e218acc417d8972945656a31cb2dba8dc947842152a225f4d538`.
|
||||
Control declaration directory:
|
||||
`D:\NDC_MISSIONCORE\runtime\services\observatory-heartbeat-recovery-20260903-OjYeg7\release`.
|
||||
Control agent image identities and compute-step image identities differ.
|
||||
|
||||
After cleanup:11 containers,7 running/4 stopped,80 images,9 named volumes.
|
||||
Other five running services retained pending consumer/dependency retirement:
|
||||
Gaussian gateway, Gaussian pipeline, Gaussian terrain executor, perception worker,
|
||||
Triton. Gateway/pipeline health is unhealthy; perception has recurring restarts.
|
||||
No causal connection to the new cleanup was inferred from those pre-existing states.
|
||||
Frigate and Ollama remain stopped, restart=no, as previously requested.
|
||||
|
||||
One Worker006/RTX4090 retains exclusive composition ownership and sequential heavy
|
||||
GPU execution. No model inference or performance benchmark was launched this pass.
|
||||
The owner-approved EoMT disk floor is250GiB plus estimated working set, instead
|
||||
of350GiB. Source/tests changed earlier and were checked again here; installed
|
||||
EoMT remains350GiB until a new sealed image/release. No GPU/RAM limits or clocks
|
||||
were changed in this pass.
|
||||
|
||||
## 6. Implementation and bounded cleanup
|
||||
|
||||
Two-phase archive then delete; recheck all exact IDs/names/images/stopped states,
|
||||
restart policies and layer diffs. Check both current agents running, queue fully
|
||||
terminal, no active lease or running LAB compute container. Deletions use exact
|
||||
container IDs, `force=false`, `v=false`. No volume, image, bind directory or
|
||||
recording deletion; no global prune. The archive-only first attempt failed on
|
||||
unordered Docker diff results; sorting corrected the comparison before any delete.
|
||||
|
||||
Removed13 stopped instances:
|
||||
|
||||
| Short ID | Name |
|
||||
| --- | --- |
|
||||
| 05222a8e343e | ndc-observatory-installed-lab-worker-agent-pre-source-reuse-05222a8e343e |
|
||||
| 51027773cd1b | ndc-observatory-m49-worker-agent-pre-source-reuse-51027773cd1b |
|
||||
| 7a3e07929c82 | ndc-observatory-m49-worker-agent-pre-progress-7a3e07929c82 |
|
||||
| a8e8353d4eb9 | ndc-observatory-installed-lab-worker-agent-pre-progress-a8e8353d4eb9 |
|
||||
| 810212622de9 | ndc-observatory-installed-lab-worker-agent-v2-rollback-810212622de9 |
|
||||
| b30b41d9e2e2 | ndc-observatory-m49-worker-agent-v2-rollback-b30b41d9e2e2 |
|
||||
| 7921ab34712f | ndc-observatory-m49-worker-agent-legacy-v1-20260901 |
|
||||
| 623c651a5a71 | ndc-observatory-m49-worker-agent-892d008-retired |
|
||||
| 1898416f461e | ndc-mission-core-perception-worker-pre-e19-20260731 |
|
||||
| b61e5b237f80 | mission-core-perception-worker-e21-debug3 |
|
||||
| 432cae7d87f6 | mission-core-perception-worker-e21-debug2 |
|
||||
| d928b067f9b9 | mission-core-perception-worker-e21-debug1 |
|
||||
| b33b06d1061b | mission-core-perception-worker-e16-backup-20260723 |
|
||||
|
||||
Private archive contains2078504 log bytes plus redacted instance configuration.
|
||||
It is not a rootfs/volume backup. Removed Docker IDs cannot be restored; services
|
||||
can be recreated from retained images/declarations/data if necessary. Removed
|
||||
writable-layer sizes sum1175552bytes, not tens of GB. No claimed physical disk
|
||||
recovery was measured from sparse backing-store compaction.
|
||||
|
||||
Files changed by this architecture/document pass: ADR0051 added, ADR0050 amended,
|
||||
current ExecPlan upper route replaced by modular route (old text archived), this
|
||||
report added, Desktop status renamed with `_` and self-contained0.46 prepended.
|
||||
Private audit/cleanup scripts are evidence tooling, not a new product runtime.
|
||||
Pre-existing UI/runtime changes were neither reverted nor treated as newly built.
|
||||
|
||||
## 7. Validation and reproduced evidence
|
||||
|
||||
- Exact before/after container set difference is the13 approved stopped IDs;
|
||||
no new containers appeared. Images and volume identity sets unchanged80/9.
|
||||
- Installed release file SHA256 mappings unchanged; both agents running.
|
||||
- Queue13 job tuples `(job_id,state,result_id,claim_generation)` unchanged:
|
||||
11failed/2succeeded,0 live leases.
|
||||
- Core `/api/health` operational=true at `2026-09-03T15:59:16.426016+00:00`;
|
||||
verification local monotonic1923.95940075, duration0.026172833s.
|
||||
PID2421 serves8000; no8765 Mission Core listener. Build `app-w6onjKPq.js`.
|
||||
Recording cache20 entries/653266743bytes; artifact cache9 pinned objects.
|
||||
- `pytest -q tests/test_observatory_portable_lab_v1_component_adapters.py -k
|
||||
'disk or legacy_python39'`:7PASS. Ruff on component and its tests:PASS.
|
||||
No complete frontend suite/build, heavy viewer QA or model benchmark this pass.
|
||||
- The complete former Desktop body is preserved below the historical divider;
|
||||
its SHA is recorded above. Existing architecture and before-full-profile
|
||||
Desktop documents remain untouched.
|
||||
- `git diff --check`:PASS. Desktop historical-body SHA reproduced exactly;
|
||||
both preserved sibling-document SHAs unchanged; old unprefixed current filename
|
||||
absent and new underscore-prefixed file present.
|
||||
|
||||
## 8. Results retained, not newly computed
|
||||
|
||||
M49×RAVNOVES004TREE published job:
|
||||
`observatory-run-b230216709dc4c59bc56c98c7e329bf1`.
|
||||
Result:`m49-tgs-portable-review-a09d2b4a07d103e4f3693ba746a51be2197214768774eabfded4f109ae80dce4`.
|
||||
6830 camera anchors,6811 LiDAR,19 UNOBSERVED, cycle1416.763s, schedule39.215–757.160s
|
||||
of808.779s source. This is CPU TGS, not a full ML perception profile or complete
|
||||
capture coverage. Detailed saved camera/TGS visual and lifecycle acceptance is open.
|
||||
|
||||
Latest LAB V1×004TREE job
|
||||
`observatory-run-67a6bf3e4d644e4a8ee3c03b915f8f01` failed on lease loss at EoMT.
|
||||
It did not complete DDRNet/publication. Reconciliation preserved failure history
|
||||
after resource-release verification. Current heartbeat fixes are installed;
|
||||
there is no newly proved successful full LAB V1 run. Disk-floor admission is a
|
||||
separately discovered issue; its causality for the lease failure is not proved.
|
||||
|
||||
## 9. Limitations and rejected approaches
|
||||
|
||||
- New Module/Composition contracts, grouped UI and per-node result reuse remain
|
||||
unimplemented. Source CAS is not node-result cache; current cold input barrier
|
||||
remains. Existing fixed-stack support is useful but not complete modularity.
|
||||
- 749frontend/166backend checks belong to the previous increment, not this design.
|
||||
Historical replay overlays or cached FPS do not establish full-graph inference.
|
||||
- Images, build caches and input/model caches were not pruned. Before deletion,
|
||||
enumerate reachable pinned packages, prototype, models, current consumers and
|
||||
rollback. Image5ad7… is still required by prepare/assemble although one old
|
||||
agent instance using it was retired. Logical image sizes share layers.
|
||||
- Active legacy/Gaussian/Triton retirement needs a separate dependency decision;
|
||||
unhealthy does not itself authorize removing another consumer's data.
|
||||
- No blanket cache deletion, extra agent per model, per-frame Docker startup,
|
||||
GPU concurrency, MacMini onboard target, or hidden motor authority.
|
||||
|
||||
## 10. Decision
|
||||
|
||||
Adopt modular composition as TARGET; preserve the recorded-first product and
|
||||
verified queue/source/publication/viewer foundation. Retain successful M49 and
|
||||
legacy releases during migration. Current audit and bounded cleanup are complete;
|
||||
the new four-stage architecture implementation and broad cleanup are not complete.
|
||||
|
||||
## 11. Next stage and forbidden authority
|
||||
|
||||
Next chat begins with current Desktop0.46, ADR0051, ExecPlan upper section and this
|
||||
report; read `git status` and installed identities before changes. Start executable
|
||||
composition/dependency/identity contracts and retention closure, then reusable
|
||||
module releases/common runtime, grouped UI/end-to-end saved review, finally CUDA
|
||||
onboard qualification. Do not rerun old installers with stale predecessor IDs.
|
||||
|
||||
No Synology/deploy-canon21, device mutation, autonomy, motor controls, Little
|
||||
Snitch changes or Docker Desktop resource-limit changes. Core8000 is durable and
|
||||
remains running. Existing worktree changes, raw records and historical evidence
|
||||
are user-owned and preserved.
|
||||
|
||||
## 12. Acceptance checker
|
||||
|
||||
- [x] Accepted architecture distinguished from installed code.
|
||||
- [x] Single-provider groups; no dual segmentation in new profiles; CUDA not MacMini.
|
||||
- [x] Worker exact inventory,13-target archive/delete, identity/queue/health checks.
|
||||
- [x] Desktop rename/context, ADR and current four-stage route prepared.
|
||||
- [ ] Remaining cache/image/active-legacy retirement after retention closure.
|
||||
- [ ] New executable composition/module contracts and module releases.
|
||||
- [ ] Operator composer and exact node-cache implementation.
|
||||
- [ ] Full saved visual/lifecycle and source×composition acceptance.
|
||||
- [ ] CUDA onboard qualification and separate control/safety acceptance.
|
||||
@@ -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 \
|
||||
&& find /opt/nodedc/mission-core/src/k1link -type d -exec chmod 0555 {} + \
|
||||
&& find /opt/nodedc/mission-core/src/k1link -type f -exec chmod 0444 {} + \
|
||||
&& python3 -B -c "import k1link.observatory.installed_lab_worker_container_main as entrypoint; import k1link.observatory.installed_lab_worker_service as worker; import k1link.observatory.lab_v1_installed_package_steps as steps; assert callable(entrypoint.main); assert callable(worker.main); assert callable(steps.main)"
|
||||
&& python3 -B -c "import k1link.observatory.installed_lab_worker_container_main as entrypoint; import k1link.observatory.installed_lab_worker_service as worker; import k1link.observatory.lab_v1_installed_package_steps as legacy_steps; import k1link.observatory.modular_installed_package_steps as modular_steps; assert callable(entrypoint.main); assert callable(worker.main); assert callable(legacy_steps.main); assert callable(modular_steps.main)"
|
||||
|
||||
LABEL com.nodedc.product="mission-core" \
|
||||
com.nodedc.stack="observatory" \
|
||||
|
||||
+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}$")
|
||||
_CAMERA_JOB_ID = re.compile(r"^recorded-camera-[a-f0-9]{24}$")
|
||||
|
||||
Component = Literal["eomt", "ddrnet"]
|
||||
Component = Literal["camera-source", "eomt", "ddrnet"]
|
||||
AssetKind = Literal["file", "tree"]
|
||||
AssetVerification = Literal["sha256", "identity-sha256"]
|
||||
CommandRunner = Callable[[Sequence[str], Optional[Mapping[str, str]]], None] # noqa: UP045
|
||||
@@ -168,7 +168,7 @@ class RuntimeLayout:
|
||||
component: Component,
|
||||
expectations: Sequence[AssetExpectation],
|
||||
) -> RuntimeLayout:
|
||||
if component not in ("eomt", "ddrnet"):
|
||||
if component not in ("camera-source", "eomt", "ddrnet"):
|
||||
raise ComponentAdapterError("installed package component is invalid")
|
||||
prepared = Path(PACKAGE_STEP_INPUT_ROOT) / "prepare"
|
||||
return cls(
|
||||
@@ -176,7 +176,7 @@ class RuntimeLayout:
|
||||
camera_job_root=prepared / "camera-job",
|
||||
output_root=Path(PACKAGE_OUTPUT_ROOT),
|
||||
effective_ddrnet_config=prepared / "effective-ddrnet-config.json",
|
||||
eomt_result_root=Path(PACKAGE_STEP_INPUT_ROOT) / "eomt",
|
||||
eomt_result_root=Path(PACKAGE_STEP_INPUT_ROOT) / "camera-source",
|
||||
asset_paths={item.asset_id: Path(item.path) for item in expectations},
|
||||
)
|
||||
|
||||
@@ -289,7 +289,7 @@ def load_component_request(
|
||||
source = _source_binding(document["source"])
|
||||
paths = _paths(document["paths"], component)
|
||||
effective_value = document["effective_ddrnet_config_sha256"]
|
||||
if component == "eomt":
|
||||
if component != "ddrnet":
|
||||
if effective_value is not None:
|
||||
raise ComponentAdapterError("EoMT request contains a DDRNet config")
|
||||
effective_sha256: str | None = None
|
||||
@@ -440,6 +440,61 @@ def validate_tree_asset(
|
||||
return _real_directory(layout.asset_paths[asset_id], f"asset {asset_id}")
|
||||
|
||||
|
||||
def validate_prepared_camera_root(
|
||||
root: Path,
|
||||
request: ComponentRequest,
|
||||
) -> dict[str, Path]:
|
||||
"""Verify the model-neutral camera-source output before any AI consumes it."""
|
||||
resolved = _real_directory(root, "prepared camera root")
|
||||
expected_children = {
|
||||
"camera-source.json",
|
||||
"decode-repair.json",
|
||||
"source-frames",
|
||||
"source-frames.json",
|
||||
"timeline.jsonl",
|
||||
}
|
||||
if {path.name for path in resolved.iterdir()} != expected_children:
|
||||
raise ComponentAdapterError("prepared camera artifact set changed")
|
||||
receipt = load_canonical_json(
|
||||
resolved / "camera-source.json",
|
||||
label="prepared camera receipt",
|
||||
maximum=1024 * 1024,
|
||||
confinement_root=resolved,
|
||||
)
|
||||
source = request.source
|
||||
if receipt.get("schema_version") != "missioncore.observatory-prepared-camera/v1" or receipt.get(
|
||||
"source"
|
||||
) != {
|
||||
"camera_job_id": source.camera_job_id,
|
||||
"input_sha256": source.camera_input_sha256,
|
||||
"frame_count": source.frame_count,
|
||||
}:
|
||||
raise ComponentAdapterError("prepared camera identity changed")
|
||||
rows = receipt.get("artifacts")
|
||||
expected = {
|
||||
"decode-repair": "decode-repair.json",
|
||||
"source-frames": "source-frames.json",
|
||||
"timeline": "timeline.jsonl",
|
||||
}
|
||||
if not isinstance(rows, list) or len(rows) != len(expected):
|
||||
raise ComponentAdapterError("prepared camera receipt changed")
|
||||
result: dict[str, Path] = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or set(row) != {"role", "path", "byte_length", "sha256"}:
|
||||
raise ComponentAdapterError("prepared camera artifact changed")
|
||||
role = row.get("role")
|
||||
name = expected.get(role) if isinstance(role, str) else None
|
||||
if name != row.get("path"):
|
||||
raise ComponentAdapterError("prepared camera artifact role changed")
|
||||
path = _real_file(resolved / name, "prepared camera artifact")
|
||||
if path.parent != resolved or (
|
||||
row.get("byte_length") != path.stat().st_size or row.get("sha256") != sha256_file(path)
|
||||
):
|
||||
raise ComponentAdapterError("prepared camera artifact identity changed")
|
||||
result[role] = path
|
||||
return result
|
||||
|
||||
|
||||
def validate_tree_receipt(
|
||||
request: ComponentRequest,
|
||||
root: Path,
|
||||
@@ -833,12 +888,12 @@ def _paths(value: object, component: Component) -> dict[str, str | None]:
|
||||
f"{prepared}/effective-ddrnet-config.json" if component == "ddrnet" else None
|
||||
),
|
||||
"eomt_result_root": (
|
||||
f"{PACKAGE_STEP_INPUT_ROOT}/eomt" if component == "ddrnet" else None
|
||||
f"{PACKAGE_STEP_INPUT_ROOT}/camera-source" if component == "ddrnet" else None
|
||||
),
|
||||
"decoded_frames_root": (
|
||||
f"{PACKAGE_OUTPUT_ROOT}/source-frames"
|
||||
if component == "eomt"
|
||||
else f"{PACKAGE_STEP_INPUT_ROOT}/eomt/source-frames"
|
||||
if component == "camera-source"
|
||||
else f"{PACKAGE_STEP_INPUT_ROOT}/camera-source/source-frames"
|
||||
),
|
||||
}
|
||||
_exact_keys(document, set(legacy), "component paths")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Small synthetic shared-cache proof; never claims work or loads a model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.observatory.worker_service import ObservatoryWorkerServiceConfiguration
|
||||
from k1link.observatory.worker_source_cache import WorkerSourceCache
|
||||
|
||||
PAYLOAD = b"missioncore-source-reuse-proof-AsldTM-v1\n" * 4096
|
||||
SHA256 = hashlib.sha256(PAYLOAD).hexdigest()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("mode", choices=("retain", "restore", "cleanup"))
|
||||
args = parser.parse_args()
|
||||
configuration = ObservatoryWorkerServiceConfiguration.from_environment()
|
||||
assert configuration.source_cache_root == Path("/source-cache")
|
||||
started, mono = datetime.now(UTC).isoformat(), time.monotonic_ns()
|
||||
cache = WorkerSourceCache(configuration.source_cache_root)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix=".source-reuse-proof-", dir=configuration.work_root
|
||||
) as root:
|
||||
source = Path(root) / "synthetic.bin"
|
||||
if args.mode == "retain":
|
||||
source.write_bytes(PAYLOAD)
|
||||
assert cache.retain(source, sha256=SHA256, byte_length=len(PAYLOAD))
|
||||
else:
|
||||
assert cache.restore(source, sha256=SHA256, byte_length=len(PAYLOAD))
|
||||
assert source.read_bytes() == PAYLOAD
|
||||
if args.mode == "cleanup":
|
||||
cached = cache.root / SHA256
|
||||
assert cached.read_bytes() == PAYLOAD
|
||||
cached.unlink() # Only this probe's verified synthetic cache object.
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "missioncore.worker-source-cache-proof/v1",
|
||||
"mode": args.mode,
|
||||
"source_sha256": SHA256,
|
||||
"byte_length": len(PAYLOAD),
|
||||
"exact": True,
|
||||
"model_jobs": 0,
|
||||
"temporary_work_removed": True,
|
||||
"synthetic_cache_removed": args.mode == "cleanup",
|
||||
"started_at_utc": started,
|
||||
"finished_at_utc": datetime.now(UTC).isoformat(),
|
||||
"started_monotonic_ns": mono,
|
||||
"finished_monotonic_ns": time.monotonic_ns(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,546 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Associate RF-DETR boxes with current K1 LiDAR and publish metric ranges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.compute.lidar_local_surface_shadow import (
|
||||
K1LocalSurfaceShadowEstimator,
|
||||
K1LocalSurfaceShadowInput,
|
||||
)
|
||||
from k1link.perception.contracts import (
|
||||
ClockBasis,
|
||||
ModalityOutcome,
|
||||
ModalityStatus,
|
||||
ObjectProposal2D,
|
||||
SourceEnvelope,
|
||||
TimestampBundle,
|
||||
)
|
||||
from k1link.perception.geometry import (
|
||||
GEOMETRY_PROVIDER_ID,
|
||||
GeometryFrame,
|
||||
GeometryProfile,
|
||||
Ravnoves00GeometryAssociationProvider,
|
||||
)
|
||||
from k1link.perception.geometry_math import GeometryAssociationProfile, Kb4ProjectionProfile
|
||||
from k1link.perception.providers import SourcePacket
|
||||
|
||||
SCHEMA = "missioncore.observatory-ai-module-object-distance-result/v1"
|
||||
ROW_SCHEMA = "missioncore.observatory-ai-module-object-distance-frame/v1"
|
||||
RF_ROW_SCHEMA = "missioncore.observatory-ai-module-rf-detr-frame/v1"
|
||||
AUTHORITY = {
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"production_accepted": False,
|
||||
}
|
||||
_PACKAGE_SOURCE = Path("/missioncore/input/steps/prepare/source-input.json")
|
||||
_PACKAGE_DETECTIONS = Path("/missioncore/input/steps/rf-detr/detections.jsonl")
|
||||
_PACKAGE_LIDAR_PACK = Path("/missioncore/input/steps/prepare/lidar-pack")
|
||||
_PACKAGE_BINDING_INDEX = Path("/missioncore/input/steps/prepare/m49-source/sequence-index.ndjson")
|
||||
_PACKAGE_CALIBRATION = Path("/opt/nodedc/assets/k1-camera-lidar-calibration")
|
||||
_PACKAGE_OUTPUT = Path("/missioncore/output")
|
||||
_MAX_CALIBRATION_PACK_BYTES = 128 * 1024 * 1024
|
||||
_LIDAR_PACK_ID = re.compile(r"^lidar-replay-pack-([a-f0-9]{64})$")
|
||||
ASSOCIATION = GeometryAssociationProfile(
|
||||
bbox_inset_fraction=0.03,
|
||||
depth_cluster_minimum_gap_m=0.45,
|
||||
depth_cluster_gap_fraction=0.08,
|
||||
spatial_cluster_radius_m=0.6,
|
||||
semantic_minimum_occupied_points=2,
|
||||
semantic_minimum_occupied_voxels=1,
|
||||
semantic_voxel_size_m=0.35,
|
||||
conflict_minimum_classified_points=6,
|
||||
conflict_surface_fraction=0.8,
|
||||
geometry_local_radius_m=10.0,
|
||||
geometry_voxel_size_m=0.45,
|
||||
geometry_minimum_cluster_points=4,
|
||||
geometry_minimum_cluster_voxels=1,
|
||||
maximum_geometry_clusters_per_frame=64,
|
||||
)
|
||||
|
||||
|
||||
class ObjectDistanceModuleError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _CurrentStore:
|
||||
def __init__(self, profile: GeometryProfile) -> None:
|
||||
self.profile = profile
|
||||
self.current: GeometryFrame | None = None
|
||||
|
||||
def frame(self, _packet: SourcePacket) -> GeometryFrame | None:
|
||||
return self.current
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PointFrame:
|
||||
received_monotonic_ns: int
|
||||
xyz_map: np.ndarray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PoseFrame:
|
||||
received_monotonic_ns: int
|
||||
position_map: tuple[float, float, float]
|
||||
orientation_map_from_lidar: tuple[float, float, float, float]
|
||||
|
||||
|
||||
class _LidarPack:
|
||||
"""Narrow reader for the already sealed replay-pack arrays."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
candidate = root.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
|
||||
self.root = candidate.resolve(strict=True)
|
||||
manifest_path = self.root / "manifest.json"
|
||||
if manifest_path.is_symlink() or not manifest_path.is_file():
|
||||
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
pack_id = manifest.get("pack_id") if isinstance(manifest, dict) else None
|
||||
match = _LIDAR_PACK_ID.fullmatch(pack_id) if isinstance(pack_id, str) else None
|
||||
if (
|
||||
self.root.is_symlink()
|
||||
or not self.root.is_dir()
|
||||
or not isinstance(manifest, dict)
|
||||
or manifest.get("schema_version") != "missioncore.lidar-replay-pack/v2"
|
||||
or match is None
|
||||
or manifest.get("identity_sha256") != match.group(1)
|
||||
):
|
||||
raise ObjectDistanceModuleError("LiDAR replay pack identity changed")
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
raise ObjectDistanceModuleError("LiDAR replay pack artifacts changed")
|
||||
row = next(
|
||||
(
|
||||
item
|
||||
for item in artifacts
|
||||
if isinstance(item, dict) and item.get("kind") == "lidar-arrays"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if row is None or row.get("path") != "lidar-replay.npz":
|
||||
raise ObjectDistanceModuleError("LiDAR replay arrays are unavailable")
|
||||
arrays_path = self.root / "lidar-replay.npz"
|
||||
if (
|
||||
arrays_path.is_symlink()
|
||||
or not arrays_path.is_file()
|
||||
or arrays_path.resolve(strict=True).parent != self.root
|
||||
or arrays_path.stat().st_size != row.get("byte_length")
|
||||
or _sha(arrays_path) != row.get("sha256")
|
||||
):
|
||||
raise ObjectDistanceModuleError("LiDAR replay arrays identity changed")
|
||||
self.arrays_path = arrays_path.resolve(strict=True)
|
||||
archive = np.load(arrays_path, allow_pickle=False)
|
||||
try:
|
||||
self.arrays = {name: np.asarray(archive[name]) for name in archive.files}
|
||||
finally:
|
||||
archive.close()
|
||||
required = {
|
||||
"point_offsets",
|
||||
"point_xyz_map",
|
||||
"point_received_monotonic_ns",
|
||||
"pose_positions_map",
|
||||
"pose_quaternions_map_from_lidar",
|
||||
"pose_received_monotonic_ns",
|
||||
}
|
||||
if not required.issubset(self.arrays):
|
||||
raise ObjectDistanceModuleError("LiDAR replay array set changed")
|
||||
self.pack_id = pack_id
|
||||
self.point_frame_count = int(self.arrays["point_received_monotonic_ns"].shape[0])
|
||||
self.pose_frame_count = int(self.arrays["pose_received_monotonic_ns"].shape[0])
|
||||
self.point_count = int(self.arrays["point_xyz_map"].shape[0])
|
||||
|
||||
def point_frame(self, index: int) -> _PointFrame:
|
||||
if not 0 <= index < self.point_frame_count:
|
||||
raise ObjectDistanceModuleError("LiDAR frame index is outside the replay pack")
|
||||
begin, end = (int(self.arrays["point_offsets"][index + offset]) for offset in (0, 1))
|
||||
points = np.asarray(self.arrays["point_xyz_map"][begin:end], dtype=np.float64)
|
||||
if points.ndim != 2 or points.shape[1:] != (3,) or not np.isfinite(points).all():
|
||||
raise ObjectDistanceModuleError("LiDAR frame points changed")
|
||||
return _PointFrame(int(self.arrays["point_received_monotonic_ns"][index]), points)
|
||||
|
||||
def pose_frame(self, index: int) -> _PoseFrame:
|
||||
if not 0 <= index < self.pose_frame_count:
|
||||
raise ObjectDistanceModuleError("pose frame index is outside the replay pack")
|
||||
position = tuple(float(value) for value in self.arrays["pose_positions_map"][index])
|
||||
orientation = tuple(
|
||||
float(value) for value in self.arrays["pose_quaternions_map_from_lidar"][index]
|
||||
)
|
||||
return _PoseFrame(
|
||||
int(self.arrays["pose_received_monotonic_ns"][index]),
|
||||
cast(tuple[float, float, float], position),
|
||||
cast(tuple[float, float, float, float], orientation),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.arrays.clear()
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
||||
|
||||
|
||||
def _sha(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _projection(path: Path) -> Kb4ProjectionProfile:
|
||||
candidate = path.expanduser().resolve(strict=True)
|
||||
# The admitted E10 source pack contains the three small calibration arrays
|
||||
# together with the full point cloud. Bound the sealed archive itself while
|
||||
# retaining strict per-member limits for the arrays read below.
|
||||
if (
|
||||
candidate.is_symlink()
|
||||
or not candidate.is_file()
|
||||
or candidate.stat().st_size > _MAX_CALIBRATION_PACK_BYTES
|
||||
):
|
||||
raise ObjectDistanceModuleError("camera/LiDAR calibration is unavailable")
|
||||
with zipfile.ZipFile(candidate) as archive:
|
||||
arrays = {}
|
||||
for name in ("intrinsic_fx_fy_cx_cy", "distortion_kb4", "t_camera_from_lidar"):
|
||||
info = archive.getinfo(name + ".npy")
|
||||
if info.file_size > 4096:
|
||||
raise ObjectDistanceModuleError("camera/LiDAR calibration exceeds its bound")
|
||||
with archive.open(info) as stream:
|
||||
arrays[name] = np.lib.format.read_array(stream, allow_pickle=False)
|
||||
return Kb4ProjectionProfile(
|
||||
800,
|
||||
600,
|
||||
cast(
|
||||
tuple[float, float, float, float],
|
||||
tuple(float(value) for value in arrays["intrinsic_fx_fy_cx_cy"]),
|
||||
),
|
||||
cast(
|
||||
tuple[float, float, float, float],
|
||||
tuple(float(value) for value in arrays["distortion_kb4"]),
|
||||
),
|
||||
np.asarray(arrays["t_camera_from_lidar"], dtype=np.float64),
|
||||
)
|
||||
|
||||
|
||||
def _detections(path: Path, count: int) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
with path.expanduser().resolve(strict=True).open(encoding="utf-8") as stream:
|
||||
for raw in stream:
|
||||
if len(raw) > 8 * 1024 * 1024 or len(rows) >= count:
|
||||
raise ObjectDistanceModuleError("RF-DETR result exceeds its bound")
|
||||
row = json.loads(raw)
|
||||
if not isinstance(row, dict) or row.get("schema_version") != RF_ROW_SCHEMA:
|
||||
raise ObjectDistanceModuleError("RF-DETR result contract changed")
|
||||
rows.append(row)
|
||||
if len(rows) != count:
|
||||
raise ObjectDistanceModuleError("RF-DETR and LiDAR timelines differ")
|
||||
return rows
|
||||
|
||||
|
||||
def _integer(value: object, label: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ObjectDistanceModuleError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _number(value: object, label: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ObjectDistanceModuleError(f"{label} is invalid")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise ObjectDistanceModuleError(f"{label} is invalid")
|
||||
return number
|
||||
|
||||
|
||||
def _profile(session_id: str, pack: _LidarPack) -> GeometryProfile:
|
||||
return GeometryProfile(
|
||||
profile_id="observatory-object-distance-v1",
|
||||
provider_id=GEOMETRY_PROVIDER_ID,
|
||||
source_id="recorded-k1",
|
||||
session_id=session_id,
|
||||
source_pack_id=pack.pack_id,
|
||||
source_pack_sha256=_sha(pack.arrays_path),
|
||||
frame_count=pack.point_frame_count,
|
||||
point_count=pack.point_count,
|
||||
local_surface_model_id="k1-local-surface-shadow-v1",
|
||||
local_surface_sha256="0" * 64,
|
||||
valid_frame_count=pack.point_frame_count,
|
||||
width=800,
|
||||
height=600,
|
||||
coordinate_frame="map",
|
||||
association=ASSOCIATION,
|
||||
profile_sha256=hashlib.sha256(
|
||||
_canonical(
|
||||
{
|
||||
"module": "object-distance",
|
||||
"association": ASSOCIATION.__dict__
|
||||
if hasattr(ASSOCIATION, "__dict__")
|
||||
else str(ASSOCIATION),
|
||||
}
|
||||
)
|
||||
).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def _packet(
|
||||
*,
|
||||
session_id: str,
|
||||
source_id: str,
|
||||
frame_id: str,
|
||||
frame_index: int,
|
||||
session_seconds: float,
|
||||
spatial_available: bool,
|
||||
) -> SourcePacket:
|
||||
available = ModalityStatus(True, ModalityOutcome.AVAILABLE, "recorded-module-input")
|
||||
missing = ModalityStatus(False, ModalityOutcome.UNAVAILABLE, "no-synchronous-lidar")
|
||||
spatial = available if spatial_available else missing
|
||||
nanoseconds = round(session_seconds * 1_000_000_000)
|
||||
return SourcePacket(
|
||||
SourceEnvelope(
|
||||
source_id=source_id,
|
||||
session_id=session_id,
|
||||
frame_id=frame_id,
|
||||
sequence=frame_index,
|
||||
timestamps=TimestampBundle(
|
||||
utc_ns=nanoseconds,
|
||||
monotonic_ns=nanoseconds,
|
||||
source_ns=nanoseconds,
|
||||
clock_basis=ClockBasis.RECORDED_HOST,
|
||||
),
|
||||
source_age_ns=0,
|
||||
binding_reason="camera-lidar-past-only-binding",
|
||||
calibration_id="camera-1-kb4-05f3ad9b",
|
||||
representation_id="object-distance-current-cloud-v1",
|
||||
image=available,
|
||||
registered_point_increment=spatial,
|
||||
pose=spatial,
|
||||
),
|
||||
b"rf-detr-proposals",
|
||||
b"current-k1-cloud" if spatial_available else None,
|
||||
b"current-k1-pose" if spatial_available else None,
|
||||
)
|
||||
|
||||
|
||||
def _binding_rows(path: Path) -> list[dict[str, object]]:
|
||||
rows: list[dict[str, object]] = []
|
||||
previous = -1.0
|
||||
with path.expanduser().resolve(strict=True).open(encoding="utf-8") as stream:
|
||||
for raw in stream:
|
||||
if len(raw) > 64 * 1024 or len(rows) >= 250_000:
|
||||
raise ObjectDistanceModuleError("LiDAR binding timeline exceeds its bound")
|
||||
row = json.loads(raw)
|
||||
if (
|
||||
not isinstance(row, dict)
|
||||
or row.get("schema_version") != "missioncore.m49-tgs-portable-source-index-row/v1"
|
||||
or row.get("timeline_frame_index") != len(rows)
|
||||
):
|
||||
raise ObjectDistanceModuleError("LiDAR binding row identity changed")
|
||||
seconds = _number(row.get("session_seconds"), "LiDAR binding time")
|
||||
if seconds <= previous or not isinstance(row.get("sample_available"), bool):
|
||||
raise ObjectDistanceModuleError("LiDAR binding timeline changed")
|
||||
rows.append(row)
|
||||
previous = seconds
|
||||
if not rows:
|
||||
raise ObjectDistanceModuleError("LiDAR binding timeline is empty")
|
||||
return rows
|
||||
|
||||
|
||||
def _aligned_camera_seconds(
|
||||
binding: dict[str, object],
|
||||
rf_row: dict[str, object],
|
||||
*,
|
||||
frame_index: int,
|
||||
previous_camera_seconds: float,
|
||||
) -> float:
|
||||
# Validate both clocks, but join the sealed products by their shared frame
|
||||
# index. Camera-source repairs MP4 discontinuities; the LiDAR binding keeps
|
||||
# the original segment clock, so equality between their seconds is invalid.
|
||||
_number(binding.get("session_seconds"), "LiDAR binding time")
|
||||
if rf_row.get("frame_index") != frame_index:
|
||||
raise ObjectDistanceModuleError("RF-DETR and LiDAR frame identities differ")
|
||||
seconds = _number(rf_row.get("session_seconds"), "camera session time")
|
||||
if seconds <= previous_camera_seconds:
|
||||
raise ObjectDistanceModuleError("RF-DETR camera timeline changed")
|
||||
return seconds
|
||||
|
||||
|
||||
def execute(
|
||||
*,
|
||||
detections: Path,
|
||||
lidar_pack: Path,
|
||||
binding_index: Path,
|
||||
calibration: Path,
|
||||
output: Path,
|
||||
session_id: str,
|
||||
) -> dict[str, object]:
|
||||
index = _binding_rows(binding_index)
|
||||
rf_rows = _detections(detections, len(index))
|
||||
projection = _projection(calibration)
|
||||
output = output.expanduser().absolute()
|
||||
if output.exists():
|
||||
if output.is_symlink() or not output.is_dir() or any(output.iterdir()):
|
||||
raise ObjectDistanceModuleError("output root is unsafe")
|
||||
else:
|
||||
output.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
if output.is_symlink() or not output.is_dir():
|
||||
raise ObjectDistanceModuleError("output root is unsafe")
|
||||
pack = _LidarPack(lidar_pack)
|
||||
started = time.monotonic()
|
||||
ranged = 0
|
||||
proposal_count = 0
|
||||
unavailable = 0
|
||||
result_path = output / "object-distances.jsonl"
|
||||
try:
|
||||
store = _CurrentStore(_profile(session_id, pack))
|
||||
provider = Ravnoves00GeometryAssociationProvider(store=store) # type: ignore[arg-type]
|
||||
surface = K1LocalSurfaceShadowEstimator()
|
||||
previous_camera_seconds = -1.0
|
||||
with result_path.open("xb") as stream:
|
||||
for frame_index, (binding, rf_row) in enumerate(zip(index, rf_rows, strict=True)):
|
||||
seconds = _aligned_camera_seconds(
|
||||
binding,
|
||||
rf_row,
|
||||
frame_index=frame_index,
|
||||
previous_camera_seconds=previous_camera_seconds,
|
||||
)
|
||||
previous_camera_seconds = seconds
|
||||
raw_proposals = rf_row.get("proposals")
|
||||
if not isinstance(raw_proposals, list):
|
||||
raise ObjectDistanceModuleError("RF-DETR proposals are unavailable")
|
||||
proposals = tuple(ObjectProposal2D.from_dict(value) for value in raw_proposals)
|
||||
proposal_count += len(proposals)
|
||||
store.current = None
|
||||
available = binding["sample_available"] is True
|
||||
if available:
|
||||
point = pack.point_frame(
|
||||
_integer(binding["selected_lidar_frame_index"], "LiDAR frame index")
|
||||
)
|
||||
pose = pack.pose_frame(
|
||||
_integer(binding["selected_pose_frame_index"], "pose frame index")
|
||||
)
|
||||
age_ms = abs(point.received_monotonic_ns - pose.received_monotonic_ns) / 1e6
|
||||
surface_frame = surface.process(
|
||||
K1LocalSurfaceShadowInput(
|
||||
frame_index=frame_index,
|
||||
source_frame_index=_integer(
|
||||
binding["source_frame_index"], "source frame index"
|
||||
),
|
||||
session_seconds=seconds,
|
||||
pose_binding_age_ms=age_ms,
|
||||
points_map=point.xyz_map,
|
||||
position_map=np.asarray(pose.position_map, dtype=np.float64),
|
||||
published_monotonic_ns=point.received_monotonic_ns,
|
||||
)
|
||||
)
|
||||
store.current = GeometryFrame(
|
||||
frame_index,
|
||||
point.xyz_map,
|
||||
surface_frame.point_class,
|
||||
np.asarray(pose.position_map, dtype=np.float64),
|
||||
np.asarray(pose.orientation_map_from_lidar, dtype=np.float64),
|
||||
projection,
|
||||
surface_frame.valid,
|
||||
)
|
||||
elif proposals:
|
||||
unavailable += len(proposals)
|
||||
source_id = proposals[0].source_id if proposals else "recorded-k1"
|
||||
frame_id = proposals[0].frame_id if proposals else f"frame-{frame_index + 1:06d}"
|
||||
observations = tuple(
|
||||
item
|
||||
for item in provider.associate(
|
||||
_packet(
|
||||
session_id=session_id,
|
||||
source_id=source_id,
|
||||
frame_id=frame_id,
|
||||
frame_index=frame_index,
|
||||
session_seconds=seconds,
|
||||
spatial_available=available,
|
||||
),
|
||||
proposals,
|
||||
)
|
||||
if item.proposal_ids
|
||||
)
|
||||
ranged += sum(item.metric_geometry is not None for item in observations)
|
||||
stream.write(
|
||||
_canonical(
|
||||
{
|
||||
"schema_version": ROW_SCHEMA,
|
||||
"frame_index": frame_index,
|
||||
"session_seconds": seconds,
|
||||
"observations": [item.to_dict() for item in observations],
|
||||
}
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
result = {
|
||||
"schema_version": SCHEMA,
|
||||
"module_id": "object-distance",
|
||||
"source_session_id": session_id,
|
||||
"frame_count": len(index),
|
||||
"proposal_count": proposal_count,
|
||||
"ranged_proposal_count": ranged,
|
||||
"unavailable_proposal_count": unavailable,
|
||||
"object_distances_sha256": _sha(result_path),
|
||||
"elapsed_seconds": time.monotonic() - started,
|
||||
"range_estimator": "median-camera-z-of-owned-current-points/v1",
|
||||
"authority": AUTHORITY,
|
||||
}
|
||||
(output / "result.json").write_bytes(_canonical(result))
|
||||
return result
|
||||
finally:
|
||||
pack.close()
|
||||
|
||||
|
||||
def _package_session(path: Path) -> str:
|
||||
document = json.loads(path.read_text())
|
||||
source = document.get("source") if isinstance(document, dict) else None
|
||||
session_id = source.get("session_id") if isinstance(source, dict) else None
|
||||
if (
|
||||
not isinstance(document, dict)
|
||||
or document.get("schema_version") != "missioncore.observatory-portable-lab-v1-source/v1"
|
||||
or not isinstance(session_id, str)
|
||||
):
|
||||
raise ObjectDistanceModuleError("prepared source identity changed")
|
||||
return session_id
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = list(sys.argv[1:] if argv is None else argv)
|
||||
if arguments == ["--package-step", "object-distance"]:
|
||||
execute(
|
||||
detections=_PACKAGE_DETECTIONS,
|
||||
lidar_pack=_PACKAGE_LIDAR_PACK,
|
||||
binding_index=_PACKAGE_BINDING_INDEX,
|
||||
calibration=_PACKAGE_CALIBRATION,
|
||||
output=_PACKAGE_OUTPUT,
|
||||
session_id=_package_session(_PACKAGE_SOURCE),
|
||||
)
|
||||
return 0
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--detections", type=Path, required=True)
|
||||
parser.add_argument("--lidar-pack", type=Path, required=True)
|
||||
parser.add_argument("--binding-index", type=Path, required=True)
|
||||
parser.add_argument("--calibration", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--session-id", required=True)
|
||||
execute(**vars(parser.parse_args(arguments)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Recorded-camera RF-DETR module with a sealed, path-local output contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from k1link.perception.contracts import (
|
||||
ClockBasis,
|
||||
ModalityOutcome,
|
||||
ModalityStatus,
|
||||
SourceEnvelope,
|
||||
TimestampBundle,
|
||||
)
|
||||
from k1link.perception.detector import NativeRfDetrShadowDetectorProvider
|
||||
from k1link.perception.providers import SourcePacket
|
||||
from k1link.perception.rf_detr_native_object_detector import (
|
||||
RF_DETR_NATIVE_ENGINE_SHA256,
|
||||
TritonNativeRfDetrHttpInferenceBackend,
|
||||
)
|
||||
from k1link.perception.yolox_object_detector import load_valid_fov_mask
|
||||
|
||||
SCHEMA = "missioncore.observatory-ai-module-rf-detr-result/v1"
|
||||
ROW_SCHEMA = "missioncore.observatory-ai-module-rf-detr-frame/v1"
|
||||
AUTHORITY = {
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"production_accepted": False,
|
||||
}
|
||||
VALID_FOV_SHA256 = "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"
|
||||
_PACKAGE_SOURCE = Path("/missioncore/input/steps/prepare/source-input.json")
|
||||
_PACKAGE_FRAMES = Path("/missioncore/input/steps/camera-source/source-frames")
|
||||
_PACKAGE_TIMELINE = Path("/missioncore/input/steps/camera-source/timeline.jsonl")
|
||||
_PACKAGE_VALID_FOV = Path("/opt/nodedc/assets/valid-fov-mask")
|
||||
_PACKAGE_ENGINE = Path("/models/rf_detr_large_native_kb4/1/model.plan")
|
||||
_PACKAGE_OUTPUT = Path("/missioncore/output")
|
||||
|
||||
|
||||
class RfDetrModuleError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
||||
|
||||
|
||||
def _sha(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _file(path: Path, expected: str, label: str) -> Path:
|
||||
candidate = path.expanduser().absolute()
|
||||
if candidate.is_symlink():
|
||||
raise RfDetrModuleError(f"{label} identity changed")
|
||||
candidate = candidate.resolve(strict=True)
|
||||
if not candidate.is_file() or _sha(candidate) != expected:
|
||||
raise RfDetrModuleError(f"{label} identity changed")
|
||||
return candidate
|
||||
|
||||
|
||||
def _empty(path: Path) -> Path:
|
||||
candidate = path.expanduser().absolute()
|
||||
if candidate.exists():
|
||||
if candidate.is_symlink() or not candidate.is_dir() or any(candidate.iterdir()):
|
||||
raise RfDetrModuleError("output root is unsafe")
|
||||
else:
|
||||
candidate.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
if candidate.is_symlink() or not candidate.is_dir():
|
||||
raise RfDetrModuleError("output root is unsafe")
|
||||
return candidate
|
||||
|
||||
|
||||
def _rows(path: Path, *, maximum: int) -> list[dict[str, object]]:
|
||||
result: list[dict[str, object]] = []
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
for raw in stream:
|
||||
if len(raw) > 64 * 1024 or len(result) >= maximum:
|
||||
raise RfDetrModuleError("camera timeline exceeds the module bound")
|
||||
row = json.loads(raw)
|
||||
if not isinstance(row, dict):
|
||||
raise RfDetrModuleError("camera timeline row is invalid")
|
||||
result.append(row)
|
||||
if not result:
|
||||
raise RfDetrModuleError("camera timeline is empty")
|
||||
return result
|
||||
|
||||
|
||||
def _wait_triton(process: subprocess.Popen[bytes]) -> None:
|
||||
import http.client
|
||||
|
||||
deadline = time.monotonic() + 45
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RfDetrModuleError("RF-DETR inference runtime stopped during startup")
|
||||
try:
|
||||
connection = http.client.HTTPConnection("127.0.0.1", 8000, timeout=1)
|
||||
connection.request("GET", "/v2/models/rf_detr_large_native_kb4/ready")
|
||||
response = connection.getresponse()
|
||||
response.read()
|
||||
connection.close()
|
||||
if response.status == 200:
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.1)
|
||||
raise RfDetrModuleError("RF-DETR inference runtime did not become ready")
|
||||
|
||||
|
||||
def _packet(
|
||||
session_id: str, source_id: str, frame_index: int, session_seconds: float, image: np.ndarray
|
||||
) -> SourcePacket:
|
||||
available = ModalityStatus(True, ModalityOutcome.AVAILABLE, "recorded-camera-frame")
|
||||
unavailable = ModalityStatus(False, ModalityOutcome.UNAVAILABLE, "module-input-not-requested")
|
||||
nanoseconds = round(session_seconds * 1_000_000_000)
|
||||
envelope = SourceEnvelope(
|
||||
source_id=source_id,
|
||||
session_id=session_id,
|
||||
frame_id=f"frame-{frame_index + 1:06d}",
|
||||
sequence=frame_index,
|
||||
timestamps=TimestampBundle(
|
||||
utc_ns=nanoseconds,
|
||||
monotonic_ns=nanoseconds,
|
||||
source_ns=nanoseconds,
|
||||
clock_basis=ClockBasis.RECORDED_HOST,
|
||||
),
|
||||
source_age_ns=0,
|
||||
binding_reason="recorded-camera-timeline",
|
||||
calibration_id="camera-1-kb4-05f3ad9b",
|
||||
representation_id="rf-detr-native-kb4-v1",
|
||||
image=available,
|
||||
registered_point_increment=unavailable,
|
||||
pose=unavailable,
|
||||
)
|
||||
return SourcePacket(envelope, image, None, None)
|
||||
|
||||
|
||||
def execute(
|
||||
*,
|
||||
frames: Path,
|
||||
timeline: Path,
|
||||
valid_fov: Path,
|
||||
engine: Path,
|
||||
output: Path,
|
||||
session_id: str,
|
||||
source_id: str,
|
||||
) -> dict[str, object]:
|
||||
frames = frames.expanduser().resolve(strict=True)
|
||||
if frames.is_symlink() or not frames.is_dir():
|
||||
raise RfDetrModuleError("prepared camera frames are unavailable")
|
||||
timeline = timeline.expanduser().resolve(strict=True)
|
||||
valid_fov = _file(valid_fov, VALID_FOV_SHA256, "valid-FOV mask")
|
||||
_file(engine, RF_DETR_NATIVE_ENGINE_SHA256, "RF-DETR TensorRT engine")
|
||||
rows = _rows(timeline, maximum=100_000)
|
||||
names = tuple(f"frame-{index + 1:06d}.png" for index in range(len(rows)))
|
||||
if tuple(sorted(path.name for path in frames.iterdir())) != names:
|
||||
raise RfDetrModuleError("prepared camera frame set changed")
|
||||
output = _empty(output)
|
||||
log = (output / "triton.log").open("wb")
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
"tritonserver",
|
||||
"--model-repository=/models",
|
||||
"--model-control-mode=explicit",
|
||||
"--load-model=rf_detr_large_native_kb4",
|
||||
"--allow-grpc=false",
|
||||
"--allow-metrics=false",
|
||||
"--http-address=127.0.0.1",
|
||||
"--pinned-memory-pool-byte-size=16777216",
|
||||
"--cuda-memory-pool-byte-size=0:16777216",
|
||||
],
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
backend: TritonNativeRfDetrHttpInferenceBackend | None = None
|
||||
started = time.monotonic()
|
||||
counts = 0
|
||||
detections_path = output / "detections.jsonl"
|
||||
try:
|
||||
_wait_triton(process)
|
||||
backend = TritonNativeRfDetrHttpInferenceBackend("http://127.0.0.1:8000")
|
||||
detector = NativeRfDetrShadowDetectorProvider(
|
||||
mask=load_valid_fov_mask(valid_fov),
|
||||
backend=backend,
|
||||
)
|
||||
detector.warm_up()
|
||||
with detections_path.open("xb") as stream:
|
||||
previous = -1.0
|
||||
for frame_index, (name, row) in enumerate(zip(names, rows, strict=True)):
|
||||
seconds = row.get("session_seconds")
|
||||
if (
|
||||
row.get("frame_index") != frame_index
|
||||
or isinstance(seconds, bool)
|
||||
or not isinstance(seconds, (int, float))
|
||||
or float(seconds) <= previous
|
||||
):
|
||||
raise RfDetrModuleError("camera timeline identity changed")
|
||||
with Image.open(frames / name) as source:
|
||||
rgb = np.asarray(source.convert("RGB"), dtype=np.uint8)
|
||||
if rgb.shape != (600, 800, 3):
|
||||
raise RfDetrModuleError("camera raster changed")
|
||||
proposals = detector.detect(
|
||||
_packet(
|
||||
session_id,
|
||||
source_id,
|
||||
frame_index,
|
||||
float(seconds),
|
||||
np.ascontiguousarray(rgb[:, :, ::-1]),
|
||||
)
|
||||
)
|
||||
counts += len(proposals)
|
||||
stream.write(
|
||||
_canonical(
|
||||
{
|
||||
"schema_version": ROW_SCHEMA,
|
||||
"frame_index": frame_index,
|
||||
"session_seconds": float(seconds),
|
||||
"proposals": [proposal.to_dict() for proposal in proposals],
|
||||
}
|
||||
)
|
||||
+ b"\n"
|
||||
)
|
||||
previous = float(seconds)
|
||||
snapshot = detector.snapshot()
|
||||
result = {
|
||||
"schema_version": SCHEMA,
|
||||
"module_id": "rf-detr",
|
||||
"source": {"session_id": session_id, "source_id": source_id},
|
||||
"frame_count": len(rows),
|
||||
"proposal_count": counts,
|
||||
"zero_proposal_frame_count": snapshot.zero_proposal_frames,
|
||||
"detections_sha256": _sha(detections_path),
|
||||
"elapsed_seconds": time.monotonic() - started,
|
||||
"authority": AUTHORITY,
|
||||
}
|
||||
(output / "result.json").write_bytes(_canonical(result))
|
||||
return result
|
||||
finally:
|
||||
if backend is not None:
|
||||
backend.close()
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
log.close()
|
||||
|
||||
|
||||
def _package_source(path: Path) -> tuple[str, str]:
|
||||
document = json.loads(path.read_text())
|
||||
if (
|
||||
not isinstance(document, dict)
|
||||
or document.get("schema_version") != "missioncore.observatory-portable-lab-v1-source/v1"
|
||||
or not isinstance(document.get("source"), dict)
|
||||
or not isinstance(document.get("camera_compute_job"), dict)
|
||||
):
|
||||
raise RfDetrModuleError("prepared camera source contract changed")
|
||||
source = document["source"]
|
||||
camera = document["camera_compute_job"]
|
||||
session_id = source.get("session_id")
|
||||
source_id = camera.get("source_id")
|
||||
if not isinstance(session_id, str) or not isinstance(source_id, str):
|
||||
raise RfDetrModuleError("prepared camera source identity changed")
|
||||
return session_id, source_id
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = list(sys.argv[1:] if argv is None else argv)
|
||||
if arguments == ["--package-step", "rf-detr"]:
|
||||
session_id, source_id = _package_source(_PACKAGE_SOURCE)
|
||||
execute(
|
||||
frames=_PACKAGE_FRAMES,
|
||||
timeline=_PACKAGE_TIMELINE,
|
||||
valid_fov=_PACKAGE_VALID_FOV,
|
||||
engine=_PACKAGE_ENGINE,
|
||||
output=_PACKAGE_OUTPUT,
|
||||
session_id=session_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
return 0
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--frames", type=Path, required=True)
|
||||
parser.add_argument("--timeline", type=Path, required=True)
|
||||
parser.add_argument("--valid-fov", type=Path, required=True)
|
||||
parser.add_argument("--engine", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--session-id", required=True)
|
||||
parser.add_argument("--source-id", required=True)
|
||||
options = parser.parse_args(arguments)
|
||||
execute(**vars(options))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+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
|
||||
"""Run the sealed LAB V1 DDRNet component over EoMT-decoded K1 frames."""
|
||||
"""Run DDRNet over independently prepared, immutable K1 camera frames."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -38,6 +38,7 @@ from portable_lab_v1_component_adapter import (
|
||||
validate_file_asset,
|
||||
validate_fixed_result,
|
||||
validate_grayscale_png_payload,
|
||||
validate_prepared_camera_root,
|
||||
)
|
||||
|
||||
DDRNET_RESULT_SCHEMA: Final = "missioncore.lab-v1-goose-vegetation-run/v1"
|
||||
@@ -136,7 +137,7 @@ def execute_ddrnet_component(
|
||||
layout.effective_ddrnet_config,
|
||||
request,
|
||||
)
|
||||
eomt_root, source_frames, decode_repair = _validate_eomt_input(
|
||||
prepared_root, source_frames, decode_repair = _validate_prepared_input(
|
||||
layout.eomt_result_root,
|
||||
request,
|
||||
)
|
||||
@@ -148,7 +149,7 @@ def execute_ddrnet_component(
|
||||
# tmpfs makes a full 6,830-frame K1 run fail even though the immutable
|
||||
# input is valid. Verify every manifest digest through an O_NOFOLLOW
|
||||
# descriptor, then let the sealed runner read that same read-only tree.
|
||||
frames_root = _verify_source_frames(eomt_root, source_frames)
|
||||
frames_root = _verify_source_frames(prepared_root, source_frames)
|
||||
mapping_copy = workspace / "goose_label_mapping.csv"
|
||||
shutil.copyfile(assets["ddrnet-goose-mapping"], mapping_copy)
|
||||
os.chmod(mapping_copy, 0o400)
|
||||
@@ -182,7 +183,7 @@ def execute_ddrnet_component(
|
||||
"0",
|
||||
)
|
||||
command_runner(argv, _ddrnet_environment())
|
||||
shutil.copyfile(eomt_root / "decode-repair.json", staging / "decode-repair.json")
|
||||
shutil.copyfile(prepared_root / "decode-repair.json", staging / "decode-repair.json")
|
||||
if (
|
||||
load_json(
|
||||
staging / "decode-repair.json",
|
||||
@@ -269,13 +270,29 @@ def _validate_effective_config(
|
||||
return config
|
||||
|
||||
|
||||
def _validate_eomt_input(
|
||||
def _validate_prepared_input(
|
||||
root: Path,
|
||||
request: ComponentRequest,
|
||||
) -> tuple[Path, tuple[SourceFrameRow, ...], dict[str, object]]:
|
||||
if root.is_symlink():
|
||||
raise ComponentAdapterError("EoMT result root is a symbolic link")
|
||||
raise ComponentAdapterError("prepared camera root is a symbolic link")
|
||||
resolved = root.resolve(strict=True)
|
||||
if not (resolved / "result.json").exists():
|
||||
validate_prepared_camera_root(resolved, request)
|
||||
manifest_path = _confined_regular_file(
|
||||
resolved / "source-frames.json", resolved, "prepared source frame manifest"
|
||||
)
|
||||
source_frames = _source_frame_manifest(manifest_path, resolved, request)
|
||||
repair = load_json(
|
||||
resolved / "decode-repair.json",
|
||||
label="camera decode repair",
|
||||
maximum=1024 * 1024,
|
||||
confinement_root=resolved,
|
||||
)
|
||||
_validate_decode_repair(repair, request.source.frame_count)
|
||||
_confined_regular_file(resolved / "timeline.jsonl", resolved, "prepared camera timeline")
|
||||
return resolved, source_frames, repair
|
||||
# Migration compatibility for already sealed dual-model releases only.
|
||||
result = validate_fixed_result(
|
||||
resolved,
|
||||
schema_version=EOMT_RESULT_SCHEMA,
|
||||
|
||||
+40
-17
@@ -37,6 +37,7 @@ from portable_lab_v1_component_adapter import (
|
||||
validate_fixed_result,
|
||||
validate_grayscale_png_payload,
|
||||
validate_identity_manifest,
|
||||
validate_prepared_camera_root,
|
||||
validate_tree_asset,
|
||||
validate_tree_receipt,
|
||||
)
|
||||
@@ -50,11 +51,10 @@ MODEL_REVISION: Final = "8d6b6d1a3f7b50d441afd7d247c2ed10db186e8f"
|
||||
MODEL_ID: Final = "tue-mps/cityscapes_semantic_eomt_large_1024"
|
||||
MODEL_ARCHITECTURE: Final = "EomtForUniversalSegmentation"
|
||||
PHYSICAL_CAMERA_SOURCE_ID: Final = "sensor.camera.right"
|
||||
# Keep a large post-run floor while admitting the full 6,830-frame K1 record on
|
||||
# Worker 006. The independent ``reserve`` below already accounts for the
|
||||
# complete worst-case working set, so adding the historical 360 GiB floor made
|
||||
# the real job miss admission by about 1.4 GB despite 408 GB being free.
|
||||
DISK_FLOOR_BYTES: Final = 350 * 1024**3
|
||||
# Owner-approved post-run disk floor (2026-09-03); not a RAM/VRAM reservation.
|
||||
# The independent ``reserve`` below remains additional to this free-space floor.
|
||||
# Deploy only through a new sealed image/release, never patch an installed digest.
|
||||
DISK_FLOOR_BYTES: Final = 250 * 1024**3
|
||||
FFMPEG_TREE_SOURCE_IMAGE_SHA256: Final = (
|
||||
"8a364092b03561b9c08ac00730206e363a53d07ea0304f7d543b403b65432b5e"
|
||||
)
|
||||
@@ -163,26 +163,49 @@ def execute_eomt_component(
|
||||
component="eomt",
|
||||
expectations=EOMT_ASSETS,
|
||||
)
|
||||
input_document = validate_camera_compute_job(layout.camera_job_root, request.source)
|
||||
roots = _validate_release_assets(request, layout)
|
||||
output = _empty_output_root(layout.output_root)
|
||||
reserve = request.source.frame_count * 800 * 600 * 7 + request.source.input_byte_length
|
||||
free_before = available_bytes(output)
|
||||
if disk_floor_bytes < 0 or free_before < disk_floor_bytes + reserve:
|
||||
if (
|
||||
isinstance(disk_floor_bytes, bool)
|
||||
or not isinstance(disk_floor_bytes, int)
|
||||
or disk_floor_bytes < 0
|
||||
or free_before < disk_floor_bytes + reserve
|
||||
):
|
||||
raise ComponentAdapterError("EoMT output does not satisfy its disk reserve")
|
||||
# Fail before hashing the complete recording and several GiB of model assets.
|
||||
# The typed request supplies a bounded estimate; input/asset validation is
|
||||
# still mandatory before decoding or model execution.
|
||||
input_document = validate_camera_compute_job(layout.camera_job_root, request.source)
|
||||
roots = _validate_release_assets(request, layout)
|
||||
workspace = _prepare_workspace(output / ".eomt-work" if work_root is None else work_root)
|
||||
try:
|
||||
total_started = time.perf_counter()
|
||||
extract_started = time.perf_counter()
|
||||
frames_root, timeline_path, decode_repair = _decode_camera_epoch(
|
||||
request=request,
|
||||
input_document=input_document,
|
||||
camera_job_root=layout.camera_job_root,
|
||||
output_root=output,
|
||||
work_root=workspace,
|
||||
ffmpeg_root=roots["eomt-ffmpeg-runtime"],
|
||||
command_runner=command_runner,
|
||||
)
|
||||
if (layout.eomt_result_root / "camera-source.json").is_file():
|
||||
prepared = validate_prepared_camera_root(layout.eomt_result_root, request)
|
||||
frames_root = layout.eomt_result_root / "source-frames"
|
||||
timeline_path = prepared["timeline"]
|
||||
decode_repair = load_json(
|
||||
prepared["decode-repair"],
|
||||
label="prepared camera decode repair",
|
||||
maximum=1024 * 1024,
|
||||
confinement_root=layout.eomt_result_root,
|
||||
)
|
||||
_validate_source_frame_manifest(prepared["source-frames"], frames_root, request)
|
||||
else:
|
||||
# Historical fixed-layout tests and already sealed releases retain
|
||||
# their old in-component decode path. New modular packages always
|
||||
# mount camera-source and never make one model prepare another.
|
||||
frames_root, timeline_path, decode_repair = _decode_camera_epoch(
|
||||
request=request,
|
||||
input_document=input_document,
|
||||
camera_job_root=layout.camera_job_root,
|
||||
output_root=output,
|
||||
work_root=workspace,
|
||||
ffmpeg_root=roots["eomt-ffmpeg-runtime"],
|
||||
command_runner=command_runner,
|
||||
)
|
||||
extract_seconds = _elapsed(extract_started)
|
||||
free_post_extract = available_bytes(output)
|
||||
if free_post_extract < disk_floor_bytes:
|
||||
|
||||
@@ -68,6 +68,7 @@ M49_RELEASE_SOURCES: Final = (
|
||||
Path("experiments/perception/worker/m49_t3_travel/build_tgs_full_shadow_binary.sh"),
|
||||
Path("experiments/perception/worker/m49_t3_travel/build_tgs_full_shadow_evidence.py"),
|
||||
Path("src/k1link/compute/lidar_replay.py"),
|
||||
Path("src/k1link/compute/lidar_preparation.py"),
|
||||
Path("src/k1link/observatory/m49_portable_executor.py"),
|
||||
Path("src/k1link/observatory/m49_portable_result.py"),
|
||||
Path("src/k1link/observatory/m49_portable_source.py"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
RENDERER_VERSION: Final = "upstream-rerun-0.36.3-encoded-optimized-v6"
|
||||
REPLAY_RENDERER_VERSION: Final = "upstream-rerun-0.36.3-canonical-replay-v1"
|
||||
CANONICAL_REPLAY_RESULT_ID: Final = re.compile(
|
||||
r"^(?:lab-v1-vegetation-shadow|m49-tgs-portable-review|lab-v1-eomt-ddrnet|"
|
||||
r"ai-layer-(?:ddrnet|eomt|rf-detr|object-distance)|ai-composition)-[a-f0-9]{64}$"
|
||||
)
|
||||
MAX_SOURCE_BYTES: Final = 768 * 1024 * 1024
|
||||
MAX_OVERLAY_BYTES: Final = 256 * 1024 * 1024
|
||||
MAX_REPLAY_BYTES: Final = 1024 * 1024 * 1024
|
||||
@@ -138,9 +142,7 @@ class CanonicalLabReplayArtifact:
|
||||
_render_lock = threading.Lock()
|
||||
_memory_cache: dict[tuple[str, str, str], CanonicalLabOverlayArtifact] = {}
|
||||
_replay_lock = threading.Lock()
|
||||
_replay_memory_cache: dict[
|
||||
tuple[str, str, str, str], CanonicalLabReplayArtifact
|
||||
] = {}
|
||||
_replay_memory_cache: dict[tuple[str, str, str, str], CanonicalLabReplayArtifact] = {}
|
||||
|
||||
|
||||
def canonical_recording_id(path: Path) -> str:
|
||||
@@ -283,9 +285,7 @@ def canonical_lab_replay(
|
||||
or not _is_sha256(base_generation_sha256)
|
||||
or _sha256(base) != base_generation_sha256
|
||||
or not _artifact_is_regular(overlay)
|
||||
or re.fullmatch(
|
||||
r"(?:lab-v1-vegetation-shadow|m49-tgs-portable-review)-[a-f0-9]{64}", result_id
|
||||
) is None
|
||||
or CANONICAL_REPLAY_RESULT_ID.fullmatch(result_id) is None
|
||||
or not recording_id
|
||||
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)
|
||||
):
|
||||
raise CanonicalLabOverlayError("camera job source contract changed")
|
||||
epoch_prefix = PurePosixPath(
|
||||
"input/camera/sensor.camera.right"
|
||||
) / f"epoch-{source.get('codec_epoch')}"
|
||||
epoch_prefix = (
|
||||
PurePosixPath("input/camera/sensor.camera.right") / f"epoch-{source.get('codec_epoch')}"
|
||||
)
|
||||
required = [epoch_prefix / "init.mp4"] + [
|
||||
epoch_prefix / "segments" / f"{index}.m4s"
|
||||
for index in range(1, frame_count + 1)
|
||||
epoch_prefix / "segments" / f"{index}.m4s" for index in range(1, frame_count + 1)
|
||||
]
|
||||
descriptors = {
|
||||
item.get("path"): item
|
||||
@@ -609,10 +608,7 @@ def _render_overlay(
|
||||
"/perception/camera/image",
|
||||
rr.VideoFrameReference(nanoseconds=int(video_references[index])),
|
||||
)
|
||||
masks = {
|
||||
layer_id: _read_mask(archive, index)
|
||||
for layer_id, archive in archives.items()
|
||||
}
|
||||
masks = {layer_id: _read_mask(archive, index) for layer_id, archive in archives.items()}
|
||||
for layer_id, mask in masks.items():
|
||||
recording.log(
|
||||
f"/perception/camera/segmentation/{layer_id}",
|
||||
@@ -731,15 +727,17 @@ def _video_reference_timestamps(
|
||||
if (
|
||||
len(video_timestamps) < int(len(frame_times) * 0.9)
|
||||
or np.any(np.diff(video_timestamps) < 0)
|
||||
or abs(int(video_timestamps[-1]) - int(relative_frame_times[-1]))
|
||||
> 2_000_000_000
|
||||
or abs(int(video_timestamps[-1]) - int(relative_frame_times[-1])) > 2_000_000_000
|
||||
):
|
||||
raise CanonicalLabOverlayError("video proxy timeline changed")
|
||||
indices = np.searchsorted(
|
||||
video_timestamps,
|
||||
relative_frame_times,
|
||||
side="right",
|
||||
) - 1
|
||||
indices = (
|
||||
np.searchsorted(
|
||||
video_timestamps,
|
||||
relative_frame_times,
|
||||
side="right",
|
||||
)
|
||||
- 1
|
||||
)
|
||||
return video_timestamps[np.clip(indices, 0, len(video_timestamps) - 1)]
|
||||
|
||||
|
||||
@@ -770,9 +768,7 @@ def _semantic_palette(classes: list[object]) -> tuple[int, ...]:
|
||||
or not isinstance(color, list)
|
||||
or len(color) != 3
|
||||
or any(
|
||||
not isinstance(channel, int)
|
||||
or isinstance(channel, bool)
|
||||
or not 0 <= channel <= 255
|
||||
not isinstance(channel, int) or isinstance(channel, bool) or not 0 <= channel <= 255
|
||||
for channel in color
|
||||
)
|
||||
):
|
||||
@@ -820,9 +816,7 @@ def semantic_component_boxes(
|
||||
mask, class_id, minimum_pixels=minimum_pixels
|
||||
)[:12]:
|
||||
score = min(0.99, 0.5 + pixels / 20_000)
|
||||
candidates.append(
|
||||
(score, [left, top, right, bottom], f"{label} · {score:.0%}")
|
||||
)
|
||||
candidates.append((score, [left, top, right, bottom], f"{label} · {score:.0%}"))
|
||||
candidates.sort(key=lambda row: (-row[0], row[1][1], row[1][0]))
|
||||
selected = candidates[:32]
|
||||
return [row[1] for row in selected], [row[2] for row in selected]
|
||||
@@ -1001,6 +995,8 @@ def _sha256(path: Path) -> str:
|
||||
|
||||
|
||||
def _is_sha256(value: object) -> bool:
|
||||
return isinstance(value, str) and len(value) == 64 and all(
|
||||
character in "0123456789abcdef" for character in value
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and len(value) == 64
|
||||
and all(character in "0123456789abcdef" for character in value)
|
||||
)
|
||||
|
||||
@@ -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.job-id",
|
||||
"com.nodedc.managed-by",
|
||||
"com.nodedc.module-id",
|
||||
"com.nodedc.package-sha256",
|
||||
"com.nodedc.product",
|
||||
"com.nodedc.role",
|
||||
"com.nodedc.stack",
|
||||
}
|
||||
if set(self.labels) != required_labels:
|
||||
@@ -170,7 +172,9 @@ class InstalledLabDockerLaunch:
|
||||
self.labels["com.nodedc.authority"] != "observation-only"
|
||||
or self.labels["com.nodedc.component"] != self.container.container_id
|
||||
or self.labels["com.nodedc.managed-by"] != "mission-core-worker"
|
||||
or self.labels["com.nodedc.module-id"] != self.container.container_id
|
||||
or self.labels["com.nodedc.product"] != "mission-core"
|
||||
or self.labels["com.nodedc.role"] != "ai-module"
|
||||
or self.labels["com.nodedc.stack"] != "observatory"
|
||||
):
|
||||
raise InstalledLabPackageRunnerError("Docker launch labels changed")
|
||||
@@ -251,14 +255,10 @@ class DockerEngineInstalledLabLauncher:
|
||||
or len(image_sha256s) != len(set(image_sha256s))
|
||||
or any(_SHA256.fullmatch(value) is None for value in image_sha256s)
|
||||
):
|
||||
raise InstalledLabPackageRunnerError(
|
||||
"installed LAB image inventory is invalid"
|
||||
)
|
||||
raise InstalledLabPackageRunnerError("installed LAB image inventory is invalid")
|
||||
if self.transport_factory is None:
|
||||
_require_local_socket(self.socket_path)
|
||||
transport: httpx.BaseTransport = httpx.HTTPTransport(
|
||||
uds=str(self.socket_path)
|
||||
)
|
||||
transport: httpx.BaseTransport = httpx.HTTPTransport(uds=str(self.socket_path))
|
||||
else:
|
||||
transport = self.transport_factory()
|
||||
with httpx.Client(
|
||||
@@ -282,7 +282,7 @@ class DockerEngineInstalledLabLauncher:
|
||||
|
||||
def _create(self, client: httpx.Client, launch: InstalledLabDockerLaunch) -> str:
|
||||
component = launch.container.container_id[:32]
|
||||
name = f"ndc-observatory-{component}-{launch.name_token}"
|
||||
name = f"ndc-mission-core-ai-module-{component}-{launch.name_token}"
|
||||
response = self._response(
|
||||
client,
|
||||
"POST",
|
||||
@@ -455,7 +455,9 @@ class InstalledLabPackageProfileRunner:
|
||||
output_root.mkdir(mode=0o700)
|
||||
steps_root = job_root / "steps"
|
||||
steps_root.mkdir(mode=0o700)
|
||||
plan_path = job_root / "run-plan.json"
|
||||
plan_root = job_root / "plan"
|
||||
plan_root.mkdir(mode=0o700)
|
||||
plan_path = plan_root / "run-plan.json"
|
||||
_write_exclusive(
|
||||
plan_path,
|
||||
canonical_json(
|
||||
@@ -530,11 +532,11 @@ class InstalledLabPackageProfileRunner:
|
||||
),
|
||||
InstalledLabDockerMount(
|
||||
_translate_work_path(
|
||||
plan_path,
|
||||
plan_path.parent,
|
||||
controller_root=self.controller_work_root,
|
||||
engine_root=self.engine_work_root,
|
||||
),
|
||||
INSTALLED_LAB_PLAN_PATH,
|
||||
str(PurePosixPath(INSTALLED_LAB_PLAN_PATH).parent),
|
||||
True,
|
||||
),
|
||||
InstalledLabDockerMount(
|
||||
@@ -586,8 +588,10 @@ class InstalledLabPackageProfileRunner:
|
||||
"com.nodedc.definition-sha256": plan.definition_sha256,
|
||||
"com.nodedc.job-id": plan.job_id,
|
||||
"com.nodedc.managed-by": "mission-core-worker",
|
||||
"com.nodedc.module-id": container.container_id,
|
||||
"com.nodedc.package-sha256": self.package.package_sha256,
|
||||
"com.nodedc.product": "mission-core",
|
||||
"com.nodedc.role": "ai-module",
|
||||
"com.nodedc.stack": "observatory",
|
||||
},
|
||||
name_token=name_token,
|
||||
|
||||
@@ -33,7 +33,7 @@ INSTALLED_LAB_PACKAGE_REGISTRY_SCHEMA: Final = (
|
||||
INSTALLED_LAB_CONTAINER_IO_SCHEMA: Final = "missioncore.observatory-installed-lab-container-io/v2"
|
||||
|
||||
INSTALLED_LAB_SOURCE_ROOT: Final = "/missioncore/input/source"
|
||||
INSTALLED_LAB_PLAN_PATH: Final = "/missioncore/input/run-plan.json"
|
||||
INSTALLED_LAB_PLAN_PATH: Final = "/missioncore/plan/run-plan.json"
|
||||
INSTALLED_LAB_STEP_INPUT_ROOT: Final = "/missioncore/input/steps"
|
||||
INSTALLED_LAB_RESULT_ROOT: Final = "/missioncore/output"
|
||||
INSTALLED_LAB_WORK_ROOT: Final = "/missioncore/work"
|
||||
|
||||
@@ -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.typing as npt
|
||||
|
||||
from k1link.compute.lidar_replay import LidarReplayPackV2, build_lidar_replay_pack_v2
|
||||
from k1link.compute.lidar_preparation import prepare_lidar_replay_pack_v2
|
||||
from k1link.compute.lidar_replay import LidarReplayPackV2
|
||||
from k1link.observatory.portable_result_contract import canonical_json
|
||||
from k1link.observatory.recorded_progress import report_recorded_progress
|
||||
from k1link.observatory.source_admission import (
|
||||
@@ -225,7 +226,7 @@ def materialize_m49_portable_source_from_worker_stage(
|
||||
if parent.is_symlink() or not parent.is_dir():
|
||||
raise M49PortableSourceError("portable M4.9 output parent is unsafe")
|
||||
try:
|
||||
lidar_pack_root = build_lidar_replay_pack_v2(
|
||||
lidar_pack_root = prepare_lidar_replay_pack_v2(
|
||||
root / "mqtt.raw.k1mqtt",
|
||||
parent / "lidar-replay-packs",
|
||||
session_id=job.source_session_id,
|
||||
@@ -538,7 +539,9 @@ def _materialize_stage(
|
||||
f"\t{anchor.session_seconds:.9f}\t-1\t0"
|
||||
)
|
||||
report_recorded_progress(
|
||||
"source-preparation", anchor.timeline_frame_index + 1, len(anchors),
|
||||
"source-preparation",
|
||||
anchor.timeline_frame_index + 1,
|
||||
len(anchors),
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -600,7 +603,9 @@ def _materialize_stage(
|
||||
)
|
||||
available_slot += 1
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
PORTABLE_LAB_V1_SOURCE_SCHEMA: Final = (
|
||||
"missioncore.observatory-portable-lab-v1-source/v1"
|
||||
)
|
||||
PORTABLE_LAB_V1_PLAN_SCHEMA: Final = (
|
||||
"missioncore.observatory-portable-lab-v1-orchestration-plan/v1"
|
||||
)
|
||||
PORTABLE_LAB_V1_SOURCE_SCHEMA: Final = "missioncore.observatory-portable-lab-v1-source/v1"
|
||||
PORTABLE_LAB_V1_PLAN_SCHEMA: Final = "missioncore.observatory-portable-lab-v1-orchestration-plan/v1"
|
||||
PORTABLE_LAB_V1_PLAN_IDENTITY_SCHEMA: Final = (
|
||||
"missioncore.observatory-portable-lab-v1-orchestration-plan-identity/v1"
|
||||
)
|
||||
@@ -74,9 +70,7 @@ PORTABLE_LAB_V1_RELEASE_IDENTITY_SCHEMA: Final = (
|
||||
PORTABLE_LAB_V1_EXECUTOR_SEAL_SCHEMA: Final = (
|
||||
"missioncore.observatory-portable-lab-v1-executor-seal/v2"
|
||||
)
|
||||
PORTABLE_LAB_V1_DDRNET_PROFILE_SCHEMA: Final = (
|
||||
"missioncore.lab-v1-eomt-ddrnet-portable-profile/v2"
|
||||
)
|
||||
PORTABLE_LAB_V1_DDRNET_PROFILE_SCHEMA: Final = "missioncore.lab-v1-eomt-ddrnet-portable-profile/v2"
|
||||
PORTABLE_LAB_V1_DDRNET_EFFECTIVE_CONFIG_SCHEMA: Final = (
|
||||
"missioncore.lab-v1-goose-vegetation-benchmark/v1"
|
||||
)
|
||||
@@ -97,9 +91,7 @@ _DDRNET_CANDIDATE_KEY: Final = "ddrnet"
|
||||
_DDRNET_CHECKPOINT_SHA256: Final = (
|
||||
"b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6"
|
||||
)
|
||||
_GOOSE_MAPPING_SHA256: Final = (
|
||||
"88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f"
|
||||
)
|
||||
_GOOSE_MAPPING_SHA256: Final = "88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f"
|
||||
_OBSERVATORY_JOB_ID = re.compile(r"^observatory-run-[a-f0-9]{32}$")
|
||||
_CAMERA_JOB_ID = re.compile(r"^recorded-camera-[a-f0-9]{24}$")
|
||||
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
|
||||
@@ -214,9 +206,7 @@ class PortableLabV1SourceInput:
|
||||
"session_id": self.source_session_id,
|
||||
"catalog_sha256": self.source_catalog_sha256,
|
||||
"bundle_sha256": self.source_bundle_sha256,
|
||||
"capability_manifest_sha256": (
|
||||
self.source_capability_manifest_sha256
|
||||
),
|
||||
"capability_manifest_sha256": (self.source_capability_manifest_sha256),
|
||||
"adapter_sha256": self.source_adapter_sha256,
|
||||
},
|
||||
"camera_compute_job": {
|
||||
@@ -341,13 +331,8 @@ def materialize_lab_v1_source_input(
|
||||
)
|
||||
if hashlib.sha256(source_bundle_bytes).hexdigest() != job.source_bundle_sha256:
|
||||
raise PortableLabV1SourceError("source bundle digest differs from the sealed job")
|
||||
if (
|
||||
hashlib.sha256(capability_bytes).hexdigest()
|
||||
!= job.source_capability_manifest_sha256
|
||||
):
|
||||
raise PortableLabV1SourceError(
|
||||
"source capability digest differs from the sealed job"
|
||||
)
|
||||
if hashlib.sha256(capability_bytes).hexdigest() != job.source_capability_manifest_sha256:
|
||||
raise PortableLabV1SourceError("source capability digest differs from the sealed job")
|
||||
_validate_source_documents(
|
||||
source_bundle=source_bundle,
|
||||
capability=capability,
|
||||
@@ -484,9 +469,7 @@ class PortableLabV1ReleaseCandidate:
|
||||
_digest(self.executor_image_sha256, "release executor image sha256")
|
||||
ids = tuple(asset.asset_id for asset in self.assets)
|
||||
if ids != tuple(sorted(ids)) or len(ids) != len(set(ids)):
|
||||
raise PortableLabV1ReleaseError(
|
||||
"release assets must be unique and canonically ordered"
|
||||
)
|
||||
raise PortableLabV1ReleaseError("release assets must be unique and canonically ordered")
|
||||
if not self.phases or len(self.phases) != len(set(self.phases)):
|
||||
raise PortableLabV1ReleaseError("release phases are invalid")
|
||||
for phase in self.phases:
|
||||
@@ -561,9 +544,7 @@ class PortableLabV1ReleaseCandidate:
|
||||
return cls(
|
||||
release_id=_string(document["release_id"], "release id"),
|
||||
setup_id=_string(document["setup_id"], "release setup id"),
|
||||
definition_id=_string(
|
||||
document["definition_id"], "release definition id"
|
||||
),
|
||||
definition_id=_string(document["definition_id"], "release definition id"),
|
||||
definition_version=_positive_int(
|
||||
document["definition_version"], "release definition version"
|
||||
),
|
||||
@@ -579,9 +560,7 @@ class PortableLabV1ReleaseCandidate:
|
||||
assets=assets,
|
||||
phases=phases,
|
||||
declared_blockers=blockers,
|
||||
candidate_sha256=_string(
|
||||
document["candidate_sha256"], "release candidate sha256"
|
||||
),
|
||||
candidate_sha256=_string(document["candidate_sha256"], "release candidate sha256"),
|
||||
repository_root=_real_directory(repository_root, "repository root"),
|
||||
)
|
||||
|
||||
@@ -606,14 +585,10 @@ class PortableLabV1ReleaseCandidate:
|
||||
definition.setup_id != self.setup_id
|
||||
or definition.definition_id != self.definition_id
|
||||
or definition.version != self.definition_version
|
||||
or definition.executable_contract_sha256
|
||||
!= self.definition_contract_sha256
|
||||
or definition.result_contract.contract_sha256
|
||||
!= self.result_contract_sha256
|
||||
or definition.executable_contract_sha256 != self.definition_contract_sha256
|
||||
or definition.result_contract.contract_sha256 != self.result_contract_sha256
|
||||
):
|
||||
raise PortableLabV1ReleaseError(
|
||||
"release candidate belongs to another RunDefinition"
|
||||
)
|
||||
raise PortableLabV1ReleaseError("release candidate belongs to another RunDefinition")
|
||||
|
||||
def inspect(
|
||||
self,
|
||||
@@ -660,9 +635,7 @@ class PortableLabV1ReleaseCandidate:
|
||||
or self.executor_image_sha256 is None
|
||||
or len(inspection.matched_assets) != len(self.assets)
|
||||
):
|
||||
raise PortableLabV1ReleaseError(
|
||||
"portable LAB V1 executor candidate is not sealable"
|
||||
)
|
||||
raise PortableLabV1ReleaseError("portable LAB V1 executor candidate is not sealable")
|
||||
identity = {
|
||||
"schema_version": PORTABLE_LAB_V1_EXECUTOR_SEAL_SCHEMA,
|
||||
"release_id": self.release_id,
|
||||
@@ -725,9 +698,8 @@ class PortableLabV1PlanPhase:
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_pattern(self.phase_id, _IDENTIFIER, "plan phase id")
|
||||
if (
|
||||
not self.component_sha256s
|
||||
or self.component_sha256s != tuple(sorted(self.component_sha256s))
|
||||
if not self.component_sha256s or self.component_sha256s != tuple(
|
||||
sorted(self.component_sha256s)
|
||||
):
|
||||
raise PortableLabV1PlanError("plan component identities are not canonical")
|
||||
for digest_value in self.component_sha256s:
|
||||
@@ -787,10 +759,8 @@ class PortableLabV1OrchestrationPlan:
|
||||
_digest(value, label)
|
||||
if (
|
||||
self.source_input.observatory_job_id != self.observatory_job_id
|
||||
or self.source_input.observatory_request_sha256
|
||||
!= self.observatory_request_sha256
|
||||
or self.source_input.observatory_identity_sha256
|
||||
!= self.observatory_identity_sha256
|
||||
or self.source_input.observatory_request_sha256 != self.observatory_request_sha256
|
||||
or self.source_input.observatory_identity_sha256 != self.observatory_identity_sha256
|
||||
):
|
||||
raise PortableLabV1PlanError("plan source belongs to another job")
|
||||
if self.blockers != tuple(sorted(self.blockers)) or len(self.blockers) != len(
|
||||
@@ -804,10 +774,7 @@ class PortableLabV1OrchestrationPlan:
|
||||
"result-v2-assembly",
|
||||
):
|
||||
raise PortableLabV1PlanError("combined LAB V1 phase order changed")
|
||||
if (
|
||||
canonical_sha256(self.effective_ddrnet_config)
|
||||
!= self.effective_ddrnet_config_sha256
|
||||
):
|
||||
if canonical_sha256(self.effective_ddrnet_config) != self.effective_ddrnet_config_sha256:
|
||||
raise PortableLabV1PlanError("effective DDRNet config digest changed")
|
||||
if canonical_sha256(self.identity_document()) != self.plan_sha256:
|
||||
raise PortableLabV1PlanError("orchestration plan identity changed")
|
||||
@@ -830,8 +797,7 @@ class PortableLabV1OrchestrationPlan:
|
||||
raise PortableLabV1PlanError("release inspection belongs to another candidate")
|
||||
expected_asset_ids = tuple(asset.asset_id for asset in release.assets)
|
||||
if release_inspection.ready and (
|
||||
release_inspection.matched_assets != expected_asset_ids
|
||||
or release_inspection.blockers
|
||||
release_inspection.matched_assets != expected_asset_ids or release_inspection.blockers
|
||||
):
|
||||
raise PortableLabV1PlanError(
|
||||
"ready release inspection does not admit every exact asset"
|
||||
@@ -1147,13 +1113,8 @@ class PortableLabV1ResultAssembly:
|
||||
roles = tuple(item.role for item in self.artifacts)
|
||||
if roles != tuple(sorted(roles)) or len(roles) != len(set(roles)):
|
||||
raise PortableLabV1ResultError("assembled artifacts are not canonical")
|
||||
result_artifact = tuple(
|
||||
item for item in self.artifacts if item.role == "result-document"
|
||||
)
|
||||
if (
|
||||
len(result_artifact) != 1
|
||||
or result_artifact[0].sha256 != self.result_document_sha256
|
||||
):
|
||||
result_artifact = tuple(item for item in self.artifacts if item.role == "result-document")
|
||||
if len(result_artifact) != 1 or result_artifact[0].sha256 != self.result_document_sha256:
|
||||
raise PortableLabV1ResultError("assembled result document is not bound")
|
||||
|
||||
|
||||
@@ -1298,9 +1259,7 @@ def assemble_lab_v1_result_v2(
|
||||
"session_id": plan.source_input.source_session_id,
|
||||
"catalog_sha256": plan.source_input.source_catalog_sha256,
|
||||
"bundle_sha256": plan.source_input.source_bundle_sha256,
|
||||
"capability_manifest_sha256": (
|
||||
plan.source_input.source_capability_manifest_sha256
|
||||
),
|
||||
"capability_manifest_sha256": (plan.source_input.source_capability_manifest_sha256),
|
||||
"camera_input_sha256": plan.source_input.camera_input_sha256,
|
||||
"frame_count": plan.source_input.frame_count,
|
||||
"timeline_start_seconds": plan.source_input.timeline_start_seconds,
|
||||
@@ -1311,9 +1270,7 @@ def assemble_lab_v1_result_v2(
|
||||
"definition_id": definition.definition_id,
|
||||
"version": definition.version,
|
||||
"definition_sha256": definition.definition_sha256,
|
||||
"result_contract_sha256": (
|
||||
definition.result_contract.contract_sha256
|
||||
),
|
||||
"result_contract_sha256": (definition.result_contract.contract_sha256),
|
||||
"release_candidate_sha256": plan.release_candidate_sha256,
|
||||
"plan_sha256": plan.plan_sha256,
|
||||
},
|
||||
@@ -1343,9 +1300,7 @@ def assemble_lab_v1_result_v2(
|
||||
)
|
||||
final = parent / result_id
|
||||
if final.exists():
|
||||
raise PortableLabV1ResultError(
|
||||
"an assembled result with this identity already exists"
|
||||
)
|
||||
raise PortableLabV1ResultError("an assembled result with this identity already exists")
|
||||
_fsync_tree(staging)
|
||||
os.replace(staging, final)
|
||||
_fsync_directory(parent)
|
||||
@@ -1429,8 +1384,7 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
|
||||
definition.setup_id != _EXPECTED_SETUP_ID
|
||||
or definition.definition_id != _EXPECTED_DEFINITION_ID
|
||||
or definition.result_contract.result_schema != PORTABLE_LAB_V1_RESULT_SCHEMA
|
||||
or definition.result_contract.contract_sha256
|
||||
!= _EXPECTED_RESULT_CONTRACT_SHA256
|
||||
or definition.result_contract.contract_sha256 != _EXPECTED_RESULT_CONTRACT_SHA256
|
||||
):
|
||||
raise PortableLabV1ResultError("LAB V1 validator received another definition")
|
||||
document = dict(context.result_document)
|
||||
@@ -1444,8 +1398,7 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
|
||||
identity = _object(document["identity"], "portable LAB V1 identity")
|
||||
if (
|
||||
canonical_sha256(identity) != document["identity_sha256"]
|
||||
or document["result_id"]
|
||||
!= f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
|
||||
or document["result_id"] != f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
|
||||
):
|
||||
raise PortableLabV1ResultError("portable LAB V1 result identity changed")
|
||||
source = _object(document["source"], "portable LAB V1 source")
|
||||
@@ -1484,8 +1437,7 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
|
||||
source.get("session_id") != context.job.source_session_id
|
||||
or source.get("catalog_sha256") != context.job.source_catalog_sha256
|
||||
or source.get("bundle_sha256") != context.job.source_bundle_sha256
|
||||
or source.get("capability_manifest_sha256")
|
||||
!= context.job.source_capability_manifest_sha256
|
||||
or source.get("capability_manifest_sha256") != context.job.source_capability_manifest_sha256
|
||||
or run_definition.get("setup_id") != definition.setup_id
|
||||
or run_definition.get("definition_id") != definition.definition_id
|
||||
or run_definition.get("version") != definition.version
|
||||
@@ -1499,13 +1451,10 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
|
||||
raise PortableLabV1ResultError("portable LAB V1 artifacts are not an array")
|
||||
declared = tuple(_artifact_from_document(row) for row in artifact_rows)
|
||||
declared_roles = tuple(artifact.role for artifact in declared)
|
||||
if (
|
||||
declared_roles != tuple(sorted(declared_roles))
|
||||
or len(declared_roles) != len(set(declared_roles))
|
||||
if declared_roles != tuple(sorted(declared_roles)) or len(declared_roles) != len(
|
||||
set(declared_roles)
|
||||
):
|
||||
raise PortableLabV1ResultError(
|
||||
"portable result artifacts are not canonical"
|
||||
)
|
||||
raise PortableLabV1ResultError("portable result artifacts are not canonical")
|
||||
declared_by_role = {artifact.role: artifact for artifact in declared}
|
||||
package_by_role = {artifact.role: artifact for artifact in context.manifest.artifacts}
|
||||
if set(package_by_role) != {*declared_by_role, "result-document"}:
|
||||
@@ -1540,15 +1489,13 @@ def validate_lab_v1_result_v2(context: PortableResultValidationContext) -> None:
|
||||
if (
|
||||
source_input.identity_sha256 != identity.get("source_input_sha256")
|
||||
or plan.plan_sha256 != run_definition.get("plan_sha256")
|
||||
or plan.release_candidate_sha256
|
||||
!= run_definition.get("release_candidate_sha256")
|
||||
or plan.release_candidate_sha256 != run_definition.get("release_candidate_sha256")
|
||||
or plan.observatory_job_id != context.job.job_id
|
||||
or plan.observatory_request_sha256 != context.job.request_sha256
|
||||
or plan.observatory_identity_sha256 != context.job.identity_sha256
|
||||
or source.get("camera_input_sha256") != source_input.camera_input_sha256
|
||||
or source.get("frame_count") != source_input.frame_count
|
||||
or source.get("timeline_start_seconds")
|
||||
!= source_input.timeline_start_seconds
|
||||
or source.get("timeline_start_seconds") != source_input.timeline_start_seconds
|
||||
or source.get("timeline_end_seconds") != source_input.timeline_end_seconds
|
||||
):
|
||||
raise PortableLabV1ResultError("portable source or plan artifact changed")
|
||||
@@ -1621,9 +1568,7 @@ def portable_lab_v1_source_input_from_document(
|
||||
"portable camera compute job",
|
||||
)
|
||||
return PortableLabV1SourceInput(
|
||||
observatory_job_id=_string(
|
||||
observatory_job["job_id"], "portable source Observatory job id"
|
||||
),
|
||||
observatory_job_id=_string(observatory_job["job_id"], "portable source Observatory job id"),
|
||||
observatory_request_sha256=_string(
|
||||
observatory_job["request_sha256"],
|
||||
"portable source Observatory request sha256",
|
||||
@@ -1633,23 +1578,15 @@ def portable_lab_v1_source_input_from_document(
|
||||
"portable source Observatory identity sha256",
|
||||
),
|
||||
source_session_id=_string(source["session_id"], "portable source session id"),
|
||||
source_catalog_sha256=_string(
|
||||
source["catalog_sha256"], "portable source catalog sha256"
|
||||
),
|
||||
source_bundle_sha256=_string(
|
||||
source["bundle_sha256"], "portable source bundle sha256"
|
||||
),
|
||||
source_catalog_sha256=_string(source["catalog_sha256"], "portable source catalog sha256"),
|
||||
source_bundle_sha256=_string(source["bundle_sha256"], "portable source bundle sha256"),
|
||||
source_capability_manifest_sha256=_string(
|
||||
source["capability_manifest_sha256"],
|
||||
"portable source capability sha256",
|
||||
),
|
||||
source_adapter_sha256=_string(
|
||||
source["adapter_sha256"], "portable source adapter sha256"
|
||||
),
|
||||
source_adapter_sha256=_string(source["adapter_sha256"], "portable source adapter sha256"),
|
||||
camera_job_id=_string(camera["job_id"], "portable camera job id"),
|
||||
camera_input_sha256=_string(
|
||||
camera["input_sha256"], "portable camera input sha256"
|
||||
),
|
||||
camera_input_sha256=_string(camera["input_sha256"], "portable camera input sha256"),
|
||||
camera_source_id=_string(camera["source_id"], "portable camera source id"),
|
||||
codec_epoch=_positive_int(camera["codec_epoch"], "portable codec epoch"),
|
||||
input_byte_length=_positive_int(
|
||||
@@ -1659,15 +1596,11 @@ def portable_lab_v1_source_input_from_document(
|
||||
timeline_start_seconds=_finite_float(
|
||||
camera["timeline_start_seconds"], "portable timeline start"
|
||||
),
|
||||
timeline_end_seconds=_finite_float(
|
||||
camera["timeline_end_seconds"], "portable timeline end"
|
||||
),
|
||||
timeline_end_seconds=_finite_float(camera["timeline_end_seconds"], "portable timeline end"),
|
||||
camera_generation_sha256=_string(
|
||||
camera["generation_sha256"], "portable camera generation sha256"
|
||||
),
|
||||
calibration_sha256=_string(
|
||||
camera["calibration_sha256"], "portable calibration sha256"
|
||||
),
|
||||
calibration_sha256=_string(camera["calibration_sha256"], "portable calibration sha256"),
|
||||
)
|
||||
|
||||
|
||||
@@ -1758,9 +1691,7 @@ def portable_lab_v1_orchestration_plan_from_document(
|
||||
raise PortableLabV1ResultError("portable plan admission changed")
|
||||
phases = tuple(_plan_phase_from_document(value) for value in phases_value)
|
||||
plan = PortableLabV1OrchestrationPlan(
|
||||
observatory_job_id=_string(
|
||||
observatory_job["job_id"], "portable plan Observatory job id"
|
||||
),
|
||||
observatory_job_id=_string(observatory_job["job_id"], "portable plan Observatory job id"),
|
||||
observatory_request_sha256=_string(
|
||||
observatory_job["request_sha256"],
|
||||
"portable plan Observatory request sha256",
|
||||
@@ -1770,9 +1701,7 @@ def portable_lab_v1_orchestration_plan_from_document(
|
||||
"portable plan Observatory identity sha256",
|
||||
),
|
||||
setup_id=_string(run_definition["setup_id"], "portable plan setup id"),
|
||||
definition_id=_string(
|
||||
run_definition["definition_id"], "portable plan definition id"
|
||||
),
|
||||
definition_id=_string(run_definition["definition_id"], "portable plan definition id"),
|
||||
definition_version=_positive_int(
|
||||
run_definition["version"], "portable plan definition version"
|
||||
),
|
||||
@@ -1817,8 +1746,7 @@ def _plan_phase_from_document(value: object) -> PortableLabV1PlanPhase:
|
||||
input_values = row["input_roles"]
|
||||
output_values = row["output_roles"]
|
||||
if not all(
|
||||
isinstance(values, list)
|
||||
for values in (component_values, input_values, output_values)
|
||||
isinstance(values, list) for values in (component_values, input_values, output_values)
|
||||
):
|
||||
raise PortableLabV1ResultError("portable plan phase arrays changed")
|
||||
return PortableLabV1PlanPhase(
|
||||
@@ -1828,12 +1756,10 @@ def _plan_phase_from_document(value: object) -> PortableLabV1PlanPhase:
|
||||
for item in cast(list[object], component_values)
|
||||
),
|
||||
input_roles=tuple(
|
||||
_string(item, "portable plan input role")
|
||||
for item in cast(list[object], input_values)
|
||||
_string(item, "portable plan input role") for item in cast(list[object], input_values)
|
||||
),
|
||||
output_roles=tuple(
|
||||
_string(item, "portable plan output role")
|
||||
for item in cast(list[object], output_values)
|
||||
_string(item, "portable plan output role") for item in cast(list[object], output_values)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1859,8 +1785,7 @@ def _validate_source_documents(
|
||||
source_bundle.get("source_session_id") != job.source_session_id
|
||||
or capability.get("source_session_id") != job.source_session_id
|
||||
or camera_job.session_id != job.source_session_id
|
||||
or source_bundle.get("source_catalog_sha256")
|
||||
!= job.source_catalog_sha256
|
||||
or source_bundle.get("source_catalog_sha256") != job.source_catalog_sha256
|
||||
or capability.get("source_catalog_sha256") != job.source_catalog_sha256
|
||||
or capability.get("source_bundle_sha256") != job.source_bundle_sha256
|
||||
or capability.get("source_adapter_sha256") != job.source_adapter_sha256
|
||||
@@ -1897,8 +1822,7 @@ def _validate_source_documents(
|
||||
raise PortableLabV1SourceError("source camera segment count changed")
|
||||
if (
|
||||
video.get("source_id") != requirements.camera_source_id
|
||||
or video.get("semantic_channel_id")
|
||||
!= requirements.camera_semantic_channel_id
|
||||
or video.get("semantic_channel_id") != requirements.camera_semantic_channel_id
|
||||
or video.get("seekable") is not True
|
||||
or camera_job.source_id != camera.get("public_source_id")
|
||||
or camera_job.codec_epoch != epoch.get("ordinal")
|
||||
@@ -1934,18 +1858,19 @@ def _validate_source_documents(
|
||||
if not isinstance(files, list):
|
||||
raise PortableLabV1SourceError("camera compute job file set is invalid")
|
||||
file_rows = {
|
||||
PurePosixPath(_string(_object(row, "camera file").get("path"), "camera file path")).name:
|
||||
_object(row, "camera file")
|
||||
PurePosixPath(
|
||||
_string(_object(row, "camera file").get("path"), "camera file path")
|
||||
).name: _object(row, "camera file")
|
||||
for row in files
|
||||
if PurePosixPath(
|
||||
_string(_object(row, "camera file").get("path"), "camera file path")
|
||||
).parent.name
|
||||
in (f"epoch-{camera_job.codec_epoch}", "segments")
|
||||
}
|
||||
if (
|
||||
init.get("sha256") != _object(file_rows.get("init.mp4"), "camera init file").get("sha256")
|
||||
or init.get("byte_length")
|
||||
!= _object(file_rows.get("init.mp4"), "camera init file").get("byte_length")
|
||||
if init.get("sha256") != _object(file_rows.get("init.mp4"), "camera init file").get(
|
||||
"sha256"
|
||||
) or init.get("byte_length") != _object(file_rows.get("init.mp4"), "camera init file").get(
|
||||
"byte_length"
|
||||
):
|
||||
raise PortableLabV1SourceError("camera init differs from the admitted source")
|
||||
for index, row in enumerate(segments, start=1):
|
||||
@@ -1964,11 +1889,7 @@ def _verify_definition_and_job(
|
||||
job: SealedObservatoryRecordedJob,
|
||||
) -> None:
|
||||
if (
|
||||
definition.setup_id != _EXPECTED_SETUP_ID
|
||||
or definition.definition_id != _EXPECTED_DEFINITION_ID
|
||||
or definition.result_contract.contract_sha256
|
||||
!= _EXPECTED_RESULT_CONTRACT_SHA256
|
||||
or job.setup_id != definition.setup_id
|
||||
job.setup_id != definition.setup_id
|
||||
or job.definition_id != definition.definition_id
|
||||
or job.definition_version != definition.version
|
||||
or job.definition_sha256 != definition.definition_sha256
|
||||
@@ -1977,14 +1898,14 @@ def _verify_definition_and_job(
|
||||
or job.source_adapter_sha256 != definition.source_adapter.contract_sha256
|
||||
or job.model_release_ids != definition.learned_models
|
||||
or job.resource_profile_id != definition.resource_profile.profile_id
|
||||
or job.executor_identity.model_manifest_sha256
|
||||
!= definition.model_manifest_sha256
|
||||
or job.executor_identity.model_manifest_sha256 != definition.model_manifest_sha256
|
||||
or job.executor_identity.resource_profile_sha256
|
||||
!= definition.resource_profile.profile_sha256
|
||||
or job.checkpoint_policy != definition.resource_profile.checkpoint_policy
|
||||
or job.allowed_checkpoints != definition.resource_profile.allowed_checkpoints
|
||||
or definition.authority.as_dict() != OBSERVATION_ONLY_AUTHORITY
|
||||
):
|
||||
raise PortableLabV1SourceError("job is not the exact portable LAB V1 definition")
|
||||
raise PortableLabV1SourceError("job is not the exact portable definition")
|
||||
|
||||
|
||||
def _verify_source_and_job(
|
||||
@@ -2008,8 +1929,7 @@ def _verify_source_and_job(
|
||||
or source.source_session_id != job.source_session_id
|
||||
or source.source_catalog_sha256 != job.source_catalog_sha256
|
||||
or source.source_bundle_sha256 != job.source_bundle_sha256
|
||||
or source.source_capability_manifest_sha256
|
||||
!= job.source_capability_manifest_sha256
|
||||
or source.source_capability_manifest_sha256 != job.source_capability_manifest_sha256
|
||||
or source.source_adapter_sha256 != job.source_adapter_sha256
|
||||
or source.calibration_sha256 != requirements.calibration_identity_sha256
|
||||
):
|
||||
@@ -2069,8 +1989,7 @@ def _verify_plan_definition(
|
||||
or plan.definition_id != definition.definition_id
|
||||
or plan.definition_version != definition.version
|
||||
or plan.definition_sha256 != definition.definition_sha256
|
||||
or plan.result_contract_sha256
|
||||
!= definition.result_contract.contract_sha256
|
||||
or plan.result_contract_sha256 != definition.result_contract.contract_sha256
|
||||
):
|
||||
raise PortableLabV1PlanError("orchestration plan belongs to another definition")
|
||||
|
||||
@@ -2092,8 +2011,7 @@ def _validate_eomt_component(
|
||||
or document.get("source_id") != plan.source_input.camera_source_id
|
||||
or document.get("codec_epoch") != plan.source_input.codec_epoch
|
||||
or document.get("timestamp_basis") != "session-time-seconds"
|
||||
or document.get("timeline_start_seconds")
|
||||
!= plan.source_input.timeline_start_seconds
|
||||
or document.get("timeline_start_seconds") != plan.source_input.timeline_start_seconds
|
||||
or document.get("timeline_end_seconds") != plan.source_input.timeline_end_seconds
|
||||
or document.get("frames_processed") != plan.source_input.frame_count
|
||||
or document.get("ground_truth") is not False
|
||||
@@ -2186,10 +2104,8 @@ def _validate_ddrnet_component(
|
||||
or semantics.get("center_crop_xyxy") != [100, 0, 700, 600]
|
||||
or semantics.get("outside_crop_state") != "undefined"
|
||||
or semantics.get("base_m4_result_id") is not None
|
||||
or provenance.get("config_sha256")
|
||||
!= plan.effective_ddrnet_config_sha256
|
||||
or provenance.get("policy_sha256")
|
||||
!= components["vegetation-mission-policy-v1"].sha256
|
||||
or provenance.get("config_sha256") != plan.effective_ddrnet_config_sha256
|
||||
or provenance.get("policy_sha256") != components["vegetation-mission-policy-v1"].sha256
|
||||
or provenance.get("provider_map_sha256")
|
||||
!= components["vegetation-provider-label-map-v1"].sha256
|
||||
or authority
|
||||
@@ -2290,8 +2206,7 @@ def _validate_assembly(
|
||||
if (
|
||||
canonical_sha256(identity) != document["identity_sha256"]
|
||||
or document["result_id"] != assembly.result_id
|
||||
or document["result_id"]
|
||||
!= f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
|
||||
or document["result_id"] != f"lab-v1-eomt-ddrnet-{document['identity_sha256']}"
|
||||
):
|
||||
raise PortableLabV1ResultError("assembled result identity changed")
|
||||
for artifact in assembly.artifacts:
|
||||
@@ -2450,10 +2365,8 @@ def _validate_published_eomt_component(
|
||||
or document.get("source_id") != plan.source_input.camera_source_id
|
||||
or document.get("codec_epoch") != plan.source_input.codec_epoch
|
||||
or document.get("timestamp_basis") != "session-time-seconds"
|
||||
or document.get("timeline_start_seconds")
|
||||
!= plan.source_input.timeline_start_seconds
|
||||
or document.get("timeline_end_seconds")
|
||||
!= plan.source_input.timeline_end_seconds
|
||||
or document.get("timeline_start_seconds") != plan.source_input.timeline_start_seconds
|
||||
or document.get("timeline_end_seconds") != plan.source_input.timeline_end_seconds
|
||||
or document.get("frames_processed") != plan.source_input.frame_count
|
||||
or document.get("ground_truth") is not False
|
||||
or semantic.get("id") != model.model_id
|
||||
@@ -2472,9 +2385,7 @@ def _validate_published_eomt_component(
|
||||
}
|
||||
if not isinstance(rows, list) or len(rows) != len(expected_kinds):
|
||||
raise PortableLabV1ResultError("published EoMT artifact set changed")
|
||||
package_by_role = {
|
||||
artifact.role: artifact for artifact in context.manifest.artifacts
|
||||
}
|
||||
package_by_role = {artifact.role: artifact for artifact in context.manifest.artifacts}
|
||||
observed_kinds: set[str] = set()
|
||||
for value in rows:
|
||||
row = _object(value, "published EoMT artifact")
|
||||
@@ -2482,13 +2393,9 @@ def _validate_published_eomt_component(
|
||||
if kind not in expected_kinds or kind in observed_kinds:
|
||||
raise PortableLabV1ResultError("published EoMT artifact roles changed")
|
||||
observed_kinds.add(kind)
|
||||
relative = _safe_relative_path(
|
||||
_string(row.get("path"), "published EoMT artifact path")
|
||||
)
|
||||
relative = _safe_relative_path(_string(row.get("path"), "published EoMT artifact path"))
|
||||
if len(relative.parts) != 1:
|
||||
raise PortableLabV1ResultError(
|
||||
"published EoMT artifact path changed"
|
||||
)
|
||||
raise PortableLabV1ResultError("published EoMT artifact path changed")
|
||||
packaged = package_by_role.get(f"eomt-{kind}")
|
||||
if (
|
||||
packaged is None
|
||||
@@ -2496,9 +2403,7 @@ def _validate_published_eomt_component(
|
||||
or row.get("byte_length") != packaged.byte_length
|
||||
or row.get("sha256") != packaged.sha256
|
||||
):
|
||||
raise PortableLabV1ResultError(
|
||||
"published EoMT artifact identity changed"
|
||||
)
|
||||
raise PortableLabV1ResultError("published EoMT artifact identity changed")
|
||||
|
||||
|
||||
def _validate_published_ddrnet_component(
|
||||
@@ -2557,10 +2462,8 @@ def _validate_published_ddrnet_component(
|
||||
or semantics.get("center_crop_xyxy") != [100, 0, 700, 600]
|
||||
or semantics.get("outside_crop_state") != "undefined"
|
||||
or semantics.get("base_m4_result_id") is not None
|
||||
or provenance.get("config_sha256")
|
||||
!= plan.effective_ddrnet_config_sha256
|
||||
or provenance.get("policy_sha256")
|
||||
!= components["vegetation-mission-policy-v1"].sha256
|
||||
or provenance.get("config_sha256") != plan.effective_ddrnet_config_sha256
|
||||
or provenance.get("policy_sha256") != components["vegetation-mission-policy-v1"].sha256
|
||||
or provenance.get("provider_map_sha256")
|
||||
!= components["vegetation-provider-label-map-v1"].sha256
|
||||
or package_archive is None
|
||||
@@ -2595,6 +2498,8 @@ def _validate_published_ddrnet_component(
|
||||
"lab-v1-ravnoves-video-ddrnet-" + canonical_sha256(identity_value)
|
||||
):
|
||||
raise PortableLabV1ResultError("published DDRNet result identity changed")
|
||||
|
||||
|
||||
def _model(definition: PortableRunDefinition, release_id: str): # type: ignore[no-untyped-def]
|
||||
for model in definition.models:
|
||||
if model.release_id == release_id:
|
||||
@@ -2644,9 +2549,8 @@ def _release_binding_matches(
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return False
|
||||
return (
|
||||
(asset.byte_length is None or path.stat().st_size == asset.byte_length)
|
||||
and _sha256_file(path) == asset.sha256
|
||||
)
|
||||
asset.byte_length is None or path.stat().st_size == asset.byte_length
|
||||
) and _sha256_file(path) == asset.sha256
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
@@ -94,9 +94,7 @@ _EXPECTED_RESULT_CONTRACT_SHA256: Final = (
|
||||
"b3dfaa8e20a0f22fc510d062ac469f010a3281c650059d9ea134f0b3ccb38d9a"
|
||||
)
|
||||
_PORTABLE_CONFIG_ASSET_ID: Final = "ddrnet-portable-config"
|
||||
_WORKER_INSTALLATION_RECEIPT_ASSET_ID: Final = (
|
||||
"lab-v1-worker-installation-receipt"
|
||||
)
|
||||
_WORKER_INSTALLATION_RECEIPT_ASSET_ID: Final = "lab-v1-worker-installation-receipt"
|
||||
_MAX_SOURCE_DOCUMENT_BYTES: Final = 8 * 1024 * 1024
|
||||
_MAX_MATERIALIZATION_MANIFEST_BYTES: Final = 64 * 1024 * 1024
|
||||
_MAX_SOURCE_MEMBERS: Final = 100_000
|
||||
@@ -432,6 +430,27 @@ def materialize_lab_v1_source_from_worker_stage(
|
||||
"""Build a deterministic camera job from only manifested Worker members."""
|
||||
|
||||
_verify_definition(definition)
|
||||
return materialize_recorded_camera_source_from_worker_stage(
|
||||
worker_stage=worker_stage,
|
||||
job=job,
|
||||
definition=definition,
|
||||
output_parent=output_parent,
|
||||
)
|
||||
|
||||
|
||||
def materialize_recorded_camera_source_from_worker_stage(
|
||||
*,
|
||||
worker_stage: PortableWorkerSourceStage,
|
||||
job: SealedObservatoryRecordedJob,
|
||||
definition: PortableRunDefinition,
|
||||
output_parent: Path,
|
||||
) -> PortableLabV1MaterializedSource:
|
||||
"""Build the shared recorded-camera input for any exact installed definition.
|
||||
|
||||
The historical LAB entrypoint above retains its setup-specific admission.
|
||||
New modular packages use this model-neutral source preparation boundary.
|
||||
"""
|
||||
|
||||
if (
|
||||
worker_stage.source_bundle_sha256 != job.source_bundle_sha256
|
||||
or worker_stage.source_capability_manifest_sha256 != job.source_capability_manifest_sha256
|
||||
@@ -574,8 +593,7 @@ def _verify_candidate_release(
|
||||
or candidate.definition_id != release.definition_id
|
||||
or candidate.definition_version != release.definition_version
|
||||
or candidate.definition_sha256 != definition.definition_sha256
|
||||
or release.definition_contract_sha256
|
||||
!= definition.executable_contract_sha256
|
||||
or release.definition_contract_sha256 != definition.executable_contract_sha256
|
||||
or candidate.result_contract_sha256 != release.result_contract_sha256
|
||||
or tuple(phase.phase_id for phase in candidate.phases) != PORTABLE_LAB_V1_RUNTIME_PHASES
|
||||
or executor is None
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Project sealed RF-DETR boxes and optional K1 ranges without inference."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import rerun as rr
|
||||
|
||||
from k1link.artifact_gateway import CentralArtifactStore
|
||||
from k1link.observatory.portable_tgs_replay import PortableReplayError, read_json, verified_file
|
||||
from k1link.perception.contracts import ObjectProposal2D, ObstacleObservation
|
||||
|
||||
RESULT_SCHEMA = "missioncore.recorded-ai-layer-review/v1"
|
||||
RF_RESULT_SCHEMA = "missioncore.observatory-ai-module-rf-detr-result/v1"
|
||||
RANGE_RESULT_SCHEMA = "missioncore.observatory-ai-module-object-distance-result/v1"
|
||||
RF_ROW_SCHEMA = "missioncore.observatory-ai-module-rf-detr-frame/v1"
|
||||
RANGE_ROW_SCHEMA = "missioncore.observatory-ai-module-object-distance-frame/v1"
|
||||
RENDERER_VERSION = "portable-rf-detr-k1-range-rerun-0.36.3-v1"
|
||||
_MAX_FRAMES = 250_000
|
||||
_MAX_ROW_BYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObjectReplayFrame:
|
||||
session_seconds: float
|
||||
proposals: tuple[ObjectProposal2D, ...]
|
||||
ranges_m: dict[str, float | None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObjectReplayData:
|
||||
frames: tuple[ObjectReplayFrame, ...]
|
||||
end_seconds: float
|
||||
include_ranges: bool
|
||||
|
||||
|
||||
def _artifact(
|
||||
members: dict[str, dict[str, Any]],
|
||||
store: CentralArtifactStore,
|
||||
role: str,
|
||||
media_type: str,
|
||||
) -> Path:
|
||||
member = members.get(role)
|
||||
if member is None or member.get("media_type") != media_type:
|
||||
raise PortableReplayError("object replay artifact is missing")
|
||||
return verified_file(
|
||||
store.object_path(member["sha256"]), member["sha256"], member["byte_length"]
|
||||
)
|
||||
|
||||
|
||||
def _rows(path: Path, *, schema: str, count: int) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
for raw in stream:
|
||||
if len(raw) > _MAX_ROW_BYTES or len(result) >= count:
|
||||
raise PortableReplayError("object replay rows exceed their bound")
|
||||
try:
|
||||
row = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise PortableReplayError("object replay row is invalid") from exc
|
||||
if not isinstance(row, dict) or row.get("schema_version") != schema:
|
||||
raise PortableReplayError("object replay row contract changed")
|
||||
result.append(row)
|
||||
if len(result) != count:
|
||||
raise PortableReplayError("object replay frame count changed")
|
||||
return result
|
||||
|
||||
|
||||
def _time(value: object) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise PortableReplayError("object replay time is invalid")
|
||||
number = float(value)
|
||||
if not math.isfinite(number):
|
||||
raise PortableReplayError("object replay time is invalid")
|
||||
return number
|
||||
|
||||
|
||||
def load_object_data(
|
||||
view: dict[str, Any],
|
||||
store: CentralArtifactStore,
|
||||
*,
|
||||
source_bundle_sha256: str,
|
||||
starts: list[float],
|
||||
end_seconds: float,
|
||||
) -> ObjectReplayData:
|
||||
doc = view["result_document"]
|
||||
source = doc.get("source")
|
||||
module = doc.get("module")
|
||||
if not isinstance(source, dict) or not isinstance(module, dict):
|
||||
raise PortableReplayError("object replay result is malformed")
|
||||
module_id = module.get("module_id")
|
||||
if (
|
||||
doc.get("schema_version") != RESULT_SCHEMA
|
||||
or doc.get("result_id") != view["result_id"]
|
||||
or module_id not in {"rf-detr", "object-distance"}
|
||||
or source.get("session_id") != view["source_session_id"]
|
||||
or source.get("bundle_sha256") != source_bundle_sha256
|
||||
or not 0 < len(starts) <= _MAX_FRAMES
|
||||
or source.get("frame_count") != len(starts)
|
||||
or source.get("timeline_start_seconds") != starts[0]
|
||||
or source.get("timeline_end_seconds") != end_seconds
|
||||
or not np.isfinite([*starts, end_seconds]).all()
|
||||
or np.any(np.diff([*starts, end_seconds]) <= 0)
|
||||
):
|
||||
raise PortableReplayError("object result and source clock disagree")
|
||||
members = {row["role"]: row for row in view["artifacts"]}
|
||||
if len(members) != len(view["artifacts"]):
|
||||
raise PortableReplayError("object artifact roles are duplicated")
|
||||
rf_path = _artifact(members, store, "rf-detr-result-document", "application/json")
|
||||
rf = read_json(rf_path)
|
||||
detection_path = _artifact(members, store, "rf-detr-frame-detections", "application/x-ndjson")
|
||||
if (
|
||||
rf.get("schema_version") != RF_RESULT_SCHEMA
|
||||
or rf.get("module_id") != "rf-detr"
|
||||
or not isinstance(rf.get("source"), dict)
|
||||
or rf["source"].get("session_id") != view["source_session_id"]
|
||||
or rf.get("frame_count") != len(starts)
|
||||
or rf.get("detections_sha256") != members["rf-detr-frame-detections"]["sha256"]
|
||||
or (
|
||||
module_id == "rf-detr"
|
||||
and module.get("component_result_sha256")
|
||||
!= members["rf-detr-result-document"]["sha256"]
|
||||
)
|
||||
):
|
||||
raise PortableReplayError("RF-DETR result binding changed")
|
||||
detection_rows = _rows(detection_path, schema=RF_ROW_SCHEMA, count=len(starts))
|
||||
|
||||
range_rows: list[dict[str, Any]] | None = None
|
||||
if module_id == "object-distance":
|
||||
range_path = _artifact(
|
||||
members,
|
||||
store,
|
||||
"object-distance-frame-observations",
|
||||
"application/x-ndjson",
|
||||
)
|
||||
ranged = read_json(
|
||||
_artifact(members, store, "object-distance-result-document", "application/json")
|
||||
)
|
||||
if (
|
||||
ranged.get("schema_version") != RANGE_RESULT_SCHEMA
|
||||
or ranged.get("module_id") != "object-distance"
|
||||
or ranged.get("source_session_id") != view["source_session_id"]
|
||||
or ranged.get("frame_count") != len(starts)
|
||||
or ranged.get("object_distances_sha256")
|
||||
!= members["object-distance-frame-observations"]["sha256"]
|
||||
or module.get("component_result_sha256")
|
||||
!= members["object-distance-result-document"]["sha256"]
|
||||
):
|
||||
raise PortableReplayError("object-distance result binding changed")
|
||||
range_rows = _rows(range_path, schema=RANGE_ROW_SCHEMA, count=len(starts))
|
||||
|
||||
frames: list[ObjectReplayFrame] = []
|
||||
try:
|
||||
for index, (timestamp, row) in enumerate(zip(starts, detection_rows, strict=True)):
|
||||
if row.get("frame_index") != index or _time(row.get("session_seconds")) != timestamp:
|
||||
raise PortableReplayError("RF-DETR frame clock changed")
|
||||
raw_proposals = row.get("proposals")
|
||||
if not isinstance(raw_proposals, list):
|
||||
raise PortableReplayError("RF-DETR proposals are unavailable")
|
||||
proposals = tuple(ObjectProposal2D.from_dict(value) for value in raw_proposals)
|
||||
proposal_ids = {item.proposal_id for item in proposals}
|
||||
if len(proposal_ids) != len(proposals) or any(
|
||||
item.region.x_max > 800 or item.region.y_max > 600 for item in proposals
|
||||
):
|
||||
raise PortableReplayError("RF-DETR proposal geometry changed")
|
||||
ranges: dict[str, float | None] = {}
|
||||
if range_rows is not None:
|
||||
range_row = range_rows[index]
|
||||
if (
|
||||
range_row.get("frame_index") != index
|
||||
or _time(range_row.get("session_seconds")) != timestamp
|
||||
or not isinstance(range_row.get("observations"), list)
|
||||
):
|
||||
raise PortableReplayError("object-distance frame clock changed")
|
||||
for raw in range_row["observations"]:
|
||||
observation = ObstacleObservation.from_dict(raw)
|
||||
distance = (
|
||||
None
|
||||
if observation.metric_geometry is None
|
||||
else observation.metric_geometry.range_m
|
||||
)
|
||||
for proposal_id in observation.proposal_ids:
|
||||
if proposal_id not in proposal_ids or proposal_id in ranges:
|
||||
raise PortableReplayError("object-distance proposal binding changed")
|
||||
ranges[proposal_id] = distance
|
||||
if set(ranges) != proposal_ids:
|
||||
raise PortableReplayError("object-distance coverage changed")
|
||||
frames.append(ObjectReplayFrame(timestamp, proposals, ranges))
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
if isinstance(exc, PortableReplayError):
|
||||
raise
|
||||
raise PortableReplayError("object replay contract changed") from exc
|
||||
return ObjectReplayData(tuple(frames), end_seconds, range_rows is not None)
|
||||
|
||||
|
||||
def _color(label: str) -> list[int]:
|
||||
digest = hashlib.sha256(f"mission-core-object-{label}".encode()).digest()
|
||||
return [80 + digest[channel] % 160 for channel in range(3)] + [255]
|
||||
|
||||
|
||||
def log_objects(recording: rr.RecordingStream, data: ObjectReplayData) -> None:
|
||||
for frame in data.frames:
|
||||
recording.set_time(
|
||||
"session_time",
|
||||
duration=np.timedelta64(round(frame.session_seconds * 1e9), "ns"),
|
||||
)
|
||||
if not frame.proposals:
|
||||
recording.log("/perception/camera/detections", rr.Clear(recursive=False))
|
||||
continue
|
||||
labels: list[str] = []
|
||||
for proposal in frame.proposals:
|
||||
label = proposal.semantic_hint or "объект"
|
||||
confidence = f"{proposal.objectness * 100:.0f}%"
|
||||
if data.include_ranges:
|
||||
distance = frame.ranges_m[proposal.proposal_id]
|
||||
suffix = "дальность н/д" if distance is None else f"{distance:.1f} м"
|
||||
labels.append(f"{label} · {confidence} · {suffix}")
|
||||
else:
|
||||
labels.append(f"{label} · {confidence}")
|
||||
recording.log(
|
||||
"/perception/camera/detections",
|
||||
rr.Boxes2D(
|
||||
array=[proposal.region.as_tuple() for proposal in frame.proposals],
|
||||
array_format=rr.Box2DFormat.XYXY,
|
||||
labels=labels,
|
||||
colors=[_color(proposal.semantic_hint or "object") for proposal in frame.proposals],
|
||||
show_labels=True,
|
||||
),
|
||||
)
|
||||
recording.set_time("session_time", duration=np.timedelta64(round(data.end_seconds * 1e9), "ns"))
|
||||
recording.log("/perception/camera/detections", rr.Clear(recursive=False))
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -14,6 +14,7 @@ from .portable_artifact_transport import (
|
||||
from .portable_result_contract import PortableResultPublisherError
|
||||
from .portable_result_publisher import PortableObservatoryResultPublisher
|
||||
from .recorded_jobs import (
|
||||
ObservatoryRecordedJob,
|
||||
ObservatoryRecordedJobQueue,
|
||||
ObservatoryRecordedQueueError,
|
||||
)
|
||||
@@ -55,20 +56,19 @@ class PortablePublicationReconciler:
|
||||
now = self.clock()
|
||||
if now.tzinfo is None:
|
||||
raise ValueError("publication reconciliation clock must be timezone-aware")
|
||||
candidates = self.queue.pending_publications()[:limit]
|
||||
examined = 0
|
||||
published = 0
|
||||
failed = 0
|
||||
deferred = 0
|
||||
exhausted = 0
|
||||
for job in candidates:
|
||||
for job in self._candidates():
|
||||
examined += 1
|
||||
attempts = job.publication_attempts
|
||||
if attempts >= self.maximum_attempts:
|
||||
exhausted += 1
|
||||
continue
|
||||
updated_at = _timestamp(job.updated_at_utc)
|
||||
retry_at = updated_at + timedelta(
|
||||
seconds=self.retry_delays_seconds[attempts]
|
||||
)
|
||||
retry_at = updated_at + timedelta(seconds=self.retry_delays_seconds[attempts])
|
||||
if now < retry_at:
|
||||
deferred += 1
|
||||
continue
|
||||
@@ -82,24 +82,37 @@ class PortablePublicationReconciler:
|
||||
with suppress(ObservatoryRecordedQueueError):
|
||||
self.queue.mark_publication_failed(job.job_id, message=message)
|
||||
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(
|
||||
examined=len(candidates),
|
||||
examined=examined,
|
||||
published=published,
|
||||
failed=failed,
|
||||
deferred=deferred,
|
||||
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:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ObservatoryRecordedQueueError(
|
||||
"recorded publication timestamp is invalid"
|
||||
) from exc
|
||||
raise ObservatoryRecordedQueueError("recorded publication timestamp is invalid") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise ObservatoryRecordedQueueError(
|
||||
"recorded publication timestamp has no timezone"
|
||||
)
|
||||
raise ObservatoryRecordedQueueError("recorded publication timestamp has no timezone")
|
||||
return parsed
|
||||
|
||||
@@ -160,6 +160,19 @@ class PortableRecordedQueueBindingService:
|
||||
source_service = self._source_service(portable)
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -25,7 +25,18 @@ from k1link.laboratory.canonical_rerun_overlay import (
|
||||
canonical_lab_replay,
|
||||
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_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 (
|
||||
PortableReplayError,
|
||||
load_tgs_data,
|
||||
@@ -39,7 +50,11 @@ from k1link.sessions.media import RecordedMediaEpoch, RecordedMediaInspector
|
||||
|
||||
RENDERER_VERSION = "portable-tgs-camera-rerun-0.36.3-v1"
|
||||
_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()
|
||||
|
||||
|
||||
@@ -65,7 +80,16 @@ class PortableReplayService:
|
||||
if _RESULT.fullmatch(result_id) is None or _SHA.fullmatch(base_sha) is None:
|
||||
raise PortableReplayError("unsupported replay identity")
|
||||
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
|
||||
|
||||
def cached(self, result_id: str, base_sha: str) -> CanonicalLabReplayArtifact | None:
|
||||
@@ -141,15 +165,35 @@ class PortableReplayService:
|
||||
):
|
||||
raise PortableReplayError("source catalog changed since calculation")
|
||||
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] + [
|
||||
epoch.timeline_start_seconds + part.end_time_seconds for part in epoch.segments[:-1]
|
||||
]
|
||||
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("camera and costmap anchor clocks disagree")
|
||||
tgs = None
|
||||
semantics = None
|
||||
objects = None
|
||||
if result_id.startswith("m49-tgs-portable-review-"):
|
||||
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])
|
||||
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:
|
||||
@@ -162,7 +206,12 @@ class PortableReplayService:
|
||||
try:
|
||||
recording.set_sinks(rr.FileSink(output, write_footer=True))
|
||||
_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)
|
||||
finally:
|
||||
recording.disconnect()
|
||||
@@ -188,6 +237,191 @@ class PortableReplayService:
|
||||
os.replace(staged, self.cache / f"{key}.json")
|
||||
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:
|
||||
camera = bundle["camera"]
|
||||
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_DISPLAY_NAME: Final = "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39"
|
||||
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}$")
|
||||
_OBSERVATION_ONLY_AUTHORITY: Final = {
|
||||
@@ -63,6 +71,38 @@ _SETUP_PRESENTATION: Final = {
|
||||
"incompatible": "Запись не соответствует требованиям TRAVEL TGS.",
|
||||
"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 = {
|
||||
|
||||
@@ -21,6 +21,10 @@ from k1link.observatory.m49_portable_result import (
|
||||
M49_PORTABLE_RESULT_CONTRACT_SHA256,
|
||||
validate_m49_portable_result,
|
||||
)
|
||||
from k1link.observatory.modular_result import (
|
||||
MODULAR_RESULT_CONTRACT_SHA256,
|
||||
validate_modular_result,
|
||||
)
|
||||
from k1link.observatory.portable_artifact_transport import (
|
||||
PortableArtifactTransportError,
|
||||
PortableObservatoryArtifactTransport,
|
||||
@@ -55,9 +59,7 @@ OBSERVATORY_WORKER_SOURCE_CAS_ROOT_ENV: Final = "MISSIONCORE_OBSERVATORY_WORKER_
|
||||
OBSERVATORY_WORKER_RESULT_STAGING_ROOT_ENV: Final = (
|
||||
"MISSIONCORE_OBSERVATORY_WORKER_RESULT_STAGING_ROOT"
|
||||
)
|
||||
OBSERVATORY_WORKER_LOCAL_ENABLED_ENV: Final = (
|
||||
"MISSIONCORE_OBSERVATORY_WORKER_LOCAL_ENABLED"
|
||||
)
|
||||
OBSERVATORY_WORKER_LOCAL_ENABLED_ENV: Final = "MISSIONCORE_OBSERVATORY_WORKER_LOCAL_ENABLED"
|
||||
|
||||
|
||||
class PortableWorkerIntegrationError(RuntimeError):
|
||||
@@ -162,6 +164,10 @@ _BUILTIN_VALIDATOR_REGISTRATIONS: Final = (
|
||||
contract_sha256=M49_PORTABLE_RESULT_CONTRACT_SHA256,
|
||||
validator=validate_m49_portable_result,
|
||||
),
|
||||
PortableResultContractValidatorRegistration(
|
||||
contract_sha256=MODULAR_RESULT_CONTRACT_SHA256,
|
||||
validator=validate_modular_result,
|
||||
),
|
||||
)
|
||||
_BUILTIN_VALIDATORS_BY_CONTRACT: Final = {
|
||||
registration.contract_sha256: registration.validator
|
||||
@@ -172,26 +178,23 @@ _BUILTIN_VALIDATORS_BY_CONTRACT: Final = {
|
||||
def portable_result_validator_registry(
|
||||
definitions: PortableRunDefinitionRegistry,
|
||||
*,
|
||||
registrations: tuple[PortableResultContractValidatorRegistration, ...]
|
||||
| None = None,
|
||||
registrations: tuple[PortableResultContractValidatorRegistration, ...] | None = None,
|
||||
) -> PortableResultContractValidatorRegistry:
|
||||
"""Select server-installed validators by exact result-contract identity."""
|
||||
|
||||
installed = (
|
||||
_BUILTIN_VALIDATOR_REGISTRATIONS
|
||||
if registrations is None
|
||||
else registrations
|
||||
)
|
||||
installed = _BUILTIN_VALIDATOR_REGISTRATIONS if registrations is None else registrations
|
||||
by_contract = {
|
||||
registration.contract_sha256: registration
|
||||
for registration in PortableResultContractValidatorRegistry(installed).registrations
|
||||
}
|
||||
selected = tuple(
|
||||
by_contract[definition.result_contract.contract_sha256]
|
||||
for definition in definitions.definitions
|
||||
if definition.result_contract.contract_sha256 in by_contract
|
||||
)
|
||||
registry = PortableResultContractValidatorRegistry(selected)
|
||||
selected: list[PortableResultContractValidatorRegistration] = []
|
||||
selected_contracts: set[str] = set()
|
||||
for definition in definitions.definitions:
|
||||
contract_sha256 = definition.result_contract.contract_sha256
|
||||
if contract_sha256 in by_contract and contract_sha256 not in selected_contracts:
|
||||
selected.append(by_contract[contract_sha256])
|
||||
selected_contracts.add(contract_sha256)
|
||||
registry = PortableResultContractValidatorRegistry(tuple(selected))
|
||||
for definition in definitions.definitions:
|
||||
if definition.executor.ready:
|
||||
try:
|
||||
@@ -287,8 +290,7 @@ def build_portable_observatory_worker_integration(
|
||||
definition.setup_id
|
||||
for definition in definitions.definitions
|
||||
if any(
|
||||
registration.contract_sha256
|
||||
== definition.result_contract.contract_sha256
|
||||
registration.contract_sha256 == definition.result_contract.contract_sha256
|
||||
for registration in validator_registry.registrations
|
||||
)
|
||||
),
|
||||
|
||||
@@ -575,9 +575,7 @@ class ObservatoryRecordedJob:
|
||||
self.claim_heartbeat_at_utc,
|
||||
)
|
||||
if (self.active_claim_token is None) != (self.active_claimant_id is None):
|
||||
raise ObservatoryRecordedQueueIntegrityError(
|
||||
"recorded-job claim ownership is partial"
|
||||
)
|
||||
raise ObservatoryRecordedQueueIntegrityError("recorded-job claim ownership is partial")
|
||||
if self.active_claim_token is None:
|
||||
if any(value is not None for value in lease_values) or self.claim_renewal_count != 0:
|
||||
raise ObservatoryRecordedQueueIntegrityError(
|
||||
@@ -654,9 +652,7 @@ class ObservatoryRecordedJob:
|
||||
and self.publication_error is None
|
||||
and self.published_at_utc is None
|
||||
),
|
||||
"pending": (
|
||||
self.publication_error is None and self.published_at_utc is None
|
||||
),
|
||||
"pending": (self.publication_error is None and self.published_at_utc is None),
|
||||
"failed": (
|
||||
self.publication_attempts >= 1
|
||||
and self.publication_error is not None
|
||||
@@ -673,9 +669,7 @@ class ObservatoryRecordedJob:
|
||||
"recorded-job publication receipt is inconsistent"
|
||||
)
|
||||
if self.publication_state != "not-required" and (
|
||||
self.state != "succeeded"
|
||||
or self.result_id is None
|
||||
or self.result_sha256 is None
|
||||
self.state != "succeeded" or self.result_id is None or self.result_sha256 is None
|
||||
):
|
||||
raise ObservatoryRecordedQueueIntegrityError(
|
||||
"recorded-job publication lifecycle has no execution result"
|
||||
@@ -854,10 +848,7 @@ class ObservatoryRecordedReconciliationRequest:
|
||||
_validate_text(self.reason, "reconciliation reason", max_length=1_000)
|
||||
if self.resource_release_attestation.job_id != self.job_id:
|
||||
raise ValueError("resource-release attestation is bound to another job")
|
||||
if (
|
||||
self.resource_release_attestation.claim_generation
|
||||
!= self.expected_claim_generation
|
||||
):
|
||||
if self.resource_release_attestation.claim_generation != self.expected_claim_generation:
|
||||
raise ValueError("resource-release attestation is bound to another generation")
|
||||
|
||||
@property
|
||||
@@ -1237,16 +1228,15 @@ class ObservatoryRecordedJobQueue:
|
||||
if reject_duplicate_computation:
|
||||
# This check shares the INSERT transaction: two clients with
|
||||
# different idempotency keys cannot race into duplicate jobs.
|
||||
# A sealed result awaiting publication is not a reason to run
|
||||
# the models again. Published-cache validity is a separate gate.
|
||||
# One sealed recording/profile version is calculated once after
|
||||
# it has started successfully. Failed attempts remain as audit
|
||||
# evidence and may be resubmitted as a fresh queue attempt.
|
||||
duplicate = connection.execute(
|
||||
"SELECT job_id FROM observatory_recorded_jobs "
|
||||
"WHERE identity_sha256 = ? AND ("
|
||||
"state IN ('accepted', 'queued', 'claimed', 'running', 'paused', "
|
||||
"'preemption-pending', 'reconciliation-required') OR "
|
||||
"(state = 'succeeded' AND publication_state IN ('pending', 'failed'))) "
|
||||
"WHERE source_session_id = ? AND setup_id = ? "
|
||||
"AND definition_sha256 = ? AND state != 'failed' "
|
||||
"ORDER BY created_at_utc DESC, job_id DESC LIMIT 1",
|
||||
(identity_sha256,),
|
||||
(intent.source_session_id, definition.setup_id, definition.definition_sha256),
|
||||
).fetchone()
|
||||
if duplicate is not None:
|
||||
raise ObservatoryRecordedQueueDuplicateError(duplicate["job_id"])
|
||||
@@ -1361,12 +1351,13 @@ class ObservatoryRecordedJobQueue:
|
||||
grant_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM observatory_recorded_claim_grants_v3"
|
||||
).fetchone()[0]
|
||||
job_counts = dict(connection.execute(
|
||||
"SELECT state, COUNT(*) FROM observatory_recorded_jobs GROUP BY state"
|
||||
).fetchall())
|
||||
job_counts = dict(
|
||||
connection.execute(
|
||||
"SELECT state, COUNT(*) FROM observatory_recorded_jobs GROUP BY state"
|
||||
).fetchall()
|
||||
)
|
||||
live_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM observatory_live_leases "
|
||||
"WHERE state IN ('pending', 'active')"
|
||||
"SELECT COUNT(*) FROM observatory_live_leases WHERE state IN ('pending', 'active')"
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"schema_version": "missioncore.observatory-claim-readiness/v1",
|
||||
@@ -1622,7 +1613,11 @@ class ObservatoryRecordedJobQueue:
|
||||
return self._get_job(connection, job_id)
|
||||
|
||||
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,
|
||||
) -> None:
|
||||
"""Replace one small observation, fenced in the ownership transaction."""
|
||||
@@ -1665,10 +1660,11 @@ class ObservatoryRecordedJobQueue:
|
||||
job = self._get_job(connection, job_id)
|
||||
row = connection.execute(
|
||||
"SELECT snapshot_json, received_at_utc FROM observatory_recorded_progress "
|
||||
"WHERE job_id = ?", (job_id,),
|
||||
"WHERE job_id = ?",
|
||||
(job_id,),
|
||||
).fetchone()
|
||||
progress = None if row is None else RecordedProgress.model_validate_json(
|
||||
row["snapshot_json"]
|
||||
progress = (
|
||||
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:
|
||||
progress = None
|
||||
@@ -1681,10 +1677,15 @@ class ObservatoryRecordedJobQueue:
|
||||
"claim_generation": job.claim_generation,
|
||||
"state": job.state,
|
||||
"received_at_utc": None if progress is None else row["received_at_utc"],
|
||||
"age_seconds": None if progress is None else max(0.0, (
|
||||
_parse_timestamp(self._timestamp(), "clock")
|
||||
- _parse_timestamp(row["received_at_utc"], "progress receipt")
|
||||
).total_seconds()),
|
||||
"age_seconds": None
|
||||
if progress is None
|
||||
else max(
|
||||
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"),
|
||||
}
|
||||
|
||||
@@ -1720,10 +1721,7 @@ class ObservatoryRecordedJobQueue:
|
||||
with self._transaction() as connection:
|
||||
job = self._get_job(connection, job_id)
|
||||
self._require_active_claim(job, claim_token, now=self._timestamp())
|
||||
if (
|
||||
job.claim_generation != claim_generation
|
||||
or job.active_claimant_id != claimant_id
|
||||
):
|
||||
if job.claim_generation != claim_generation or job.active_claimant_id != claimant_id:
|
||||
raise ObservatoryRecordedQueueStaleClaimError(
|
||||
"recorded-job claim ownership is stale"
|
||||
)
|
||||
@@ -1756,8 +1754,7 @@ class ObservatoryRecordedJobQueue:
|
||||
|
||||
with self._transaction() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM observatory_recorded_reconciliations "
|
||||
"WHERE reconciliation_id = ?",
|
||||
"SELECT * FROM observatory_recorded_reconciliations WHERE reconciliation_id = ?",
|
||||
(request.reconciliation_id,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
@@ -2060,9 +2057,7 @@ class ObservatoryRecordedJobQueue:
|
||||
with self._transaction() as connection:
|
||||
job = self._get_job(connection, job_id)
|
||||
if job.state != "succeeded":
|
||||
raise ObservatoryRecordedQueueConflictError(
|
||||
"recorded execution has not succeeded"
|
||||
)
|
||||
raise ObservatoryRecordedQueueConflictError("recorded execution has not succeeded")
|
||||
if job.publication_state == "published":
|
||||
return job
|
||||
if job.publication_state not in {"pending", "failed"}:
|
||||
@@ -2080,15 +2075,39 @@ class ObservatoryRecordedJobQueue:
|
||||
)
|
||||
return self._get_job(connection, job_id)
|
||||
|
||||
def pending_publications(self) -> tuple[ObservatoryRecordedJob, ...]:
|
||||
"""Return durable outbox entries in deterministic retry order."""
|
||||
def pending_publications(
|
||||
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:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM observatory_recorded_jobs "
|
||||
"WHERE publication_state IN ('pending', 'failed') "
|
||||
"ORDER BY created_at_utc, job_id"
|
||||
).fetchall()
|
||||
rows = connection.execute(query, parameters).fetchall()
|
||||
return tuple(_job_from_row(row) for row in rows)
|
||||
|
||||
def fail(
|
||||
@@ -2187,8 +2206,12 @@ class ObservatoryRecordedJobQueue:
|
||||
return tuple(_job_from_row(row) for row in rows)
|
||||
|
||||
def published_results(
|
||||
self, *, source_session_id: str, source_catalog_sha256: str,
|
||||
setup_id: str, definition_sha256: str,
|
||||
self,
|
||||
*,
|
||||
source_session_id: str,
|
||||
source_catalog_sha256: str,
|
||||
setup_id: str,
|
||||
definition_sha256: str,
|
||||
) -> tuple[ObservatoryRecordedJob, ...]:
|
||||
"""Exact cache candidates, never inferred from labels or a truncated job page."""
|
||||
|
||||
@@ -2527,11 +2550,10 @@ class ObservatoryRecordedJobQueue:
|
||||
)
|
||||
if exact_replay:
|
||||
return job
|
||||
if (
|
||||
job.terminal_claim_token_sha256 != token_sha256
|
||||
or job.terminal_code
|
||||
in {"claim-lease-expired", "claim-lease-migration"}
|
||||
):
|
||||
if job.terminal_claim_token_sha256 != token_sha256 or job.terminal_code in {
|
||||
"claim-lease-expired",
|
||||
"claim-lease-migration",
|
||||
}:
|
||||
raise ObservatoryRecordedQueueStaleClaimError(
|
||||
"recorded-job terminal acknowledgement is stale"
|
||||
)
|
||||
@@ -2618,9 +2640,7 @@ class ObservatoryRecordedJobQueue:
|
||||
or _parse_timestamp(now, "queue timestamp")
|
||||
>= _parse_timestamp(job.claim_expires_at_utc, "claim expiry")
|
||||
):
|
||||
raise ObservatoryRecordedQueueStaleClaimError(
|
||||
"recorded-job claim lease expired"
|
||||
)
|
||||
raise ObservatoryRecordedQueueStaleClaimError("recorded-job claim lease expired")
|
||||
|
||||
def _recover_stale_claims(
|
||||
self,
|
||||
@@ -3162,10 +3182,7 @@ def _reconciliation_receipt_from_row(
|
||||
evidence_sha256=row["resource_release_evidence_sha256"],
|
||||
attested_at_utc=row["resource_release_attested_at_utc"],
|
||||
)
|
||||
if (
|
||||
attestation.attestation_sha256
|
||||
!= row["resource_release_attestation_sha256"]
|
||||
):
|
||||
if attestation.attestation_sha256 != row["resource_release_attestation_sha256"]:
|
||||
raise ObservatoryRecordedQueueIntegrityError(
|
||||
"stored resource-release attestation identity changed"
|
||||
)
|
||||
@@ -3433,9 +3450,7 @@ def _validate_claim_lease_seconds(value: object) -> None:
|
||||
if (
|
||||
not isinstance(value, int)
|
||||
or isinstance(value, bool)
|
||||
or not MIN_RECORDED_CLAIM_LEASE_SECONDS
|
||||
<= value
|
||||
<= MAX_RECORDED_CLAIM_LEASE_SECONDS
|
||||
or not MIN_RECORDED_CLAIM_LEASE_SECONDS <= value <= MAX_RECORDED_CLAIM_LEASE_SECONDS
|
||||
):
|
||||
raise ValueError("recorded-job claim lease duration is invalid")
|
||||
|
||||
|
||||
@@ -340,6 +340,20 @@ class RecordedK1SourceAdmissionService:
|
||||
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(
|
||||
self,
|
||||
source_session_id: str,
|
||||
@@ -606,14 +620,10 @@ class RecordedK1SourceAdmissionService:
|
||||
catalog_artifact = catalog_artifacts.get(replay_artifact.artifact_id)
|
||||
exact_metadata_member = (
|
||||
catalog_artifact is not None
|
||||
and replay_artifact.artifact_id
|
||||
== PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
||||
and catalog_artifact.kind
|
||||
== PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
||||
and replay_artifact.media_type
|
||||
== PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
||||
and catalog_artifact.media_type
|
||||
== PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
||||
and replay_artifact.artifact_id == PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
||||
and catalog_artifact.kind == PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
||||
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 = (
|
||||
replay_artifact.expected_sha256 == catalog_artifact.sha256
|
||||
@@ -854,13 +864,10 @@ def _seal_replay_artifact_digests(
|
||||
catalog_artifact = catalog_artifacts.get(artifact.artifact_id)
|
||||
if (
|
||||
catalog_artifact is None
|
||||
or artifact.artifact_id
|
||||
!= PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
||||
or catalog_artifact.kind
|
||||
!= PORTABLE_SPATIAL_REPLAY_METADATA_ARTIFACT_ID
|
||||
or artifact.artifact_id != 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 catalog_artifact.media_type
|
||||
!= PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
||||
or catalog_artifact.media_type != PORTABLE_SPATIAL_REPLAY_METADATA_MEDIA_TYPE
|
||||
or catalog_artifact.sha256 is not None
|
||||
or catalog_artifact.integrity_status not in _SEALED_ARTIFACT_STATES
|
||||
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