Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e436fb56d4 | ||
|
|
c2710b55a7 | ||
|
|
d777f3198b | ||
|
|
1e4ddc2cff | ||
|
|
80fc0058cb | ||
|
|
43f1cdc862 | ||
|
|
0041e9fadb | ||
|
|
cabd3081c7 | ||
|
|
049ed3eed4 | ||
|
|
35c1249631 | ||
|
|
941183d624 | ||
|
|
6af4c208ce | ||
|
|
75a5320756 | ||
|
|
59a74a3677 | ||
|
|
35d28418f3 | ||
|
|
48835a0499 | ||
|
|
93ae1edbb4 | ||
|
|
093df8b38f | ||
|
|
35b6cd9e9e | ||
|
|
d80df61a7e | ||
|
|
d7b8989e43 | ||
|
|
0cc6b96306 | ||
|
|
6469505c41 | ||
|
|
191612282b | ||
|
|
0310592ee3 | ||
|
|
df0158e1ba | ||
|
|
2ef8c08bf2 | ||
|
|
c570f7d938 | ||
|
|
03e8e71bea | ||
|
|
61cbdb30a0 | ||
|
|
e91721fe6a | ||
|
|
70927ea585 | ||
|
|
03a7e730ae | ||
|
|
c31c46caa5 | ||
|
|
06a66a3da8 | ||
|
|
92625bf48b | ||
|
|
b8ba0cae1e | ||
|
|
f39f1ff0b6 | ||
|
|
380b6eaba7 | ||
|
|
f6d73d8bfb | ||
|
|
221e429c0a | ||
|
|
a85aed2e03 | ||
|
|
f102712f0b | ||
|
|
350366bae0 | ||
|
|
c23e86fd39 | ||
|
|
79ce55d197 | ||
|
|
a937400942 | ||
|
|
da60feff90 | ||
|
|
85349f42f9 | ||
|
|
ac69e3b444 | ||
|
|
6acf468b25 | ||
|
|
d264cbce04 | ||
|
|
1f8101e4a5 | ||
|
|
bcacb02a22 | ||
|
|
adcca7aa81 | ||
|
|
097e450a87 | ||
|
|
4e633a662c | ||
|
|
c6c42c29e8 | ||
|
|
ce09d30c1a | ||
|
|
34f13ba59e | ||
|
|
d9ea7c1129 | ||
|
|
a3c67e249c | ||
|
|
294585936f | ||
|
|
380a13688c | ||
|
|
fd8313899b | ||
|
|
05c99efb68 | ||
|
|
ffd6be5606 | ||
|
|
bfe6ef4c77 | ||
|
|
4e04d06191 | ||
|
|
5a78c997ac | ||
|
|
62d5520c7a | ||
|
|
a945d665dd | ||
|
|
d655d6998d | ||
|
|
7025e173a3 | ||
|
|
10fe727561 | ||
|
|
00ee5f163a | ||
|
|
cbcc09afd6 | ||
|
|
e958729e9d | ||
|
|
0fcd037e4f | ||
|
|
11a146011f | ||
|
|
44ebbe7ca2 | ||
|
|
a3299dcfc3 | ||
|
|
2989fba99c | ||
|
|
142481f0d8 | ||
|
|
b2ae29b8d8 | ||
|
|
8eced6c34d | ||
|
|
732cc139fd | ||
|
|
104e54f4dd | ||
|
|
7b021782d3 | ||
|
|
759830b054 | ||
|
|
19c0bd70d0 | ||
|
|
35d99e40d5 | ||
|
|
bfc1f1bbed | ||
|
|
f41428f907 | ||
|
|
3744d79ad3 | ||
|
|
b57c3e94da | ||
|
|
73a7d28065 | ||
|
|
f64fbb1fa6 | ||
|
|
1ff527b264 | ||
|
|
def8c71410 | ||
|
|
7e59176581 | ||
|
|
4cd94b5805 | ||
|
|
1766c0bdb2 | ||
|
|
56bfeaee7b | ||
|
|
892d0085e3 | ||
|
|
e2de8188ab | ||
|
|
9c69d81296 | ||
|
|
2f6e45bc96 | ||
|
|
8a1c578e0e | ||
|
|
9beb534108 | ||
|
|
d1b75efcea | ||
|
|
c9302c78c2 | ||
|
|
d67e86176e | ||
|
|
e72609120f | ||
|
|
177be8869f | ||
|
|
2a5763d3fb | ||
|
|
8d5aeb0533 | ||
|
|
be83283ee0 | ||
|
|
023151c186 | ||
|
|
e6a9846167 | ||
|
|
81fdf6904a | ||
|
|
9c5259dbc9 | ||
|
|
35daf73d5c | ||
|
|
07453142c2 | ||
|
|
f5ee42751d |
Generated
+4
-5
@@ -7,7 +7,6 @@
|
||||
"": {
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
@@ -15,7 +14,7 @@
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"@rerun-io/web-viewer": "0.36.3",
|
||||
"meshoptimizer": "1.1.1",
|
||||
"playcanvas": "2.21.4",
|
||||
"react": "^19.1.0",
|
||||
@@ -900,9 +899,9 @@
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@rerun-io/web-viewer": {
|
||||
"version": "0.34.1",
|
||||
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.34.1.tgz",
|
||||
"integrity": "sha512-2Oq9Mw3qOs765XArGq4e/0pAIksPFKlAqkgo+PDsRkPd77/KdnQhb835suRmYQ6tuqlbPeVCFhlNIXT0+TjmTg==",
|
||||
"version": "0.36.3",
|
||||
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
|
||||
"integrity": "sha512-LMGnsxRmY5UwiGras2dZrMnEYkow5Xr4v+1hAUSspXWPPiilMqoz9G77jo8Ps/deAaX81TnOq123DFO8iX/Ulw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"postinstall": "node scripts/patch-rerun-web-viewer.mjs",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
@@ -18,7 +17,7 @@
|
||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||
"@rerun-io/web-viewer": "0.34.1",
|
||||
"@rerun-io/web-viewer": "0.36.3",
|
||||
"meshoptimizer": "1.1.1",
|
||||
"playcanvas": "2.21.4",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -806,6 +806,8 @@ export default function App() {
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
laboratoryAnnotation.control
|
||||
) : activeDefinition.kind === "observatory" ? (
|
||||
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
|
||||
) : activeDefinition.root === "system" ? (
|
||||
<SystemWorkspaceSelector
|
||||
value={activeDefinition.id}
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
RECORDED_BASE_POINT_COLOR_KEY,
|
||||
canPublishRecordedPlaybackController,
|
||||
createRecordedAutoplayGate,
|
||||
createRecordedInitialSeekGate,
|
||||
createRecordedOpenWatchdog,
|
||||
} from "../core/observation/recordedRerunLifecycle";
|
||||
import type {
|
||||
@@ -59,6 +60,7 @@ export {
|
||||
attemptRecordedAutoplay,
|
||||
canPublishRecordedPlaybackController,
|
||||
createRecordedAutoplayGate,
|
||||
createRecordedInitialSeekGate,
|
||||
createRecordedOpenWatchdog,
|
||||
isRecordedPlaybackFullyBuffered,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
@@ -143,14 +145,30 @@ interface RerunBlueprintChannel {
|
||||
};
|
||||
}
|
||||
|
||||
interface RerunNativeReceiver {
|
||||
endpointUrl: string;
|
||||
ready: () => boolean;
|
||||
open: (sourceUrl: string) => void;
|
||||
close: (sourceUrl: string) => void;
|
||||
}
|
||||
|
||||
interface LoadedNativePerceptionSource {
|
||||
receiver: RerunNativeReceiver;
|
||||
descriptorUrl: string;
|
||||
sourceUrl: string;
|
||||
byteLength: number;
|
||||
}
|
||||
|
||||
interface RecordedRerunIdentity {
|
||||
applicationId: "nodedc_mission_core_recorded";
|
||||
recordingId: string;
|
||||
}
|
||||
|
||||
const RECORDED_RRD_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
|
||||
const LAB_RECORDED_REPLAY_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-replay\.rrd$/;
|
||||
const RECORDED_BLUEPRINT_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/;
|
||||
const RECORDED_PERCEPTION_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/perception\.rrd$/;
|
||||
const LAB_RECORDED_PERCEPTION_PATH = /^\/api\/v1\/laboratory\/vegetation-shadow\/lab-v1-vegetation-shadow-[a-f0-9]{64}\/canonical-overlay\.rrd$/;
|
||||
const RECORDED_POINT_COLORS_PATH = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/point-colors\.rrd$/;
|
||||
const MAX_BLUEPRINT_BYTES = 1_048_576;
|
||||
const MAX_PERCEPTION_BYTES = 512 * 1024 * 1024;
|
||||
@@ -203,12 +221,7 @@ export function resolveRerunSourceUrl(sourceUrl: string, origin: string): string
|
||||
return new URL(normalized, `${base.origin}/`).href;
|
||||
}
|
||||
|
||||
/**
|
||||
* The native Rerun HTTP receiver is the only browser API in 0.34.1 with a
|
||||
* persistent incremental RRD decoder. Bind it to the exact immutable
|
||||
* generation selected by the launch contract; arbitrary `send_rrd` byte
|
||||
* slices are independently decoded files and are therefore invalid.
|
||||
*/
|
||||
/** Bind the upstream Rerun HTTP receiver to one immutable source generation. */
|
||||
export function resolveRecordedViewerSourceUrl(
|
||||
descriptor: RecordedRrdArtifactDescriptor,
|
||||
origin: string,
|
||||
@@ -217,7 +230,8 @@ export function resolveRecordedViewerSourceUrl(
|
||||
const expectedViewerSourceUrl = `${descriptor.sourceUrl}?generation=${descriptor.sha256}`;
|
||||
const endpoint = new URL(descriptor.viewerSourceUrl, `${base.origin}/`);
|
||||
if (
|
||||
!RECORDED_RRD_PATH.test(descriptor.sourceUrl) ||
|
||||
!(RECORDED_RRD_PATH.test(descriptor.sourceUrl)
|
||||
|| LAB_RECORDED_REPLAY_PATH.test(descriptor.sourceUrl)) ||
|
||||
descriptor.viewerSourceUrl !== expectedViewerSourceUrl ||
|
||||
endpoint.origin !== base.origin ||
|
||||
endpoint.pathname !== descriptor.sourceUrl ||
|
||||
@@ -238,16 +252,23 @@ export function rerunViewerInitialSource(
|
||||
return resolvedSourceUrl;
|
||||
}
|
||||
|
||||
export function rerunViewerOpenOptions(
|
||||
followLive: boolean,
|
||||
): { follow_if_http: true } | null {
|
||||
return followLive ? { follow_if_http: true } : null;
|
||||
}
|
||||
|
||||
export function resolveRecordedBlueprintUrl(sourceUrl: string, origin: string): string | null {
|
||||
export function resolveRecordedBlueprintUrl(
|
||||
sourceUrl: string,
|
||||
origin: string,
|
||||
explicitSourceUrl?: string,
|
||||
): string | null {
|
||||
const normalized = sourceUrl.trim();
|
||||
if (!RECORDED_RRD_PATH.test(normalized)) return null;
|
||||
const base = new URL(origin);
|
||||
if (explicitSourceUrl !== undefined) {
|
||||
const explicit = explicitSourceUrl.trim();
|
||||
if (
|
||||
!LAB_RECORDED_REPLAY_PATH.test(normalized)
|
||||
|| !RECORDED_BLUEPRINT_PATH.test(explicit)
|
||||
) return null;
|
||||
const endpoint = new URL(explicit, `${base.origin}/`);
|
||||
return endpoint.origin === base.origin ? endpoint.href : null;
|
||||
}
|
||||
if (!RECORDED_RRD_PATH.test(normalized)) return null;
|
||||
const endpoint = new URL(
|
||||
normalized.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
||||
`${base.origin}/`,
|
||||
@@ -365,6 +386,9 @@ export async function fetchRecordedBlueprintRrd(
|
||||
activeView = "spatial",
|
||||
viewResetGeneration = 0,
|
||||
followTrajectory = false,
|
||||
semanticLayer,
|
||||
unifiedPerception,
|
||||
planView = false,
|
||||
perceptionLayers = {
|
||||
enabled: false,
|
||||
detections2d: false,
|
||||
@@ -379,10 +403,15 @@ export async function fetchRecordedBlueprintRrd(
|
||||
activeView?: RecordedRerunView;
|
||||
viewResetGeneration?: 0 | 1;
|
||||
followTrajectory?: boolean;
|
||||
semanticLayer?: "city" | "vegetation";
|
||||
unifiedPerception?: boolean;
|
||||
planView?: boolean;
|
||||
perceptionLayers?: RecordedPerceptionLayers;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
): Promise<Uint8Array> {
|
||||
const resolvedUnifiedPerception =
|
||||
unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial");
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(endpointUrl, base.origin);
|
||||
if (
|
||||
@@ -401,6 +430,9 @@ export async function fetchRecordedBlueprintRrd(
|
||||
!/^#[0-9A-Fa-f]{6}$/.test(settings.customColor) ||
|
||||
!["spatial", "perception", "perception3d", "metrics"].includes(activeView) ||
|
||||
![0, 1].includes(viewResetGeneration) ||
|
||||
(semanticLayer !== undefined && !["city", "vegetation"].includes(semanticLayer)) ||
|
||||
typeof resolvedUnifiedPerception !== "boolean" ||
|
||||
typeof planView !== "boolean" ||
|
||||
[
|
||||
perceptionLayers.enabled,
|
||||
perceptionLayers.detections2d,
|
||||
@@ -435,8 +467,9 @@ export async function fetchRecordedBlueprintRrd(
|
||||
active_view: activeView,
|
||||
view_reset_generation: viewResetGeneration,
|
||||
follow_trajectory: followTrajectory,
|
||||
unified_perception:
|
||||
perceptionLayers.detections2d || perceptionLayers.segmentation,
|
||||
unified_perception: resolvedUnifiedPerception,
|
||||
semantic_layer: semanticLayer ?? null,
|
||||
plan_view: planView,
|
||||
show_detections_2d: perceptionLayers.detections2d,
|
||||
show_segmentation: perceptionLayers.segmentation,
|
||||
show_cuboids_3d: perceptionLayers.cuboids3d,
|
||||
@@ -487,7 +520,8 @@ export async function fetchRecordedPerceptionRrd(
|
||||
endpoint.origin !== base.origin ||
|
||||
endpoint.search ||
|
||||
endpoint.hash ||
|
||||
!RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
|
||||
!(RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
|
||||
LAB_RECORDED_PERCEPTION_PATH.test(endpoint.pathname)) ||
|
||||
identity.applicationId !== "nodedc_mission_core_recorded" ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId)
|
||||
) {
|
||||
@@ -554,6 +588,87 @@ export async function fetchRecordedPerceptionRrd(
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Bind a LAB sidecar to the exact immutable base recording generation. */
|
||||
export function resolveRecordedPerceptionViewerSourceUrl(
|
||||
endpointUrl: string,
|
||||
identity: RecordedRerunIdentity,
|
||||
baseGenerationSha256: string,
|
||||
origin: string,
|
||||
): string {
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(endpointUrl, base.origin);
|
||||
if (
|
||||
endpoint.origin !== base.origin ||
|
||||
endpoint.search ||
|
||||
endpoint.hash ||
|
||||
!LAB_RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
|
||||
identity.applicationId !== "nodedc_mission_core_recorded" ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(identity.recordingId) ||
|
||||
!/^[a-f0-9]{64}$/.test(baseGenerationSha256)
|
||||
) {
|
||||
throw new Error("Unsafe recorded perception viewer source");
|
||||
}
|
||||
endpoint.searchParams.set("application_id", identity.applicationId);
|
||||
endpoint.searchParams.set("recording_id", identity.recordingId);
|
||||
endpoint.searchParams.set("generation", baseGenerationSha256);
|
||||
return endpoint.href;
|
||||
}
|
||||
|
||||
/** Resolve the immutable overlay generation before Rerun opens its native URL. */
|
||||
export async function probeRecordedPerceptionViewerSource(
|
||||
sourceUrl: string,
|
||||
{
|
||||
origin,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
origin: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
): Promise<{ sourceUrl: string; byteLength: number }> {
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(sourceUrl, base.origin);
|
||||
const allowedParameters = ["application_id", "generation", "recording_id"];
|
||||
if (
|
||||
endpoint.origin !== base.origin ||
|
||||
endpoint.hash ||
|
||||
!LAB_RECORDED_PERCEPTION_PATH.test(endpoint.pathname) ||
|
||||
[...endpoint.searchParams.keys()].sort().join("\0") !== allowedParameters.join("\0") ||
|
||||
endpoint.searchParams.get("application_id") !== "nodedc_mission_core_recorded" ||
|
||||
!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(
|
||||
endpoint.searchParams.get("recording_id") ?? "",
|
||||
) ||
|
||||
!/^[a-f0-9]{64}$/.test(endpoint.searchParams.get("generation") ?? "")
|
||||
) {
|
||||
throw new Error("Unsafe recorded perception viewer source");
|
||||
}
|
||||
const response = await fetcher(endpoint.href, {
|
||||
method: "HEAD",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/vnd.rerun.rrd",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
|
||||
const declaredLength = Number(response.headers.get("Content-Length"));
|
||||
const etag = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
contentType !== "application/vnd.rerun.rrd" ||
|
||||
response.headers.get("X-Rerun-Format") !== "RRF2" ||
|
||||
!etag ||
|
||||
!Number.isSafeInteger(declaredLength) ||
|
||||
declaredLength < 4 ||
|
||||
declaredLength > MAX_PERCEPTION_BYTES
|
||||
) {
|
||||
throw new Error("Invalid recorded perception viewer response");
|
||||
}
|
||||
endpoint.searchParams.set("overlay_generation", etag);
|
||||
return { sourceUrl: endpoint.href, byteLength: declaredLength };
|
||||
}
|
||||
|
||||
export function RerunViewport({
|
||||
profile,
|
||||
onStatusChange,
|
||||
@@ -588,6 +703,11 @@ export function RerunViewport({
|
||||
segmentation: false,
|
||||
cuboids3d: false,
|
||||
};
|
||||
const recordedBlueprintSourceUrl = recordedProfile?.blueprintSourceUrl;
|
||||
const recordedPerceptionSourceUrl = recordedProfile?.perceptionSourceUrl;
|
||||
const recordedSemanticLayer = recordedProfile?.semanticLayer;
|
||||
const recordedUnifiedPerception = recordedProfile?.unifiedPerception ?? false;
|
||||
const recordedPlanView = recordedProfile?.planView ?? false;
|
||||
const recordedPerceptionRetryGeneration =
|
||||
recordedProfile?.perceptionRetryGeneration ?? 0;
|
||||
const lockPerceptionCameraInteraction =
|
||||
@@ -610,7 +730,9 @@ export function RerunViewport({
|
||||
const uiBuildStaleRef = useRef(false);
|
||||
const blueprintChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const perceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const perceptionReceiverRef = useRef<RerunNativeReceiver | null>(null);
|
||||
const loadedPerceptionChannelRef = useRef<RerunBlueprintChannel | null>(null);
|
||||
const loadedNativePerceptionSourceRef = useRef<LoadedNativePerceptionSource | null>(null);
|
||||
const appliedPointColorKeyRef = useRef<string | null>(null);
|
||||
const recordedIdentityRef = useRef<RecordedRerunIdentity | null>(null);
|
||||
const blueprintSessionIdRef = useRef(crypto.randomUUID().replaceAll("-", ""));
|
||||
@@ -619,11 +741,17 @@ export function RerunViewport({
|
||||
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
|
||||
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
|
||||
const recordedBlueprintUrl = sourceUrl
|
||||
? resolveRecordedBlueprintUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const recordedPerceptionUrl = sourceUrl
|
||||
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
|
||||
? resolveRecordedBlueprintUrl(
|
||||
sourceUrl,
|
||||
window.location.origin,
|
||||
recordedBlueprintSourceUrl,
|
||||
)
|
||||
: null;
|
||||
const recordedPerceptionUrl = recordedPerceptionSourceUrl
|
||||
? resolveRerunSourceUrl(recordedPerceptionSourceUrl, window.location.origin)
|
||||
: sourceUrl
|
||||
? resolveRecordedPerceptionUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
const recordedPointColorsUrl = sourceUrl
|
||||
? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin)
|
||||
: null;
|
||||
@@ -663,7 +791,8 @@ export function RerunViewport({
|
||||
return;
|
||||
}
|
||||
|
||||
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource);
|
||||
const isRecordedSource = RECORDED_RRD_PATH.test(normalizedSource)
|
||||
|| LAB_RECORDED_REPLAY_PATH.test(normalizedSource);
|
||||
let resolvedSource: string;
|
||||
try {
|
||||
if (isRecordedSource) {
|
||||
@@ -722,8 +851,10 @@ export function RerunViewport({
|
||||
let publishPlaybackBufferState: (() => void) | null = null;
|
||||
let playbackState: RerunPlaybackState | null = null;
|
||||
const recordedAutoplay = createRecordedAutoplayGate();
|
||||
const recordedInitialSeek = createRecordedInitialSeekGate();
|
||||
let blueprintChannel: RerunBlueprintChannel | null = null;
|
||||
let perceptionChannel: RerunBlueprintChannel | null = null;
|
||||
let perceptionReceiver: RerunNativeReceiver | null = null;
|
||||
const unsubscribers: Array<() => void> = [];
|
||||
const unsubscribeAll = () => {
|
||||
while (unsubscribers.length > 0) {
|
||||
@@ -980,6 +1111,9 @@ export function RerunViewport({
|
||||
if (perceptionChannelRef.current === perceptionChannel) {
|
||||
perceptionChannelRef.current = null;
|
||||
}
|
||||
if (perceptionReceiverRef.current === perceptionReceiver) {
|
||||
perceptionReceiverRef.current = null;
|
||||
}
|
||||
recordedIdentityRef.current = null;
|
||||
try {
|
||||
blueprintChannel?.channel.close();
|
||||
@@ -991,6 +1125,15 @@ export function RerunViewport({
|
||||
} catch {
|
||||
// The viewer may already have closed all auxiliary channels.
|
||||
}
|
||||
const loadedNativeSource = loadedNativePerceptionSourceRef.current;
|
||||
if (loadedNativeSource?.receiver === perceptionReceiver) {
|
||||
loadedNativePerceptionSourceRef.current = null;
|
||||
try {
|
||||
perceptionReceiver?.close(loadedNativeSource.sourceUrl);
|
||||
} catch {
|
||||
// The viewer may already have closed all native receivers.
|
||||
}
|
||||
}
|
||||
}, () => {
|
||||
try {
|
||||
if (viewer.ready) viewer.close(resolvedSource);
|
||||
@@ -1025,9 +1168,9 @@ export function RerunViewport({
|
||||
}) => {
|
||||
if (
|
||||
disposed ||
|
||||
recordingOpened ||
|
||||
(isRecordedSource && event.application_id !== "nodedc_mission_core_recorded")
|
||||
) return;
|
||||
if (recordingOpened) return;
|
||||
recordingOpened = true;
|
||||
if (!isRecordedSource) {
|
||||
// Store discovery only establishes a candidate. Admission is
|
||||
@@ -1195,6 +1338,23 @@ export function RerunViewport({
|
||||
playing = true;
|
||||
}
|
||||
}
|
||||
if (!autoplayWhenReady && !followLive && readyToRender) {
|
||||
recordedInitialSeek.attempt(
|
||||
viewerStartResolved,
|
||||
recordedBuffer.fullyBuffered,
|
||||
presentationReady,
|
||||
rangeNs,
|
||||
(initialStartNs) => {
|
||||
viewer.set_current_time(event.recording_id, timeline, initialStartNs);
|
||||
currentNs = initialStartNs;
|
||||
},
|
||||
initialPlaybackStartSeconds === undefined
|
||||
? expectedTimelineStartSeconds === undefined
|
||||
? undefined
|
||||
: expectedTimelineStartSeconds * 1_000_000_000
|
||||
: initialPlaybackStartSeconds * 1_000_000_000,
|
||||
);
|
||||
}
|
||||
emitPlayback({
|
||||
rangeNs: followLive
|
||||
? rangeNs
|
||||
@@ -1264,7 +1424,7 @@ export function RerunViewport({
|
||||
try {
|
||||
const recordingId = viewer.get_active_recording_id();
|
||||
if (!recordingId) return;
|
||||
// Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its
|
||||
// A live SDK receiver may ingest a store without forwarding its
|
||||
// recording_open event to the JavaScript wrapper. The active store
|
||||
// is the authoritative fallback and avoids hiding a ready canvas.
|
||||
admitRecording({
|
||||
@@ -1311,11 +1471,8 @@ export function RerunViewport({
|
||||
);
|
||||
|
||||
try {
|
||||
// `panel_state_overrides` is part of Rerun's runtime AppOptions but
|
||||
// omitted from the public WebViewerOptions declaration in 0.34.1.
|
||||
// Its generated TypeScript declaration says `hidden`, while the
|
||||
// WASM constructor actually deserializes the Rust enum `Hidden`.
|
||||
// Keep the post-start overrides below as a compatibility fallback.
|
||||
// Keep all application chrome in the Mission Core shell. Rerun owns
|
||||
// the synchronized canvas, data store and clock, not another window.
|
||||
const viewerOptions = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
@@ -1336,7 +1493,6 @@ export function RerunViewport({
|
||||
rerunViewerInitialSource(resolvedSource),
|
||||
host,
|
||||
viewerOptions,
|
||||
rerunViewerOpenOptions(followLive),
|
||||
);
|
||||
if (disposed) {
|
||||
disposeViewer();
|
||||
@@ -1358,9 +1514,19 @@ export function RerunViewport({
|
||||
setBlueprintChannelRevision((revision) => revision + 1);
|
||||
}
|
||||
if (recordedPerceptionUrl) {
|
||||
const channel = viewer.open_channel("missioncore/recorded-perception");
|
||||
perceptionChannel = { endpointUrl: recordedPerceptionUrl, channel };
|
||||
perceptionChannelRef.current = perceptionChannel;
|
||||
if (LAB_RECORDED_PERCEPTION_PATH.test(new URL(recordedPerceptionUrl).pathname)) {
|
||||
perceptionReceiver = {
|
||||
endpointUrl: recordedPerceptionUrl,
|
||||
ready: () => viewer.ready,
|
||||
open: (source) => viewer.open(source),
|
||||
close: (source) => viewer.close(source),
|
||||
};
|
||||
perceptionReceiverRef.current = perceptionReceiver;
|
||||
} else {
|
||||
const channel = viewer.open_channel("missioncore/recorded-perception");
|
||||
perceptionChannel = { endpointUrl: recordedPerceptionUrl, channel };
|
||||
perceptionChannelRef.current = perceptionChannel;
|
||||
}
|
||||
setPerceptionChannelRevision((revision) => revision + 1);
|
||||
}
|
||||
|
||||
@@ -1429,11 +1595,13 @@ export function RerunViewport({
|
||||
recordedArtifact?.sha256,
|
||||
recordedArtifact?.sourceUrl,
|
||||
recordedArtifact?.viewerSourceUrl,
|
||||
recordedPerceptionUrl,
|
||||
retryNonce,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
loadedPerceptionChannelRef.current = null;
|
||||
loadedNativePerceptionSourceRef.current = null;
|
||||
}, [recordedPerceptionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1453,11 +1621,92 @@ export function RerunViewport({
|
||||
}
|
||||
return;
|
||||
}
|
||||
const active = perceptionChannelRef.current;
|
||||
const identity = recordedIdentityRef.current;
|
||||
if (!identity) return;
|
||||
if (LAB_RECORDED_PERCEPTION_PATH.test(new URL(recordedPerceptionUrl).pathname)) {
|
||||
const receiver = perceptionReceiverRef.current;
|
||||
if (
|
||||
!receiver ||
|
||||
receiver.endpointUrl !== recordedPerceptionUrl ||
|
||||
!receiver.ready() ||
|
||||
!recordedArtifact
|
||||
) return;
|
||||
let sourceUrl: string;
|
||||
try {
|
||||
sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
|
||||
recordedPerceptionUrl,
|
||||
identity,
|
||||
recordedArtifact.sha256,
|
||||
window.location.origin,
|
||||
);
|
||||
} catch {
|
||||
onPerceptionLoadChange?.({
|
||||
phase: "error",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "AI-слои не привязаны к поколению записи.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const loaded = loadedNativePerceptionSourceRef.current;
|
||||
if (loaded?.receiver === receiver && loaded.descriptorUrl === sourceUrl) {
|
||||
onPerceptionLoadChange?.({
|
||||
phase: "ready",
|
||||
receivedBytes: loaded.byteLength,
|
||||
totalBytes: loaded.byteLength,
|
||||
progress: 1,
|
||||
message: "AI-слои подключены к Rerun.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const abort = new AbortController();
|
||||
onPerceptionLoadChange?.({
|
||||
phase: "loading",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "Подключаем AI-слои к Rerun.",
|
||||
});
|
||||
void probeRecordedPerceptionViewerSource(sourceUrl, {
|
||||
origin: window.location.origin,
|
||||
signal: abort.signal,
|
||||
}).then(({ sourceUrl: immutableSourceUrl, byteLength }) => {
|
||||
if (
|
||||
abort.signal.aborted ||
|
||||
perceptionReceiverRef.current !== receiver ||
|
||||
recordedIdentityRef.current !== identity ||
|
||||
!receiver.ready()
|
||||
) return;
|
||||
receiver.open(immutableSourceUrl);
|
||||
loadedNativePerceptionSourceRef.current = {
|
||||
receiver,
|
||||
descriptorUrl: sourceUrl,
|
||||
sourceUrl: immutableSourceUrl,
|
||||
byteLength,
|
||||
};
|
||||
onPerceptionLoadChange?.({
|
||||
phase: "ready",
|
||||
receivedBytes: byteLength,
|
||||
totalBytes: byteLength,
|
||||
progress: 1,
|
||||
message: "AI-слои подключены к Rerun.",
|
||||
});
|
||||
}).catch(() => {
|
||||
if (abort.signal.aborted) return;
|
||||
onPerceptionLoadChange?.({
|
||||
phase: "error",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "AI-слои не загрузились. Можно повторить.",
|
||||
});
|
||||
});
|
||||
return () => abort.abort();
|
||||
}
|
||||
const active = perceptionChannelRef.current;
|
||||
if (
|
||||
!active ||
|
||||
!identity ||
|
||||
active.endpointUrl !== recordedPerceptionUrl ||
|
||||
!active.channel.ready
|
||||
) return;
|
||||
@@ -1484,6 +1733,7 @@ export function RerunViewport({
|
||||
message: "Сервер готовит AI-слои.",
|
||||
});
|
||||
const pollPreparationStatus = () => {
|
||||
if (!RECORDED_PERCEPTION_PATH.test(new URL(recordedPerceptionUrl).pathname)) return;
|
||||
void fetchPerceptionPreparationStatus(
|
||||
recordedPerceptionUrl,
|
||||
identity.recordingId,
|
||||
@@ -1597,6 +1847,7 @@ export function RerunViewport({
|
||||
}, [
|
||||
onPerceptionLoadChange,
|
||||
perceptionChannelRevision,
|
||||
recordedArtifact?.sha256,
|
||||
recordedPerceptionLayers.enabled,
|
||||
recordedPerceptionRetryGeneration,
|
||||
recordedPerceptionUrl,
|
||||
@@ -1704,6 +1955,9 @@ export function RerunViewport({
|
||||
viewResetGeneration: recordedViewResetGeneration,
|
||||
followTrajectory: recordedFollowTrajectory,
|
||||
perceptionLayers: recordedPerceptionLayers,
|
||||
semanticLayer: recordedSemanticLayer,
|
||||
unifiedPerception: recordedUnifiedPerception,
|
||||
planView: recordedPlanView,
|
||||
}).then((payload) => {
|
||||
if (
|
||||
abort.signal.aborted ||
|
||||
@@ -1725,6 +1979,9 @@ export function RerunViewport({
|
||||
recordedView,
|
||||
recordedViewResetGeneration,
|
||||
recordedFollowTrajectory,
|
||||
recordedSemanticLayer,
|
||||
recordedUnifiedPerception,
|
||||
recordedPlanView,
|
||||
recordedPerceptionLayers.enabled,
|
||||
recordedPerceptionLayers.detections2d,
|
||||
recordedPerceptionLayers.segmentation,
|
||||
|
||||
@@ -9,6 +9,7 @@ export const CANONICAL_RECORDED_LAB_REPLAY_CONTRACT =
|
||||
export interface CanonicalRecordedLabMode<T extends string> {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +91,7 @@ export function CanonicalRecordedLabReplay<
|
||||
mediaMultiLayer = false,
|
||||
mediaContent,
|
||||
spatialContent,
|
||||
unifiedContent,
|
||||
emptyMessage,
|
||||
deckOverlays,
|
||||
actions,
|
||||
@@ -115,8 +117,10 @@ export function CanonicalRecordedLabReplay<
|
||||
spatialLayerControls?: ReactNode;
|
||||
spatialLeadingControl?: ReactNode;
|
||||
mediaMultiLayer?: boolean;
|
||||
mediaContent: ReactNode;
|
||||
spatialContent: ReactNode;
|
||||
mediaContent?: ReactNode;
|
||||
spatialContent?: ReactNode;
|
||||
/** One upstream Rerun viewer owns both panes and the shared playback clock. */
|
||||
unifiedContent?: ReactNode;
|
||||
emptyMessage: string;
|
||||
deckOverlays?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
@@ -135,6 +139,7 @@ export function CanonicalRecordedLabReplay<
|
||||
value={mediaMode}
|
||||
items={[...mediaModes]}
|
||||
label="Видео и камера"
|
||||
size="dense"
|
||||
onChange={onMediaModeChange}
|
||||
/>
|
||||
</div>
|
||||
@@ -145,6 +150,7 @@ export function CanonicalRecordedLabReplay<
|
||||
value={spatialMode}
|
||||
items={[...spatialModes]}
|
||||
label="3D и план"
|
||||
size="dense"
|
||||
onChange={onSpatialModeChange}
|
||||
/>
|
||||
</div>
|
||||
@@ -220,7 +226,13 @@ export function CanonicalRecordedLabReplay<
|
||||
className="m4-replay-threat-visual__deck"
|
||||
data-split={splitView ? "true" : undefined}
|
||||
data-empty={mediaMode === "none" && spatialMode === "none" ? "true" : undefined}
|
||||
data-native-rerun={unifiedContent ? "true" : undefined}
|
||||
>
|
||||
{unifiedContent ? (
|
||||
<div className="m4-replay-threat-visual__unified-content">
|
||||
{unifiedContent}
|
||||
</div>
|
||||
) : null}
|
||||
<SplitPane
|
||||
primary={mediaPane}
|
||||
secondary={spatialPane ?? <div />}
|
||||
@@ -229,8 +241,8 @@ export function CanonicalRecordedLabReplay<
|
||||
orientation={splitOrientation}
|
||||
minPrimarySize={splitView ? 24 : 0}
|
||||
minSecondarySize={splitView ? 24 : 0}
|
||||
resizable={splitView}
|
||||
separatorLabel="Изменить размер VIDEO/CAMERA и 3D/PLAN"
|
||||
resizable={splitView && !unifiedContent}
|
||||
separatorLabel="Изменить размер видео/камеры и 3D/плана"
|
||||
/>
|
||||
{mediaMode === "none" && spatialMode === "none" ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { ObservationTimeline } from "../ObservationTimeline";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
type RerunViewportStatus,
|
||||
} from "../RerunViewport";
|
||||
import {
|
||||
CanonicalRecordedLabReplay,
|
||||
useCanonicalRecordedLabReplayState,
|
||||
} from "../laboratory/CanonicalRecordedLabReplay";
|
||||
import {
|
||||
resolveCanonicalLabReplay,
|
||||
type CanonicalLabReplayDescriptor,
|
||||
} from "../../core/laboratory/canonicalLabReplay";
|
||||
import type { VegetationFullRouteReview } from "../../core/laboratory/vegetationShadow";
|
||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||
import { defaultSceneSettings } from "../../sceneSettings";
|
||||
|
||||
type MediaMode = "video" | "camera";
|
||||
type SpatialMode = "3d" | "plan";
|
||||
type SpatialLayer = "source" | "local" | "tgs" | "semantic";
|
||||
type SemanticLayer = "city" | "vegetation";
|
||||
|
||||
interface CanonicalReplayLaunch {
|
||||
base: ObservationSessionReplayLaunch;
|
||||
replay: CanonicalLabReplayDescriptor;
|
||||
}
|
||||
|
||||
const RERUN_UNIFIED_CAMERA_SHARE_PERCENT = 46;
|
||||
const RERUN_NATIVE_DIVIDER_HIT_SLOP_PX = 10;
|
||||
|
||||
export function CanonicalVegetationRerunReplay({
|
||||
resultId,
|
||||
review,
|
||||
}: {
|
||||
resultId: string;
|
||||
review: VegetationFullRouteReview;
|
||||
}) {
|
||||
const {
|
||||
mediaMode,
|
||||
spatialMode,
|
||||
splitPrimarySize,
|
||||
splitOrientation,
|
||||
expanded,
|
||||
onMediaModeChange,
|
||||
onSpatialModeChange,
|
||||
onSplitPrimarySizeChange,
|
||||
onExpandedChange,
|
||||
} = useCanonicalRecordedLabReplayState<MediaMode, SpatialMode>({
|
||||
initialMediaMode: "video",
|
||||
initialSpatialMode: "3d",
|
||||
});
|
||||
const splitView = mediaMode !== null && spatialMode !== null;
|
||||
const [semanticLayer, setSemanticLayer] = useState<SemanticLayer>("vegetation");
|
||||
const [showSemantics, setShowSemantics] = useState(true);
|
||||
const [spatialLayer, setSpatialLayer] = useState<SpatialLayer>("source");
|
||||
const [viewResetGeneration, setViewResetGeneration] = useState<0 | 1>(0);
|
||||
const [playback, setPlayback] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] =
|
||||
useState<RerunPlaybackController | null>(null);
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>("idle");
|
||||
const [launch, setLaunch] = useState<CanonicalReplayLaunch | null>(null);
|
||||
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||
const viewerFrameRef = useRef<HTMLDivElement>(null);
|
||||
const nativeSplitPercentRef = useRef(RERUN_UNIFIED_CAMERA_SHARE_PERCENT);
|
||||
const nativeSplitTrackingCleanupRef = useRef<(() => void) | null>(null);
|
||||
const previousSplitViewRef = useRef(splitView);
|
||||
const trackedLaunchSha256Ref = useRef<string | null>(null);
|
||||
|
||||
const stopNativeSplitTracking = useCallback(() => {
|
||||
nativeSplitTrackingCleanupRef.current?.();
|
||||
nativeSplitTrackingCleanupRef.current = null;
|
||||
}, []);
|
||||
|
||||
const trackNativeSplit = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (
|
||||
!splitView
|
||||
|| event.button !== 0
|
||||
|| !(event.target instanceof HTMLCanvasElement)
|
||||
) return;
|
||||
const frame = viewerFrameRef.current;
|
||||
if (!frame) return;
|
||||
const bounds = frame.getBoundingClientRect();
|
||||
if (bounds.width <= 0) return;
|
||||
const dividerX = bounds.left
|
||||
+ bounds.width * nativeSplitPercentRef.current / 100;
|
||||
if (Math.abs(event.clientX - dividerX) > RERUN_NATIVE_DIVIDER_HIT_SLOP_PX) return;
|
||||
|
||||
stopNativeSplitTracking();
|
||||
const pointerId = event.pointerId;
|
||||
const update = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return;
|
||||
const currentBounds = frame.getBoundingClientRect();
|
||||
if (currentBounds.width <= 0) return;
|
||||
const next = Math.min(90, Math.max(
|
||||
10,
|
||||
(pointerEvent.clientX - currentBounds.left) / currentBounds.width * 100,
|
||||
));
|
||||
nativeSplitPercentRef.current = next;
|
||||
frame.style.setProperty("--canonical-rerun-camera-pane", `${next}%`);
|
||||
};
|
||||
const stop = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return;
|
||||
stopNativeSplitTracking();
|
||||
};
|
||||
window.addEventListener("pointermove", update, true);
|
||||
window.addEventListener("pointerup", stop, true);
|
||||
window.addEventListener("pointercancel", stop, true);
|
||||
nativeSplitTrackingCleanupRef.current = () => {
|
||||
window.removeEventListener("pointermove", update, true);
|
||||
window.removeEventListener("pointerup", stop, true);
|
||||
window.removeEventListener("pointercancel", stop, true);
|
||||
};
|
||||
}, [splitView, stopNativeSplitTracking]);
|
||||
|
||||
useEffect(() => stopNativeSplitTracking, [stopNativeSplitTracking]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!splitView) stopNativeSplitTracking();
|
||||
const launchSha256 = launch?.replay.sha256 ?? null;
|
||||
if (
|
||||
trackedLaunchSha256Ref.current !== launchSha256
|
||||
|| (splitView && !previousSplitViewRef.current)
|
||||
) {
|
||||
nativeSplitPercentRef.current = RERUN_UNIFIED_CAMERA_SHARE_PERCENT;
|
||||
}
|
||||
trackedLaunchSha256Ref.current = launchSha256;
|
||||
previousSplitViewRef.current = splitView;
|
||||
const cameraPanePercent = mediaMode === null
|
||||
? 0
|
||||
: splitView
|
||||
? nativeSplitPercentRef.current
|
||||
: 100;
|
||||
viewerFrameRef.current?.style.setProperty(
|
||||
"--canonical-rerun-camera-pane",
|
||||
`${cameraPanePercent}%`,
|
||||
);
|
||||
}, [launch?.replay.sha256, mediaMode, splitView, stopNativeSplitTracking]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLaunch(null);
|
||||
setLaunchError(null);
|
||||
setPlayback(null);
|
||||
setPlaybackController(null);
|
||||
setViewerStatus("idle");
|
||||
void resolveObservationSessionReplay(review.sessionId, {
|
||||
signal: controller.signal,
|
||||
maximumWaitMs: 30 * 60 * 1000,
|
||||
onUpdate: () => undefined,
|
||||
}).then(async (value) => ({
|
||||
base: value,
|
||||
replay: await resolveCanonicalLabReplay(resultId, value, {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
})).then((value) => {
|
||||
if (!controller.signal.aborted) setLaunch(value);
|
||||
}).catch((caught: unknown) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setLaunchError(
|
||||
caught instanceof Error ? caught.message : "Каноническая запись RAV004 недоступна.",
|
||||
);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [resultId, review.sessionId]);
|
||||
|
||||
const presentationReady = playbackController !== null
|
||||
&& isRecordedPlaybackPresentationReady(viewerStatus, playback);
|
||||
const presentationState = launchError || viewerStatus === "error"
|
||||
? "error"
|
||||
: presentationReady
|
||||
? "ready"
|
||||
: "loading";
|
||||
const sceneSettings = useMemo(() => ({
|
||||
...defaultSceneSettings,
|
||||
accumulationSeconds: spatialLayer === "local" ? 5 : 0,
|
||||
showPoints: spatialMode !== null,
|
||||
showTrajectory: spatialMode !== null,
|
||||
showGrid: spatialMode !== null,
|
||||
pointSize: 3.8,
|
||||
}), [spatialLayer, spatialMode]);
|
||||
const profile = launch ? recordedSessionRerunProfile({
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
artifact: {
|
||||
sourceUrl: launch.replay.sourceUrl,
|
||||
viewerSourceUrl: launch.replay.viewerSourceUrl,
|
||||
byteLength: launch.replay.byteLength,
|
||||
sha256: launch.replay.sha256,
|
||||
},
|
||||
blueprintSourceUrl: launch.replay.blueprintSourceUrl,
|
||||
autoplayWhenReady: false,
|
||||
presentationGate: "ready",
|
||||
expectedTimelineStartSeconds: launch.base.timelineStartSeconds,
|
||||
expectedTimelineEndSeconds: launch.base.timelineEndSeconds,
|
||||
initialPlaybackStartSeconds: review.timelineStartSeconds,
|
||||
view: mediaMode !== null ? "perception" : "spatial",
|
||||
viewResetGeneration,
|
||||
followTrajectory: true,
|
||||
semanticLayer,
|
||||
unifiedPerception: splitView,
|
||||
planView: spatialMode === "plan",
|
||||
perceptionLayers: {
|
||||
enabled: mediaMode !== null,
|
||||
detections2d: mediaMode === "video",
|
||||
segmentation: mediaMode === "video" && showSemantics,
|
||||
cuboids3d: false,
|
||||
},
|
||||
perceptionRetryGeneration: 0,
|
||||
lockPerceptionCameraInteraction: mediaMode !== null,
|
||||
}) : null;
|
||||
|
||||
const mediaLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Слои камеры и видео"
|
||||
>
|
||||
<Button
|
||||
size="dense"
|
||||
shape="pill"
|
||||
variant={showSemantics ? "primary" : "secondary"}
|
||||
aria-pressed={showSemantics}
|
||||
onClick={() => setShowSemantics((visible) => !visible)}
|
||||
>
|
||||
СЕМАНТИКА
|
||||
</Button>
|
||||
<SegmentedControl
|
||||
value={semanticLayer}
|
||||
items={[
|
||||
{ value: "city", label: "ГОРОД · EoMT" },
|
||||
{ value: "vegetation", label: "ПРИРОДА · DDRNet" },
|
||||
]}
|
||||
label="Источник семантики"
|
||||
size="dense"
|
||||
onChange={(value) => {
|
||||
setSemanticLayer(value);
|
||||
setShowSemantics(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const spatialLayerControls = (
|
||||
<div
|
||||
className="m4-replay-threat-visual__pane-layer-controls"
|
||||
role="group"
|
||||
aria-label="Пространственные слои RAV004"
|
||||
>
|
||||
<SegmentedControl
|
||||
value={spatialLayer}
|
||||
items={[
|
||||
{ value: "source", label: "ИСХ. ТОЧКИ" },
|
||||
{ value: "local", label: "ЛОК. SLAM" },
|
||||
{ value: "tgs", label: "TGS", disabled: true },
|
||||
{ value: "semantic", label: "СЕМАНТИКА", disabled: true },
|
||||
]}
|
||||
label="Пространственные слои"
|
||||
size="dense"
|
||||
onChange={setSpatialLayer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const resetSpatialView = (
|
||||
<Button
|
||||
size="dense"
|
||||
variant="ghost"
|
||||
icon={<Icon name="refresh" size={14} />}
|
||||
aria-label="Сбросить положение 3D камеры"
|
||||
title="Сбросить положение 3D камеры"
|
||||
onClick={() => setViewResetGeneration((value) => value === 0 ? 1 : 0)}
|
||||
>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const transport = presentationReady && playback && playbackController ? (
|
||||
<ObservationTimeline
|
||||
className="m4-replay-threat-visual__timeline canonical-vegetation-rerun-replay__timeline"
|
||||
active
|
||||
sourceCount={3}
|
||||
mode="recorded"
|
||||
seekable
|
||||
synchronization="shared-clock"
|
||||
rangeNs={playback.rangeNs}
|
||||
currentNs={playback.currentNs}
|
||||
playing={playback.playing}
|
||||
onSeek={playbackController.seek}
|
||||
onPlayingChange={playbackController.setPlaying}
|
||||
showJumpToEnd={false}
|
||||
/>
|
||||
) : undefined;
|
||||
return (
|
||||
<div
|
||||
className="canonical-vegetation-rerun-replay"
|
||||
data-presentation-state={presentationState}
|
||||
aria-busy={presentationState === "loading"}
|
||||
>
|
||||
<CanonicalRecordedLabReplay
|
||||
label="RAVNOVES004TREE · канонический повтор Rerun"
|
||||
mediaMode={mediaMode ?? "none"}
|
||||
mediaModes={[
|
||||
{ value: "video", label: "ВИДЕО" },
|
||||
{ value: "camera", label: "КАМЕРА" },
|
||||
]}
|
||||
spatialMode={spatialMode ?? "none"}
|
||||
spatialModes={[
|
||||
{ value: "3d", label: "3D" },
|
||||
{ value: "plan", label: "ПЛАН" },
|
||||
]}
|
||||
expanded={expanded}
|
||||
splitPrimarySize={splitPrimarySize}
|
||||
splitOrientation={splitOrientation}
|
||||
mediaAriaLabel={mediaMode === "camera" ? "Камера" : "Видео и семантика"}
|
||||
spatialAriaLabel={spatialMode === "plan" ? "Вид сверху" : "Трёхмерная сцена"}
|
||||
mediaLayerControls={mediaLayerControls}
|
||||
spatialLayerControls={spatialLayerControls}
|
||||
spatialLeadingControl={resetSpatialView}
|
||||
mediaMultiLayer
|
||||
unifiedContent={profile ? (
|
||||
<div
|
||||
ref={viewerFrameRef}
|
||||
className="canonical-vegetation-rerun-replay__viewport-lock"
|
||||
data-split-view={splitView ? "true" : undefined}
|
||||
style={{
|
||||
"--canonical-rerun-camera-pane": `${
|
||||
mediaMode === null
|
||||
? 0
|
||||
: splitView
|
||||
? nativeSplitPercentRef.current
|
||||
: 100
|
||||
}%`,
|
||||
} as CSSProperties}
|
||||
onPointerDownCapture={splitView ? trackNativeSplit : undefined}
|
||||
>
|
||||
<RerunViewport
|
||||
profile={profile}
|
||||
sceneSettings={sceneSettings}
|
||||
onStatusChange={setViewerStatus}
|
||||
onPlaybackChange={setPlayback}
|
||||
onPlaybackControllerChange={setPlaybackController}
|
||||
/>
|
||||
</div>
|
||||
) : launchError ? (
|
||||
<div className="l3-visual-audit__state" role="alert">
|
||||
{launchError}
|
||||
</div>
|
||||
) : (
|
||||
<div aria-hidden="true" />
|
||||
)}
|
||||
emptyMessage="Выберите ВИДЕО/КАМЕРА или 3D/ПЛАН. Общие часы Rerun останутся на месте."
|
||||
deckOverlays={presentationState === "loading" ? (
|
||||
<div className="canonical-vegetation-rerun-replay__loading">
|
||||
<ActivityIndicator label="Загружаем синхронизированную запись" />
|
||||
</div>
|
||||
) : undefined}
|
||||
transport={transport}
|
||||
onMediaModeChange={onMediaModeChange}
|
||||
onSpatialModeChange={onSpatialModeChange}
|
||||
onExpandedChange={onExpandedChange}
|
||||
onSplitPrimarySizeChange={onSplitPrimarySizeChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ObservationSessionReplayLaunch } from "../observation/sessionArchive";
|
||||
import type { RecordedRrdArtifactDescriptor } from "../observation/viewerProfile";
|
||||
|
||||
const SAFE_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
|
||||
const SAFE_SESSION_SOURCE = /^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/recording\.rrd$/;
|
||||
const MAX_CANONICAL_REPLAY_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
export interface CanonicalLabReplayDescriptor extends RecordedRrdArtifactDescriptor {
|
||||
blueprintSourceUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the one immutable RRD used by the canonical recorded LAB.
|
||||
*
|
||||
* The server caches the merge of the sealed spatial recording and the LAB AI
|
||||
* evidence. Rerun therefore opens one source and cannot present the base store
|
||||
* before a second receiver has finished decoding the semantic layer.
|
||||
*/
|
||||
export async function resolveCanonicalLabReplay(
|
||||
resultId: string,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
{
|
||||
origin = window.location.origin,
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
origin?: string;
|
||||
signal?: AbortSignal;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
} = {},
|
||||
): Promise<CanonicalLabReplayDescriptor> {
|
||||
const base = new URL(origin);
|
||||
if (
|
||||
!SAFE_RESULT_ID.test(resultId)
|
||||
|| !SAFE_SESSION_SOURCE.test(launch.sourceUrl)
|
||||
|| launch.viewerSourceUrl !== `${launch.sourceUrl}?generation=${launch.sha256}`
|
||||
|| !/^[a-f0-9]{64}$/.test(launch.sha256)
|
||||
) {
|
||||
throw new Error("Канонический replay LAB имеет небезопасный descriptor.");
|
||||
}
|
||||
const sourceUrl =
|
||||
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}`
|
||||
+ "/canonical-replay.rrd";
|
||||
const descriptorUrl = new URL(sourceUrl, `${base.origin}/`);
|
||||
descriptorUrl.searchParams.set("base_generation", launch.sha256);
|
||||
if (descriptorUrl.origin !== base.origin) {
|
||||
throw new Error("Канонический replay LAB должен быть same-origin.");
|
||||
}
|
||||
const response = await fetcher(descriptorUrl.href, {
|
||||
method: "HEAD",
|
||||
credentials: "same-origin",
|
||||
headers: { Accept: "application/vnd.rerun.rrd" },
|
||||
signal,
|
||||
});
|
||||
const contentType = response.headers.get("Content-Type")?.split(";", 1)[0].trim();
|
||||
const byteLength = Number(response.headers.get("Content-Length"));
|
||||
const sha256 = response.headers.get("ETag")?.match(/^"([a-f0-9]{64})"$/)?.[1];
|
||||
if (
|
||||
response.status !== 200
|
||||
|| contentType !== "application/vnd.rerun.rrd"
|
||||
|| response.headers.get("X-Rerun-Format") !== "RRF2"
|
||||
|| !sha256
|
||||
|| !Number.isSafeInteger(byteLength)
|
||||
|| byteLength < 4
|
||||
|| byteLength > MAX_CANONICAL_REPLAY_BYTES
|
||||
) {
|
||||
throw new Error("Единый replay LAB не прошёл проверку.");
|
||||
}
|
||||
return {
|
||||
sourceUrl,
|
||||
viewerSourceUrl: `${sourceUrl}?generation=${sha256}`,
|
||||
byteLength,
|
||||
sha256,
|
||||
blueprintSourceUrl: launch.sourceUrl.replace(/\/recording\.rrd$/, "/blueprint.rrd"),
|
||||
};
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function laboratoryRecordedEvidenceDemand(
|
||||
profile: LaboratoryRecordedEvidenceViewerProfile =
|
||||
LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE,
|
||||
): LaboratoryRecordedEvidenceDemand {
|
||||
if (profile.loadPolicy !== "visible-evidence-only") {
|
||||
if (profile.loadPolicy !== "explicit-legacy-comparison-only") {
|
||||
throw new Error("Unsupported LAB recorded evidence load policy");
|
||||
}
|
||||
const mediaVisible = visibility.mediaMode !== null;
|
||||
|
||||
@@ -1026,7 +1026,7 @@ export async function fetchVegetationRouteTgsAnchor(
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchVegetationShadowResult(
|
||||
export async function fetchVegetationShadowResultMetadata(
|
||||
resultId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
@@ -1048,6 +1048,17 @@ export async function fetchVegetationShadowResult(
|
||||
resultId,
|
||||
"/api/v1/laboratory/vegetation-shadow",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function fetchVegetationShadowResult(
|
||||
resultId: string,
|
||||
{
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: { fetcher?: LaboratoryFetch; signal?: AbortSignal } = {},
|
||||
): Promise<VegetationShadowResult> {
|
||||
const result = await fetchVegetationShadowResultMetadata(resultId, { fetcher, signal });
|
||||
if (!result.routeFullReview) return result;
|
||||
const timelineResponse = await fetcher(
|
||||
`/api/v1/laboratory/vegetation-shadow/${encodeURIComponent(resultId)}/route-timeline`,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
export const OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA =
|
||||
"missioncore.observatory-calculation-profile/v1" as const;
|
||||
|
||||
export type ObservationLabCalculationProfileOrigin =
|
||||
| "archived-definition"
|
||||
| "existing-result";
|
||||
|
||||
export interface ObservationLabCalculationProfile {
|
||||
readonly schemaVersion: typeof OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA;
|
||||
readonly setupId: string;
|
||||
readonly displayName: string;
|
||||
readonly origin: ObservationLabCalculationProfileOrigin;
|
||||
readonly definitionId: string | null;
|
||||
readonly definitionVersion: number | null;
|
||||
readonly definitionSha256: string | null;
|
||||
}
|
||||
|
||||
export class ObservationLabCalculationProfileContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservationLabCalculationProfileContractError";
|
||||
}
|
||||
}
|
||||
|
||||
const PROFILE_KEYS = new Set([
|
||||
"schema_version",
|
||||
"setup_id",
|
||||
"display_name",
|
||||
"origin",
|
||||
"definition_id",
|
||||
"definition_version",
|
||||
"definition_sha256",
|
||||
]);
|
||||
const IDENTIFIER = /^[a-z][a-z0-9-]{2,95}$/;
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireText(value: unknown, field: string, maximum: number): string {
|
||||
if (
|
||||
typeof value !== "string"
|
||||
|| value.length === 0
|
||||
|| value.length > maximum
|
||||
|| value !== value.trim()
|
||||
) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Поле calculation_profile.${field} должно быть непустой строкой.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireIdentifier(value: unknown, field: string): string {
|
||||
const identifier = requireText(value, field, 96);
|
||||
if (!IDENTIFIER.test(identifier)) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Поле calculation_profile.${field} содержит некорректный идентификатор.`,
|
||||
);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
export function decodeObservationLabCalculationProfile(
|
||||
value: unknown,
|
||||
sessionId: string,
|
||||
): ObservationLabCalculationProfile | null {
|
||||
if (value === null) return null;
|
||||
if (!isRecord(value) || Object.keys(value).some((key) => !PROFILE_KEYS.has(key))) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} нарушил контракт полей.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
Object.keys(value).length !== PROFILE_KEYS.size
|
||||
|| value.schema_version !== OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA
|
||||
) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестную версию.`,
|
||||
);
|
||||
}
|
||||
|
||||
const origin = value.origin;
|
||||
if (origin !== "archived-definition" && origin !== "existing-result") {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} имеет неизвестное происхождение.`,
|
||||
);
|
||||
}
|
||||
const definitionId = value.definition_id === null
|
||||
? null
|
||||
: requireIdentifier(value.definition_id, "definition_id");
|
||||
const definitionVersion = value.definition_version === null
|
||||
? null
|
||||
: value.definition_version;
|
||||
if (
|
||||
definitionVersion !== null
|
||||
&& (
|
||||
typeof definitionVersion !== "number"
|
||||
|| !Number.isInteger(definitionVersion)
|
||||
|| definitionVersion < 1
|
||||
)
|
||||
) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} содержит некорректную версию определения.`,
|
||||
);
|
||||
}
|
||||
const definitionSha256 = value.definition_sha256 === null
|
||||
? null
|
||||
: requireText(value.definition_sha256, "definition_sha256", 64);
|
||||
if (definitionSha256 !== null && !SHA256.test(definitionSha256)) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} не содержит SHA-256 определения.`,
|
||||
);
|
||||
}
|
||||
|
||||
const definitionFieldCount = [
|
||||
definitionId,
|
||||
definitionVersion,
|
||||
definitionSha256,
|
||||
].filter((entry) => entry !== null).length;
|
||||
if (
|
||||
(origin === "existing-result" && definitionFieldCount !== 0)
|
||||
|| (origin === "archived-definition" && definitionFieldCount !== 3)
|
||||
) {
|
||||
throw new ObservationLabCalculationProfileContractError(
|
||||
`Профиль расчёта LAB-сессии ${sessionId} содержит неполную идентичность определения.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: OBSERVATION_LAB_CALCULATION_PROFILE_SCHEMA,
|
||||
setupId: requireIdentifier(value.setup_id, "setup_id"),
|
||||
displayName: requireText(value.display_name, "display_name", 256),
|
||||
origin,
|
||||
definitionId,
|
||||
definitionVersion,
|
||||
definitionSha256,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
export interface ObservationCanonicalLabReplayCapability {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1";
|
||||
kind: "canonical-recorded-rerun";
|
||||
viewerProfile: "recorded-session";
|
||||
timeline: "session_time";
|
||||
activation: "explicit";
|
||||
commandsEnabled: false;
|
||||
}
|
||||
|
||||
export interface ObservationPortableLabReplayCapability {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v2";
|
||||
kind: "portable-result-review";
|
||||
viewerProfile: "portable-result";
|
||||
timeline: "result-defined";
|
||||
activation: "explicit";
|
||||
commandsEnabled: false;
|
||||
}
|
||||
|
||||
export type ObservationLabReplayCapability =
|
||||
| ObservationCanonicalLabReplayCapability
|
||||
| ObservationPortableLabReplayCapability;
|
||||
|
||||
export class ObservationLabReplayCapabilityContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservationLabReplayCapabilityContractError";
|
||||
}
|
||||
}
|
||||
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const CAPABILITY_KEYS = new Set([
|
||||
"schema_version",
|
||||
"kind",
|
||||
"viewer_profile",
|
||||
"timeline",
|
||||
"activation",
|
||||
"commands_enabled",
|
||||
]);
|
||||
const CANONICAL_PROVENANCE_KEYS = new Set([
|
||||
"schema_version",
|
||||
"evidence_identity_sha256",
|
||||
"result_document_sha256",
|
||||
"replay_capability",
|
||||
"authority",
|
||||
"method",
|
||||
]);
|
||||
const PORTABLE_PROVENANCE_KEYS = new Set([
|
||||
"schema_version",
|
||||
"authority",
|
||||
"calculation_profile",
|
||||
"calculation_profile_sha256",
|
||||
"job",
|
||||
"source",
|
||||
"run_definition",
|
||||
"result_package",
|
||||
"replay_capability",
|
||||
"storage",
|
||||
"method",
|
||||
]);
|
||||
const AUTHORITY_KEYS = new Set([
|
||||
"commands_enabled",
|
||||
"navigation_or_safety_accepted",
|
||||
"actuation_accepted",
|
||||
]);
|
||||
const METHOD_KEYS = new Set([
|
||||
"schema_version",
|
||||
"completeness",
|
||||
"execution_class",
|
||||
"pipeline_id",
|
||||
"components",
|
||||
]);
|
||||
const METHOD_COMPONENT_KEYS = new Set([
|
||||
"kind",
|
||||
"name",
|
||||
"version",
|
||||
"role",
|
||||
"identity_sha256",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertExactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: ReadonlySet<string>,
|
||||
label: string,
|
||||
): void {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`${label} содержит неизвестные или пропущенные поля.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeLabReplayCapability(
|
||||
value: unknown,
|
||||
sessionId: string,
|
||||
): ObservationLabReplayCapability | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (!isRecord(value)) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Replay-возможность LAB-сессии ${sessionId} должна быть объектом или null.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(value, CAPABILITY_KEYS, `Replay-возможность LAB-сессии ${sessionId}`);
|
||||
if (
|
||||
value.activation !== "explicit"
|
||||
|| value.commands_enabled !== false
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Replay-возможность LAB-сессии ${sessionId} нарушает observation-only контракт.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.schema_version === "missioncore.observation-lab-replay-capability/v1"
|
||||
&& value.kind === "canonical-recorded-rerun"
|
||||
&& value.viewer_profile === "recorded-session"
|
||||
&& value.timeline === "session_time"
|
||||
) {
|
||||
return {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
};
|
||||
}
|
||||
if (
|
||||
value.schema_version === "missioncore.observation-lab-replay-capability/v2"
|
||||
&& value.kind === "portable-result-review"
|
||||
&& value.viewer_profile === "portable-result"
|
||||
&& value.timeline === "result-defined"
|
||||
) {
|
||||
return {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
|
||||
kind: "portable-result-review",
|
||||
viewerProfile: "portable-result",
|
||||
timeline: "result-defined",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
};
|
||||
}
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Replay-возможность LAB-сессии ${sessionId} содержит неизвестный viewer capability.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function decodeCanonicalLabReplayCapabilityProvenance(
|
||||
provenance: Readonly<Record<string, unknown>>,
|
||||
sessionId: string,
|
||||
resultId: string,
|
||||
): ObservationCanonicalLabReplayCapability {
|
||||
assertExactKeys(provenance, CANONICAL_PROVENANCE_KEYS, `Canonical provenance LAB-сессии ${sessionId}`);
|
||||
if (
|
||||
provenance.schema_version !== "missioncore.canonical-recorded-lab-projection/v1"
|
||||
|| typeof provenance.evidence_identity_sha256 !== "string"
|
||||
|| !SHA256.test(provenance.evidence_identity_sha256)
|
||||
|| resultId !== `lab-v1-vegetation-shadow-${provenance.evidence_identity_sha256}`
|
||||
|| typeof provenance.result_document_sha256 !== "string"
|
||||
|| !SHA256.test(provenance.result_document_sha256)
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical provenance LAB-сессии ${sessionId} потеряла immutable identity.`,
|
||||
);
|
||||
}
|
||||
if (!isRecord(provenance.authority)) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical provenance LAB-сессии ${sessionId} не содержит authority.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(provenance.authority, AUTHORITY_KEYS, `Canonical authority LAB-сессии ${sessionId}`);
|
||||
if (
|
||||
provenance.authority.commands_enabled !== false
|
||||
|| provenance.authority.navigation_or_safety_accepted !== false
|
||||
|| provenance.authority.actuation_accepted !== false
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical provenance LAB-сессии ${sessionId} нарушает observation-only authority.`,
|
||||
);
|
||||
}
|
||||
if (!isRecord(provenance.method)) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical provenance LAB-сессии ${sessionId} не содержит method manifest.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(provenance.method, METHOD_KEYS, `Canonical method LAB-сессии ${sessionId}`);
|
||||
if (
|
||||
provenance.method.schema_version !== "missioncore.laboratory-method/v1"
|
||||
|| provenance.method.completeness !== "legacy-partial"
|
||||
|| provenance.method.execution_class !== "ai-inference"
|
||||
|| provenance.method.pipeline_id
|
||||
!== "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1"
|
||||
|| !Array.isArray(provenance.method.components)
|
||||
|| provenance.method.components.length !== 1
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical method LAB-сессии ${sessionId} не соответствует записанному прогону.`,
|
||||
);
|
||||
}
|
||||
const component = provenance.method.components[0];
|
||||
if (!isRecord(component)) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical method LAB-сессии ${sessionId} не содержит source component.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(
|
||||
component,
|
||||
METHOD_COMPONENT_KEYS,
|
||||
`Canonical source component LAB-сессии ${sessionId}`,
|
||||
);
|
||||
if (
|
||||
component.kind !== "source"
|
||||
|| component.name !== "sealed full-route LAB result"
|
||||
|| component.version !== "missioncore.lab-v1-vegetation-shadow/v1"
|
||||
|| component.role !== "immutable Session catalog projection"
|
||||
|| component.identity_sha256 !== provenance.evidence_identity_sha256
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical source component LAB-сессии ${sessionId} потерял immutable identity.`,
|
||||
);
|
||||
}
|
||||
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
|
||||
if (capability === null || capability.kind !== "canonical-recorded-rerun") {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Canonical provenance LAB-сессии ${sessionId} не содержит replay capability.`,
|
||||
);
|
||||
}
|
||||
return capability;
|
||||
}
|
||||
|
||||
export function decodePortableLabReplayCapabilityProvenance(
|
||||
provenance: Readonly<Record<string, unknown>>,
|
||||
sessionId: string,
|
||||
resultId: string,
|
||||
sourceSessionId: string,
|
||||
definitionSha256: string,
|
||||
): ObservationPortableLabReplayCapability {
|
||||
assertExactKeys(
|
||||
provenance,
|
||||
PORTABLE_PROVENANCE_KEYS,
|
||||
`Portable provenance LAB-сессии ${sessionId}`,
|
||||
);
|
||||
if (
|
||||
provenance.schema_version !== "missioncore.observatory-portable-result-publication/v1"
|
||||
|| !isRecord(provenance.source)
|
||||
|| provenance.source.session_id !== sourceSessionId
|
||||
|| !isRecord(provenance.run_definition)
|
||||
|| provenance.run_definition.definition_sha256 !== definitionSha256
|
||||
|| !isRecord(provenance.result_package)
|
||||
|| typeof provenance.result_package.manifest_sha256 !== "string"
|
||||
|| !SHA256.test(provenance.result_package.manifest_sha256)
|
||||
|| typeof provenance.result_package.artifact_manifest_id !== "string"
|
||||
|| !SHA256.test(provenance.result_package.artifact_manifest_id)
|
||||
|| sessionId !== resultId
|
||||
) {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Portable provenance LAB-сессии ${sessionId} потеряла immutable identity.`,
|
||||
);
|
||||
}
|
||||
const capability = decodeLabReplayCapability(provenance.replay_capability, sessionId);
|
||||
if (capability === null || capability.kind !== "portable-result-review") {
|
||||
throw new ObservationLabReplayCapabilityContractError(
|
||||
`Portable provenance LAB-сессии ${sessionId} не содержит viewer capability.`,
|
||||
);
|
||||
}
|
||||
return capability;
|
||||
}
|
||||
|
||||
/** Rolling bridge for a new frontend against the pre-v2 catalog endpoint. */
|
||||
export function decodeRollingCanonicalLabReplayCapability(
|
||||
provenance: Readonly<Record<string, unknown>>,
|
||||
sessionId: string,
|
||||
resultId: string,
|
||||
): ObservationLabReplayCapability | null {
|
||||
if (!Object.prototype.hasOwnProperty.call(provenance, "replay_capability")) return null;
|
||||
return decodeCanonicalLabReplayCapabilityProvenance(provenance, sessionId, resultId);
|
||||
}
|
||||
@@ -209,6 +209,49 @@ export function attemptRecordedAutoplay(
|
||||
}
|
||||
}
|
||||
|
||||
export function createRecordedInitialSeekGate(): {
|
||||
attempt: (
|
||||
viewerStarted: boolean,
|
||||
fullyBuffered: boolean,
|
||||
presentationReady: boolean,
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
seekToStart: (startNs: number) => void,
|
||||
preferredStartNs?: number,
|
||||
) => boolean;
|
||||
attempted: () => boolean;
|
||||
} {
|
||||
let consumed = false;
|
||||
return {
|
||||
attempt(
|
||||
viewerStarted,
|
||||
fullyBuffered,
|
||||
presentationReady,
|
||||
rangeNs,
|
||||
seekToStart,
|
||||
preferredStartNs,
|
||||
) {
|
||||
if (
|
||||
consumed
|
||||
|| !viewerStarted
|
||||
|| !fullyBuffered
|
||||
|| !presentationReady
|
||||
|| !isUsableRecordedPlaybackRange(rangeNs)
|
||||
) return false;
|
||||
consumed = true;
|
||||
const startNs = Number.isFinite(preferredStartNs)
|
||||
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
|
||||
: rangeNs.min;
|
||||
try {
|
||||
seekToStart(startNs);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
attempted: () => consumed,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRecordedAutoplayGate(): {
|
||||
attempt: (
|
||||
viewerStarted: boolean,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface ObservationReplayAttempt {
|
||||
readonly signal: AbortSignal;
|
||||
isCurrent: () => boolean;
|
||||
finish: () => boolean;
|
||||
}
|
||||
|
||||
export interface ObservationReplayCoordinator {
|
||||
begin: () => ObservationReplayAttempt;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/** Latest explicit admission owns the viewer, even if an older promise settles later. */
|
||||
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
|
||||
let sequence = 0;
|
||||
let active: AbortController | null = null;
|
||||
|
||||
return {
|
||||
begin() {
|
||||
active?.abort();
|
||||
const controller = new AbortController();
|
||||
const attemptSequence = ++sequence;
|
||||
active = controller;
|
||||
return {
|
||||
signal: controller.signal,
|
||||
isCurrent: () => (
|
||||
!controller.signal.aborted
|
||||
&& active === controller
|
||||
&& sequence === attemptSequence
|
||||
),
|
||||
finish: () => {
|
||||
if (active !== controller || sequence !== attemptSequence) return false;
|
||||
active = null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
active?.abort();
|
||||
// Keep settlement ownership until finish(). isCurrent() already fails,
|
||||
// while begin() can replace this attempt immediately.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import {
|
||||
MAX_RECORDED_CAMERA_SOURCES,
|
||||
} from "./recordedSessionAdmission";
|
||||
import {
|
||||
decodeLabReplayCapability,
|
||||
decodeRollingCanonicalLabReplayCapability,
|
||||
ObservationLabReplayCapabilityContractError,
|
||||
type ObservationLabReplayCapability,
|
||||
} from "./labReplayCapability";
|
||||
import {
|
||||
decodeObservationLabCalculationProfile,
|
||||
ObservationLabCalculationProfileContractError,
|
||||
type ObservationLabCalculationProfile,
|
||||
} from "./labCalculationProfile";
|
||||
|
||||
export type { ObservationLabReplayCapability } from "./labReplayCapability";
|
||||
export type { ObservationLabCalculationProfile } from "./labCalculationProfile";
|
||||
|
||||
export type ObservationSessionStatus =
|
||||
| "recording"
|
||||
@@ -20,6 +34,8 @@ export interface ObservationLabInstance {
|
||||
configSha256: string | null;
|
||||
runCreatedAtUtc: string;
|
||||
publishedAtUtc: string;
|
||||
replayCapability: ObservationLabReplayCapability | null;
|
||||
calculationProfile: ObservationLabCalculationProfile | null;
|
||||
provenance: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
@@ -32,10 +48,21 @@ export interface ObservationSessionSummary {
|
||||
modalities: readonly string[];
|
||||
durationSeconds: number;
|
||||
replayable: boolean;
|
||||
captureAttestation: ObservationCaptureAttestation | null;
|
||||
preparation: ObservationSessionCatalogPreparation | null;
|
||||
lab: ObservationLabInstance | null;
|
||||
}
|
||||
|
||||
export interface ObservationCaptureAttestation {
|
||||
readonly equipmentModelId: string;
|
||||
readonly equipmentModelSha256: string;
|
||||
readonly equipmentDisplayName: string;
|
||||
readonly captureProfileId: string;
|
||||
readonly captureProfileSha256: string;
|
||||
readonly captureAttestationSha256: string;
|
||||
readonly state: "attested" | "attested-with-legacy-evidence" | "unknown";
|
||||
}
|
||||
|
||||
export interface ObservationSessionCatalog {
|
||||
items: readonly ObservationSessionSummary[];
|
||||
}
|
||||
@@ -152,10 +179,11 @@ const ITEM_KEYS = new Set([
|
||||
"modalities",
|
||||
"duration_seconds",
|
||||
"replayable",
|
||||
"capture_attestation",
|
||||
"preparation",
|
||||
"lab",
|
||||
]);
|
||||
const LAB_KEYS = new Set([
|
||||
const LEGACY_LAB_KEYS = new Set([
|
||||
"lab_id",
|
||||
"source_session_id",
|
||||
"result_kind",
|
||||
@@ -166,6 +194,27 @@ const LAB_KEYS = new Set([
|
||||
"published_at_utc",
|
||||
"provenance",
|
||||
]);
|
||||
const LAB_V2_KEYS = new Set([...LEGACY_LAB_KEYS, "replay_capability"]);
|
||||
const LAB_V3_KEYS = new Set([
|
||||
...LAB_V2_KEYS,
|
||||
"calculation_profile",
|
||||
]);
|
||||
const CAPTURE_ATTESTATION_KEYS = new Set([
|
||||
"schema_version",
|
||||
"equipment_model",
|
||||
"capture_profile",
|
||||
"capture_attestation_sha256",
|
||||
"state",
|
||||
]);
|
||||
const EQUIPMENT_MODEL_KEYS = new Set([
|
||||
"equipment_model_id",
|
||||
"equipment_model_sha256",
|
||||
"display_name",
|
||||
]);
|
||||
const CAPTURE_PROFILE_KEYS = new Set([
|
||||
"capture_profile_id",
|
||||
"capture_profile_sha256",
|
||||
]);
|
||||
const CATALOG_PREPARATION_KEYS = new Set([
|
||||
"preparation_id",
|
||||
"state",
|
||||
@@ -379,11 +428,103 @@ function decodeItem(value: unknown, index: number): ObservationSessionSummary {
|
||||
modalities: requireModalities(value.modalities),
|
||||
durationSeconds: value.duration_seconds,
|
||||
replayable: value.replayable,
|
||||
captureAttestation: decodeCaptureAttestation(value.capture_attestation, id),
|
||||
preparation: decodeCatalogPreparation(value.preparation, id),
|
||||
lab: decodeLabInstance(value.lab, id),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeCaptureAttestation(
|
||||
value: unknown,
|
||||
sessionId: string,
|
||||
): ObservationCaptureAttestation | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (!isRecord(value)) {
|
||||
throw new ObservationSessionContractError(
|
||||
`Аттестация записи ${sessionId} должна быть объектом или null.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(value, CAPTURE_ATTESTATION_KEYS, `Аттестация записи ${sessionId}`);
|
||||
if (value.schema_version !== "missioncore.recorded-capture-attestation/v1") {
|
||||
throw new ObservationSessionContractError(
|
||||
`Аттестация записи ${sessionId} имеет неизвестную схему.`,
|
||||
);
|
||||
}
|
||||
if (!isRecord(value.equipment_model) || !isRecord(value.capture_profile)) {
|
||||
throw new ObservationSessionContractError(
|
||||
`Аттестация записи ${sessionId} не содержит оборудование или профиль записи.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(
|
||||
value.equipment_model,
|
||||
EQUIPMENT_MODEL_KEYS,
|
||||
`Оборудование записи ${sessionId}`,
|
||||
);
|
||||
assertExactKeys(
|
||||
value.capture_profile,
|
||||
CAPTURE_PROFILE_KEYS,
|
||||
`Профиль записи ${sessionId}`,
|
||||
);
|
||||
const equipmentModelId = requireString(
|
||||
value.equipment_model.equipment_model_id,
|
||||
"equipment_model_id",
|
||||
128,
|
||||
);
|
||||
const equipmentModelSha256 = requireString(
|
||||
value.equipment_model.equipment_model_sha256,
|
||||
"equipment_model_sha256",
|
||||
64,
|
||||
);
|
||||
const captureProfileId = requireString(
|
||||
value.capture_profile.capture_profile_id,
|
||||
"capture_profile_id",
|
||||
128,
|
||||
);
|
||||
const captureProfileSha256 = requireString(
|
||||
value.capture_profile.capture_profile_sha256,
|
||||
"capture_profile_sha256",
|
||||
64,
|
||||
);
|
||||
const captureAttestationSha256 = requireString(
|
||||
value.capture_attestation_sha256,
|
||||
"capture_attestation_sha256",
|
||||
64,
|
||||
);
|
||||
if (
|
||||
!SAFE_ID.test(equipmentModelId)
|
||||
|| !SAFE_ID.test(captureProfileId)
|
||||
|| !SHA256.test(equipmentModelSha256)
|
||||
|| !SHA256.test(captureProfileSha256)
|
||||
|| !SHA256.test(captureAttestationSha256)
|
||||
) {
|
||||
throw new ObservationSessionContractError(
|
||||
`Аттестация записи ${sessionId} содержит некорректную immutable identity.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.state !== "attested"
|
||||
&& value.state !== "attested-with-legacy-evidence"
|
||||
&& value.state !== "unknown"
|
||||
) {
|
||||
throw new ObservationSessionContractError(
|
||||
`Аттестация записи ${sessionId} содержит неизвестное состояние.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
equipmentModelId,
|
||||
equipmentModelSha256,
|
||||
equipmentDisplayName: requireString(
|
||||
value.equipment_model.display_name,
|
||||
"equipment display_name",
|
||||
128,
|
||||
),
|
||||
captureProfileId,
|
||||
captureProfileSha256,
|
||||
captureAttestationSha256,
|
||||
state: value.state,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeLabInstance(
|
||||
value: unknown,
|
||||
sessionId: string,
|
||||
@@ -394,7 +535,23 @@ function decodeLabInstance(
|
||||
`LAB-привязка сессии ${sessionId} должна быть объектом или null.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(value, LAB_KEYS, `LAB-привязка сессии ${sessionId}`);
|
||||
const hasTypedCapability = Object.prototype.hasOwnProperty.call(
|
||||
value,
|
||||
"replay_capability",
|
||||
);
|
||||
const hasCalculationProfile = Object.prototype.hasOwnProperty.call(
|
||||
value,
|
||||
"calculation_profile",
|
||||
);
|
||||
assertExactKeys(
|
||||
value,
|
||||
hasCalculationProfile
|
||||
? LAB_V3_KEYS
|
||||
: hasTypedCapability
|
||||
? LAB_V2_KEYS
|
||||
: LEGACY_LAB_KEYS,
|
||||
`LAB-привязка сессии ${sessionId}`,
|
||||
);
|
||||
const labId = requireString(value.lab_id, `lab(${sessionId}).lab_id`, 36);
|
||||
if (!/^LAB [A-Z][A-Z0-9._-]{0,31}$/.test(labId)) {
|
||||
throw new ObservationSessionContractError("Каталог содержит некорректный LAB-маркер.");
|
||||
@@ -430,6 +587,28 @@ function decodeLabInstance(
|
||||
if (!isRecord(value.provenance)) {
|
||||
throw new ObservationSessionContractError("LAB provenance должен быть JSON-объектом.");
|
||||
}
|
||||
let replayCapability: ObservationLabReplayCapability | null;
|
||||
try {
|
||||
replayCapability = hasTypedCapability
|
||||
? decodeLabReplayCapability(value.replay_capability, sessionId)
|
||||
: decodeRollingCanonicalLabReplayCapability(value.provenance, sessionId, resultId);
|
||||
} catch (error) {
|
||||
if (error instanceof ObservationLabReplayCapabilityContractError) {
|
||||
throw new ObservationSessionContractError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let calculationProfile: ObservationLabCalculationProfile | null;
|
||||
try {
|
||||
calculationProfile = hasCalculationProfile
|
||||
? decodeObservationLabCalculationProfile(value.calculation_profile, sessionId)
|
||||
: null;
|
||||
} catch (error) {
|
||||
if (error instanceof ObservationLabCalculationProfileContractError) {
|
||||
throw new ObservationSessionContractError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
labId,
|
||||
sourceSessionId,
|
||||
@@ -445,6 +624,8 @@ function decodeLabInstance(
|
||||
value.published_at_utc,
|
||||
`lab(${sessionId}).published_at_utc`,
|
||||
),
|
||||
replayCapability,
|
||||
calculationProfile,
|
||||
provenance: value.provenance,
|
||||
};
|
||||
}
|
||||
@@ -1181,6 +1362,7 @@ export async function fetchObservationSessionCatalog({
|
||||
queryParameters.set("limit", String(Number(limit)));
|
||||
}
|
||||
if (scope !== "all") queryParameters.set("scope", scope);
|
||||
if (scope === "laboratory") queryParameters.set("lab_contract", "v3");
|
||||
const serializedQuery = queryParameters.toString();
|
||||
const query = serializedQuery ? `?${serializedQuery}` : "";
|
||||
let response: Response;
|
||||
|
||||
@@ -12,6 +12,16 @@ import {
|
||||
type ObservationSessionScope,
|
||||
type ObservationSessionSummary,
|
||||
} from "./sessionArchive";
|
||||
import {
|
||||
createObservationReplayCoordinator,
|
||||
type ObservationReplayCoordinator,
|
||||
} from "./replayCoordinator";
|
||||
|
||||
export {
|
||||
createObservationReplayCoordinator,
|
||||
type ObservationReplayAttempt,
|
||||
type ObservationReplayCoordinator,
|
||||
} from "./replayCoordinator";
|
||||
|
||||
export type ObservationSessionsLoadState = "idle" | "loading" | "ready" | "error";
|
||||
export type ObservationReplayOutcome = "accepted" | "error" | "cancelled";
|
||||
@@ -41,17 +51,6 @@ export interface ObservationSessionsController {
|
||||
remove: (sessionId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ObservationReplayAttempt {
|
||||
readonly signal: AbortSignal;
|
||||
isCurrent: () => boolean;
|
||||
finish: () => boolean;
|
||||
}
|
||||
|
||||
export interface ObservationReplayCoordinator {
|
||||
begin: () => ObservationReplayAttempt;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export async function deleteObservationSessionAfterTeardown(
|
||||
sessionId: string,
|
||||
{
|
||||
@@ -93,41 +92,6 @@ export class ObservationPreparationStalledError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** Latest selection wins, even if an obsolete server job finishes later. */
|
||||
export function createObservationReplayCoordinator(): ObservationReplayCoordinator {
|
||||
let sequence = 0;
|
||||
let active: AbortController | null = null;
|
||||
|
||||
return {
|
||||
begin() {
|
||||
active?.abort();
|
||||
const controller = new AbortController();
|
||||
const attemptSequence = ++sequence;
|
||||
active = controller;
|
||||
return {
|
||||
signal: controller.signal,
|
||||
isCurrent: () => (
|
||||
!controller.signal.aborted &&
|
||||
active === controller &&
|
||||
sequence === attemptSequence
|
||||
),
|
||||
finish: () => {
|
||||
if (active !== controller || sequence !== attemptSequence) return false;
|
||||
active = null;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
cancel() {
|
||||
active?.abort();
|
||||
// Keep ownership until the cancelled attempt reaches `finish()`. This
|
||||
// lets its finally block settle a replacement that already passed
|
||||
// onReplayBegin, while `isCurrent()` still fails immediately because the
|
||||
// signal is aborted. A later `begin()` replaces and invalidates it.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
|
||||
@@ -67,20 +67,31 @@ export interface RecordedSessionRerunProfile {
|
||||
viewResetGeneration: 0 | 1;
|
||||
followTrajectory: boolean;
|
||||
perceptionLayers: RecordedPerceptionLayers;
|
||||
/** Explicit small blueprint endpoint when the viewer source is a merged LAB RRD. */
|
||||
blueprintSourceUrl?: string;
|
||||
/** Optional immutable RRD sidecar for LAB/model evidence on the same recording clock. */
|
||||
perceptionSourceUrl?: string;
|
||||
/** Selects one semantic entity without changing the sealed sidecar. */
|
||||
semanticLayer?: "city" | "vegetation";
|
||||
/** Keeps one native Rerun store/viewer while presenting the accepted two-pane LAB layout. */
|
||||
unifiedPerception?: boolean;
|
||||
/** Requests the canonical top-down eye without changing the world coordinate frame. */
|
||||
planView?: boolean;
|
||||
perceptionRetryGeneration: number;
|
||||
lockPerceptionCameraInteraction: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* LAB recorded evidence is not a native Rerun mode. It owns a source-sequence
|
||||
* clock and composes the existing sealed fMP4 and retained spatial primitives.
|
||||
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
||||
* been republished as a native Rerun sidecar. It must never be selected by a
|
||||
* canonical LAB route or start work in the background.
|
||||
*/
|
||||
export interface LaboratoryRecordedEvidenceViewerProfile {
|
||||
kind: "lab-recorded-evidence";
|
||||
clock: "source-sequence";
|
||||
cameraTransport: "generation-bound-fmp4";
|
||||
spatialTransport: "bounded-sealed-artifacts";
|
||||
loadPolicy: "visible-evidence-only";
|
||||
loadPolicy: "explicit-legacy-comparison-only";
|
||||
workerRequired: false;
|
||||
}
|
||||
|
||||
@@ -97,7 +108,7 @@ export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
|
||||
clock: "source-sequence",
|
||||
cameraTransport: "generation-bound-fmp4",
|
||||
spatialTransport: "bounded-sealed-artifacts",
|
||||
loadPolicy: "visible-evidence-only",
|
||||
loadPolicy: "explicit-legacy-comparison-only",
|
||||
workerRequired: false,
|
||||
} satisfies LaboratoryRecordedEvidenceViewerProfile);
|
||||
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import {
|
||||
fetchObservationSessionCatalog,
|
||||
type ObservationLabInstance,
|
||||
type ObservationSessionFetch,
|
||||
type ObservationSessionSummary,
|
||||
} from "../observation/sessionArchive";
|
||||
import {
|
||||
observatoryRecordedRunBinding,
|
||||
type ObservatoryRecordedRunBinding,
|
||||
} from "./recordedRun";
|
||||
|
||||
export interface ObservatoryEvidence {
|
||||
readonly sessionId: string;
|
||||
readonly label: string;
|
||||
readonly status: ObservationSessionSummary["status"];
|
||||
readonly publishedAtUtc: string;
|
||||
readonly lab: ObservationLabInstance;
|
||||
readonly recordedRun: ObservatoryRecordedRunBinding | null;
|
||||
}
|
||||
|
||||
export interface ObservatorySession {
|
||||
readonly source: ObservationSessionSummary;
|
||||
readonly evidence: readonly ObservatoryEvidence[];
|
||||
}
|
||||
|
||||
export interface ObservatoryCatalog {
|
||||
readonly items: readonly ObservatorySession[];
|
||||
readonly unresolvedEvidence: readonly ObservatoryEvidence[];
|
||||
readonly window: {
|
||||
readonly limit: number;
|
||||
readonly sourceCount: number;
|
||||
readonly laboratoryCount: number;
|
||||
readonly sourceLimitReached: boolean;
|
||||
readonly laboratoryLimitReached: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type ObservatoryCatalogMutation =
|
||||
| {
|
||||
readonly kind: "rename";
|
||||
readonly displayName: string;
|
||||
readonly revision: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "delete";
|
||||
readonly revision: number;
|
||||
};
|
||||
|
||||
export type ObservatoryCatalogMutationOverlay = ReadonlyMap<
|
||||
string,
|
||||
ObservatoryCatalogMutation
|
||||
>;
|
||||
|
||||
export interface ObservatoryCatalogReconciliation {
|
||||
readonly catalog: ObservatoryCatalog;
|
||||
readonly overlay: ObservatoryCatalogMutationOverlay;
|
||||
}
|
||||
|
||||
export class ObservatoryCatalogContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservatoryCatalogContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export function findObservatoryEvidence(
|
||||
catalog: ObservatoryCatalog,
|
||||
sessionId: string,
|
||||
): ObservatoryEvidence | null {
|
||||
for (const item of catalog.items) {
|
||||
const evidence = item.evidence.find(
|
||||
(candidate) => candidate.sessionId === sessionId,
|
||||
);
|
||||
if (evidence) return evidence;
|
||||
}
|
||||
return catalog.unresolvedEvidence.find(
|
||||
(candidate) => candidate.sessionId === sessionId,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function observatoryCatalogConfirmsEvidenceDeletion(
|
||||
catalog: ObservatoryCatalog,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
return !catalog.window.laboratoryLimitReached
|
||||
&& findObservatoryEvidence(catalog, sessionId) === null;
|
||||
}
|
||||
|
||||
export function applyObservatoryCatalogMutationOverlay(
|
||||
catalog: ObservatoryCatalog,
|
||||
overlay: ObservatoryCatalogMutationOverlay,
|
||||
): ObservatoryCatalog {
|
||||
if (overlay.size === 0) return catalog;
|
||||
|
||||
const removedSessionIds = new Set<string>();
|
||||
const projectEvidence = (
|
||||
evidence: ObservatoryEvidence,
|
||||
): ObservatoryEvidence | null => {
|
||||
const mutation = overlay.get(evidence.sessionId);
|
||||
if (!mutation) return evidence;
|
||||
if (mutation.kind === "delete") {
|
||||
removedSessionIds.add(evidence.sessionId);
|
||||
return null;
|
||||
}
|
||||
return evidence.label === mutation.displayName
|
||||
? evidence
|
||||
: { ...evidence, label: mutation.displayName };
|
||||
};
|
||||
const projectEvidenceList = (
|
||||
evidence: readonly ObservatoryEvidence[],
|
||||
): ObservatoryEvidence[] => evidence.flatMap((candidate) => {
|
||||
const projected = projectEvidence(candidate);
|
||||
return projected ? [projected] : [];
|
||||
});
|
||||
|
||||
const items = catalog.items.map((item) => ({
|
||||
...item,
|
||||
evidence: projectEvidenceList(item.evidence),
|
||||
}));
|
||||
const unresolvedEvidence = projectEvidenceList(catalog.unresolvedEvidence);
|
||||
return {
|
||||
...catalog,
|
||||
items,
|
||||
unresolvedEvidence,
|
||||
window: {
|
||||
...catalog.window,
|
||||
laboratoryCount: Math.max(
|
||||
0,
|
||||
catalog.window.laboratoryCount - removedSessionIds.size,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileObservatoryCatalogMutationOverlay(
|
||||
serverCatalog: ObservatoryCatalog,
|
||||
overlay: ObservatoryCatalogMutationOverlay,
|
||||
requestMutationRevision: number,
|
||||
): ObservatoryCatalogReconciliation {
|
||||
const remaining = new Map(overlay);
|
||||
for (const [sessionId, mutation] of overlay) {
|
||||
if (mutation.revision > requestMutationRevision) continue;
|
||||
const evidence = findObservatoryEvidence(serverCatalog, sessionId);
|
||||
const confirmed = mutation.kind === "rename"
|
||||
? evidence?.label === mutation.displayName
|
||||
: observatoryCatalogConfirmsEvidenceDeletion(serverCatalog, sessionId);
|
||||
if (confirmed) remaining.delete(sessionId);
|
||||
}
|
||||
return {
|
||||
catalog: applyObservatoryCatalogMutationOverlay(serverCatalog, remaining),
|
||||
overlay: remaining,
|
||||
};
|
||||
}
|
||||
|
||||
function newestFirst(left: string, right: string): number {
|
||||
return Date.parse(right) - Date.parse(left);
|
||||
}
|
||||
|
||||
function boundedLimit(limit: number): number {
|
||||
return Number.isFinite(limit)
|
||||
? Math.min(100, Math.max(1, Math.floor(limit)))
|
||||
: 100;
|
||||
}
|
||||
|
||||
function evidenceFromSession(
|
||||
session: ObservationSessionSummary,
|
||||
): ObservatoryEvidence {
|
||||
if (!session.lab) {
|
||||
throw new ObservatoryCatalogContractError(
|
||||
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
sessionId: session.id,
|
||||
label: session.label,
|
||||
status: session.status,
|
||||
publishedAtUtc: session.lab.publishedAtUtc,
|
||||
lab: session.lab,
|
||||
recordedRun: observatoryRecordedRunBinding(session.id, session.lab),
|
||||
};
|
||||
}
|
||||
|
||||
function sortEvidence(
|
||||
evidence: readonly ObservatoryEvidence[],
|
||||
): ObservatoryEvidence[] {
|
||||
return [...evidence].sort((left, right) =>
|
||||
newestFirst(left.publishedAtUtc, right.publishedAtUtc)
|
||||
|| left.sessionId.localeCompare(right.sessionId));
|
||||
}
|
||||
|
||||
export function buildObservatoryCatalog(
|
||||
sourceSessions: readonly ObservationSessionSummary[],
|
||||
laboratorySessions: readonly ObservationSessionSummary[],
|
||||
limit = 100,
|
||||
): ObservatoryCatalog {
|
||||
const safeLimit = boundedLimit(limit);
|
||||
const sources = new Map<string, ObservationSessionSummary>();
|
||||
for (const source of sourceSessions) {
|
||||
if (source.lab !== null) {
|
||||
throw new ObservatoryCatalogContractError(
|
||||
`Каталог источников вернул LAB-сессию ${source.id}.`,
|
||||
);
|
||||
}
|
||||
sources.set(source.id, source);
|
||||
}
|
||||
|
||||
const linked = new Map<string, ObservatoryEvidence[]>();
|
||||
const unresolvedEvidence: ObservatoryEvidence[] = [];
|
||||
for (const laboratorySession of laboratorySessions) {
|
||||
const evidence = evidenceFromSession(laboratorySession);
|
||||
const sourceId = evidence.lab.sourceSessionId;
|
||||
if (!sources.has(sourceId)) {
|
||||
// Both API projections are bounded newest-first windows without a
|
||||
// cursor. Absence from the source window is not proof of a broken
|
||||
// relationship and must not be presented as an integrity fault.
|
||||
unresolvedEvidence.push(evidence);
|
||||
continue;
|
||||
}
|
||||
const current = linked.get(sourceId) ?? [];
|
||||
current.push(evidence);
|
||||
linked.set(sourceId, current);
|
||||
}
|
||||
|
||||
const items = [...sources.values()]
|
||||
.sort((left, right) =>
|
||||
newestFirst(left.startedAtUtc, right.startedAtUtc)
|
||||
|| left.id.localeCompare(right.id))
|
||||
.map((source) => ({
|
||||
source,
|
||||
evidence: sortEvidence(linked.get(source.id) ?? []),
|
||||
}));
|
||||
|
||||
return {
|
||||
items,
|
||||
unresolvedEvidence: sortEvidence(unresolvedEvidence),
|
||||
window: {
|
||||
limit: safeLimit,
|
||||
sourceCount: sourceSessions.length,
|
||||
laboratoryCount: laboratorySessions.length,
|
||||
sourceLimitReached: sourceSessions.length >= safeLimit,
|
||||
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchObservatoryCatalog({
|
||||
signal,
|
||||
limit = 100,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
limit?: number;
|
||||
fetcher?: ObservationSessionFetch;
|
||||
} = {}): Promise<ObservatoryCatalog> {
|
||||
const safeLimit = boundedLimit(limit);
|
||||
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
|
||||
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
|
||||
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
|
||||
]);
|
||||
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { ObservatoryRecordedRunBinding } from "./recordedRun";
|
||||
|
||||
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RENAME_SCHEMA = "missioncore.observatory-lab-projection-rename/v1";
|
||||
const PROJECTION_SCHEMA = "missioncore.observatory-lab-projection/v1";
|
||||
|
||||
export type ObservatoryCatalogMutationFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export interface ObservatoryLabProjectionMutationResult {
|
||||
readonly schemaVersion: typeof PROJECTION_SCHEMA;
|
||||
readonly sessionId: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
export class ObservatoryCatalogMutationError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryCatalogMutationError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameObservatoryLabProjection(
|
||||
binding: ObservatoryRecordedRunBinding,
|
||||
displayName: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryCatalogMutationFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLabProjectionMutationResult> {
|
||||
const sessionId = admittedProjectionId(binding);
|
||||
const normalizedName = displayName.trim();
|
||||
if (normalizedName.length < 1 || normalizedName.length > 160) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Название лабораторного результата должно содержать от 1 до 160 символов.",
|
||||
);
|
||||
}
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
schema_version: RENAME_SCHEMA,
|
||||
display_name: normalizedName,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
"Не удалось переименовать лабораторный результат.",
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok || response.status !== 200) {
|
||||
throw apiError(
|
||||
body,
|
||||
`Переименование лабораторного результата вернуло HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const decoded = decodeProjection(body);
|
||||
if (decoded.sessionId !== sessionId || decoded.displayName !== normalizedName) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер не подтвердил точное переименование выбранного результата.",
|
||||
);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export async function deleteObservatoryLabProjection(
|
||||
binding: ObservatoryRecordedRunBinding,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryCatalogMutationFetch;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const sessionId = admittedProjectionId(binding);
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
"Не удалось удалить лабораторный результат из Обсерватории.",
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok || response.status !== 204) {
|
||||
throw apiError(
|
||||
body,
|
||||
`Удаление лабораторного результата вернуло HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (body !== undefined) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул данные после подтверждённого удаления лабораторного результата.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string {
|
||||
const sessionId = binding.evidenceSessionId;
|
||||
const admittedViewer = (
|
||||
binding.kind === "canonical-recorded-rerun"
|
||||
&& binding.viewerProfile === "recorded-session"
|
||||
&& binding.timeline === "session_time"
|
||||
) || (
|
||||
binding.kind === "portable-result-review"
|
||||
&& binding.viewerProfile === "portable-result"
|
||||
&& binding.timeline === "result-defined"
|
||||
);
|
||||
if (
|
||||
!admittedViewer
|
||||
|| binding.activation !== "explicit"
|
||||
|| binding.resultId !== sessionId
|
||||
|| binding.sourceSessionId === sessionId
|
||||
|| !SAFE_SESSION_ID.test(sessionId)
|
||||
) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Выбранный результат не допущен к изменению каталога Обсерватории.",
|
||||
);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function request(
|
||||
fetcher: ObservatoryCatalogMutationFetch,
|
||||
input: string,
|
||||
init: RequestInit,
|
||||
fallback: string,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryCatalogMutationError(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text) return undefined;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(body: unknown, fallback: string, status: number): Error {
|
||||
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
|
||||
return new ObservatoryCatalogMutationError(body.detail.trim(), status);
|
||||
}
|
||||
if (typeof body === "string" && body.trim()) {
|
||||
return new ObservatoryCatalogMutationError(body.trim(), status);
|
||||
}
|
||||
return new ObservatoryCatalogMutationError(fallback, status);
|
||||
}
|
||||
|
||||
function decodeProjection(value: unknown): ObservatoryLabProjectionMutationResult {
|
||||
if (!isRecord(value)) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул некорректное подтверждение лабораторного результата.",
|
||||
);
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const expected = ["display_name", "schema_version", "session_id"];
|
||||
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Подтверждение лабораторного результата содержит неизвестные поля.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.schema_version !== PROJECTION_SCHEMA
|
||||
|| typeof value.session_id !== "string"
|
||||
|| !SAFE_SESSION_ID.test(value.session_id)
|
||||
|| typeof value.display_name !== "string"
|
||||
|| value.display_name.trim() !== value.display_name
|
||||
|| value.display_name.length < 1
|
||||
|| value.display_name.length > 160
|
||||
) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул неподдерживаемый контракт лабораторного результата.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
schemaVersion: PROJECTION_SCHEMA,
|
||||
sessionId: value.session_id,
|
||||
displayName: value.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
export { fetchObservatoryPortableLaboratorySetups } from "./portableLaboratorySetups";
|
||||
|
||||
const CATALOG_SCHEMA = "missioncore.observatory-laboratory-setup-catalog/v1";
|
||||
const PREFLIGHT_REQUEST_SCHEMA = "missioncore.observatory-run-preflight-request/v1";
|
||||
const PREFLIGHT_SCHEMA = "missioncore.observatory-run-preflight/v1";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type ObservatoryLaboratorySetupOrigin =
|
||||
| "archived-definition"
|
||||
| "existing-result"
|
||||
| "portable-definition";
|
||||
export type ObservatoryLaboratorySetupAction =
|
||||
| "open-existing"
|
||||
| "open-legacy"
|
||||
| "check"
|
||||
| "blocked";
|
||||
|
||||
export interface ObservatoryLaboratoryRunDefinition {
|
||||
readonly definitionId: string;
|
||||
readonly version: number;
|
||||
readonly workId: string | null;
|
||||
readonly definitionSha256: string;
|
||||
readonly resultSchema: string | null;
|
||||
readonly resultKind: string | null;
|
||||
readonly models: readonly {
|
||||
readonly name: string;
|
||||
readonly releaseId: string;
|
||||
readonly modelId: string;
|
||||
readonly architecture: string;
|
||||
}[];
|
||||
readonly configuration: readonly {
|
||||
readonly role: string;
|
||||
readonly sha256: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratoryPreservedResult {
|
||||
readonly resultId: string;
|
||||
readonly resultKind: string;
|
||||
readonly relation: string;
|
||||
readonly access: "legacy-lab" | "observatory" | "evidence-only";
|
||||
readonly createdAtUtc: string;
|
||||
readonly observatoryProjectionAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratorySetup {
|
||||
readonly setupId: string;
|
||||
readonly displayName: string;
|
||||
readonly description: string;
|
||||
readonly origin: ObservatoryLaboratorySetupOrigin;
|
||||
readonly runDefinition: ObservatoryLaboratoryRunDefinition | null;
|
||||
readonly compatibility: {
|
||||
readonly compatible: boolean;
|
||||
readonly reasons: readonly { readonly code: string; readonly message: string }[];
|
||||
};
|
||||
readonly executor: {
|
||||
readonly contourId: string;
|
||||
readonly state: "not-installed" | "ready";
|
||||
readonly reasonCode: string;
|
||||
readonly reason: string;
|
||||
};
|
||||
readonly preservedResults: readonly ObservatoryLaboratoryPreservedResult[];
|
||||
readonly preflight: {
|
||||
readonly outcome: "existing" | "ready" | "blocked";
|
||||
readonly action: ObservatoryLaboratorySetupAction;
|
||||
readonly reason: string;
|
||||
readonly submissionAllowed: boolean;
|
||||
readonly existingResultIds: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratorySetupCatalog {
|
||||
readonly sourceSessionId: string;
|
||||
readonly setups: readonly ObservatoryLaboratorySetup[];
|
||||
}
|
||||
|
||||
// Cache identity is verified by the portable catalog decoder/server, never by a LAB label.
|
||||
export function selectableObservatorySetups(catalog: ObservatoryLaboratorySetupCatalog | null) {
|
||||
return catalog?.setups.filter((setup) => (
|
||||
setup.origin === "portable-definition"
|
||||
&& setup.runDefinition !== null
|
||||
&& setup.compatibility.compatible
|
||||
&& setup.preflight.outcome !== "existing"
|
||||
)) ?? [];
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratoryRunPreflight {
|
||||
readonly sourceSessionId: string;
|
||||
readonly setupId: string;
|
||||
readonly definitionSha256: string | null;
|
||||
readonly checkSha256: string | null;
|
||||
readonly outcome: "existing" | "queueable" | "blocked";
|
||||
readonly submissionAllowed: boolean;
|
||||
readonly checks: readonly {
|
||||
readonly checkId: string;
|
||||
readonly outcome: "pass" | "fail" | "not-applicable";
|
||||
readonly reasonCode: string;
|
||||
readonly message: string;
|
||||
}[];
|
||||
readonly existingResultIds: readonly string[];
|
||||
}
|
||||
|
||||
export type ObservatoryLaboratorySetupFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export class ObservatoryLaboratorySetupContractError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryLaboratorySetupContractError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchObservatoryLaboratorySetups(
|
||||
sourceSessionId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratorySetupCatalog> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const catalog = decodeCatalog(body);
|
||||
if (catalog.sourceSessionId !== sourceSessionId) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Каталог сетапов относится к другой исходной сессии.",
|
||||
);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export async function preflightObservatoryLaboratorySetup(
|
||||
sourceSessionId: string,
|
||||
setup: ObservatoryLaboratorySetup,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratoryRunPreflight> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
"/api/v1/observatory/run-preflights",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: PREFLIGHT_REQUEST_SCHEMA,
|
||||
source_session_id: sourceSessionId,
|
||||
setup_id: setup.setupId,
|
||||
definition_sha256: setup.runDefinition?.definitionSha256 ?? null,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const preflight = decodePreflight(body);
|
||||
if (preflight.sourceSessionId !== sourceSessionId || preflight.setupId !== setup.setupId) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Preflight относится к другому источнику или сетапу.",
|
||||
);
|
||||
}
|
||||
if (preflight.definitionSha256 !== (setup.runDefinition?.definitionSha256 ?? null)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Preflight относится к другой версии сетапа.",
|
||||
);
|
||||
}
|
||||
return preflight;
|
||||
}
|
||||
|
||||
function decodeCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
|
||||
const row = record(value, "каталог сетапов");
|
||||
exactKeys(row, ["authority", "schema_version", "setups", "source_session_id"], "каталог сетапов");
|
||||
exact(row.schema_version, CATALOG_SCHEMA, "schema_version каталога сетапов");
|
||||
observationAuthority(row.authority);
|
||||
return {
|
||||
sourceSessionId: text(row.source_session_id, "source_session_id"),
|
||||
setups: array(row.setups, "setups").map(decodeSetup),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSetup(value: unknown): ObservatoryLaboratorySetup {
|
||||
const row = record(value, "сетап");
|
||||
exactKeys(row, [
|
||||
"authority", "compatibility", "description", "display_name", "executor", "origin",
|
||||
"preflight", "preserved_results", "run_definition", "setup_id", "source",
|
||||
], "сетап");
|
||||
observationAuthority(row.authority);
|
||||
const origin = oneOf(row.origin, ["archived-definition", "existing-result"] as const, "origin");
|
||||
const compatibility = record(row.compatibility, "compatibility");
|
||||
const executor = record(row.executor, "executor");
|
||||
const preflight = record(row.preflight, "preflight");
|
||||
const source = record(row.source, "source");
|
||||
const runDefinition = row.run_definition === null
|
||||
? null
|
||||
: decodeRunDefinition(row.run_definition);
|
||||
if ((origin === "archived-definition") !== (runDefinition !== null)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Происхождение сетапа не совпадает с RunDefinition.",
|
||||
);
|
||||
}
|
||||
exactKeys(source, ["label", "required_modalities", "session_id"], "source");
|
||||
text(source.session_id, "source.session_id");
|
||||
text(source.label, "source.label");
|
||||
array(source.required_modalities, "source.required_modalities").forEach((item) => text(item, "source modality"));
|
||||
exactKeys(compatibility, ["compatible", "reasons"], "compatibility");
|
||||
exactKeys(executor, ["contour_id", "reason", "reason_code", "state"], "executor");
|
||||
exactKeys(preflight, ["action", "existing_result_ids", "outcome", "reason", "submission_allowed"], "preflight");
|
||||
return {
|
||||
setupId: text(row.setup_id, "setup_id"),
|
||||
displayName: text(row.display_name, "display_name"),
|
||||
description: text(row.description, "description"),
|
||||
origin,
|
||||
runDefinition,
|
||||
compatibility: {
|
||||
compatible: boolean(row.compatibility && compatibility.compatible, "compatible"),
|
||||
reasons: array(compatibility.reasons, "compatibility.reasons").map((item) => {
|
||||
const reason = record(item, "compatibility reason");
|
||||
exactKeys(reason, ["code", "message"], "compatibility reason");
|
||||
return { code: text(reason.code, "reason.code"), message: text(reason.message, "reason.message") };
|
||||
}),
|
||||
},
|
||||
executor: {
|
||||
contourId: text(executor.contour_id, "executor.contour_id"),
|
||||
state: exact(executor.state, "not-installed", "executor.state"),
|
||||
reasonCode: text(executor.reason_code, "executor.reason_code"),
|
||||
reason: text(executor.reason, "executor.reason"),
|
||||
},
|
||||
preservedResults: array(row.preserved_results, "preserved_results").map(decodePreservedResult),
|
||||
preflight: {
|
||||
outcome: oneOf(preflight.outcome, ["existing", "blocked"] as const, "preflight.outcome"),
|
||||
action: oneOf(preflight.action, ["open-existing", "open-legacy", "blocked"] as const, "preflight.action"),
|
||||
reason: text(preflight.reason, "preflight.reason"),
|
||||
submissionAllowed: exact(preflight.submission_allowed, false, "preflight.submission_allowed"),
|
||||
existingResultIds: array(preflight.existing_result_ids, "preflight.existing_result_ids").map((item) => text(item, "existing result id")),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
|
||||
const row = record(value, "RunDefinition");
|
||||
exactKeys(row, [
|
||||
"authority", "configuration", "definition_id", "definition_sha256", "schema_version",
|
||||
"source", "version", "work_id",
|
||||
], "RunDefinition");
|
||||
exact(row.schema_version, "missioncore.observatory-run-definition/v1", "RunDefinition schema");
|
||||
observationAuthority(row.authority);
|
||||
const source = record(row.source, "RunDefinition source");
|
||||
exactKeys(source, ["label", "required_modalities", "session_id"], "RunDefinition source");
|
||||
text(source.session_id, "RunDefinition source.session_id");
|
||||
text(source.label, "RunDefinition source.label");
|
||||
array(source.required_modalities, "RunDefinition source.required_modalities")
|
||||
.forEach((item) => text(item, "RunDefinition source modality"));
|
||||
const digest = text(row.definition_sha256, "definition_sha256");
|
||||
if (!SHA256.test(digest)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest RunDefinition.");
|
||||
return {
|
||||
definitionId: text(row.definition_id, "definition_id"),
|
||||
version: positiveInteger(row.version, "definition version"),
|
||||
workId: text(row.work_id, "work_id"),
|
||||
definitionSha256: digest,
|
||||
resultSchema: null,
|
||||
resultKind: null,
|
||||
models: [],
|
||||
configuration: array(row.configuration, "configuration").map((item) => {
|
||||
const reference = record(item, "configuration reference");
|
||||
exactKeys(reference, ["role", "sha256"], "configuration reference");
|
||||
const sha256 = text(reference.sha256, "configuration sha256");
|
||||
if (!SHA256.test(sha256)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest конфигурации.");
|
||||
return { role: text(reference.role, "configuration role"), sha256 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePreservedResult(value: unknown): ObservatoryLaboratoryPreservedResult {
|
||||
const row = record(value, "preserved result");
|
||||
exactKeys(row, [
|
||||
"access", "created_at_utc", "observatory_projection_available", "relation",
|
||||
"result_id", "result_kind",
|
||||
], "preserved result");
|
||||
return {
|
||||
resultId: text(row.result_id, "result_id"),
|
||||
resultKind: text(row.result_kind, "result_kind"),
|
||||
relation: text(row.relation, "relation"),
|
||||
access: oneOf(row.access, ["legacy-lab", "observatory", "evidence-only"] as const, "result access"),
|
||||
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
|
||||
observatoryProjectionAvailable: boolean(row.observatory_projection_available, "observatory projection availability"),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight {
|
||||
const row = record(value, "preflight");
|
||||
const baseKeys = [
|
||||
"authority", "checks", "definition_sha256", "executor", "existing_result_ids",
|
||||
"outcome", "schema_version", "setup_id", "source_session_id", "submission_allowed",
|
||||
] as const;
|
||||
const hasPortableCheck = Object.hasOwn(row, "check_sha256");
|
||||
exactKeys(
|
||||
row,
|
||||
hasPortableCheck ? [...baseKeys, "check_sha256"] : baseKeys,
|
||||
"preflight",
|
||||
);
|
||||
exact(row.schema_version, PREFLIGHT_SCHEMA, "preflight schema");
|
||||
observationAuthority(row.authority);
|
||||
const digest = row.definition_sha256;
|
||||
if (digest !== null && (typeof digest !== "string" || !SHA256.test(digest))) {
|
||||
throw new ObservatoryLaboratorySetupContractError("Некорректный digest preflight.");
|
||||
}
|
||||
const checkDigest = hasPortableCheck ? row.check_sha256 : null;
|
||||
if (
|
||||
checkDigest !== null
|
||||
&& (typeof checkDigest !== "string" || !SHA256.test(checkDigest))
|
||||
) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Некорректный check digest preflight.",
|
||||
);
|
||||
}
|
||||
const outcome = oneOf(
|
||||
row.outcome,
|
||||
["existing", "queueable", "blocked"] as const,
|
||||
"preflight outcome",
|
||||
);
|
||||
const submissionAllowed = boolean(
|
||||
row.submission_allowed,
|
||||
"preflight submission_allowed",
|
||||
);
|
||||
if (
|
||||
submissionAllowed !== (outcome === "queueable")
|
||||
|| (hasPortableCheck && outcome === "queueable" && checkDigest === null)
|
||||
) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Preflight содержит противоречивое разрешение постановки в очередь.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
sourceSessionId: text(row.source_session_id, "preflight source_session_id"),
|
||||
setupId: text(row.setup_id, "preflight setup_id"),
|
||||
definitionSha256: digest,
|
||||
checkSha256: checkDigest,
|
||||
outcome,
|
||||
submissionAllowed,
|
||||
checks: array(row.checks, "preflight checks").map((item) => {
|
||||
const check = record(item, "preflight check");
|
||||
exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check");
|
||||
return {
|
||||
checkId: text(check.check_id, "check_id"),
|
||||
outcome: oneOf(check.outcome, ["pass", "fail", "not-applicable"] as const, "check outcome"),
|
||||
reasonCode: text(check.reason_code, "check reason_code"),
|
||||
message: text(check.message, "check message"),
|
||||
};
|
||||
}),
|
||||
existingResultIds: array(row.existing_result_ids, "existing_result_ids").map((item) => text(item, "existing result id")),
|
||||
};
|
||||
}
|
||||
|
||||
function observationAuthority(value: unknown): void {
|
||||
const row = record(value, "authority");
|
||||
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
|
||||
exactKeys(row, keys, "authority");
|
||||
for (const key of keys) {
|
||||
exact(row[key], false, `authority.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function request(fetcher: ObservatoryLaboratorySetupFetch, input: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryLaboratorySetupContractError("Каталог сетапов недоступен.");
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const textBody = await response.text();
|
||||
if (!textBody) return undefined;
|
||||
try { return JSON.parse(textBody) as unknown; } catch { return textBody; }
|
||||
}
|
||||
|
||||
function apiError(body: unknown, status: number): ObservatoryLaboratorySetupContractError {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>).detail
|
||||
: null;
|
||||
return new ObservatoryLaboratorySetupContractError(
|
||||
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался текст.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 1) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидалось положительное целое.`);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) throw new ObservatoryLaboratorySetupContractError(`${label}: значение изменилось.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
|
||||
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(`${label}: значение не поддерживается.`);
|
||||
}
|
||||
return value as T[number];
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
|
||||
throw new ObservatoryLaboratorySetupContractError(`${label}: обнаружены неизвестные поля.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import type {
|
||||
ObservatoryLaboratoryPreservedResult,
|
||||
ObservatoryLaboratoryRunDefinition,
|
||||
ObservatoryLaboratorySetup,
|
||||
ObservatoryLaboratorySetupCatalog,
|
||||
} from "./laboratorySetups";
|
||||
|
||||
const PORTABLE_CATALOG_SCHEMA = "missioncore.observatory-portable-setup-catalog/v2";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export class ObservatoryPortableSetupDecodeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservatoryPortableSetupDecodeError";
|
||||
}
|
||||
}
|
||||
|
||||
export function decodePortableCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
|
||||
const row = record(value, "portable-каталог сетапов");
|
||||
exactKeys(
|
||||
row,
|
||||
["authority", "schema_version", "setups", "source_session_id"],
|
||||
"portable-каталог сетапов",
|
||||
);
|
||||
exact(row.schema_version, PORTABLE_CATALOG_SCHEMA, "schema_version portable-каталога");
|
||||
observationAuthority(row.authority);
|
||||
const sourceSessionId = text(row.source_session_id, "portable source_session_id");
|
||||
return {
|
||||
sourceSessionId,
|
||||
setups: array(row.setups, "portable setups").map((item) => decodePortableSetup(item, sourceSessionId)),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePortableSetup(value: unknown, sourceSessionId: string): ObservatoryLaboratorySetup {
|
||||
const row = record(value, "portable-сетап");
|
||||
exactKeys(row, [
|
||||
"authority", "description", "display_name", "executor", "existing_results",
|
||||
"origin", "preflight", "run_definition", "setup_id", "source_compatibility",
|
||||
"source_requirements",
|
||||
], "portable-сетап");
|
||||
exact(row.origin, "portable-definition", "portable origin");
|
||||
observationAuthority(row.authority);
|
||||
decodePortableSourceRequirements(row.source_requirements);
|
||||
const setupId = text(row.setup_id, "portable setup_id");
|
||||
const runDefinition = decodePortableRunDefinition(row.run_definition);
|
||||
|
||||
const compatibility = record(row.source_compatibility, "portable source_compatibility");
|
||||
exactKeys(
|
||||
compatibility,
|
||||
["compatible", "outcome", "reason", "reason_code"],
|
||||
"portable source_compatibility",
|
||||
);
|
||||
const compatible = boolean(compatibility.compatible, "portable compatible");
|
||||
exact(
|
||||
compatibility.outcome,
|
||||
compatible ? "pass" : "blocked",
|
||||
"portable compatibility outcome",
|
||||
);
|
||||
const compatibilityReason = text(compatibility.reason, "portable compatibility reason");
|
||||
const compatibilityReasonCode = text(
|
||||
compatibility.reason_code,
|
||||
"portable compatibility reason_code",
|
||||
);
|
||||
|
||||
const executor = record(row.executor, "portable executor");
|
||||
exactKeys(
|
||||
executor,
|
||||
["contour_id", "ready", "reason", "reason_code", "state"],
|
||||
"portable executor",
|
||||
);
|
||||
const executorState = oneOf(
|
||||
executor.state,
|
||||
["not-installed", "ready"] as const,
|
||||
"portable executor state",
|
||||
);
|
||||
const executorReady = boolean(executor.ready, "portable executor ready");
|
||||
if (executorReady !== (executorState === "ready")) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable executor: состояние готовности противоречиво.",
|
||||
);
|
||||
}
|
||||
const executorReason = executor.reason === null
|
||||
? "Исполнитель установлен."
|
||||
: text(executor.reason, "portable executor reason");
|
||||
const executorReasonCode = executor.reason_code === null
|
||||
? "portable-executor-ready"
|
||||
: text(executor.reason_code, "portable executor reason_code");
|
||||
if (
|
||||
(executorReady && (executor.reason !== null || executor.reason_code !== null))
|
||||
|| (!executorReady && (executor.reason === null || executor.reason_code === null))
|
||||
) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable executor: причина недоступности противоречит состоянию.",
|
||||
);
|
||||
}
|
||||
|
||||
const preflight = record(row.preflight, "portable preflight");
|
||||
exactKeys(preflight, [
|
||||
"action", "existing_result_ids", "outcome", "reason", "submission_allowed",
|
||||
], "portable preflight");
|
||||
const preflightOutcome = oneOf(
|
||||
preflight.outcome,
|
||||
["existing", "ready", "blocked"] as const,
|
||||
"portable preflight outcome",
|
||||
);
|
||||
const preflightAction = oneOf(
|
||||
preflight.action,
|
||||
["open-existing", "check", "blocked"] as const,
|
||||
"portable preflight action",
|
||||
);
|
||||
const submissionAllowed = boolean(
|
||||
preflight.submission_allowed,
|
||||
"portable preflight submission_allowed",
|
||||
);
|
||||
if (
|
||||
submissionAllowed !== (preflightOutcome === "ready")
|
||||
|| (preflightOutcome === "ready" && preflightAction !== "check")
|
||||
|| (preflightOutcome === "blocked" && preflightAction !== "blocked")
|
||||
|| (preflightOutcome === "existing" && preflightAction !== "open-existing")
|
||||
|| (submissionAllowed && (!compatible || !executorReady))
|
||||
) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable preflight: состояние запуска противоречиво.",
|
||||
);
|
||||
}
|
||||
const existingResults = array(row.existing_results, "portable existing_results").map(
|
||||
(item) => decodePortableResult(item, sourceSessionId, setupId, runDefinition),
|
||||
);
|
||||
const existingResultIds = array(
|
||||
preflight.existing_result_ids,
|
||||
"portable existing_result_ids",
|
||||
).map((item) => text(item, "portable existing result id"));
|
||||
if (
|
||||
(existingResults.length > 0) !== (preflightOutcome === "existing")
|
||||
|| existingResultIds.length !== existingResults.length
|
||||
|| new Set(existingResultIds).size !== existingResultIds.length
|
||||
|| existingResults.some((result, index) => result.resultId !== existingResultIds[index])
|
||||
) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable result: готовность не соответствует проверенным результатам.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
setupId,
|
||||
displayName: text(row.display_name, "portable display_name"),
|
||||
description: text(row.description, "portable description"),
|
||||
origin: "portable-definition",
|
||||
runDefinition,
|
||||
compatibility: {
|
||||
compatible,
|
||||
reasons: compatible
|
||||
? []
|
||||
: [{ code: compatibilityReasonCode, message: compatibilityReason }],
|
||||
},
|
||||
executor: {
|
||||
contourId: text(executor.contour_id, "portable executor contour_id"),
|
||||
state: executorState,
|
||||
reasonCode: executorReasonCode,
|
||||
reason: executorReason,
|
||||
},
|
||||
preservedResults: existingResults,
|
||||
preflight: {
|
||||
outcome: preflightOutcome,
|
||||
action: preflightAction,
|
||||
reason: text(preflight.reason, "portable preflight reason"),
|
||||
submissionAllowed,
|
||||
existingResultIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodePortableResult(
|
||||
value: unknown, sourceSessionId: string, setupId: string,
|
||||
definition: ObservatoryLaboratoryRunDefinition,
|
||||
): ObservatoryLaboratoryPreservedResult {
|
||||
const row = record(value, "portable result");
|
||||
exactKeys(row, [
|
||||
"result_id", "result_kind", "relation", "access", "created_at_utc",
|
||||
"observatory_projection_available", "identity",
|
||||
], "portable result");
|
||||
exact(row.result_kind, definition.resultKind, "portable result kind");
|
||||
exact(row.relation, "exact-recorded-computation", "portable result relation");
|
||||
exact(row.access, "observatory", "portable result access");
|
||||
exact(row.observatory_projection_available, true, "portable result availability");
|
||||
const identity = record(row.identity, "portable result identity");
|
||||
exactKeys(identity, [
|
||||
"job_id", "source_session_id", "source_catalog_sha256", "source_bundle_sha256",
|
||||
"source_capability_manifest_sha256", "setup_id", "definition_sha256",
|
||||
"package_sha256", "artifact_manifest_id",
|
||||
], "portable result identity");
|
||||
exact(identity.source_session_id, sourceSessionId, "portable result source");
|
||||
exact(identity.setup_id, setupId, "portable result setup");
|
||||
exact(identity.definition_sha256, definition.definitionSha256, "portable result definition");
|
||||
text(identity.job_id, "portable result job");
|
||||
for (const key of [
|
||||
"source_catalog_sha256", "source_bundle_sha256", "source_capability_manifest_sha256",
|
||||
"definition_sha256", "package_sha256", "artifact_manifest_id",
|
||||
]) {
|
||||
if (!SHA256.test(text(identity[key], `portable result ${key}`))) {
|
||||
throw new ObservatoryPortableSetupDecodeError(`Portable result: некорректный ${key}.`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
resultId: text(row.result_id, "portable result id"),
|
||||
resultKind: text(row.result_kind, "portable result kind"),
|
||||
relation: "exact-recorded-computation",
|
||||
access: "observatory",
|
||||
createdAtUtc: text(row.created_at_utc, "portable result created_at_utc"),
|
||||
observatoryProjectionAvailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function decodePortableSourceRequirements(value: unknown): void {
|
||||
const row = record(value, "portable source_requirements");
|
||||
exactKeys(row, [
|
||||
"archive_id", "calibration_identity_sha256", "calibration_slot", "camera_height",
|
||||
"camera_semantic_channel_id", "camera_source_id", "camera_width",
|
||||
"exactly_one_media_epoch", "plugin_id", "recorded_media_init_sha256",
|
||||
"recorded_media_type", "required_modalities", "seekable",
|
||||
], "portable source_requirements");
|
||||
for (const key of [
|
||||
"archive_id", "calibration_slot", "camera_semantic_channel_id", "camera_source_id",
|
||||
"plugin_id", "recorded_media_type",
|
||||
]) text(row[key], `portable source_requirements.${key}`);
|
||||
for (const key of ["calibration_identity_sha256", "recorded_media_init_sha256"]) {
|
||||
const digest = text(row[key], `portable source_requirements.${key}`);
|
||||
if (!SHA256.test(digest)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`portable source_requirements.${key}: некорректный digest.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
positiveInteger(row.camera_width, "portable camera_width");
|
||||
positiveInteger(row.camera_height, "portable camera_height");
|
||||
exact(row.exactly_one_media_epoch, true, "portable exactly_one_media_epoch");
|
||||
exact(row.seekable, true, "portable seekable");
|
||||
array(row.required_modalities, "portable required_modalities")
|
||||
.forEach((item) => text(item, "portable required modality"));
|
||||
}
|
||||
|
||||
function decodePortableRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
|
||||
const row = record(value, "portable RunDefinition");
|
||||
exactKeys(row, [
|
||||
"definition_id", "definition_sha256", "models", "result_kind", "result_schema",
|
||||
"version",
|
||||
], "portable RunDefinition");
|
||||
const digest = text(row.definition_sha256, "portable definition_sha256");
|
||||
if (!SHA256.test(digest)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
"Portable RunDefinition: некорректный digest.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
definitionId: text(row.definition_id, "portable definition_id"),
|
||||
version: positiveInteger(row.version, "portable definition version"),
|
||||
workId: null,
|
||||
definitionSha256: digest,
|
||||
resultSchema: text(row.result_schema, "portable result_schema"),
|
||||
resultKind: text(row.result_kind, "portable result_kind"),
|
||||
models: array(row.models, "portable models").map((item) => {
|
||||
const model = record(item, "portable model");
|
||||
exactKeys(
|
||||
model,
|
||||
["architecture", "model_id", "name", "release_id"],
|
||||
"portable model",
|
||||
);
|
||||
return {
|
||||
name: text(model.name, "portable model name"),
|
||||
releaseId: text(model.release_id, "portable model release_id"),
|
||||
modelId: text(model.model_id, "portable model model_id"),
|
||||
architecture: text(model.architecture, "portable model architecture"),
|
||||
};
|
||||
}),
|
||||
configuration: [],
|
||||
};
|
||||
}
|
||||
|
||||
function observationAuthority(value: unknown): void {
|
||||
const row = record(value, "authority");
|
||||
const keys = [
|
||||
"commands_enabled",
|
||||
"actuation_allowed",
|
||||
"navigation_or_safety_accepted",
|
||||
"production_accepted",
|
||||
];
|
||||
exactKeys(row, keys, "authority");
|
||||
for (const key of keys) exact(row[key], false, `authority.${key}`);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался объект.`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался массив.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался текст.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидался boolean.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 1) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: ожидалось положительное целое.`,
|
||||
);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: значение изменилось.`,
|
||||
);
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<const T extends readonly string[]>(
|
||||
value: unknown,
|
||||
allowed: T,
|
||||
label: string,
|
||||
): T[number] {
|
||||
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: значение не поддерживается.`,
|
||||
);
|
||||
}
|
||||
return value as T[number];
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: Record<string, unknown>,
|
||||
expected: readonly string[],
|
||||
label: string,
|
||||
): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
if (
|
||||
actual.length !== sortedExpected.length
|
||||
|| actual.some((key, index) => key !== sortedExpected[index])
|
||||
) {
|
||||
throw new ObservatoryPortableSetupDecodeError(
|
||||
`${label}: обнаружены неизвестные поля.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { decodePortableCatalog } from "./portableLaboratorySetupDecoder";
|
||||
import type {
|
||||
ObservatoryLaboratorySetupCatalog,
|
||||
} from "./laboratorySetups";
|
||||
|
||||
export type ObservatoryPortableLaboratorySetupFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
class ObservatoryPortableLaboratorySetupContractError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryPortableLaboratorySetupContractError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchObservatoryPortableLaboratorySetups(
|
||||
sourceSessionId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryPortableLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratorySetupCatalog> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/portable-laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const catalog = decodePortableCatalog(body);
|
||||
if (catalog.sourceSessionId !== sourceSessionId) {
|
||||
throw new ObservatoryPortableLaboratorySetupContractError(
|
||||
"Portable-каталог сетапов относится к другой исходной сессии.",
|
||||
);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
async function request(
|
||||
fetcher: ObservatoryPortableLaboratorySetupFetch,
|
||||
input: string,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryPortableLaboratorySetupContractError(
|
||||
"Portable-каталог сетапов недоступен.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const textBody = await response.text();
|
||||
if (!textBody) return undefined;
|
||||
try {
|
||||
return JSON.parse(textBody) as unknown;
|
||||
} catch {
|
||||
return textBody;
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(
|
||||
body: unknown,
|
||||
status: number,
|
||||
): ObservatoryPortableLaboratorySetupContractError {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>).detail
|
||||
: null;
|
||||
return new ObservatoryPortableLaboratorySetupContractError(
|
||||
typeof detail === "string" && detail.trim()
|
||||
? detail
|
||||
: `Observatory API вернул HTTP ${status}.`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
const SUBMIT_SCHEMA = "missioncore.observatory-recorded-run-submit/v1";
|
||||
const JOB_SCHEMA = "missioncore.observatory-recorded-job/v1";
|
||||
const JOB_LIST_SCHEMA = "missioncore.observatory-recorded-job-list/v1";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type ObservatoryRecordedJobState =
|
||||
| "accepted"
|
||||
| "queued"
|
||||
| "claimed"
|
||||
| "running"
|
||||
| "paused"
|
||||
| "preemption-pending"
|
||||
| "succeeded"
|
||||
| "failed"
|
||||
| "reconciliation-required";
|
||||
|
||||
export type ObservatoryRecordedJobPublicationState =
|
||||
| "not-required"
|
||||
| "pending"
|
||||
| "failed"
|
||||
| "published";
|
||||
|
||||
export interface ObservatoryRecordedJob {
|
||||
readonly jobId: string;
|
||||
readonly idempotencyKey: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly setupId: string;
|
||||
readonly definitionSha256: string;
|
||||
readonly state: ObservatoryRecordedJobState;
|
||||
readonly restartFromZero: boolean;
|
||||
readonly resultId: string | null;
|
||||
readonly terminalMessage: string | null;
|
||||
readonly publication: {
|
||||
readonly state: ObservatoryRecordedJobPublicationState;
|
||||
readonly attempts: number;
|
||||
readonly error: string | null;
|
||||
readonly publishedAtUtc: string | null;
|
||||
};
|
||||
readonly createdAtUtc: string;
|
||||
readonly updatedAtUtc: string;
|
||||
}
|
||||
|
||||
export type ObservatoryRecordedJobFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export class ObservatoryRecordedJobContractError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryRecordedJobContractError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchObservatoryRecordedJobs(
|
||||
sourceSessionId: string,
|
||||
setupId: string,
|
||||
{
|
||||
signal,
|
||||
definitionSha256,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
definitionSha256?: string;
|
||||
fetcher?: ObservatoryRecordedJobFetch;
|
||||
} = {},
|
||||
): Promise<readonly ObservatoryRecordedJob[]> {
|
||||
const query = new URLSearchParams({
|
||||
source_session_id: sourceSessionId,
|
||||
setup_id: setupId,
|
||||
limit: "20",
|
||||
});
|
||||
if (definitionSha256 !== undefined) query.set("definition_sha256", definitionSha256);
|
||||
const response = await request(fetcher, `/api/v1/observatory/runs?${query}`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const row = record(body, "список расчётов");
|
||||
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
|
||||
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
|
||||
observationAuthority(row.authority);
|
||||
return array(row.items, "items").map(decodeJob).filter((job) => (
|
||||
job.sourceSessionId === sourceSessionId && job.setupId === setupId
|
||||
&& (definitionSha256 === undefined || job.definitionSha256 === definitionSha256)
|
||||
));
|
||||
}
|
||||
|
||||
export async function submitObservatoryRecordedJob(
|
||||
sourceSessionId: string,
|
||||
setupId: string,
|
||||
idempotencyKey: string,
|
||||
portableBinding: {
|
||||
readonly definitionSha256: string;
|
||||
readonly checkSha256: string;
|
||||
} | null,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryRecordedJobFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryRecordedJob> {
|
||||
const response = await request(fetcher, "/api/v1/observatory/runs", {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: SUBMIT_SCHEMA,
|
||||
idempotency_key: idempotencyKey,
|
||||
source_session_id: sourceSessionId,
|
||||
setup_id: setupId,
|
||||
...(portableBinding === null ? {} : {
|
||||
definition_sha256: portableBinding.definitionSha256,
|
||||
check_sha256: portableBinding.checkSha256,
|
||||
}),
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const job = decodeJob(body);
|
||||
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId
|
||||
|| (portableBinding !== null && job.definitionSha256 !== portableBinding.definitionSha256)) {
|
||||
throw new ObservatoryRecordedJobContractError(
|
||||
"Расчёт относится к другой сессии, сетапу или версии профиля.",
|
||||
);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
export async function retryObservatoryRecordedJobPublication(
|
||||
jobId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryRecordedJobFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryRecordedJob> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/runs/${encodeURIComponent(jobId)}/publication/retry`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const job = decodeJob(body);
|
||||
if (job.jobId !== jobId) {
|
||||
throw new ObservatoryRecordedJobContractError(
|
||||
"Повтор публикации вернул другой расчёт.",
|
||||
);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
function decodeJob(value: unknown): ObservatoryRecordedJob {
|
||||
const row = record(value, "расчёт");
|
||||
exactKeys(row, [
|
||||
"authority", "checkpoint_policy", "claim_generation", "claim_lease", "created_at_utc", "executor",
|
||||
"idempotency_key", "identity_sha256", "job_id", "preemption_receipt_sha256",
|
||||
"preemption_requested", "priority", "publication", "request_sha256", "restart_from_zero", "result",
|
||||
"schema_version", "setup", "source", "state", "submission_receipt_sha256", "terminal",
|
||||
"updated_at_utc",
|
||||
], "расчёт");
|
||||
exact(row.schema_version, JOB_SCHEMA, "schema_version расчёта");
|
||||
observationAuthority(row.authority);
|
||||
digest(row.request_sha256, "request_sha256");
|
||||
digest(row.identity_sha256, "identity_sha256");
|
||||
digest(row.submission_receipt_sha256, "submission_receipt_sha256");
|
||||
const source = record(row.source, "source");
|
||||
exactKeys(source, [
|
||||
"adapter", "bundle_sha256", "capability_manifest_sha256", "catalog_sha256", "session_id",
|
||||
], "source");
|
||||
digest(source.catalog_sha256, "source.catalog_sha256");
|
||||
digest(source.bundle_sha256, "source.bundle_sha256");
|
||||
digest(source.capability_manifest_sha256, "source.capability_manifest_sha256");
|
||||
const setup = record(row.setup, "setup");
|
||||
exactKeys(setup, ["definition_id", "definition_sha256", "definition_version", "setup_id"], "setup");
|
||||
digest(setup.definition_sha256, "setup.definition_sha256");
|
||||
const state = oneOf(row.state, [
|
||||
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
|
||||
"succeeded", "failed", "reconciliation-required",
|
||||
] as const, "state");
|
||||
if (row.claim_lease !== null) {
|
||||
const lease = record(row.claim_lease, "claim_lease");
|
||||
exactKeys(
|
||||
lease,
|
||||
["claimed_at_utc", "expires_at_utc", "heartbeat_at_utc", "renewal_count"],
|
||||
"claim_lease",
|
||||
);
|
||||
text(lease.claimed_at_utc, "claim_lease.claimed_at_utc");
|
||||
text(lease.expires_at_utc, "claim_lease.expires_at_utc");
|
||||
text(lease.heartbeat_at_utc, "claim_lease.heartbeat_at_utc");
|
||||
nonNegativeInteger(lease.renewal_count, "claim_lease.renewal_count");
|
||||
}
|
||||
const result = row.result === null ? null : record(row.result, "result");
|
||||
if (result !== null) exactKeys(result, ["result_id", "sha256"], "result");
|
||||
const terminal = row.terminal === null ? null : record(row.terminal, "terminal");
|
||||
if (terminal !== null) exactKeys(terminal, ["code", "message"], "terminal");
|
||||
const publication = record(row.publication, "publication");
|
||||
exactKeys(
|
||||
publication,
|
||||
["attempts", "error", "published_at_utc", "state"],
|
||||
"publication",
|
||||
);
|
||||
const publicationState = oneOf(publication.state, [
|
||||
"not-required", "pending", "failed", "published",
|
||||
] as const, "publication.state");
|
||||
const publicationError = publication.error === null
|
||||
? null
|
||||
: text(publication.error, "publication.error");
|
||||
const publishedAtUtc = publication.published_at_utc === null
|
||||
? null
|
||||
: text(publication.published_at_utc, "publication.published_at_utc");
|
||||
return {
|
||||
jobId: text(row.job_id, "job_id"),
|
||||
idempotencyKey: text(row.idempotency_key, "idempotency_key"),
|
||||
sourceSessionId: text(source.session_id, "source.session_id"),
|
||||
setupId: text(setup.setup_id, "setup.setup_id"),
|
||||
definitionSha256: String(setup.definition_sha256),
|
||||
state,
|
||||
restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"),
|
||||
resultId: result === null ? null : text(result.result_id, "result.result_id"),
|
||||
terminalMessage: terminal === null || terminal.message === null
|
||||
? null
|
||||
: text(terminal.message, "terminal.message"),
|
||||
publication: {
|
||||
state: publicationState,
|
||||
attempts: nonNegativeInteger(publication.attempts, "publication.attempts"),
|
||||
error: publicationError,
|
||||
publishedAtUtc,
|
||||
},
|
||||
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
|
||||
updatedAtUtc: text(row.updated_at_utc, "updated_at_utc"),
|
||||
};
|
||||
}
|
||||
|
||||
function observationAuthority(value: unknown): void {
|
||||
const row = record(value, "authority");
|
||||
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
|
||||
exactKeys(row, keys, "authority");
|
||||
keys.forEach((key) => exact(row[key], false, `authority.${key}`));
|
||||
}
|
||||
|
||||
async function request(fetcher: ObservatoryRecordedJobFetch, input: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryRecordedJobContractError("Очередь расчётов недоступна.");
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const body = await response.text();
|
||||
if (!body) return undefined;
|
||||
try { return JSON.parse(body) as unknown; } catch { return body; }
|
||||
}
|
||||
|
||||
function apiError(body: unknown, status: number): ObservatoryRecordedJobContractError {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>).detail
|
||||
: null;
|
||||
return new ObservatoryRecordedJobContractError(
|
||||
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ObservatoryRecordedJobContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new ObservatoryRecordedJobContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new ObservatoryRecordedJobContractError(`${label}: ожидался текст.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function digest(value: unknown, label: string): string {
|
||||
const valueText = text(value, label);
|
||||
if (!SHA256.test(valueText)) throw new ObservatoryRecordedJobContractError(`${label}: некорректный digest.`);
|
||||
return valueText;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new ObservatoryRecordedJobContractError(`${label}: ожидался boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 0) {
|
||||
throw new ObservatoryRecordedJobContractError(`${label}: ожидалось целое число.`);
|
||||
}
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) throw new ObservatoryRecordedJobContractError(`${label}: значение изменилось.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
|
||||
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||
throw new ObservatoryRecordedJobContractError(`${label}: значение не поддерживается.`);
|
||||
}
|
||||
return value as T[number];
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
|
||||
throw new ObservatoryRecordedJobContractError(`${label}: обнаружены неизвестные поля.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import type { LaboratoryFetch } from "../laboratory/advancedResults";
|
||||
import {
|
||||
fetchVegetationShadowResultMetadata,
|
||||
type VegetationFullRouteReview,
|
||||
type VegetationShadowResult,
|
||||
} from "../laboratory/vegetationShadow";
|
||||
import type { ObservationLabInstance } from "../observation/sessionArchive";
|
||||
import {
|
||||
decodeLabReplayCapability,
|
||||
decodeCanonicalLabReplayCapabilityProvenance,
|
||||
decodePortableLabReplayCapabilityProvenance,
|
||||
ObservationLabReplayCapabilityContractError,
|
||||
} from "../observation/labReplayCapability";
|
||||
|
||||
const CANONICAL_RESULT_ID = /^lab-v1-vegetation-shadow-[a-f0-9]{64}$/;
|
||||
const CANONICAL_SOURCE_SESSION_ID = "20260828T130511Z_viewer_live";
|
||||
|
||||
interface ObservatoryRecordedRunBindingBase {
|
||||
readonly evidenceSessionId: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly resultId: string;
|
||||
readonly activation: "explicit";
|
||||
}
|
||||
|
||||
export interface ObservatoryCanonicalRecordedRunBinding
|
||||
extends ObservatoryRecordedRunBindingBase {
|
||||
readonly kind: "canonical-recorded-rerun";
|
||||
readonly viewerProfile: "recorded-session";
|
||||
readonly timeline: "session_time";
|
||||
}
|
||||
|
||||
export interface ObservatoryPortableResultBinding
|
||||
extends ObservatoryRecordedRunBindingBase {
|
||||
readonly kind: "portable-result-review";
|
||||
readonly viewerProfile: "portable-result";
|
||||
readonly timeline: "result-defined";
|
||||
readonly definitionSha256: string;
|
||||
}
|
||||
|
||||
export type ObservatoryRecordedRunBinding =
|
||||
| ObservatoryCanonicalRecordedRunBinding
|
||||
| ObservatoryPortableResultBinding;
|
||||
|
||||
export interface ObservatoryPortableResultReview {
|
||||
readonly kind: "portable-result";
|
||||
readonly resultId: string;
|
||||
readonly sourceSessionId: string;
|
||||
readonly resultKind: string;
|
||||
readonly definitionSha256: string;
|
||||
readonly calculationProfile: Readonly<Record<string, unknown>> | null;
|
||||
readonly artifactManifestId: string;
|
||||
readonly artifacts: readonly {
|
||||
readonly role: string;
|
||||
readonly mediaType: string;
|
||||
readonly sha256: string;
|
||||
readonly byteLength: number;
|
||||
}[];
|
||||
readonly resultDocument: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type ObservatoryRecordedRunReview =
|
||||
| { readonly kind: "canonical-recorded-rerun"; readonly review: VegetationFullRouteReview }
|
||||
| ObservatoryPortableResultReview;
|
||||
|
||||
export class ObservatoryRecordedRunContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ObservatoryRecordedRunContractError";
|
||||
}
|
||||
}
|
||||
|
||||
export function observatoryRecordedRunBinding(
|
||||
evidenceSessionId: string,
|
||||
lab: ObservationLabInstance,
|
||||
): ObservatoryRecordedRunBinding | null {
|
||||
const capability = lab.replayCapability;
|
||||
if (!capability) return null;
|
||||
if (capability.kind === "portable-result-review") {
|
||||
if (
|
||||
evidenceSessionId !== lab.resultId
|
||||
|| lab.sourceSessionId === evidenceSessionId
|
||||
|| lab.configSha256 === null
|
||||
|| !/^[a-f0-9]{64}$/.test(lab.configSha256)
|
||||
|| capability.viewerProfile !== "portable-result"
|
||||
|| capability.timeline !== "result-defined"
|
||||
|| capability.activation !== "explicit"
|
||||
|| capability.commandsEnabled !== false
|
||||
) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
`Portable-результат ${lab.resultId} не допущен к универсальному viewer.`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
decodePortableLabReplayCapabilityProvenance(
|
||||
lab.provenance,
|
||||
evidenceSessionId,
|
||||
lab.resultId,
|
||||
lab.sourceSessionId,
|
||||
lab.configSha256,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
|
||||
throw new ObservatoryRecordedRunContractError(error.message);
|
||||
}
|
||||
return {
|
||||
kind: "portable-result-review",
|
||||
evidenceSessionId,
|
||||
sourceSessionId: lab.sourceSessionId,
|
||||
resultId: lab.resultId,
|
||||
viewerProfile: "portable-result",
|
||||
timeline: "result-defined",
|
||||
activation: "explicit",
|
||||
definitionSha256: lab.configSha256,
|
||||
};
|
||||
}
|
||||
try {
|
||||
decodeCanonicalLabReplayCapabilityProvenance(
|
||||
lab.provenance,
|
||||
evidenceSessionId,
|
||||
lab.resultId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
|
||||
throw new ObservatoryRecordedRunContractError(error.message);
|
||||
}
|
||||
if (
|
||||
evidenceSessionId !== lab.resultId
|
||||
|| lab.sourceSessionId !== CANONICAL_SOURCE_SESSION_ID
|
||||
|| lab.labId !== "LAB V1"
|
||||
|| lab.resultKind !== "recorded-perception-qualification"
|
||||
|| !CANONICAL_RESULT_ID.test(lab.resultId)
|
||||
|| lab.configSha256 !== null
|
||||
|| lab.sourceResultId === null
|
||||
|| !CANONICAL_RESULT_ID.test(lab.sourceResultId)
|
||||
|| capability.kind !== "canonical-recorded-rerun"
|
||||
|| capability.viewerProfile !== "recorded-session"
|
||||
|| capability.timeline !== "session_time"
|
||||
|| capability.activation !== "explicit"
|
||||
|| capability.commandsEnabled !== false
|
||||
) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
`LAB-результат ${lab.resultId} не допущен к каноническому recorded replay.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: "canonical-recorded-rerun",
|
||||
evidenceSessionId,
|
||||
sourceSessionId: lab.sourceSessionId,
|
||||
resultId: lab.resultId,
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchObservatoryRecordedRunReview(
|
||||
binding: ObservatoryRecordedRunBinding,
|
||||
{
|
||||
selectedSourceSessionId,
|
||||
fetcher = fetch,
|
||||
signal,
|
||||
}: {
|
||||
selectedSourceSessionId: string;
|
||||
fetcher?: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<ObservatoryRecordedRunReview> {
|
||||
if (binding.sourceSessionId !== selectedSourceSessionId) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
"Запуск не связан с выбранной исходной сессией.",
|
||||
);
|
||||
}
|
||||
if (binding.kind === "portable-result-review") {
|
||||
return fetchPortableResultReview(binding, { fetcher, signal });
|
||||
}
|
||||
const result = await fetchVegetationShadowResultMetadata(binding.resultId, {
|
||||
fetcher,
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
kind: "canonical-recorded-rerun",
|
||||
review: admitObservatoryRecordedRunReview(
|
||||
binding,
|
||||
selectedSourceSessionId,
|
||||
result,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function admitObservatoryRecordedRunReview(
|
||||
binding: ObservatoryCanonicalRecordedRunBinding,
|
||||
selectedSourceSessionId: string,
|
||||
result: VegetationShadowResult,
|
||||
): VegetationFullRouteReview {
|
||||
if (binding.sourceSessionId !== selectedSourceSessionId) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
"Запуск не связан с выбранной исходной сессией.",
|
||||
);
|
||||
}
|
||||
if (result.resultId !== binding.resultId) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
"Запечатанный результат не совпадает с выбранным запуском.",
|
||||
);
|
||||
}
|
||||
const review = result.routeFullReview;
|
||||
if (!review || review.sessionId !== binding.sourceSessionId) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
"Запечатанный результат потерял точную связь с исходной сессией.",
|
||||
);
|
||||
}
|
||||
return review;
|
||||
}
|
||||
|
||||
async function fetchPortableResultReview(
|
||||
binding: ObservatoryPortableResultBinding,
|
||||
{
|
||||
fetcher,
|
||||
signal,
|
||||
}: {
|
||||
fetcher: LaboratoryFetch;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<ObservatoryPortableResultReview> {
|
||||
const response = await fetcher(
|
||||
`/api/v1/observatory/portable-results/${encodeURIComponent(binding.resultId)}`,
|
||||
{ headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
"Portable viewer получил некорректный ответ сервера.",
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new ObservatoryRecordedRunContractError(
|
||||
isRecord(body) && typeof body.detail === "string"
|
||||
? body.detail
|
||||
: `Portable viewer вернул HTTP ${response.status}.`,
|
||||
);
|
||||
}
|
||||
return decodePortableResultReview(body, binding);
|
||||
}
|
||||
|
||||
function decodePortableResultReview(
|
||||
value: unknown,
|
||||
binding: ObservatoryPortableResultBinding,
|
||||
): ObservatoryPortableResultReview {
|
||||
if (!isRecord(value)) {
|
||||
throw new ObservatoryRecordedRunContractError("Portable viewer вернул не объект.");
|
||||
}
|
||||
const expected = new Set([
|
||||
"schema_version",
|
||||
"result_id",
|
||||
"source_session_id",
|
||||
"result_kind",
|
||||
"definition_sha256",
|
||||
"viewer_capability",
|
||||
"calculation_profile",
|
||||
"artifact_manifest_id",
|
||||
"artifacts",
|
||||
"result_document",
|
||||
]);
|
||||
if (Object.keys(value).some((key) => !expected.has(key)) || Object.keys(value).length !== expected.size) {
|
||||
throw new ObservatoryRecordedRunContractError("Portable viewer изменил контракт ответа.");
|
||||
}
|
||||
let viewerCapability;
|
||||
try {
|
||||
viewerCapability = decodeLabReplayCapability(
|
||||
value.viewer_capability,
|
||||
binding.evidenceSessionId,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ObservationLabReplayCapabilityContractError)) throw error;
|
||||
throw new ObservatoryRecordedRunContractError(error.message);
|
||||
}
|
||||
if (
|
||||
value.schema_version !== "missioncore.observatory-portable-result-view/v1"
|
||||
|| value.result_id !== binding.resultId
|
||||
|| value.source_session_id !== binding.sourceSessionId
|
||||
|| value.definition_sha256 !== binding.definitionSha256
|
||||
|| typeof value.result_kind !== "string"
|
||||
|| typeof value.artifact_manifest_id !== "string"
|
||||
|| !/^[a-f0-9]{64}$/.test(value.artifact_manifest_id)
|
||||
|| !Array.isArray(value.artifacts)
|
||||
|| !isRecord(value.result_document)
|
||||
|| !isRecord(value.calculation_profile)
|
||||
|| viewerCapability === null
|
||||
|| viewerCapability.kind !== "portable-result-review"
|
||||
|| viewerCapability.viewerProfile !== binding.viewerProfile
|
||||
|| viewerCapability.timeline !== binding.timeline
|
||||
) {
|
||||
throw new ObservatoryRecordedRunContractError("Portable viewer потерял identity результата.");
|
||||
}
|
||||
const artifacts = value.artifacts.map((item) => {
|
||||
if (
|
||||
!isRecord(item)
|
||||
|| Object.keys(item).length !== 4
|
||||
|| !["role", "media_type", "sha256", "byte_length"].every(
|
||||
(key) => Object.prototype.hasOwnProperty.call(item, key),
|
||||
)
|
||||
|| typeof item.role !== "string"
|
||||
|| typeof item.media_type !== "string"
|
||||
|| typeof item.sha256 !== "string"
|
||||
|| !/^[a-f0-9]{64}$/.test(item.sha256)
|
||||
|| typeof item.byte_length !== "number"
|
||||
|| !Number.isSafeInteger(item.byte_length)
|
||||
|| item.byte_length < 0
|
||||
) {
|
||||
throw new ObservatoryRecordedRunContractError("Portable viewer получил неверный artifact.");
|
||||
}
|
||||
return {
|
||||
role: item.role,
|
||||
mediaType: item.media_type,
|
||||
sha256: item.sha256,
|
||||
byteLength: item.byte_length,
|
||||
};
|
||||
});
|
||||
return {
|
||||
kind: "portable-result",
|
||||
resultId: binding.resultId,
|
||||
sourceSessionId: binding.sourceSessionId,
|
||||
resultKind: value.result_kind,
|
||||
definitionSha256: binding.definitionSha256,
|
||||
calculationProfile: value.calculation_profile,
|
||||
artifactManifestId: value.artifact_manifest_id,
|
||||
artifacts,
|
||||
resultDocument: value.result_document,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
applyObservatoryCatalogMutationOverlay,
|
||||
fetchObservatoryCatalog,
|
||||
reconcileObservatoryCatalogMutationOverlay,
|
||||
type ObservatoryCatalog,
|
||||
type ObservatoryCatalogMutationOverlay,
|
||||
} from "./catalog";
|
||||
|
||||
export type ObservatoryCatalogState =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "ready"
|
||||
| "refreshing"
|
||||
| "error";
|
||||
|
||||
export interface ObservatoryCatalogController {
|
||||
readonly catalog: ObservatoryCatalog | null;
|
||||
readonly state: ObservatoryCatalogState;
|
||||
readonly error: string | null;
|
||||
readonly refresh: () => Promise<ObservatoryCatalog | null>;
|
||||
readonly applyEvidenceRename: (
|
||||
sessionId: string,
|
||||
displayName: string,
|
||||
) => void;
|
||||
readonly applyEvidenceDeletion: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
interface ActiveCatalogRequest {
|
||||
readonly id: number;
|
||||
readonly controller: AbortController;
|
||||
}
|
||||
|
||||
type ObservatoryCatalogMutationDraft =
|
||||
| { readonly kind: "rename"; readonly displayName: string }
|
||||
| { readonly kind: "delete" };
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Каталог Обсерватории недоступен.";
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return error instanceof DOMException && error.name === "AbortError";
|
||||
}
|
||||
|
||||
export function useObservatoryCatalog(): ObservatoryCatalogController {
|
||||
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
|
||||
const [state, setState] = useState<ObservatoryCatalogState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const catalogRef = useRef<ObservatoryCatalog | null>(null);
|
||||
const overlayRef = useRef<ObservatoryCatalogMutationOverlay>(new Map());
|
||||
const mutationRevisionRef = useRef(0);
|
||||
const requestSequenceRef = useRef(0);
|
||||
const activeRequestRef = useRef<ActiveCatalogRequest | null>(null);
|
||||
|
||||
const loadCatalog = useCallback(async (): Promise<ObservatoryCatalog | null> => {
|
||||
const id = requestSequenceRef.current + 1;
|
||||
requestSequenceRef.current = id;
|
||||
activeRequestRef.current?.controller.abort();
|
||||
const controller = new AbortController();
|
||||
activeRequestRef.current = { id, controller };
|
||||
const requestMutationRevision = mutationRevisionRef.current;
|
||||
|
||||
setState(catalogRef.current ? "refreshing" : "loading");
|
||||
setError(null);
|
||||
try {
|
||||
const serverCatalog = await fetchObservatoryCatalog({
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted || requestSequenceRef.current !== id) {
|
||||
return null;
|
||||
}
|
||||
const reconciled = reconcileObservatoryCatalogMutationOverlay(
|
||||
serverCatalog,
|
||||
overlayRef.current,
|
||||
requestMutationRevision,
|
||||
);
|
||||
overlayRef.current = reconciled.overlay;
|
||||
catalogRef.current = reconciled.catalog;
|
||||
activeRequestRef.current = null;
|
||||
setCatalog(reconciled.catalog);
|
||||
setState("ready");
|
||||
return reconciled.catalog;
|
||||
} catch (loadError: unknown) {
|
||||
if (
|
||||
controller.signal.aborted
|
||||
|| requestSequenceRef.current !== id
|
||||
|| isAbortError(loadError)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
activeRequestRef.current = null;
|
||||
setError(errorMessage(loadError));
|
||||
setState("error");
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCatalog();
|
||||
return () => {
|
||||
requestSequenceRef.current += 1;
|
||||
activeRequestRef.current?.controller.abort();
|
||||
activeRequestRef.current = null;
|
||||
};
|
||||
}, [loadCatalog]);
|
||||
|
||||
const applyMutation = useCallback((
|
||||
sessionId: string,
|
||||
mutation: ObservatoryCatalogMutationDraft,
|
||||
) => {
|
||||
const revision = mutationRevisionRef.current + 1;
|
||||
mutationRevisionRef.current = revision;
|
||||
const overlay = new Map(overlayRef.current);
|
||||
overlay.set(sessionId, { ...mutation, revision });
|
||||
overlayRef.current = overlay;
|
||||
if (!catalogRef.current) return;
|
||||
const projected = applyObservatoryCatalogMutationOverlay(
|
||||
catalogRef.current,
|
||||
overlay,
|
||||
);
|
||||
catalogRef.current = projected;
|
||||
setCatalog(projected);
|
||||
}, []);
|
||||
|
||||
const applyEvidenceRename = useCallback((
|
||||
sessionId: string,
|
||||
displayName: string,
|
||||
) => {
|
||||
applyMutation(sessionId, { kind: "rename", displayName });
|
||||
}, [applyMutation]);
|
||||
|
||||
const applyEvidenceDeletion = useCallback((sessionId: string) => {
|
||||
applyMutation(sessionId, { kind: "delete" });
|
||||
}, [applyMutation]);
|
||||
|
||||
return {
|
||||
catalog,
|
||||
state,
|
||||
error,
|
||||
refresh: loadCatalog,
|
||||
applyEvidenceRename,
|
||||
applyEvidenceDeletion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservatoryPortableLaboratorySetups,
|
||||
preflightObservatoryLaboratorySetup,
|
||||
selectableObservatorySetups,
|
||||
type ObservatoryLaboratoryRunPreflight,
|
||||
type ObservatoryLaboratorySetupCatalog,
|
||||
} from "./laboratorySetups";
|
||||
|
||||
type SetupCatalogState = "idle" | "loading" | "ready" | "refreshing" | "error";
|
||||
export type SetupPreflightState =
|
||||
| { readonly kind: "idle" }
|
||||
| { readonly kind: "checking" }
|
||||
| { readonly kind: "ready"; readonly value: ObservatoryLaboratoryRunPreflight }
|
||||
| { readonly kind: "error"; readonly message: string };
|
||||
|
||||
export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
||||
const [catalog, setCatalog] = useState<ObservatoryLaboratorySetupCatalog | null>(null);
|
||||
const [state, setState] = useState<SetupCatalogState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSetupId, setSelectedSetupId] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [preflight, setPreflight] = useState<SetupPreflightState>({ kind: "idle" });
|
||||
const requestSequence = useRef(0);
|
||||
const preflightRequest = useRef<AbortController | null>(null);
|
||||
const activeCatalog = catalog?.sourceSessionId === sourceSessionId ? catalog : null;
|
||||
const selectableSetups = useMemo(() => selectableObservatorySetups(activeCatalog), [activeCatalog]);
|
||||
const selectedSetup = selectableSetups.find((setup) => setup.setupId === selectedSetupId) ?? null;
|
||||
const selectedDefinitionSha256 = selectedSetup?.runDefinition?.definitionSha256 ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
preflightRequest.current?.abort();
|
||||
preflightRequest.current = null;
|
||||
if (!sourceSessionId) {
|
||||
setCatalog(null);
|
||||
setState("idle");
|
||||
setError(null);
|
||||
setSelectedSetupId("");
|
||||
setPreflight({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
const sequence = ++requestSequence.current;
|
||||
const request = new AbortController();
|
||||
setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading");
|
||||
setError(null);
|
||||
setPreflight({ kind: "idle" });
|
||||
// Archived definitions belong to evidence, not to the calculation selector.
|
||||
void fetchObservatoryPortableLaboratorySetups(sourceSessionId, { signal: request.signal })
|
||||
.then((next) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setCatalog(next);
|
||||
const selectable = selectableObservatorySetups(next);
|
||||
setSelectedSetupId((current) => (
|
||||
selectable.some((setup) => setup.setupId === current)
|
||||
? current
|
||||
: selectable[0]?.setupId ?? ""
|
||||
));
|
||||
setState("ready");
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setState("error");
|
||||
setError(caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Каталог профилей недоступен.");
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [revision, sourceSessionId]);
|
||||
|
||||
useEffect(() => () => preflightRequest.current?.abort(), []);
|
||||
|
||||
useEffect(() => {
|
||||
preflightRequest.current?.abort();
|
||||
preflightRequest.current = null;
|
||||
setPreflight((current) => current.kind === "idle" ? current : { kind: "idle" });
|
||||
}, [selectedSetupId, sourceSessionId, selectedDefinitionSha256]);
|
||||
|
||||
const selectSetup = useCallback((setupId: string) => {
|
||||
preflightRequest.current?.abort();
|
||||
preflightRequest.current = null;
|
||||
setSelectedSetupId(setupId);
|
||||
setPreflight({ kind: "idle" });
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => setRevision((value) => value + 1), []);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (!sourceSessionId || !selectedSetup || preflight.kind === "checking") return;
|
||||
preflightRequest.current?.abort();
|
||||
const request = new AbortController();
|
||||
preflightRequest.current = request;
|
||||
setPreflight({ kind: "checking" });
|
||||
try {
|
||||
const value = await preflightObservatoryLaboratorySetup(
|
||||
sourceSessionId,
|
||||
selectedSetup,
|
||||
{ signal: request.signal },
|
||||
);
|
||||
if (request.signal.aborted || preflightRequest.current !== request) return;
|
||||
setPreflight({ kind: "ready", value });
|
||||
} catch (caught) {
|
||||
if (request.signal.aborted || preflightRequest.current !== request) return;
|
||||
setPreflight({
|
||||
kind: "error",
|
||||
message: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Не удалось проверить возможность запуска.",
|
||||
});
|
||||
} finally {
|
||||
if (preflightRequest.current === request) preflightRequest.current = null;
|
||||
}
|
||||
}, [preflight.kind, selectedSetup, sourceSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || !selectedSetup || preflight.kind !== "idle") return;
|
||||
void check();
|
||||
}, [check, preflight.kind, selectedSetup, state]);
|
||||
|
||||
return {
|
||||
catalog: activeCatalog,
|
||||
selectableSetups,
|
||||
state,
|
||||
error,
|
||||
selectedSetupId: selectedSetup?.setupId ?? "",
|
||||
selectedSetup,
|
||||
selectSetup,
|
||||
refresh,
|
||||
preflight,
|
||||
check,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservatoryRecordedJobs,
|
||||
retryObservatoryRecordedJobPublication,
|
||||
submitObservatoryRecordedJob,
|
||||
type ObservatoryRecordedJob,
|
||||
} from "./recordedJobs";
|
||||
|
||||
type RecordedJobsState =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "ready"
|
||||
| "refreshing"
|
||||
| "submitting"
|
||||
| "retrying-publication"
|
||||
| "error";
|
||||
|
||||
interface JobSnapshot {
|
||||
readonly selectionKey: string;
|
||||
readonly jobs: readonly ObservatoryRecordedJob[];
|
||||
readonly state: RecordedJobsState;
|
||||
readonly error: string | null;
|
||||
}
|
||||
const EMPTY_JOBS = [] as const;
|
||||
const OPEN_STATES = new Set([
|
||||
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
|
||||
"reconciliation-required",
|
||||
]);
|
||||
const POLL_INTERVAL_MS = 1_500;
|
||||
|
||||
export function useObservatoryRecordedJobs(
|
||||
sourceSessionId: string,
|
||||
setupId: string,
|
||||
definitionSha256: string,
|
||||
) {
|
||||
const selectionKey = JSON.stringify([sourceSessionId, setupId, definitionSha256]);
|
||||
const [snapshot, setSnapshot] = useState<JobSnapshot>({
|
||||
selectionKey: "", jobs: EMPTY_JOBS, state: "idle", error: null,
|
||||
});
|
||||
const [revision, setRevision] = useState(0);
|
||||
const requestRef = useRef<AbortController | null>(null);
|
||||
const requestSequence = useRef(0);
|
||||
const idempotencyKeys = useRef(new Map<string, string>());
|
||||
const observedJobId = useRef<string | null>(null);
|
||||
const hasSelection = Boolean(sourceSessionId && setupId && definitionSha256);
|
||||
// A source/profile change must not display even one render of the previous queue.
|
||||
const current = snapshot.selectionKey === selectionKey ? snapshot : null;
|
||||
const jobs = current?.jobs ?? EMPTY_JOBS;
|
||||
const state = current?.state ?? (hasSelection ? "loading" : "idle");
|
||||
const error = current?.error ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
requestRef.current?.abort();
|
||||
requestRef.current = null;
|
||||
if (!hasSelection) {
|
||||
setSnapshot({ selectionKey, jobs: EMPTY_JOBS, state: "idle", error: null });
|
||||
return;
|
||||
}
|
||||
const sequence = ++requestSequence.current;
|
||||
const request = new AbortController();
|
||||
requestRef.current = request;
|
||||
setSnapshot({
|
||||
selectionKey, jobs,
|
||||
state: jobs.length > 0 ? "refreshing" : "loading", error: null,
|
||||
});
|
||||
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, {
|
||||
definitionSha256, signal: request.signal,
|
||||
}).then((next) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
const pending = next.find((job) => OPEN_STATES.has(job.state));
|
||||
if (pending) observedJobId.current = pending.jobId;
|
||||
setSnapshot({ selectionKey, jobs: next, state: "ready", error: null });
|
||||
}).catch((caught: unknown) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setSnapshot({
|
||||
selectionKey, jobs, state: "error",
|
||||
error: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Очередь расчётов недоступна.",
|
||||
});
|
||||
}).finally(() => {
|
||||
if (requestRef.current === request) requestRef.current = null;
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [revision, selectionKey]);
|
||||
|
||||
useEffect(() => () => requestRef.current?.abort(), []);
|
||||
|
||||
const latestJob = jobs[0] ?? null;
|
||||
const activeJob = jobs.find((job) => OPEN_STATES.has(job.state)) ?? null;
|
||||
const publicationPending = jobs.some((job) => job.publication.state === "pending");
|
||||
// Do not promote historical terminal logs to a new operator action's error.
|
||||
const computationFailed = latestJob?.state === "failed"
|
||||
&& latestJob.jobId === observedJobId.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || (!activeJob && !publicationPending)) return;
|
||||
const timer = globalThis.setTimeout(
|
||||
() => setRevision((value) => value + 1),
|
||||
POLL_INTERVAL_MS,
|
||||
);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}, [activeJob, publicationPending, revision, state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestJob || activeJob) return;
|
||||
idempotencyKeys.current.delete(selectionKey);
|
||||
}, [activeJob, latestJob, selectionKey]);
|
||||
|
||||
const refresh = useCallback(() => setRevision((value) => value + 1), []);
|
||||
|
||||
const submit = useCallback(async (
|
||||
portableBinding: {
|
||||
readonly definitionSha256: string;
|
||||
readonly checkSha256: string;
|
||||
} | null = null,
|
||||
): Promise<ObservatoryRecordedJob | null> => {
|
||||
if (!hasSelection || state !== "ready"
|
||||
|| portableBinding?.definitionSha256 !== definitionSha256
|
||||
|| publicationPending || latestJob?.publication.state === "failed") return null;
|
||||
if (activeJob) return activeJob;
|
||||
requestRef.current?.abort();
|
||||
const request = new AbortController();
|
||||
requestRef.current = request;
|
||||
const key = idempotencyKeys.current.get(selectionKey) ?? createIdempotencyKey();
|
||||
idempotencyKeys.current.set(selectionKey, key);
|
||||
setSnapshot({ selectionKey, jobs, state: "submitting", error: null });
|
||||
try {
|
||||
const job = await submitObservatoryRecordedJob(
|
||||
sourceSessionId, setupId, key, portableBinding, { signal: request.signal },
|
||||
);
|
||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||
observedJobId.current = job.jobId;
|
||||
setSnapshot({
|
||||
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
|
||||
state: "ready", error: null,
|
||||
});
|
||||
return job;
|
||||
} catch (caught) {
|
||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||
setSnapshot({
|
||||
selectionKey, jobs, state: "error",
|
||||
error: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Не удалось поставить расчёт в очередь.",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
if (requestRef.current === request) requestRef.current = null;
|
||||
}
|
||||
}, [activeJob, definitionSha256, hasSelection, jobs, latestJob,
|
||||
publicationPending, selectionKey, setupId, sourceSessionId, state]);
|
||||
|
||||
const retryPublication = useCallback(async (): Promise<ObservatoryRecordedJob | null> => {
|
||||
if (!latestJob || latestJob.publication.state !== "failed"
|
||||
|| state === "retrying-publication") return null;
|
||||
requestRef.current?.abort();
|
||||
const request = new AbortController();
|
||||
requestRef.current = request;
|
||||
setSnapshot({ selectionKey, jobs, state: "retrying-publication", error: null });
|
||||
try {
|
||||
const job = await retryObservatoryRecordedJobPublication(latestJob.jobId, {
|
||||
signal: request.signal,
|
||||
});
|
||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||
setSnapshot({
|
||||
selectionKey, jobs: [job, ...jobs.filter((candidate) => candidate.jobId !== job.jobId)],
|
||||
state: "ready", error: null,
|
||||
});
|
||||
return job;
|
||||
} catch (caught) {
|
||||
if (request.signal.aborted || requestRef.current !== request) return null;
|
||||
setSnapshot({
|
||||
selectionKey, jobs, state: "error",
|
||||
error: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Не удалось повторить публикацию результата.",
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
if (requestRef.current === request) requestRef.current = null;
|
||||
}
|
||||
}, [jobs, latestJob, selectionKey, state]);
|
||||
|
||||
return {
|
||||
jobs, latestJob, activeJob, publicationPending, computationFailed,
|
||||
state, error, refresh, submit, retryPublication,
|
||||
};
|
||||
}
|
||||
|
||||
function createIdempotencyKey(): string {
|
||||
const entropy = typeof globalThis.crypto?.randomUUID === "function"
|
||||
? globalThis.crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
|
||||
return `observatory-ui:${entropy}`;
|
||||
}
|
||||
|
||||
export type ObservatoryRecordedJobsController = ReturnType<typeof useObservatoryRecordedJobs>;
|
||||
@@ -38,6 +38,8 @@ export function useWorkerTelemetry(
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
setTelemetry(null);
|
||||
setError(null);
|
||||
const controller = new AbortController();
|
||||
const stopPolling = startSequentialPolling(async () => {
|
||||
setLoading(true);
|
||||
@@ -48,6 +50,7 @@ export function useWorkerTelemetry(
|
||||
setError(null);
|
||||
} catch (reason: unknown) {
|
||||
if (controller.signal.aborted) return;
|
||||
setTelemetry(null);
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
|
||||
@@ -114,6 +114,9 @@ export interface WorkerTelemetry {
|
||||
power_watts?: number;
|
||||
temperature_celsius?: number;
|
||||
memory_used_percent?: number;
|
||||
sm_clock_mhz?: number | null;
|
||||
memory_clock_mhz?: number | null;
|
||||
driver_version?: string | null;
|
||||
} | null;
|
||||
triton: {
|
||||
ready: boolean;
|
||||
@@ -131,6 +134,13 @@ export interface WorkerTelemetry {
|
||||
completed_runs: number | null;
|
||||
failed_runs: number | null;
|
||||
model_load_seconds: number | null;
|
||||
profile_name?: string | null;
|
||||
input_state?: string | null;
|
||||
wait_reason?: string | null;
|
||||
live_children?: number | null;
|
||||
input_pauses?: number | null;
|
||||
pending_bundles?: number | null;
|
||||
buffer_bytes?: number | null;
|
||||
stages: WorkerPipelineStage[];
|
||||
};
|
||||
network: {
|
||||
@@ -189,7 +199,9 @@ export async function fetchWorkerTelemetry(
|
||||
`/api/v1/system/contours/${encodeURIComponent(contourId)}/telemetry?history=90`,
|
||||
{
|
||||
method: "GET",
|
||||
signal,
|
||||
signal: signal
|
||||
? AbortSignal.any([signal, AbortSignal.timeout(5_000)])
|
||||
: AbortSignal.timeout(5_000),
|
||||
},
|
||||
),
|
||||
"missioncore.worker-telemetry/v1",
|
||||
|
||||
@@ -23,7 +23,8 @@ export type WorkspaceKind =
|
||||
| "datasets"
|
||||
| "artifact-health"
|
||||
| "lab-archive"
|
||||
| "simulations";
|
||||
| "simulations"
|
||||
| "observatory";
|
||||
|
||||
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
|
||||
|
||||
@@ -166,6 +167,17 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
kind: "simulations",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "observatory",
|
||||
root: "polygon",
|
||||
label: "Обсерватория",
|
||||
title: "Проверка компьютерного зрения",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
||||
icon: "eye",
|
||||
kind: "observatory",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "contour-health",
|
||||
root: "fleet",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
@import "./styles/device.css";
|
||||
@import "./styles/responsive.css";
|
||||
@import "./styles/observation.css";
|
||||
@import "./styles/observatory.css";
|
||||
@import "./styles/environment-settings.css";
|
||||
@import "./styles/system-telemetry.css";
|
||||
@import "./styles/artifact-health.css";
|
||||
|
||||
@@ -27,6 +27,88 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__unified-content {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__unified-content > *,
|
||||
.m4-replay-threat-visual__unified-content .rerun-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"] > .nodedc-split-pane {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.m4-replay-threat-visual__pane {
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.nodedc-split-pane__separator,
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.m4-replay-threat-visual__pane-toolbar,
|
||||
.m4-replay-threat-visual__deck[data-native-rerun="true"]
|
||||
.m4-replay-threat-visual__pane-toolbar * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||
.m4-replay-threat-visual__pane-toolbar,
|
||||
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||
.laboratory-evidence-viewer__controls,
|
||||
.canonical-vegetation-rerun-replay:not([data-presentation-state="ready"])
|
||||
.laboratory-evidence-viewer__transport {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay[data-presentation-state="loading"]
|
||||
.m4-replay-threat-visual__unified-content {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__viewport-lock {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__viewport-lock
|
||||
.rerun-viewport__camera-lock {
|
||||
width: var(--canonical-rerun-camera-pane, 100%);
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__viewport-lock[data-split-view="true"]
|
||||
.rerun-viewport__camera-lock {
|
||||
width: calc(var(--canonical-rerun-camera-pane, 46%) - 0.75rem);
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__loading {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--nodedc-canvas);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__deck[data-empty="true"] > .l3-visual-audit__state {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
@@ -247,6 +329,10 @@
|
||||
grid-template-columns: auto auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.canonical-vegetation-rerun-replay__timeline .observation-timeline__playback {
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.m4-replay-threat-visual__overlay {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
.observatory-workspace {
|
||||
display: grid;
|
||||
grid-auto-rows: max-content;
|
||||
align-content: start;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
min-height: 100%;
|
||||
padding: 1rem;
|
||||
container-name: observatory-workspace;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.observatory-lead,
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice,
|
||||
.observatory-evidence > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.observatory-lead,
|
||||
.observatory-notice {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.observatory-lead > div,
|
||||
.observatory-catalog-bar__copy,
|
||||
.observatory-evidence header > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-lead h2,
|
||||
.observatory-catalog-bar h3,
|
||||
.observatory-session-summary h3,
|
||||
.observatory-evidence h3 {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar h3,
|
||||
.observatory-session-summary h3,
|
||||
.observatory-evidence h3 {
|
||||
font-size: var(--nodedc-font-size-lg);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.observatory-lead p,
|
||||
.observatory-state p,
|
||||
.observatory-evidence-empty p {
|
||||
max-width: 52rem;
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar,
|
||||
.observatory-session-summary {
|
||||
align-self: start;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__copy {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
align-content: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar h3 {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls .nodedc-select-anchor {
|
||||
flex: 1 1 18rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls > .nodedc-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__run {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.observatory-state {
|
||||
display: grid;
|
||||
min-height: 19rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observatory-state h3 {
|
||||
max-width: 42rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.observatory-session-stack {
|
||||
display: grid;
|
||||
grid-auto-rows: max-content;
|
||||
align-content: start;
|
||||
gap: 0.9rem;
|
||||
container-name: observatory-session;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.observatory-session-summary {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(11rem, 1.05fr) minmax(20rem, 1.6fr) minmax(11rem, auto);
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.observatory-session-summary__identity {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.observatory-session-summary__identity h3 {
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
|
||||
.observatory-session-summary__identity code {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observatory-session-summary__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(6.5rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.observatory-session-summary__facts > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.24rem;
|
||||
}
|
||||
|
||||
.observatory-session-summary__facts dt {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-session-summary__facts dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.observatory-session-summary__end {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
justify-items: end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.observatory-modalities {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observatory-evidence {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.observatory-evidence > header {
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.observatory-evidence-list {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.observatory-evidence-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
min-height: 4.25rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: var(--nodedc-radius-control-compact);
|
||||
background: var(--nodedc-glass-control-bg);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__icon {
|
||||
display: grid;
|
||||
width: 2.15rem;
|
||||
height: 2.15rem;
|
||||
place-items: center;
|
||||
border-radius: var(--nodedc-radius-circle);
|
||||
background: var(--nodedc-panel-icon-bg);
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.16rem;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy strong,
|
||||
.observatory-evidence-card__copy span,
|
||||
.observatory-evidence-card__copy small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy span {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: var(--nodedc-font-size-xs);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__actions,
|
||||
.observatory-replay__header,
|
||||
.observatory-replay-state,
|
||||
.observatory-replay-state__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.observatory-replay-state {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.observatory-replay__header > div,
|
||||
.observatory-replay-state > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-evidence-card__actions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.observatory-replay,
|
||||
.observatory-replay-state {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-replay {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.observatory-replay__header {
|
||||
padding: 0.25rem 0.25rem 0;
|
||||
}
|
||||
|
||||
.observatory-replay__header h3,
|
||||
.observatory-replay-state h3 {
|
||||
margin: 0.3rem 0 0;
|
||||
}
|
||||
|
||||
.observatory-replay__header p,
|
||||
.observatory-replay-state p {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--nodedc-text-muted);
|
||||
}
|
||||
|
||||
.observatory-evidence-empty {
|
||||
display: grid;
|
||||
min-height: 13rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.55rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.observatory-notice {
|
||||
justify-content: flex-start;
|
||||
color: var(--nodedc-text-secondary);
|
||||
}
|
||||
|
||||
.observatory-notice__copy {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.observatory-rename-form,
|
||||
.observatory-delete-confirmation {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.observatory-delete-confirmation p,
|
||||
.observatory-mutation-error {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.observatory-mutation-error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
}
|
||||
|
||||
@container observatory-session (max-width: 46rem) {
|
||||
.observatory-session-summary {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.observatory-session-summary__end,
|
||||
.observatory-modalities {
|
||||
justify-items: start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@container observatory-session (max-width: 48rem) {
|
||||
.observatory-evidence-card {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observatory-evidence-card > .nodedc-status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container observatory-session (max-width: 38rem) {
|
||||
.observatory-session-summary__facts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.observatory-evidence-card__copy small {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@container observatory-workspace (max-width: 920px) {
|
||||
.observatory-lead,
|
||||
.observatory-catalog-bar,
|
||||
.observatory-catalog-bar__controls,
|
||||
.observatory-notice {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls {
|
||||
flex-basis: auto;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__controls .nodedc-select-anchor {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.observatory-catalog-bar__run {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.observatory-replay__header,
|
||||
.observatory-replay-state {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Rerun WebViewer 0.34.1 keeps three fixed 24px canvas rows even after its
|
||||
/* The upstream Rerun canvas keeps three fixed 24px rows even after its
|
||||
panels are overridden: the native top row, recording tab and view tab.
|
||||
They are drawn inside WASM and cannot be styled independently, so crop the
|
||||
fixed native chrome while keeping the actual 3D viewport full-height. */
|
||||
|
||||
@@ -41,6 +41,7 @@ import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
|
||||
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
|
||||
import { NetworkWorkspace } from "./system/NetworkWorkspace";
|
||||
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
|
||||
import { ObservatoryWorkspace } from "./observatory/ObservatoryWorkspace";
|
||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||
if (status === "active") return "success";
|
||||
if (status === "ready") return "accent";
|
||||
@@ -1191,6 +1192,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
);
|
||||
case "simulations":
|
||||
return <SimulationWorkspace />;
|
||||
case "observatory":
|
||||
return <ObservatoryWorkspace definition={props.definition} />;
|
||||
case "device":
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
buildLaboratoryProfiles,
|
||||
experimentOptionsForProfile,
|
||||
freshestLaboratorySelection,
|
||||
isLegacyPublishedLaboratoryWork,
|
||||
workOptionsForExperiment,
|
||||
} from "./laboratoryArchiveProfiles";
|
||||
import {
|
||||
@@ -499,12 +500,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
|
||||
onDeleteBegin: props.sessionArchive.onDeleteBegin,
|
||||
});
|
||||
const publishedWorks = useMemo(
|
||||
() => sessions.items.filter((session) => (
|
||||
session.lab !== null
|
||||
&& session.status === "ready"
|
||||
&& session.replayable
|
||||
&& session.modalities.includes("point-cloud")
|
||||
)).sort((left, right) => (
|
||||
() => sessions.items.filter(isLegacyPublishedLaboratoryWork).sort((left, right) => (
|
||||
Date.parse(right.lab?.runCreatedAtUtc ?? right.startedAtUtc)
|
||||
- Date.parse(left.lab?.runCreatedAtUtc ?? left.startedAtUtc)
|
||||
)),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
LaboratoryEvidence,
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
LaboratoryWorkTemplate,
|
||||
} from "../../components/laboratory/LaboratoryPresentation";
|
||||
import {
|
||||
vegetationFullRouteMaskUrl,
|
||||
vegetationVideoMaskUrl,
|
||||
type VegetationFullRouteReview,
|
||||
type VegetationShadowResult,
|
||||
@@ -17,13 +16,7 @@ import {
|
||||
type M49TgsFullShadowResult,
|
||||
} from "../../core/laboratory/m49TgsFullShadow";
|
||||
import { M49TgsFullShadowEvidence } from "./M49TgsFullShadowEvidence";
|
||||
import {
|
||||
M4ReplayThreatVisual,
|
||||
type M4ReplayClassifiedSpatialLayer,
|
||||
type M4ReplayThreatSemanticLayer,
|
||||
} from "./M4ReplayThreatVisual";
|
||||
|
||||
const VEGETATION_TIMELINE_ENDPOINT = "/api/v1/laboratory/vegetation-shadow";
|
||||
import { CanonicalVegetationRerunReplay } from "./CanonicalVegetationRerunReplay";
|
||||
|
||||
function decimal(value: number, digits = 1): string {
|
||||
return value.toLocaleString("ru-RU", { maximumFractionDigits: digits });
|
||||
@@ -36,56 +29,7 @@ function FullRouteReviewEvidence({
|
||||
resultId: string;
|
||||
review: VegetationFullRouteReview;
|
||||
}) {
|
||||
const semanticLayers = useMemo<readonly M4ReplayThreatSemanticLayer[]>(() => ([
|
||||
{
|
||||
id: "city",
|
||||
controlLabel: "ГОРОД · EoMT",
|
||||
resultId,
|
||||
spatialResultId: null,
|
||||
taxonomy: review.city.taxonomy,
|
||||
maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "city", sequence),
|
||||
label: review.city.name,
|
||||
maskAriaLabel: "EoMT city semantic prediction",
|
||||
},
|
||||
{
|
||||
id: "vegetation",
|
||||
controlLabel: "ПРИРОДА · DDRNet",
|
||||
resultId,
|
||||
spatialResultId: null,
|
||||
taxonomy: review.vegetation.taxonomy,
|
||||
maskUrl: (sequence) => vegetationFullRouteMaskUrl(resultId, "vegetation", sequence),
|
||||
label: review.vegetation.name,
|
||||
maskAriaLabel: "DDRNet nature semantic prediction",
|
||||
},
|
||||
]), [resultId, review.city, review.vegetation]);
|
||||
const sealedSpatialGap = useMemo<M4ReplayClassifiedSpatialLayer>(() => ({
|
||||
label: "RAVNOVES004TREE",
|
||||
pointLayerLabel: "SOURCE POINTS",
|
||||
cellLayerLabel: "TGS COSTMAP",
|
||||
cellLayerAvailable: false,
|
||||
expectedAtSequence: false,
|
||||
frame: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
replacePointCloud: false,
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<M4ReplayThreatVisual
|
||||
resultId={resultId}
|
||||
timelineEndpointRoot={VEGETATION_TIMELINE_ENDPOINT}
|
||||
semanticLayers={semanticLayers}
|
||||
initialSemanticLayerId="vegetation"
|
||||
initialSpatialMode="3d"
|
||||
classifiedSpatialLayer={sealedSpatialGap}
|
||||
evidenceLabel="RAVNOVES004TREE"
|
||||
playbackTransport="segmented"
|
||||
spatialPlaybackTransport="sealed-binary"
|
||||
recoverTimestampStalls
|
||||
showReferenceMediaLayers
|
||||
showSpatialOverlaySummary
|
||||
/>
|
||||
);
|
||||
return <CanonicalVegetationRerunReplay resultId={resultId} review={review} />;
|
||||
}
|
||||
|
||||
function FullRouteReviewResult({
|
||||
@@ -102,16 +46,16 @@ function FullRouteReviewResult({
|
||||
summary={(
|
||||
<LaboratorySummary
|
||||
title="LAB V1 · RAVNOVES004TREE · полный маршрут"
|
||||
description="Принятый recorded-LAB инструмент воспроизводит RAV004 без отдельного viewer: одна media-clock timeline, RIGHT camera, source points, bounded Local SLAM и переключаемые EoMT/DDRNet."
|
||||
status="FULL RECORDED REVIEW · truth отсутствует · commands OFF"
|
||||
description="Принятый инструмент записанной LAB воспроизводит RAV004 без отдельного viewer: единый таймлайн, правая камера, исходные точки, ограниченный Local SLAM и переключаемые EoMT/DDRNet."
|
||||
status="ПОЛНЫЙ ПРОСМОТР ЗАПИСИ · эталон отсутствует · команды ВЫКЛ"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount}/${review.frameCount} camera frames` },
|
||||
{ label: "3D", value: "1444 source cloud increments · gravity-stable RFU → body" },
|
||||
{ label: "Город", value: `${review.city.name} · ${decimal(review.city.inferenceFps, 2)} fps` },
|
||||
{ label: "Природа", value: `${review.vegetation.name} · ${decimal(review.vegetation.inferenceFps, 2)} fps` },
|
||||
{ label: "TGS", value: "10 review anchors существуют · full-route artifact отсутствует" },
|
||||
{ label: "Authority", value: `${rigLabel} · VISUAL REVIEW ONLY · commands OFF` },
|
||||
{ label: "Источник", value: `${review.sourceId} · ${review.frameCount}/${review.frameCount} кадров камеры` },
|
||||
{ label: "3D", value: "1444 приращения исходного облака · стабильная по гравитации RFU → корпус" },
|
||||
{ label: "Город", value: `${review.city.name} · ${decimal(review.city.inferenceFps, 2)} кадра/с` },
|
||||
{ label: "Природа", value: `${review.vegetation.name} · ${decimal(review.vegetation.inferenceFps, 2)} кадра/с` },
|
||||
{ label: "TGS", value: "существуют 10 контрольных якорей · артефакт полного маршрута отсутствует" },
|
||||
{ label: "Полномочия", value: `${rigLabel} · ТОЛЬКО ВИЗУАЛЬНЫЙ ПРОСМОТР · команды ВЫКЛ` },
|
||||
]}
|
||||
brief={{
|
||||
question: "Что реально видно на полном RAV004-прогоне с высокой травой, оврагами и переходом к городу?",
|
||||
@@ -134,8 +78,8 @@ function FullRouteReviewResult({
|
||||
)}
|
||||
evidence={(
|
||||
<LaboratoryEvidence
|
||||
eyebrow="CANONICAL RECORDED LAB · RAVNOVES004TREE"
|
||||
title="CAMERA + SOURCE POINTS + LOCAL SLAM + TGS COSTMAP + SEMANTICS · 6830/6830"
|
||||
eyebrow="КАНОНИЧЕСКАЯ ЗАПИСАННАЯ LAB · RAVNOVES004TREE"
|
||||
title="КАМЕРА + ИСХОДНЫЕ ТОЧКИ + ЛОКАЛЬНЫЙ SLAM + КАРТА TGS + СЕМАНТИКА · 6830/6830"
|
||||
kind="recorded-replay"
|
||||
resizable
|
||||
>
|
||||
@@ -145,18 +89,18 @@ function FullRouteReviewResult({
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="RAV004 переведён на общий replay-каркас; safety evidence ещё не полно"
|
||||
status="Recorded evidence · navigation/actuation OFF"
|
||||
status="Записанные доказательства · навигация/управление ВЫКЛ"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{ label: "Camera timeline", value: "6830 frames · ≈9.51 Hz", hint: "media clock owns video, overlays and spatial" },
|
||||
{ label: "Source geometry", value: "1444 increments · ≈2 Hz", hint: "last proven spatial frame is held between source arrivals" },
|
||||
{ label: "EoMT throughput", value: `${decimal(review.city.inferenceFps, 2)} fps`, hint: "изолированный full pass; не realtime stack" },
|
||||
{ label: "DDRNet throughput", value: `${decimal(review.vegetation.inferenceFps, 2)} fps`, hint: "изолированный full pass; temporal stability не принята" },
|
||||
{ label: "Таймлайн камеры", value: "6830 кадров · ≈9,51 Гц", hint: "единые часы управляют видео, слоями и пространством" },
|
||||
{ label: "Исходная геометрия", value: "1444 приращения · ≈2 Гц", hint: "между поступлениями удерживается последний подтверждённый пространственный кадр" },
|
||||
{ label: "Пропускная способность EoMT", value: `${decimal(review.city.inferenceFps, 2)} кадра/с`, hint: "изолированный полный прогон; не стек реального времени" },
|
||||
{ label: "Пропускная способность DDRNet", value: `${decimal(review.vegetation.inferenceFps, 2)} кадра/с`, hint: "изолированный полный прогон; временная стабильность не принята" },
|
||||
]}
|
||||
conclusion={{
|
||||
proved: "Camera, seek, spatial layers and semantic switching use one accepted reusable viewer and one media clock; RFU source geometry no longer inherits LiDAR roll/pitch.",
|
||||
notProved: "Не доказаны continuous TGS, независимый detector/STOP, truth accuracy, temporal stability DDRNet и ≥10 FPS совместного live stack.",
|
||||
decision: "Продолжать как visual audit. До запечатанного full-route TGS и detector/load gate navigation/actuation остаются OFF.",
|
||||
proved: "Камера, перемотка, пространственные слои и переключение семантики используют один принятый переиспользуемый viewer и единые часы; исходная геометрия RFU больше не наследует крен и тангаж LiDAR.",
|
||||
notProved: "Не доказаны непрерывная TGS, независимый детектор/STOP, точность относительно эталона, временная стабильность DDRNet и ≥10 кадров/с совместного стека реального времени.",
|
||||
decision: "Продолжать как визуальный аудит. До запечатанной TGS полного маршрута и барьера детектора/нагрузки навигация и управление остаются выключенными.",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -459,6 +459,17 @@ function pipelineIdForSession(session: ObservationSessionSummary): string {
|
||||
return session.lab?.resultKind ?? "legacy-perception";
|
||||
}
|
||||
|
||||
/** Keep capability projections owned by Observatory out of the legacy LAB surface. */
|
||||
export function isLegacyPublishedLaboratoryWork(
|
||||
session: ObservationSessionSummary,
|
||||
): boolean {
|
||||
return session.lab !== null
|
||||
&& session.lab.replayCapability === null
|
||||
&& session.status === "ready"
|
||||
&& session.replayable
|
||||
&& session.modalities.includes("point-cloud");
|
||||
}
|
||||
|
||||
export function buildLaboratoryCatalog({
|
||||
rigLabel,
|
||||
knownWorks,
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
ConfirmationModal,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
IconButton,
|
||||
Select,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { CanonicalVegetationRerunReplay } from "../../components/laboratory/CanonicalVegetationRerunReplay";
|
||||
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
|
||||
import { createObservationReplayCoordinator } from "../../core/observation/replayCoordinator";
|
||||
import {
|
||||
fetchObservatoryRecordedRunReview,
|
||||
type ObservatoryRecordedRunBinding,
|
||||
} from "../../core/observatory/recordedRun";
|
||||
import {
|
||||
findObservatoryEvidence,
|
||||
observatoryCatalogConfirmsEvidenceDeletion,
|
||||
type ObservatoryEvidence,
|
||||
} from "../../core/observatory/catalog";
|
||||
import {
|
||||
deleteObservatoryLabProjection,
|
||||
renameObservatoryLabProjection,
|
||||
} from "../../core/observatory/catalogMutations";
|
||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
|
||||
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
|
||||
import type { WorkspaceDefinition } from "../../productModel";
|
||||
|
||||
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
||||
type ObservatoryRecordedRunReview = Awaited<
|
||||
ReturnType<typeof fetchObservatoryRecordedRunReview>
|
||||
>;
|
||||
|
||||
type ObservatoryMutationReconciliation =
|
||||
| {
|
||||
readonly kind: "rename";
|
||||
readonly sessionId: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
| {
|
||||
readonly kind: "delete";
|
||||
readonly sessionId: string;
|
||||
};
|
||||
|
||||
type ObservatoryReplayState =
|
||||
| { readonly kind: "closed" }
|
||||
| {
|
||||
readonly kind: "loading";
|
||||
readonly binding: ObservatoryRecordedRunBinding;
|
||||
}
|
||||
| {
|
||||
readonly kind: "ready";
|
||||
readonly binding: ObservatoryRecordedRunBinding;
|
||||
readonly review: ObservatoryRecordedRunReview;
|
||||
}
|
||||
| {
|
||||
readonly kind: "error";
|
||||
readonly binding: ObservatoryRecordedRunBinding;
|
||||
readonly message: string;
|
||||
};
|
||||
|
||||
const statusLabel: Record<ObservationSessionStatus, string> = {
|
||||
recording: "Запись идёт",
|
||||
ready: "Готова",
|
||||
degraded: "С деградацией",
|
||||
interrupted: "Прервана",
|
||||
failed: "Ошибка",
|
||||
};
|
||||
|
||||
const modalityLabel: Record<string, string> = {
|
||||
"point-cloud": "Облако точек",
|
||||
pose: "Поза",
|
||||
trajectory: "Траектория",
|
||||
video: "Видео",
|
||||
image: "Изображения",
|
||||
depth: "Глубина",
|
||||
telemetry: "Телеметрия",
|
||||
};
|
||||
|
||||
function statusTone(
|
||||
status: ObservationSessionStatus,
|
||||
): "success" | "accent" | "warning" | "danger" | "neutral" {
|
||||
if (status === "ready") return "success";
|
||||
if (status === "recording") return "accent";
|
||||
if (status === "degraded" || status === "interrupted") return "warning";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string): string {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const totalSeconds = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(totalSeconds / 3_600);
|
||||
const minutes = Math.floor((totalSeconds % 3_600) / 60);
|
||||
const remainingSeconds = totalSeconds % 60;
|
||||
return hours > 0
|
||||
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
|
||||
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function mutationErrorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: "Не удалось изменить лабораторный результат в Обсерватории.";
|
||||
}
|
||||
|
||||
function evidenceResultSubtitle(evidence: ObservatoryEvidence): string {
|
||||
const profileName = evidence.lab.calculationProfile?.displayName;
|
||||
return profileName ? `${evidence.label} · ${profileName}` : evidence.label;
|
||||
}
|
||||
|
||||
export function ObservatoryWorkspace({
|
||||
definition,
|
||||
}: {
|
||||
definition: WorkspaceDefinition;
|
||||
}) {
|
||||
const controller = useObservatoryCatalog();
|
||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
|
||||
const recordedJobsController = useObservatoryRecordedJobs(
|
||||
selectedSessionId,
|
||||
setupController.selectedSetupId,
|
||||
setupController.selectedSetup?.runDefinition?.definitionSha256 ?? "",
|
||||
);
|
||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservatoryEvidence | null>(null);
|
||||
const [mutationPending, setMutationPending] = useState<"rename" | "delete" | null>(null);
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [mutationReconciliation, setMutationReconciliation] = useState<
|
||||
ObservatoryMutationReconciliation | null
|
||||
>(null);
|
||||
const overviewRef = useRef<HTMLElement | null>(null);
|
||||
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
|
||||
const refreshedPublishedJobRef = useRef<string | null>(null);
|
||||
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
|
||||
|
||||
const closeReplay = useCallback(() => {
|
||||
replayCoordinatorRef.current.cancel();
|
||||
setReplay((current) => current.kind === "closed" ? current : { kind: "closed" });
|
||||
}, []);
|
||||
|
||||
const returnToOverview = useCallback(() => {
|
||||
closeReplay();
|
||||
overviewRef.current?.scrollIntoView({ block: "start" });
|
||||
}, [closeReplay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (items.some((item) => item.source.id === selectedSessionId)) return;
|
||||
closeReplay();
|
||||
setSelectedSessionId(items[0]?.source.id ?? "");
|
||||
}, [closeReplay, items, selectedSessionId]);
|
||||
|
||||
useEffect(() => () => replayCoordinatorRef.current.cancel(), []);
|
||||
|
||||
const selectedSession = items.find(
|
||||
(item) => item.source.id === selectedSessionId,
|
||||
) ?? null;
|
||||
const presentedEvidence = selectedSession?.evidence ?? [];
|
||||
const options = useMemo(() => items.map(({ source, evidence }) => ({
|
||||
value: source.id,
|
||||
label: source.label,
|
||||
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
|
||||
})), [items]);
|
||||
const setupOptions = useMemo(() => (
|
||||
setupController.selectableSetups.map((setup) => ({
|
||||
value: setup.setupId,
|
||||
label: setup.displayName,
|
||||
description: setup.description,
|
||||
}))
|
||||
), [setupController.selectableSetups]);
|
||||
const preflightCandidate = setupController.preflight.kind === "ready"
|
||||
? setupController.preflight.value
|
||||
: null;
|
||||
const runPreflight = preflightCandidate
|
||||
&& preflightCandidate.sourceSessionId === selectedSession?.source.id
|
||||
&& preflightCandidate.setupId === setupController.selectedSetup?.setupId
|
||||
&& preflightCandidate.definitionSha256
|
||||
=== setupController.selectedSetup?.runDefinition?.definitionSha256
|
||||
? preflightCandidate
|
||||
: null;
|
||||
const queueSubmissionAllowed = Boolean(
|
||||
setupController.selectedSetup?.runDefinition
|
||||
&& setupController.selectedSetup.compatibility.compatible
|
||||
&& runPreflight?.outcome === "queueable"
|
||||
&& runPreflight.submissionAllowed,
|
||||
);
|
||||
const presentedJob = recordedJobsController.activeJob ?? recordedJobsController.latestJob;
|
||||
const publicationFailed = presentedJob?.publication.state === "failed";
|
||||
const calculationPending = recordedJobsController.activeJob !== null
|
||||
|| recordedJobsController.publicationPending
|
||||
|| recordedJobsController.state === "submitting"
|
||||
|| recordedJobsController.state === "retrying-publication";
|
||||
const showCalculate = setupController.selectedSetup !== null
|
||||
&& !calculationPending && !publicationFailed;
|
||||
const canSubmitRecordedJob = queueSubmissionAllowed
|
||||
&& setupController.state === "ready"
|
||||
&& recordedJobsController.state === "ready"
|
||||
&& !calculationPending && !publicationFailed;
|
||||
const queueStatusError = setupController.preflight.kind === "error"
|
||||
? setupController.preflight.message
|
||||
: recordedJobsController.error
|
||||
?? (publicationFailed
|
||||
? presentedJob?.publication.error ?? "Не удалось опубликовать результат."
|
||||
: recordedJobsController.computationFailed
|
||||
? "Не удалось выполнить расчёт. Можно повторить запуск."
|
||||
: runPreflight?.outcome === "blocked"
|
||||
? runPreflight.checks.filter((check) => check.outcome === "fail")
|
||||
.map((check) => check.message).join(" ") || "Запуск профиля недоступен."
|
||||
: null);
|
||||
const initialLoading = !controller.catalog
|
||||
&& ["idle", "loading"].includes(controller.state);
|
||||
const unavailable = !controller.catalog && controller.state === "error";
|
||||
const replayEvidenceId = replay.kind === "closed"
|
||||
? null
|
||||
: replay.binding.evidenceSessionId;
|
||||
const replayEvidence = replayEvidenceId === null
|
||||
? null
|
||||
: selectedSession?.evidence.find(
|
||||
(evidence) => evidence.sessionId === replayEvidenceId,
|
||||
) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const publishedJob = recordedJobsController.jobs.find(
|
||||
(job) => job.publication.state === "published" && job.resultId !== null,
|
||||
);
|
||||
if (!publishedJob || refreshedPublishedJobRef.current === publishedJob.jobId) return;
|
||||
refreshedPublishedJobRef.current = publishedJob.jobId;
|
||||
void controller.refresh();
|
||||
setupController.refresh();
|
||||
}, [controller.refresh, recordedJobsController.jobs, setupController.refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
replayEvidenceId === null
|
||||
|| replayEvidence
|
||||
) return;
|
||||
closeReplay();
|
||||
}, [closeReplay, replayEvidence, replayEvidenceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
mutationPending !== null
|
||||
|| mutationReconciliation === null
|
||||
|| controller.catalog === null
|
||||
) return;
|
||||
const evidence = findObservatoryEvidence(
|
||||
controller.catalog,
|
||||
mutationReconciliation.sessionId,
|
||||
);
|
||||
const confirmed = mutationReconciliation.kind === "rename"
|
||||
? evidence?.label === mutationReconciliation.displayName
|
||||
: observatoryCatalogConfirmsEvidenceDeletion(
|
||||
controller.catalog,
|
||||
mutationReconciliation.sessionId,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
setRenameTarget(null);
|
||||
setDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
if (mutationReconciliation.kind === "delete") setupController.refresh();
|
||||
}, [controller.catalog, mutationPending, mutationReconciliation, setupController.refresh]);
|
||||
|
||||
const openReplay = useCallback((binding: ObservatoryRecordedRunBinding) => {
|
||||
const attempt = replayCoordinatorRef.current.begin();
|
||||
setReplay({ kind: "loading", binding });
|
||||
void fetchObservatoryRecordedRunReview(binding, {
|
||||
selectedSourceSessionId: selectedSessionId,
|
||||
signal: attempt.signal,
|
||||
}).then((review) => {
|
||||
if (!attempt.isCurrent() || !attempt.finish()) return;
|
||||
setReplay({ kind: "ready", binding, review });
|
||||
}).catch((caught: unknown) => {
|
||||
if (!attempt.isCurrent() || !attempt.finish()) return;
|
||||
setReplay({
|
||||
kind: "error",
|
||||
binding,
|
||||
message: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Канонический визуальный разбор недоступен.",
|
||||
});
|
||||
});
|
||||
}, [selectedSessionId]);
|
||||
|
||||
const selectSession = useCallback((sessionId: string) => {
|
||||
closeReplay();
|
||||
setSelectedSessionId(sessionId);
|
||||
}, [closeReplay]);
|
||||
|
||||
const openRename = useCallback((evidence: ObservatoryEvidence) => {
|
||||
if (!evidence.recordedRun) return;
|
||||
setDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
setRenameValue(evidence.label);
|
||||
setRenameTarget(evidence);
|
||||
}, []);
|
||||
|
||||
const openDelete = useCallback((evidence: ObservatoryEvidence) => {
|
||||
if (!evidence.recordedRun) return;
|
||||
setRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
setDeleteTarget(evidence);
|
||||
}, []);
|
||||
|
||||
const submitRename = useCallback(async () => {
|
||||
if (!renameTarget?.recordedRun || mutationPending !== null) return;
|
||||
const requestedDisplayName = renameValue.trim();
|
||||
const reconciliation: ObservatoryMutationReconciliation = {
|
||||
kind: "rename",
|
||||
sessionId: renameTarget.sessionId,
|
||||
displayName: requestedDisplayName,
|
||||
};
|
||||
setMutationPending("rename");
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(reconciliation);
|
||||
try {
|
||||
const result = await renameObservatoryLabProjection(
|
||||
renameTarget.recordedRun,
|
||||
renameValue,
|
||||
);
|
||||
controller.applyEvidenceRename(result.sessionId, result.displayName);
|
||||
setRenameTarget(null);
|
||||
setMutationReconciliation(null);
|
||||
void controller.refresh();
|
||||
} catch (error) {
|
||||
const reconciled = await controller.refresh();
|
||||
const evidence = reconciled
|
||||
? findObservatoryEvidence(reconciled, reconciliation.sessionId)
|
||||
: null;
|
||||
if (reconciled && evidence?.label === reconciliation.displayName) {
|
||||
setRenameTarget(null);
|
||||
setMutationReconciliation(null);
|
||||
} else {
|
||||
setMutationError(mutationErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
}, [controller, mutationPending, renameTarget, renameValue]);
|
||||
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!deleteTarget?.recordedRun || mutationPending !== null) return;
|
||||
const reconciliation: ObservatoryMutationReconciliation = {
|
||||
kind: "delete",
|
||||
sessionId: deleteTarget.sessionId,
|
||||
};
|
||||
setMutationPending("delete");
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(reconciliation);
|
||||
try {
|
||||
if (
|
||||
replay.kind !== "closed"
|
||||
&& replay.binding.evidenceSessionId === deleteTarget.sessionId
|
||||
) {
|
||||
flushSync(() => {
|
||||
closeReplay();
|
||||
});
|
||||
}
|
||||
await deleteObservatoryLabProjection(deleteTarget.recordedRun);
|
||||
controller.applyEvidenceDeletion(deleteTarget.sessionId);
|
||||
setupController.refresh();
|
||||
setDeleteTarget(null);
|
||||
setMutationReconciliation(null);
|
||||
void controller.refresh();
|
||||
} catch (error) {
|
||||
const reconciled = await controller.refresh();
|
||||
if (
|
||||
reconciled
|
||||
&& observatoryCatalogConfirmsEvidenceDeletion(
|
||||
reconciled,
|
||||
reconciliation.sessionId,
|
||||
)
|
||||
) {
|
||||
setDeleteTarget(null);
|
||||
setMutationReconciliation(null);
|
||||
} else {
|
||||
setMutationError(mutationErrorMessage(error));
|
||||
}
|
||||
} finally {
|
||||
setMutationPending(null);
|
||||
}
|
||||
}, [closeReplay, controller, deleteTarget, mutationPending, replay, setupController.refresh]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="observatory-workspace"
|
||||
data-observatory-authority="observation-only"
|
||||
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
|
||||
>
|
||||
<section ref={overviewRef} className="observatory-lead">
|
||||
<div>
|
||||
<span className="section-eyebrow">{definition.eyebrow}</span>
|
||||
<h2>{definition.title}</h2>
|
||||
<p>
|
||||
Записанные источники и строго связанные результаты без запуска тяжёлого
|
||||
визуализатора и без доступа к управлению аппаратом.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<GlassSurface className="observatory-catalog-bar" padding="sm">
|
||||
<div className="observatory-catalog-bar__copy">
|
||||
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
|
||||
<h3>Сохранённая сессия</h3>
|
||||
</div>
|
||||
<div className="observatory-catalog-bar__controls">
|
||||
<Select
|
||||
label="Выбрать сохранённую сессию"
|
||||
value={selectedSessionId}
|
||||
options={options}
|
||||
disabled={items.length === 0}
|
||||
searchable
|
||||
searchPlaceholder="Поиск по сессиям"
|
||||
emptyLabel="Сессия не найдена"
|
||||
minMenuWidth={360}
|
||||
menuWidth={460}
|
||||
onChange={selectSession}
|
||||
/>
|
||||
<Select
|
||||
label="Выбрать сетап лаборатории"
|
||||
value={setupController.selectedSetupId}
|
||||
options={setupOptions}
|
||||
disabled={!selectedSessionId || setupOptions.length === 0}
|
||||
searchable
|
||||
searchPlaceholder="Поиск по сетапам"
|
||||
emptyLabel={setupController.state === "error" ? "Каталог профилей недоступен" : "Нет профилей для расчёта"}
|
||||
minMenuWidth={360}
|
||||
menuWidth={500}
|
||||
onChange={setupController.selectSetup}
|
||||
/>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
controller.state === "loading"
|
||||
|| controller.state === "refreshing"
|
||||
|| setupController.state === "loading"
|
||||
|| setupController.state === "refreshing"
|
||||
}
|
||||
icon={<Icon name="refresh" />}
|
||||
onClick={() => {
|
||||
void controller.refresh();
|
||||
setupController.refresh();
|
||||
recordedJobsController.refresh();
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
<div className="observatory-catalog-bar__run" aria-busy={calculationPending}>
|
||||
{calculationPending ? (
|
||||
<ActivityIndicator size="compact" label="Ожидание результата расчёта" />
|
||||
) : null}
|
||||
{showCalculate ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
disabled={!canSubmitRecordedJob}
|
||||
onClick={() => {
|
||||
void recordedJobsController.submit(
|
||||
setupController.selectedSetup?.origin === "portable-definition"
|
||||
&& runPreflight?.definitionSha256
|
||||
&& runPreflight.checkSha256
|
||||
? {
|
||||
definitionSha256: runPreflight.definitionSha256,
|
||||
checkSha256: runPreflight.checkSha256,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Рассчитать
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
{queueStatusError ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||
<span className="observatory-notice__copy">{queueStatusError}</span>
|
||||
{publicationFailed ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={recordedJobsController.state === "retrying-publication"}
|
||||
onClick={() => void recordedJobsController.retryPublication()}
|
||||
>
|
||||
Повторить публикацию
|
||||
</Button>
|
||||
) : null}
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{controller.error && controller.catalog ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||
<StatusBadge tone="warning">Показан последний срез</StatusBadge>
|
||||
<span className="observatory-notice__copy">{controller.error}</span>
|
||||
<Button size="compact" variant="ghost" onClick={controller.refresh}>Повторить</Button>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{setupController.error ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||
<span className="observatory-notice__copy">{setupController.error}</span>
|
||||
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{controller.catalog
|
||||
&& (controller.catalog.window.sourceLimitReached
|
||||
|| controller.catalog.window.laboratoryLimitReached) ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
|
||||
<StatusBadge tone="warning">Срез ограничен</StatusBadge>
|
||||
<span className="observatory-notice__copy">
|
||||
Загружены последние {controller.catalog.window.sourceCount} исходных сессий и{
|
||||
" "
|
||||
}{controller.catalog.window.laboratoryCount} LAB-результатов. Полнота исторических
|
||||
связей не подтверждена.
|
||||
</span>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{initialLoading ? (
|
||||
<GlassSurface className="observatory-state" padding="lg">
|
||||
<ActivityIndicator label="Читаем каталог сессий" />
|
||||
<h3>Читаем источники и связи результатов</h3>
|
||||
<p>Визуализатор и лабораторные сцены при этом не запускаются.</p>
|
||||
</GlassSurface>
|
||||
) : unavailable ? (
|
||||
<GlassSurface className="observatory-state" padding="lg" role="alert">
|
||||
<Icon name="alert" size={20} />
|
||||
<StatusBadge tone="danger">Каталог недоступен</StatusBadge>
|
||||
<h3>{controller.error}</h3>
|
||||
<Button size="compact" variant="secondary" onClick={controller.refresh}>Повторить</Button>
|
||||
</GlassSurface>
|
||||
) : items.length === 0 ? (
|
||||
<GlassSurface className="observatory-state" padding="lg">
|
||||
<Icon name="database" size={20} />
|
||||
<h3>Сохранённых сессий пока нет</h3>
|
||||
<p>После завершения записи источник появится здесь без создания демонстрационных данных.</p>
|
||||
</GlassSurface>
|
||||
) : selectedSession ? (
|
||||
<section className="observatory-session-stack" aria-label="Выбранная сессия и связанные результаты">
|
||||
<GlassSurface className="observatory-session-summary" padding="sm">
|
||||
<div className="observatory-session-summary__identity">
|
||||
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
|
||||
<h3>{selectedSession.source.label}</h3>
|
||||
<code>{selectedSession.source.id}</code>
|
||||
</div>
|
||||
<dl className="observatory-session-summary__facts">
|
||||
<div><dt>Начало</dt><dd>{formatTimestamp(selectedSession.source.startedAtUtc)}</dd></div>
|
||||
<div><dt>Длительность</dt><dd>{formatDuration(selectedSession.source.durationSeconds)}</dd></div>
|
||||
<div>
|
||||
<dt>Чтение</dt>
|
||||
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Оборудование</dt>
|
||||
<dd>
|
||||
{selectedSession.source.captureAttestation?.equipmentDisplayName
|
||||
?? "Не аттестовано"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Профиль записи</dt>
|
||||
<dd title={selectedSession.source.captureAttestation?.captureProfileSha256}>
|
||||
{selectedSession.source.captureAttestation?.captureProfileId
|
||||
?? "Не определён"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="observatory-session-summary__end">
|
||||
<StatusBadge tone={statusTone(selectedSession.source.status)}>
|
||||
{statusLabel[selectedSession.source.status]}
|
||||
</StatusBadge>
|
||||
<div className="observatory-modalities" aria-label="Каналы сессии">
|
||||
{selectedSession.source.modalities.length > 0
|
||||
? selectedSession.source.modalities.map((modality) => (
|
||||
<StatusBadge key={modality} tone="neutral">
|
||||
{modalityLabel[modality] ?? modality}
|
||||
</StatusBadge>
|
||||
))
|
||||
: <span>Каналы не зафиксированы</span>}
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<section className="observatory-evidence">
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
|
||||
<h3>Лабораторные доказательства</h3>
|
||||
</div>
|
||||
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
|
||||
{selectedSession.evidence.length}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
{selectedSession.evidence.length > 0 ? (
|
||||
<ol className="observatory-evidence-list">
|
||||
{presentedEvidence.map((evidence) => (
|
||||
<li key={evidence.sessionId}>
|
||||
<div className="observatory-evidence-card">
|
||||
<span className="observatory-evidence-card__icon" aria-hidden="true">
|
||||
<Icon name="clipboard" size={18} />
|
||||
</span>
|
||||
<div className="observatory-evidence-card__copy">
|
||||
<strong>{evidence.lab.labId}</strong>
|
||||
<span>{evidenceResultSubtitle(evidence)}</span>
|
||||
<small>
|
||||
{evidence.lab.resultKind} · {formatTimestamp(evidence.publishedAtUtc)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="observatory-evidence-card__actions">
|
||||
{evidence.recordedRun ? (
|
||||
<>
|
||||
<IconButton
|
||||
label={`Удалить ${evidence.lab.labId} из Обсерватории`}
|
||||
onClick={() => openDelete(evidence)}
|
||||
>
|
||||
<Icon name="trash" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={`Переименовать ${evidence.lab.labId}`}
|
||||
onClick={() => openRename(evidence)}
|
||||
>
|
||||
<Icon name="edit" size={16} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={`Открыть визуальный разбор: ${evidence.lab.labId}`}
|
||||
disabled={
|
||||
replay.kind === "loading"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
}
|
||||
onClick={() => openReplay(evidence.recordedRun!)}
|
||||
>
|
||||
{replay.kind === "loading"
|
||||
&& replay.binding.evidenceSessionId === evidence.sessionId
|
||||
? <ActivityIndicator size="compact" />
|
||||
: <Icon name="eye" size={16} />}
|
||||
</IconButton>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<div className="observatory-evidence-empty">
|
||||
<Icon name="clipboard" size={18} />
|
||||
<strong>Связанных результатов нет</strong>
|
||||
<p>
|
||||
Наличие исходной записи само по себе не является выводом о качестве
|
||||
компьютерного зрения или безопасности прохождения.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{replay.kind === "loading" ? (
|
||||
<GlassSurface className="observatory-replay-state" padding="lg" role="status">
|
||||
<ActivityIndicator label="Проверяем запечатанный результат" />
|
||||
<div>
|
||||
<h3>Проверяем точную связь результата с исходной сессией</h3>
|
||||
<p>Визуализатор и данные маршрута ещё не запущены.</p>
|
||||
</div>
|
||||
<Button size="compact" variant="ghost" onClick={returnToOverview}>Отменить</Button>
|
||||
</GlassSurface>
|
||||
) : replay.kind === "error" ? (
|
||||
<GlassSurface className="observatory-replay-state" padding="lg" role="alert">
|
||||
<Icon name="alert" size={20} />
|
||||
<div>
|
||||
<StatusBadge tone="danger">Просмотр недоступен</StatusBadge>
|
||||
<h3>{replay.message}</h3>
|
||||
</div>
|
||||
<div className="observatory-replay-state__actions">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => openReplay(replay.binding)}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
) : replay.kind === "ready" ? (
|
||||
<section className="observatory-replay" aria-label="Визуальный разбор результата">
|
||||
<header className="observatory-replay__header">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
{replay.review.kind === "canonical-recorded-rerun"
|
||||
? "ВИЗУАЛЬНЫЙ РАЗБОР / ЗАПИСАННАЯ СЕССИЯ"
|
||||
: "РЕЗУЛЬТАТ / ПРОВЕРЕННЫЙ ДОКУМЕНТ"}
|
||||
</span>
|
||||
<h3>{replayEvidence?.label ?? replay.binding.resultId}</h3>
|
||||
<p>
|
||||
{replay.review.kind === "canonical-recorded-rerun"
|
||||
? "Записанный маршрут синхронизирован по общей временной шкале."
|
||||
: "Показан проверенный документ результата и связанные с ним артефакты."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="close" size={14} />}
|
||||
onClick={returnToOverview}
|
||||
>
|
||||
Закрыть разбор
|
||||
</Button>
|
||||
</header>
|
||||
{replay.review.kind === "canonical-recorded-rerun" ? (
|
||||
<CanonicalVegetationRerunReplay
|
||||
resultId={replay.binding.resultId}
|
||||
review={replay.review.review}
|
||||
/>
|
||||
) : (
|
||||
<GlassSurface className="observatory-replay-state" padding="lg">
|
||||
<div>
|
||||
<StatusBadge tone="success">Проверено</StatusBadge>
|
||||
<h3>{replay.review.resultKind}</h3>
|
||||
<p>Связанных артефактов: {replay.review.artifacts.length}</p>
|
||||
<details>
|
||||
<summary>Документ результата</summary>
|
||||
<pre>{JSON.stringify(replay.review.resultDocument, null, 2)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{(controller.catalog?.unresolvedEvidence.length ?? 0) > 0 ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
|
||||
<StatusBadge tone="warning">Вне среза</StatusBadge>
|
||||
<span className="observatory-notice__copy">
|
||||
{controller.catalog?.unresolvedEvidence.length} результатов ссылаются на источники
|
||||
вне текущего загруженного среза и не приписаны к квалификационным доказательствам.
|
||||
</span>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
<Window
|
||||
open={renameTarget !== null}
|
||||
title="Переименовать лабораторный результат"
|
||||
subtitle="Меняется только отображаемое название в Обсерватории"
|
||||
size="sm"
|
||||
closeOnBackdrop={mutationPending !== "rename"}
|
||||
closeOnEscape={mutationPending !== "rename"}
|
||||
onClose={() => {
|
||||
if (mutationPending === "rename") return;
|
||||
setRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}}
|
||||
footer={(
|
||||
<WindowFooterActions>
|
||||
<Button
|
||||
disabled={mutationPending === "rename"}
|
||||
onClick={() => {
|
||||
setRenameTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="observatory-rename-form"
|
||||
variant="primary"
|
||||
disabled={mutationPending === "rename" || renameValue.trim().length === 0}
|
||||
>
|
||||
{mutationPending === "rename" ? "Сохраняем…" : "Сохранить"}
|
||||
</Button>
|
||||
</WindowFooterActions>
|
||||
)}
|
||||
>
|
||||
<form
|
||||
id="observatory-rename-form"
|
||||
className="observatory-rename-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submitRename();
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Название"
|
||||
value={renameValue}
|
||||
maxLength={160}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
disabled={mutationPending === "rename"}
|
||||
onChange={(event) => setRenameValue(event.currentTarget.value)}
|
||||
/>
|
||||
{mutationError && renameTarget ? (
|
||||
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Window>
|
||||
|
||||
<ConfirmationModal
|
||||
open={deleteTarget !== null}
|
||||
title="Удалить результат из Обсерватории?"
|
||||
description={deleteTarget ? (
|
||||
<div className="observatory-delete-confirmation">
|
||||
<p>
|
||||
Будут удалены только каталожная проекция <strong>{deleteTarget.label}</strong>
|
||||
{" "}и её отображаемое название в Обсерватории.
|
||||
</p>
|
||||
<p>
|
||||
Исходная сессия, запечатанный лабораторный результат и файлы доказательств
|
||||
останутся неизменными.
|
||||
</p>
|
||||
{mutationError ? (
|
||||
<p className="observatory-mutation-error" role="alert">{mutationError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
confirmLabel="Удалить из Обсерватории"
|
||||
pendingLabel="Удаляем…"
|
||||
danger
|
||||
onClose={() => {
|
||||
if (mutationPending === "delete") return;
|
||||
setDeleteTarget(null);
|
||||
setMutationError(null);
|
||||
setMutationReconciliation(null);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,9 +17,14 @@ import {
|
||||
} from "../../core/system/useWorkerTelemetry";
|
||||
|
||||
function pipelineStateLabel(state: string): string {
|
||||
if (state === "busy") return "Выполняет задачу";
|
||||
if (state === "busy" || state === "running") return "Выполняет задачу";
|
||||
if (state === "waiting") return "Ожидает восстановления данных";
|
||||
if (state === "synchronizing") return "Синхронизирует поток";
|
||||
if (state === "starting") return "Загружает профиль";
|
||||
if (state === "stopped" || state === "cancelled") return "Профиль остановлен";
|
||||
if (state === "failed") return "Ошибка профиля";
|
||||
if (state === "ready") return "Готов к задаче";
|
||||
return "Нет live-состояния";
|
||||
return "Нет свежих данных о задаче";
|
||||
}
|
||||
|
||||
function formatResourcePair(
|
||||
@@ -116,7 +121,7 @@ export function ComputeModulesWorkspace() {
|
||||
{!legacyDiagnostic && !agentTelemetry ? (
|
||||
<GlassSurface className="system-workspace__notice" padding="md" tone="soft">
|
||||
<StatusBadge tone="warning">
|
||||
Агентный data-plane ещё не опубликовал нормализованный срез этого контура.
|
||||
Ожидаем свежую телеметрию выбранного узла.
|
||||
</StatusBadge>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
@@ -187,6 +192,8 @@ export function ComputeModulesWorkspace() {
|
||||
? `${node.gpu.temperature_celsius} °C`
|
||||
: "—"
|
||||
}</dd></div>
|
||||
<div><dt>Частота GPU</dt><dd>{node?.gpu?.sm_clock_mhz == null ? "—" : `${node.gpu.sm_clock_mhz} МГц`}</dd></div>
|
||||
<div><dt>Частота памяти GPU</dt><dd>{node?.gpu?.memory_clock_mhz == null ? "—" : `${node.gpu.memory_clock_mhz} МГц`}</dd></div>
|
||||
<div><dt>Мощность</dt><dd>{
|
||||
typeof node?.gpu?.power_watts === "number"
|
||||
? `${node.gpu.power_watts.toFixed(1)} Вт`
|
||||
@@ -214,7 +221,7 @@ export function ComputeModulesWorkspace() {
|
||||
<div>
|
||||
<span className="section-eyebrow">PROCESSING RUNTIME</span>
|
||||
<h3>Контейнеры Mission Core</h3>
|
||||
<p>Два ограниченных runtime: inference server и прикладной perception pipeline.</p>
|
||||
<p>Состояние контейнера и готовность вычислительного профиля проверяются отдельно.</p>
|
||||
</div>
|
||||
<StatusBadge tone={node?.triton.ready ? "success" : "danger"}>
|
||||
{node?.triton.ready ? "Triton ready" : "Triton недоступен"}
|
||||
@@ -233,19 +240,31 @@ export function ComputeModulesWorkspace() {
|
||||
<span className="section-eyebrow">ТЕКУЩАЯ ЗАДАЧА</span>
|
||||
<h3>{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}</h3>
|
||||
<p>
|
||||
{telemetry?.pipeline.profile_name ?? "Perception"}
|
||||
{" · "}
|
||||
{telemetry?.pipeline.active_request_id
|
||||
? `Run ${telemetry.pipeline.active_request_id}`
|
||||
: "Очередь свободна; модели остаются загруженными в persistent worker."}
|
||||
: telemetry?.pipeline.service_state === "ready"
|
||||
? "Готов к приёму данных."
|
||||
: "Готовность моделей не подтверждена."}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge tone={
|
||||
telemetry?.pipeline.service_state === "busy" ? "warning"
|
||||
["busy", "running", "waiting", "synchronizing", "starting"].includes(telemetry?.pipeline.service_state ?? "") ? "warning"
|
||||
: telemetry?.pipeline.service_state === "ready" ? "success"
|
||||
: "danger"
|
||||
}>
|
||||
{telemetry?.pipeline.service_state ?? "unavailable"}
|
||||
{pipelineStateLabel(telemetry?.pipeline.service_state ?? "unavailable")}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
{telemetry?.pipeline.profile_name ? (
|
||||
<div className="worker-pipeline__summary">
|
||||
<div><span>Процессы профиля</span><strong>{telemetry.pipeline.live_children ?? "—"}</strong></div>
|
||||
<div><span>Ожиданий потока</span><strong>{telemetry.pipeline.input_pauses ?? "—"}</strong></div>
|
||||
<div><span>Пакетов в очереди</span><strong>{telemetry.pipeline.pending_bundles ?? "—"}</strong></div>
|
||||
<div><span>Входные буферы</span><strong>{formatBytes(telemetry.pipeline.buffer_bytes)}</strong></div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="worker-pipeline__summary">
|
||||
<div><span>Завершено запусков</span><strong>{telemetry?.pipeline.completed_runs ?? "—"}</strong></div>
|
||||
<div><span>Ошибок запусков</span><strong>{telemetry?.pipeline.failed_runs ?? "—"}</strong></div>
|
||||
|
||||
@@ -11,6 +11,7 @@ let fetchM49TgsAnchorSpatial;
|
||||
let AdvancedLaboratoryContractError;
|
||||
let buildLaboratoryCatalog;
|
||||
let buildLaboratoryProfiles;
|
||||
let isLegacyPublishedLaboratoryWork;
|
||||
let experimentOptionsForProfile;
|
||||
let freshestLaboratorySelection;
|
||||
let workOptionsForExperiment;
|
||||
@@ -928,6 +929,7 @@ before(async () => {
|
||||
({
|
||||
buildLaboratoryCatalog,
|
||||
buildLaboratoryProfiles,
|
||||
isLegacyPublishedLaboratoryWork,
|
||||
experimentOptionsForProfile,
|
||||
freshestLaboratorySelection,
|
||||
workOptionsForExperiment,
|
||||
@@ -962,6 +964,31 @@ after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("capability-owned projections never enter the legacy LAB catalog", () => {
|
||||
const legacy = {
|
||||
status: "ready",
|
||||
replayable: true,
|
||||
modalities: ["point-cloud", "trajectory"],
|
||||
lab: { replayCapability: null },
|
||||
};
|
||||
const capabilityProjection = {
|
||||
...legacy,
|
||||
lab: {
|
||||
replayCapability: {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(isLegacyPublishedLaboratoryWork(legacy), true);
|
||||
assert.equal(isLegacyPublishedLaboratoryWork(capabilityProjection), false);
|
||||
});
|
||||
|
||||
test("LAB catalog is pipeline-scoped and ordered by real run time", () => {
|
||||
const catalog = buildLaboratoryCatalog({
|
||||
rigLabel: "K1",
|
||||
|
||||
@@ -105,23 +105,45 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
|
||||
assert.match(e30HumanReviewCss, /\.e30-human-review/);
|
||||
});
|
||||
|
||||
test("central composition files cannot silently become monoliths again", async () => {
|
||||
const ratchets = [
|
||||
["App.tsx", 1_250],
|
||||
["workspaces/Workspaces.tsx", 1_200],
|
||||
["workspaces/laboratory/LaboratoryArchiveWorkspace.tsx", 1_000],
|
||||
["core/laboratory/advancedResults.ts", 1_000],
|
||||
["core/laboratory/e40ProductGate.ts", 500],
|
||||
["styles/workspaces.css", 4_350],
|
||||
["styles/laboratory.css", 900],
|
||||
["styles/laboratory-reporting.css", 100],
|
||||
];
|
||||
test("Observatory owns a bounded explicit-open lifecycle around the shared recorded viewer", async () => {
|
||||
const workspaceHub = await read("workspaces/Workspaces.tsx");
|
||||
const observatory = await read("workspaces/observatory/ObservatoryWorkspace.tsx");
|
||||
const observatoryCore = await read("core/observatory/catalog.ts");
|
||||
const recordedRun = await read("core/observatory/recordedRun.ts");
|
||||
const sharedReplay = await read(
|
||||
"components/laboratory/CanonicalVegetationRerunReplay.tsx",
|
||||
);
|
||||
const workspaceCss = await read("styles/workspaces.css");
|
||||
const observatoryCss = await read("styles/observatory.css");
|
||||
|
||||
for (const [relativePath, maximumLines] of ratchets) {
|
||||
const lineCount = (await read(relativePath)).split("\n").length;
|
||||
assert.ok(
|
||||
lineCount <= maximumLines,
|
||||
`${relativePath} has ${lineCount} lines; split the feature instead of raising ${maximumLines}`,
|
||||
);
|
||||
}
|
||||
assert.match(
|
||||
workspaceHub,
|
||||
/case "observatory":[\s\S]*<ObservatoryWorkspace definition=\{props\.definition\} \/>/,
|
||||
);
|
||||
assert.match(observatory, /export function ObservatoryWorkspace/);
|
||||
assert.match(observatory, /useObservatoryCatalog/);
|
||||
assert.match(observatory, /data-observatory-authority="observation-only"/);
|
||||
assert.match(
|
||||
observatory,
|
||||
/data-observatory-viewer={replay\.kind === "ready" \? "attached" : "detached"}/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
`${observatory}\n${observatoryCore}`,
|
||||
/(?:core|workspaces)\/laboratory|\/api\/v1\/laboratory|RerunViewport|ObservationSessionSelect/,
|
||||
);
|
||||
assert.match(observatory, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||
assert.match(observatory, /closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
||||
assert.doesNotMatch(
|
||||
observatory,
|
||||
/replayObservationSession|deleteObservationSession|useObservationSessions|useAdvancedLaboratoryCatalog/,
|
||||
);
|
||||
assert.match(recordedRun, /fetchVegetationShadowResultMetadata/);
|
||||
assert.doesNotMatch(
|
||||
recordedRun,
|
||||
/advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport/,
|
||||
);
|
||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.doesNotMatch(workspaceCss, /\.observatory-/);
|
||||
assert.match(observatoryCss, /\.observatory-workspace/);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ let server;
|
||||
let canonicalMapGravityLocalPointToBodyGround;
|
||||
let canonicalRecordedLabPackedTgsCells;
|
||||
let canonicalRecordedLabTgsIsCurrent;
|
||||
let resolveCanonicalLabReplay;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
@@ -20,6 +21,9 @@ before(async () => {
|
||||
canonicalRecordedLabPackedTgsCells,
|
||||
canonicalRecordedLabTgsIsCurrent,
|
||||
} = await server.ssrLoadModule("/src/core/laboratory/canonicalRecordedLab.ts"));
|
||||
({ resolveCanonicalLabReplay } = await server.ssrLoadModule(
|
||||
"/src/core/laboratory/canonicalLabReplay.ts",
|
||||
));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
@@ -76,3 +80,55 @@ test("recorded LAB spatial loading is shared, profile-bound and experiment-neutr
|
||||
assert.doesNotMatch(scheduler, /RAVNOVES|vegetation|DDRNet/);
|
||||
assert.doesNotMatch(vegetation, /fetchCanonicalRecordedLabSpatialFrame|CanonicalRecordedLabSpatialFrame/);
|
||||
});
|
||||
|
||||
test("canonical LAB resolves one generation-bound merged RRD", async () => {
|
||||
const baseGeneration = "a".repeat(64);
|
||||
const replayGeneration = "b".repeat(64);
|
||||
const resultId = `lab-v1-vegetation-shadow-${"c".repeat(64)}`;
|
||||
let request;
|
||||
const replay = await resolveCanonicalLabReplay(resultId, {
|
||||
kind: "rerun-recording",
|
||||
sessionId: "session-001",
|
||||
sourceUrl: "/api/v1/observation-sessions/session-001/recording.rrd",
|
||||
viewerSourceUrl:
|
||||
`/api/v1/observation-sessions/session-001/recording.rrd?generation=${baseGeneration}`,
|
||||
mediaType: "application/vnd.rerun.rrd",
|
||||
timeline: "session_time",
|
||||
timelineStartSeconds: 0,
|
||||
timelineEndSeconds: 10,
|
||||
seekable: true,
|
||||
byteLength: 100,
|
||||
sha256: baseGeneration,
|
||||
playback: { speed: 1, loop: false },
|
||||
mediaSources: [],
|
||||
}, {
|
||||
origin: "http://mission-core.test",
|
||||
fetcher: async (url, options) => {
|
||||
request = { url, options };
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"Content-Length": "234567",
|
||||
"ETag": `"${replayGeneration}"`,
|
||||
"X-Rerun-Format": "RRF2",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const sourceUrl =
|
||||
`/api/v1/laboratory/vegetation-shadow/${resultId}/canonical-replay.rrd`;
|
||||
assert.equal(
|
||||
request.url,
|
||||
`http://mission-core.test${sourceUrl}?base_generation=${baseGeneration}`,
|
||||
);
|
||||
assert.equal(request.options.method, "HEAD");
|
||||
assert.deepEqual(replay, {
|
||||
sourceUrl,
|
||||
viewerSourceUrl: `${sourceUrl}?generation=${replayGeneration}`,
|
||||
byteLength: 234567,
|
||||
sha256: replayGeneration,
|
||||
blueprintSourceUrl: "/api/v1/observation-sessions/session-001/blueprint.rrd",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -886,8 +886,8 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
|
||||
assert.match(canonical, /primary=\{mediaPane\}/);
|
||||
assert.match(canonical, /secondary=\{spatialPane/);
|
||||
assert.match(canonical, /primarySize=\{splitView \? splitPrimarySize : mediaMode !== "none" \? 100 : 0\}/);
|
||||
assert.match(canonical, /resizable=\{splitView\}/);
|
||||
assert.match(canonical, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
||||
assert.match(canonical, /resizable=\{splitView && !unifiedContent\}/);
|
||||
assert.match(canonical, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
||||
assert.match(canonical, /secondaryMode=\{\{/);
|
||||
assert.match(visual, /playback=\{playbackController\.playback\}/);
|
||||
assert.match(visual, /clock: "external"/);
|
||||
|
||||
@@ -77,6 +77,70 @@ function session(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalLab(overrides = {}) {
|
||||
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
return {
|
||||
lab_id: "LAB V1",
|
||||
source_session_id: "20260828T130511Z_viewer_live",
|
||||
result_kind: "recorded-perception-qualification",
|
||||
result_id: resultId,
|
||||
source_result_id: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||
config_sha256: null,
|
||||
run_created_at_utc: "2026-08-29T18:05:11.329061+00:00",
|
||||
published_at_utc: "2026-08-30T12:00:00.000Z",
|
||||
replay_capability: {
|
||||
schema_version: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewer_profile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commands_enabled: false,
|
||||
},
|
||||
provenance: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyCalculationProfile(overrides = {}) {
|
||||
return {
|
||||
schema_version: "missioncore.observatory-calculation-profile/v1",
|
||||
setup_id: "lab-v1-ravnoves004tree-final",
|
||||
display_name: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
|
||||
origin: "existing-result",
|
||||
definition_id: null,
|
||||
definition_version: null,
|
||||
definition_sha256: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalProjectionProvenance(resultId, replayCapability) {
|
||||
return {
|
||||
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
|
||||
evidence_identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
|
||||
result_document_sha256: "a".repeat(64),
|
||||
replay_capability: replayCapability,
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
actuation_accepted: false,
|
||||
},
|
||||
method: {
|
||||
schema_version: "missioncore.laboratory-method/v1",
|
||||
completeness: "legacy-partial",
|
||||
execution_class: "ai-inference",
|
||||
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
||||
components: [{
|
||||
kind: "source",
|
||||
name: "sealed full-route LAB result",
|
||||
version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||
role: "immutable Session catalog projection",
|
||||
identity_sha256: resultId.slice("lab-v1-vegetation-shadow-".length),
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function replay(overrides = {}) {
|
||||
const sessionId = overrides.session_id ?? "session-20260716T205632Z";
|
||||
const sourceUrl = overrides.source_url ??
|
||||
@@ -134,6 +198,7 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
|
||||
modalities: ["point-cloud", "pose"],
|
||||
durationSeconds: 1_450.744,
|
||||
replayable: true,
|
||||
captureAttestation: null,
|
||||
preparation: null,
|
||||
lab: null,
|
||||
}]);
|
||||
@@ -141,6 +206,137 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
|
||||
assert.equal("path" in catalog.items[0], false);
|
||||
});
|
||||
|
||||
test("session catalog strictly decodes the explicit recorded LAB replay capability", () => {
|
||||
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
const decoded = decodeObservationSessionCatalog({
|
||||
items: [session({ id: resultId, lab: canonicalLab() })],
|
||||
}).items[0].lab;
|
||||
|
||||
assert.deepEqual(decoded.replayCapability, {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
});
|
||||
const rollingUpgradeLab = canonicalLab();
|
||||
rollingUpgradeLab.provenance = canonicalProjectionProvenance(
|
||||
resultId,
|
||||
rollingUpgradeLab.replay_capability,
|
||||
);
|
||||
delete rollingUpgradeLab.replay_capability;
|
||||
assert.deepEqual(
|
||||
decodeObservationSessionCatalog({
|
||||
items: [session({ id: resultId, lab: rollingUpgradeLab })],
|
||||
}).items[0].lab.replayCapability,
|
||||
decoded.replayCapability,
|
||||
);
|
||||
const invalidComponents = [
|
||||
{ kind: "tool" },
|
||||
{ name: "unsealed result" },
|
||||
{ version: "missioncore.lab-v1-vegetation-shadow/v0" },
|
||||
{ role: "mutable projection" },
|
||||
{ identity_sha256: "0".repeat(64) },
|
||||
{ unexpected: true },
|
||||
];
|
||||
for (const componentOverride of invalidComponents) {
|
||||
const invalidRollingUpgradeLab = canonicalLab();
|
||||
invalidRollingUpgradeLab.provenance = canonicalProjectionProvenance(
|
||||
resultId,
|
||||
invalidRollingUpgradeLab.replay_capability,
|
||||
);
|
||||
invalidRollingUpgradeLab.provenance.method.components = [{
|
||||
...invalidRollingUpgradeLab.provenance.method.components[0],
|
||||
...componentOverride,
|
||||
}];
|
||||
delete invalidRollingUpgradeLab.replay_capability;
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({ id: resultId, lab: invalidRollingUpgradeLab })],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
}
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
id: resultId,
|
||||
lab: canonicalLab({
|
||||
replay_capability: {
|
||||
...canonicalLab().replay_capability,
|
||||
commands_enabled: true,
|
||||
},
|
||||
}),
|
||||
})],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("session catalog decodes a typed calculation profile without inferring unknown results", () => {
|
||||
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
const decoded = decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
id: resultId,
|
||||
lab: canonicalLab({ calculation_profile: legacyCalculationProfile() }),
|
||||
})],
|
||||
}).items[0].lab;
|
||||
|
||||
assert.deepEqual(decoded.calculationProfile, {
|
||||
schemaVersion: "missioncore.observatory-calculation-profile/v1",
|
||||
setupId: "lab-v1-ravnoves004tree-final",
|
||||
displayName: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
|
||||
origin: "existing-result",
|
||||
definitionId: null,
|
||||
definitionVersion: null,
|
||||
definitionSha256: null,
|
||||
});
|
||||
assert.equal(
|
||||
decodeObservationSessionCatalog({
|
||||
items: [session({ id: resultId, lab: canonicalLab() })],
|
||||
}).items[0].lab.calculationProfile,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
id: resultId,
|
||||
lab: canonicalLab({ calculation_profile: null }),
|
||||
})],
|
||||
}).items[0].lab.calculationProfile,
|
||||
null,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
id: resultId,
|
||||
lab: canonicalLab({
|
||||
calculation_profile: legacyCalculationProfile({
|
||||
origin: "archived-definition",
|
||||
definition_id: "lab-v1-portable",
|
||||
}),
|
||||
}),
|
||||
})],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
id: resultId,
|
||||
lab: canonicalLab({
|
||||
calculation_profile: {
|
||||
...legacyCalculationProfile(),
|
||||
guessed_from_provenance: true,
|
||||
},
|
||||
}),
|
||||
})],
|
||||
}),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("opened archive is named in the scene header and trash hover has no pill", async () => {
|
||||
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
|
||||
const styles = await readFile(
|
||||
@@ -246,7 +442,7 @@ test("source and laboratory catalogs are requested as disjoint backend projectio
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"/api/v1/observation-sessions?limit=100&scope=source",
|
||||
"/api/v1/observation-sessions?limit=100&scope=laboratory",
|
||||
"/api/v1/observation-sessions?limit=100&scope=laboratory&lab_contract=v3",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ let resolveRecordedBlueprintUrl;
|
||||
let fetchRecordedBlueprintRrd;
|
||||
let resolveRecordedPerceptionUrl;
|
||||
let fetchRecordedPerceptionRrd;
|
||||
let resolveRecordedPerceptionViewerSourceUrl;
|
||||
let probeRecordedPerceptionViewerSource;
|
||||
let resolveRecordedPointColorsUrl;
|
||||
let fetchRecordedPointColorsRrd;
|
||||
let recordedPointColorKey;
|
||||
@@ -110,6 +112,8 @@ before(async () => {
|
||||
fetchRecordedBlueprintRrd,
|
||||
resolveRecordedPerceptionUrl,
|
||||
fetchRecordedPerceptionRrd,
|
||||
resolveRecordedPerceptionViewerSourceUrl,
|
||||
probeRecordedPerceptionViewerSource,
|
||||
resolveRecordedPointColorsUrl,
|
||||
fetchRecordedPointColorsRrd,
|
||||
recordedPointColorKey,
|
||||
@@ -407,6 +411,15 @@ test("recorded blueprint endpoint is derived only from canonical same-origin RRD
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveRecordedBlueprintUrl(
|
||||
`/api/v1/laboratory/vegetation-shadow/lab-v1-vegetation-shadow-${"a".repeat(64)}`
|
||||
+ "/canonical-replay.rrd",
|
||||
"http://127.0.0.1:5174",
|
||||
"/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
),
|
||||
"http://127.0.0.1:5174/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded replay becomes ready only after the complete declared timeline is buffered", () => {
|
||||
@@ -485,6 +498,8 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
view_reset_generation: 1,
|
||||
follow_trajectory: true,
|
||||
unified_perception: true,
|
||||
semantic_layer: null,
|
||||
plan_view: false,
|
||||
show_detections_2d: true,
|
||||
show_segmentation: false,
|
||||
show_cuboids_3d: true,
|
||||
@@ -664,6 +679,74 @@ test("recorded perception fetch admits one complete same-origin RRD or no layer"
|
||||
assert.equal(absent, null);
|
||||
});
|
||||
|
||||
test("LAB perception sidecar is streamed by native Rerun from one generation-bound URL", async () => {
|
||||
const endpoint =
|
||||
`http://127.0.0.1:5174/api/v1/laboratory/vegetation-shadow/` +
|
||||
`lab-v1-vegetation-shadow-${"a".repeat(64)}/canonical-overlay.rrd`;
|
||||
const sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
|
||||
endpoint,
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
"b".repeat(64),
|
||||
"http://127.0.0.1:5174",
|
||||
);
|
||||
assert.equal(
|
||||
sourceUrl,
|
||||
`${endpoint}?application_id=nodedc_mission_core_recorded` +
|
||||
`&recording_id=recording-001&generation=${"b".repeat(64)}`,
|
||||
);
|
||||
|
||||
const overlayGeneration = "c".repeat(64);
|
||||
const probe = await probeRecordedPerceptionViewerSource(sourceUrl, {
|
||||
origin: "http://127.0.0.1:5174",
|
||||
fetcher: async (input, init) => {
|
||||
assert.equal(String(input), sourceUrl);
|
||||
assert.equal(init.method, "HEAD");
|
||||
assert.equal(new Headers(init.headers).get("Range"), null);
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"Content-Length": "186058411",
|
||||
"ETag": `"${overlayGeneration}"`,
|
||||
"X-Rerun-Format": "RRF2",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.deepEqual(probe, {
|
||||
sourceUrl: `${sourceUrl}&overlay_generation=${overlayGeneration}`,
|
||||
byteLength: 186_058_411,
|
||||
});
|
||||
});
|
||||
|
||||
test("LAB native source rejects an unsealed overlay descriptor", async () => {
|
||||
const endpoint =
|
||||
`http://127.0.0.1:5174/api/v1/laboratory/vegetation-shadow/` +
|
||||
`lab-v1-vegetation-shadow-${"a".repeat(64)}/canonical-overlay.rrd`;
|
||||
const sourceUrl = resolveRecordedPerceptionViewerSourceUrl(
|
||||
endpoint,
|
||||
{ applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001" },
|
||||
"b".repeat(64),
|
||||
"http://127.0.0.1:5174",
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
probeRecordedPerceptionViewerSource(sourceUrl, {
|
||||
origin: "http://127.0.0.1:5174",
|
||||
fetcher: async () => new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.rerun.rrd",
|
||||
"Content-Length": "186058411",
|
||||
"ETag": `"${"c".repeat(64)}"`,
|
||||
"X-Rerun-Format": "RRF1",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
/Invalid recorded perception viewer response/,
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded replay creates an isolated source catalog without live device bindings", () => {
|
||||
const sources = recordedObservationSources({
|
||||
kind: "rerun-recording",
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let applyObservatoryCatalogMutationOverlay;
|
||||
let buildObservatoryCatalog;
|
||||
let fetchObservatoryCatalog;
|
||||
let findObservatoryEvidence;
|
||||
let observatoryCatalogConfirmsEvidenceDeletion;
|
||||
let reconcileObservatoryCatalogMutationOverlay;
|
||||
let ObservatoryCatalogContractError;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
applyObservatoryCatalogMutationOverlay,
|
||||
buildObservatoryCatalog,
|
||||
fetchObservatoryCatalog,
|
||||
findObservatoryEvidence,
|
||||
observatoryCatalogConfirmsEvidenceDeletion,
|
||||
reconcileObservatoryCatalogMutationOverlay,
|
||||
ObservatoryCatalogContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observatory/catalog.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
function source(id, startedAtUtc) {
|
||||
return {
|
||||
id,
|
||||
label: `Источник ${id}`,
|
||||
startedAtUtc,
|
||||
completedAtUtc: startedAtUtc,
|
||||
status: "ready",
|
||||
modalities: ["point-cloud", "video"],
|
||||
durationSeconds: 42,
|
||||
replayable: true,
|
||||
preparation: null,
|
||||
lab: null,
|
||||
};
|
||||
}
|
||||
|
||||
function evidence(id, sourceSessionId, publishedAtUtc) {
|
||||
return {
|
||||
id,
|
||||
label: `Результат ${id}`,
|
||||
startedAtUtc: publishedAtUtc,
|
||||
completedAtUtc: publishedAtUtc,
|
||||
status: "ready",
|
||||
modalities: ["video"],
|
||||
durationSeconds: 12,
|
||||
replayable: true,
|
||||
preparation: null,
|
||||
lab: {
|
||||
labId: `LAB-${id}`,
|
||||
sourceSessionId,
|
||||
resultKind: "recorded-evidence",
|
||||
resultId: `result-${id}`,
|
||||
sourceResultId: null,
|
||||
configSha256: "a".repeat(64),
|
||||
runCreatedAtUtc: publishedAtUtc,
|
||||
publishedAtUtc,
|
||||
replayCapability: null,
|
||||
calculationProfile: null,
|
||||
provenance: { verdict: "must-not-be-inferred" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalProvenance(resultId) {
|
||||
const identitySha256 = resultId.slice("lab-v1-vegetation-shadow-".length);
|
||||
return {
|
||||
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
|
||||
evidence_identity_sha256: identitySha256,
|
||||
result_document_sha256: "a".repeat(64),
|
||||
replay_capability: {
|
||||
schema_version: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewer_profile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commands_enabled: false,
|
||||
},
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
actuation_accepted: false,
|
||||
},
|
||||
method: {
|
||||
schema_version: "missioncore.laboratory-method/v1",
|
||||
completeness: "legacy-partial",
|
||||
execution_class: "ai-inference",
|
||||
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
||||
components: [{
|
||||
kind: "source",
|
||||
name: "sealed full-route LAB result",
|
||||
version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||
role: "immutable Session catalog projection",
|
||||
identity_sha256: identitySha256,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("Observatory joins evidence only by sourceSessionId and keeps deterministic order", () => {
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[
|
||||
source("older", "2026-08-28T10:00:00Z"),
|
||||
source("newer", "2026-08-29T10:00:00Z"),
|
||||
],
|
||||
[
|
||||
evidence("old-result", "newer", "2026-08-29T11:00:00Z"),
|
||||
evidence("new-result", "newer", "2026-08-29T12:00:00Z"),
|
||||
evidence("orphan", "missing", "2026-08-29T13:00:00Z"),
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(catalog.items.map(({ source: item }) => item.id), ["newer", "older"]);
|
||||
assert.deepEqual(
|
||||
catalog.items[0].evidence.map((item) => item.sessionId),
|
||||
["new-result", "old-result"],
|
||||
);
|
||||
assert.deepEqual(catalog.unresolvedEvidence.map((item) => item.sessionId), ["orphan"]);
|
||||
assert.deepEqual(catalog.window, {
|
||||
limit: 100,
|
||||
sourceCount: 2,
|
||||
laboratoryCount: 3,
|
||||
sourceLimitReached: false,
|
||||
laboratoryLimitReached: false,
|
||||
});
|
||||
assert.equal("verdict" in catalog.items[0].evidence[0], false);
|
||||
assert.equal(catalog.items[1].evidence.length, 0);
|
||||
});
|
||||
|
||||
test("Observatory fails visibly when the laboratory projection loses its typed link", () => {
|
||||
assert.throws(
|
||||
() => buildObservatoryCatalog(
|
||||
[source("source", "2026-08-29T10:00:00Z")],
|
||||
[source("not-lab", "2026-08-29T11:00:00Z")],
|
||||
),
|
||||
ObservatoryCatalogContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory fetches disjoint read-only source and laboratory projections", async () => {
|
||||
const calls = [];
|
||||
const fetcher = async (input, init) => {
|
||||
calls.push({ input: String(input), method: init?.method });
|
||||
return new Response(JSON.stringify({ items: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const catalog = await fetchObservatoryCatalog({ fetcher, limit: 50 });
|
||||
assert.deepEqual(catalog, {
|
||||
items: [],
|
||||
unresolvedEvidence: [],
|
||||
window: {
|
||||
limit: 50,
|
||||
sourceCount: 0,
|
||||
laboratoryCount: 0,
|
||||
sourceLimitReached: false,
|
||||
laboratoryLimitReached: false,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
calls.map(({ input }) => input).sort(),
|
||||
[
|
||||
"/api/v1/observation-sessions?limit=50&scope=laboratory&lab_contract=v3",
|
||||
"/api/v1/observation-sessions?limit=50&scope=source",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
|
||||
});
|
||||
|
||||
test("Observatory exposes bounded-window uncertainty without inventing a broken link", () => {
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[
|
||||
source("newer", "2026-08-29T10:00:00Z"),
|
||||
source("older", "2026-08-28T10:00:00Z"),
|
||||
],
|
||||
[
|
||||
evidence("linked", "newer", "2026-08-29T11:00:00Z"),
|
||||
evidence("outside-window", "older-than-window", "2026-08-29T12:00:00Z"),
|
||||
],
|
||||
2,
|
||||
);
|
||||
|
||||
assert.equal(catalog.window.sourceLimitReached, true);
|
||||
assert.equal(catalog.window.laboratoryLimitReached, true);
|
||||
assert.deepEqual(
|
||||
catalog.unresolvedEvidence.map((item) => item.sessionId),
|
||||
["outside-window"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory projects a typed canonical run only through its exact sourceSessionId", () => {
|
||||
const canonicalResultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
const canonical = evidence(
|
||||
canonicalResultId,
|
||||
"20260828T130511Z_viewer_live",
|
||||
"2026-08-29T18:05:11Z",
|
||||
);
|
||||
canonical.lab = {
|
||||
...canonical.lab,
|
||||
labId: "LAB V1",
|
||||
resultKind: "recorded-perception-qualification",
|
||||
resultId: canonicalResultId,
|
||||
sourceResultId: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||
configSha256: null,
|
||||
provenance: canonicalProvenance(canonicalResultId),
|
||||
replayCapability: {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
},
|
||||
};
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[source("20260828T130511Z_viewer_live", "2026-08-28T13:05:11Z")],
|
||||
[canonical],
|
||||
);
|
||||
|
||||
assert.deepEqual(catalog.items[0].evidence[0].recordedRun, {
|
||||
kind: "canonical-recorded-rerun",
|
||||
evidenceSessionId: canonicalResultId,
|
||||
sourceSessionId: "20260828T130511Z_viewer_live",
|
||||
resultId: canonicalResultId,
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
test("Observatory applies an exact rename locally without mutating canonical identity", () => {
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[source("source", "2026-08-29T10:00:00Z")],
|
||||
[evidence("result", "source", "2026-08-29T11:00:00Z")],
|
||||
);
|
||||
const original = catalog.items[0].evidence[0];
|
||||
const projected = applyObservatoryCatalogMutationOverlay(
|
||||
catalog,
|
||||
new Map([["result", {
|
||||
kind: "rename",
|
||||
displayName: "Операторское имя",
|
||||
revision: 1,
|
||||
}]]),
|
||||
);
|
||||
const renamed = findObservatoryEvidence(projected, "result");
|
||||
|
||||
assert.equal(renamed.label, "Операторское имя");
|
||||
assert.equal(renamed.sessionId, original.sessionId);
|
||||
assert.equal(renamed.lab, original.lab);
|
||||
assert.equal(renamed.recordedRun, original.recordedRun);
|
||||
assert.equal(projected.items[0].source, catalog.items[0].source);
|
||||
assert.equal(projected.window.laboratoryCount, 1);
|
||||
});
|
||||
|
||||
test("Observatory tombstone removes only its catalog evidence projection", () => {
|
||||
const catalog = buildObservatoryCatalog(
|
||||
[source("source", "2026-08-29T10:00:00Z")],
|
||||
[
|
||||
evidence("remove", "source", "2026-08-29T12:00:00Z"),
|
||||
evidence("keep", "source", "2026-08-29T11:00:00Z"),
|
||||
],
|
||||
);
|
||||
const projected = applyObservatoryCatalogMutationOverlay(
|
||||
catalog,
|
||||
new Map([["remove", { kind: "delete", revision: 1 }]]),
|
||||
);
|
||||
|
||||
assert.equal(projected.items.length, 1);
|
||||
assert.equal(projected.items[0].source, catalog.items[0].source);
|
||||
assert.deepEqual(
|
||||
projected.items[0].evidence.map(({ sessionId }) => sessionId),
|
||||
["keep"],
|
||||
);
|
||||
assert.equal(projected.window.sourceCount, 1);
|
||||
assert.equal(projected.window.laboratoryCount, 1);
|
||||
});
|
||||
|
||||
test("Observatory confirms an ambiguous delete only from a complete LAB window", () => {
|
||||
const bounded = buildObservatoryCatalog(
|
||||
[source("source", "2026-08-29T10:00:00Z")],
|
||||
[evidence("other", "source", "2026-08-29T11:00:00Z")],
|
||||
1,
|
||||
);
|
||||
const complete = buildObservatoryCatalog(
|
||||
[source("source", "2026-08-29T10:00:00Z")],
|
||||
[],
|
||||
);
|
||||
|
||||
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(bounded, "result"), false);
|
||||
assert.equal(observatoryCatalogConfirmsEvidenceDeletion(complete, "result"), true);
|
||||
});
|
||||
|
||||
test("Observatory reconciliation cannot let a stale in-flight fetch undo a local mutation", () => {
|
||||
const oldCatalog = buildObservatoryCatalog(
|
||||
[source("source", "2026-08-29T10:00:00Z")],
|
||||
[evidence("result", "source", "2026-08-29T11:00:00Z")],
|
||||
);
|
||||
const renameOverlay = new Map([["result", {
|
||||
kind: "rename",
|
||||
displayName: "Новое имя",
|
||||
revision: 1,
|
||||
}]]);
|
||||
|
||||
const staleRename = reconcileObservatoryCatalogMutationOverlay(
|
||||
oldCatalog,
|
||||
renameOverlay,
|
||||
0,
|
||||
);
|
||||
assert.equal(findObservatoryEvidence(staleRename.catalog, "result").label, "Новое имя");
|
||||
assert.equal(staleRename.overlay.has("result"), true);
|
||||
|
||||
const reconciledOldRename = reconcileObservatoryCatalogMutationOverlay(
|
||||
oldCatalog,
|
||||
renameOverlay,
|
||||
1,
|
||||
);
|
||||
assert.equal(findObservatoryEvidence(reconciledOldRename.catalog, "result").label, "Новое имя");
|
||||
assert.equal(reconciledOldRename.overlay.has("result"), true);
|
||||
|
||||
const renamedEvidence = evidence("result", "source", "2026-08-29T11:00:00Z");
|
||||
renamedEvidence.label = "Новое имя";
|
||||
const confirmedRename = reconcileObservatoryCatalogMutationOverlay(
|
||||
buildObservatoryCatalog(
|
||||
[source("source", "2026-08-29T10:00:00Z")],
|
||||
[renamedEvidence],
|
||||
),
|
||||
staleRename.overlay,
|
||||
1,
|
||||
);
|
||||
assert.equal(findObservatoryEvidence(confirmedRename.catalog, "result").label, "Новое имя");
|
||||
assert.equal(confirmedRename.overlay.size, 0);
|
||||
|
||||
const deleteOverlay = new Map([["result", { kind: "delete", revision: 2 }]]);
|
||||
const staleDelete = reconcileObservatoryCatalogMutationOverlay(
|
||||
oldCatalog,
|
||||
deleteOverlay,
|
||||
1,
|
||||
);
|
||||
assert.equal(findObservatoryEvidence(staleDelete.catalog, "result"), null);
|
||||
assert.equal(staleDelete.overlay.has("result"), true);
|
||||
|
||||
const reconciledOldDelete = reconcileObservatoryCatalogMutationOverlay(
|
||||
oldCatalog,
|
||||
deleteOverlay,
|
||||
2,
|
||||
);
|
||||
assert.equal(findObservatoryEvidence(reconciledOldDelete.catalog, "result"), null);
|
||||
assert.equal(reconciledOldDelete.overlay.has("result"), true);
|
||||
|
||||
const confirmedDelete = reconcileObservatoryCatalogMutationOverlay(
|
||||
buildObservatoryCatalog(
|
||||
[source("source", "2026-08-29T10:00:00Z")],
|
||||
[],
|
||||
),
|
||||
staleDelete.overlay,
|
||||
2,
|
||||
);
|
||||
assert.equal(findObservatoryEvidence(confirmedDelete.catalog, "result"), null);
|
||||
assert.equal(confirmedDelete.overlay.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let deleteObservatoryLabProjection;
|
||||
let renameObservatoryLabProjection;
|
||||
let ObservatoryCatalogMutationError;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
deleteObservatoryLabProjection,
|
||||
renameObservatoryLabProjection,
|
||||
ObservatoryCatalogMutationError,
|
||||
} = await server.ssrLoadModule("/src/core/observatory/catalogMutations.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
const sessionId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
const binding = {
|
||||
kind: "canonical-recorded-rerun",
|
||||
evidenceSessionId: sessionId,
|
||||
sourceSessionId: "20260828T130511Z_viewer_live",
|
||||
resultId: sessionId,
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
};
|
||||
|
||||
test("Observatory rename sends the closed projection-only contract", async () => {
|
||||
const calls = [];
|
||||
const result = await renameObservatoryLabProjection(binding, " Новый разбор ", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input, init });
|
||||
return Response.json({
|
||||
schema_version: "missioncore.observatory-lab-projection/v1",
|
||||
session_id: sessionId,
|
||||
display_name: "Новый разбор",
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
schemaVersion: "missioncore.observatory-lab-projection/v1",
|
||||
sessionId,
|
||||
displayName: "Новый разбор",
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
|
||||
assert.equal(calls[0].init.method, "PATCH");
|
||||
assert.equal(calls[0].init.headers.Accept, "application/json");
|
||||
assert.equal(calls[0].init.headers["Content-Type"], "application/json");
|
||||
assert.deepEqual(JSON.parse(calls[0].init.body), {
|
||||
schema_version: "missioncore.observatory-lab-projection-rename/v1",
|
||||
display_name: "Новый разбор",
|
||||
});
|
||||
});
|
||||
|
||||
test("Observatory delete accepts only an empty 204 response", async () => {
|
||||
const calls = [];
|
||||
await deleteObservatoryLabProjection(binding, {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input, init });
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].input, `/api/v1/observatory/lab-projections/${sessionId}`);
|
||||
assert.equal(calls[0].init.method, "DELETE");
|
||||
await assert.rejects(
|
||||
deleteObservatoryLabProjection(binding, {
|
||||
fetcher: async () => new Response("unexpected", { status: 200 }),
|
||||
}),
|
||||
ObservatoryCatalogMutationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory mutations fail closed before network access", async () => {
|
||||
let calls = 0;
|
||||
const fetcher = async () => {
|
||||
calls += 1;
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
await assert.rejects(
|
||||
renameObservatoryLabProjection(binding, " ", { fetcher }),
|
||||
/от 1 до 160/,
|
||||
);
|
||||
await assert.rejects(
|
||||
deleteObservatoryLabProjection({ ...binding, resultId: "different" }, { fetcher }),
|
||||
/не допущен/,
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
|
||||
test("Observatory mutations reject response drift and preserve API detail", async () => {
|
||||
await assert.rejects(
|
||||
renameObservatoryLabProjection(binding, "Разбор", {
|
||||
fetcher: async () => Response.json({
|
||||
schema_version: "missioncore.observatory-lab-projection/v1",
|
||||
session_id: sessionId,
|
||||
display_name: "Разбор",
|
||||
unexpected: true,
|
||||
}),
|
||||
}),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
await assert.rejects(
|
||||
deleteObservatoryLabProjection(binding, {
|
||||
fetcher: async () => Response.json({ detail: "Проекция не принадлежит Обсерватории." }, { status: 409 }),
|
||||
}),
|
||||
(error) => error instanceof ObservatoryCatalogMutationError
|
||||
&& error.status === 409
|
||||
&& error.message === "Проекция не принадлежит Обсерватории.",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let useObservatoryRecordedJobs;
|
||||
let useObservatoryLaboratorySetups;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom", logLevel: "silent", server: { middlewareMode: true },
|
||||
});
|
||||
({ useObservatoryRecordedJobs } = await server.ssrLoadModule("/src/core/observatory/useObservatoryRecordedJobs.ts"));
|
||||
({ useObservatoryLaboratorySetups } = await server.ssrLoadModule("/src/core/observatory/useObservatoryLaboratorySetups.ts"));
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
// Deliberately inspect the render BEFORE effects/cleanup: stale state must already be hidden.
|
||||
function renderBeforeEffects(hook, args, stateSlots) {
|
||||
const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
||||
const previous = internals.H;
|
||||
let stateIndex = 0;
|
||||
internals.H = {
|
||||
useState: (initial) => [
|
||||
stateIndex < stateSlots.length ? stateSlots[stateIndex++]
|
||||
: typeof initial === "function" ? initial() : initial,
|
||||
() => {},
|
||||
],
|
||||
useRef: (value) => ({ current: value }),
|
||||
useMemo: (factory) => factory(),
|
||||
useCallback: (fn) => fn,
|
||||
useEffect: () => {},
|
||||
};
|
||||
try { return hook(...args); } finally { internals.H = previous; }
|
||||
}
|
||||
|
||||
test("queue data and errors cannot survive a source/setup/version change for one render", () => {
|
||||
const original = ["source-a", "profile-a", "a".repeat(64)];
|
||||
const activeJob = { state: "running", publication: { state: "not-required" } };
|
||||
const snapshot = {
|
||||
selectionKey: JSON.stringify(original), jobs: [activeJob], state: "ready", error: "old error",
|
||||
};
|
||||
assert.equal(renderBeforeEffects(useObservatoryRecordedJobs, original, [snapshot]).activeJob, activeJob);
|
||||
for (const next of [
|
||||
["source-b", original[1], original[2]],
|
||||
[original[0], "profile-b", original[2]],
|
||||
[original[0], original[1], "b".repeat(64)],
|
||||
["", "", ""],
|
||||
]) {
|
||||
const view = renderBeforeEffects(useObservatoryRecordedJobs, next, [snapshot]);
|
||||
assert.deepEqual(view.jobs, []);
|
||||
assert.equal(view.activeJob, null);
|
||||
assert.equal(view.latestJob, null);
|
||||
assert.equal(view.error, null);
|
||||
assert.notEqual(view.state, "ready");
|
||||
}
|
||||
});
|
||||
|
||||
test("published, incompatible and previous-source profiles have no effective selection", () => {
|
||||
const profile = {
|
||||
setupId: "profile-a", origin: "portable-definition",
|
||||
compatibility: { compatible: true },
|
||||
runDefinition: { definitionSha256: "a".repeat(64) },
|
||||
preflight: { outcome: "ready" },
|
||||
};
|
||||
function render(sourceId, setups) {
|
||||
return renderBeforeEffects(useObservatoryLaboratorySetups, [sourceId], [
|
||||
{ sourceSessionId: "source-a", setups }, "ready", null, "profile-a", 0, { kind: "idle" },
|
||||
]);
|
||||
}
|
||||
assert.equal(render("source-a", [profile]).selectedSetupId, "profile-a");
|
||||
for (const view of [
|
||||
render("source-b", [profile]),
|
||||
render("source-a", [{ ...profile, preflight: { outcome: "existing" } }]),
|
||||
render("source-a", [{ ...profile, compatibility: { compatible: false } }]),
|
||||
render("source-a", []),
|
||||
]) {
|
||||
assert.equal(view.selectedSetupId, "");
|
||||
assert.equal(view.selectedSetup, null);
|
||||
assert.deepEqual(view.selectableSetups, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("historical failures are not presented as errors of a new operator action", () => {
|
||||
const selection = ["source-a", "profile-a", "a".repeat(64)];
|
||||
const snapshot = {
|
||||
selectionKey: JSON.stringify(selection),
|
||||
jobs: [{ jobId: "old-run", state: "failed", publication: { state: "not-required" } }],
|
||||
state: "ready", error: null,
|
||||
};
|
||||
const view = renderBeforeEffects(useObservatoryRecordedJobs, selection, [snapshot]);
|
||||
assert.equal(view.computationFailed, false);
|
||||
assert.equal(view.error, null);
|
||||
assert.equal(view.activeJob, null);
|
||||
});
|
||||
@@ -0,0 +1,591 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchObservatoryLaboratorySetups;
|
||||
let fetchObservatoryPortableLaboratorySetups;
|
||||
let preflightObservatoryLaboratorySetup;
|
||||
let selectableObservatorySetups;
|
||||
let ObservatoryLaboratorySetupContractError;
|
||||
|
||||
const authority = {
|
||||
commands_enabled: false,
|
||||
actuation_allowed: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
production_accepted: false,
|
||||
};
|
||||
|
||||
function definition() {
|
||||
return {
|
||||
schema_version: "missioncore.observatory-run-definition/v1",
|
||||
definition_id: "m49-tgs-full-shadow",
|
||||
version: 1,
|
||||
work_id: "m49-tgs-full-shadow",
|
||||
source: {
|
||||
session_id: "source-a",
|
||||
label: "RAVNOVES00",
|
||||
required_modalities: ["point-cloud", "trajectory", "video"],
|
||||
},
|
||||
configuration: [{ role: "primary-profile", sha256: "a".repeat(64) }],
|
||||
authority,
|
||||
definition_sha256: "b".repeat(64),
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
return {
|
||||
setup_id: "m49-tgs-full-shadow-v1",
|
||||
display_name: "M4.9T5",
|
||||
description: "Сохранённый полный source-paced shadow.",
|
||||
origin: "archived-definition",
|
||||
source: {
|
||||
session_id: "source-a",
|
||||
label: "RAVNOVES00",
|
||||
required_modalities: ["point-cloud", "trajectory", "video"],
|
||||
},
|
||||
run_definition: definition(),
|
||||
compatibility: { compatible: true, reasons: [] },
|
||||
executor: {
|
||||
contour_id: "worker-006",
|
||||
state: "not-installed",
|
||||
reason_code: "laboratory-runner-adapter-not-installed",
|
||||
reason: "Адаптер не установлен.",
|
||||
},
|
||||
preserved_results: [{
|
||||
result_id: `m49-tgs-full-shadow-${"c".repeat(64)}`,
|
||||
result_kind: "recorded-source-paced-tgs-shadow",
|
||||
relation: "primary-visual",
|
||||
access: "legacy-lab",
|
||||
created_at_utc: "2026-08-26T20:27:19Z",
|
||||
observatory_projection_available: false,
|
||||
}],
|
||||
preflight: {
|
||||
outcome: "blocked",
|
||||
action: "open-legacy",
|
||||
reason: "Точный результат сохранён в legacy LAB.",
|
||||
submission_allowed: false,
|
||||
existing_result_ids: [],
|
||||
},
|
||||
authority,
|
||||
};
|
||||
}
|
||||
|
||||
function portableSetup() {
|
||||
return {
|
||||
setup_id: "lab-v1-eomt-ddrnet-portable-v1",
|
||||
display_name: "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
|
||||
description: "Проверка записанной K1-сессии моделями EoMT и DDRNet; только наблюдение.",
|
||||
origin: "portable-definition",
|
||||
source_requirements: {
|
||||
plugin_id: "nodedc.device.xgrids-lixelkity-k1",
|
||||
archive_id: "xgrids-k1.viewer-live.evidence",
|
||||
required_modalities: ["point-cloud", "trajectory", "video"],
|
||||
camera_source_id: "sensor.camera.right",
|
||||
camera_semantic_channel_id: "camera.video.recorded",
|
||||
recorded_media_type: 'video/mp4; codecs="avc1.641028"',
|
||||
recorded_media_init_sha256: "1".repeat(64),
|
||||
camera_width: 800,
|
||||
camera_height: 600,
|
||||
calibration_slot: "camera_1",
|
||||
calibration_identity_sha256: "2".repeat(64),
|
||||
exactly_one_media_epoch: true,
|
||||
seekable: true,
|
||||
},
|
||||
run_definition: {
|
||||
definition_id: "lab-v1-eomt-ddrnet-portable",
|
||||
version: 1,
|
||||
definition_sha256: "3".repeat(64),
|
||||
result_schema: "missioncore.recorded-eomt-ddrnet-review/v2",
|
||||
result_kind: "recorded-perception-qualification",
|
||||
models: [
|
||||
{
|
||||
name: "EoMT Cityscapes Large 1024",
|
||||
release_id: "eomt-cityscapes-large-1024-v1",
|
||||
model_id: "tue-mps/cityscapes_semantic_eomt_large_1024",
|
||||
architecture: "EomtForUniversalSegmentation",
|
||||
},
|
||||
{
|
||||
name: "DDRNet-39",
|
||||
release_id: "lab-v1-ddrnet-39-goose-fine-64-v1",
|
||||
model_id: "goose-ddrnet-class-512",
|
||||
architecture: "ddrnet_39",
|
||||
},
|
||||
],
|
||||
},
|
||||
source_compatibility: {
|
||||
outcome: "pass",
|
||||
compatible: true,
|
||||
reason_code: "source-compatible",
|
||||
reason: "Запись соответствует требованиям EoMT + DDRNet.",
|
||||
},
|
||||
executor: {
|
||||
contour_id: "worker-006",
|
||||
state: "not-installed",
|
||||
ready: false,
|
||||
reason_code: "eomt-executor-release-unsealed",
|
||||
reason: "Immutable executor release не установлен.",
|
||||
},
|
||||
existing_results: [],
|
||||
preflight: {
|
||||
outcome: "blocked",
|
||||
action: "blocked",
|
||||
reason: "Вычислительный контур LAB V1 пока недоступен.",
|
||||
submission_allowed: false,
|
||||
existing_result_ids: [],
|
||||
},
|
||||
authority,
|
||||
};
|
||||
}
|
||||
|
||||
function portableM49Setup() {
|
||||
const profile = portableSetup();
|
||||
profile.setup_id = "m49-tgs-portable-v2";
|
||||
profile.display_name = "M4.9T5 · TRAVEL TGS · CPU-only, без ML";
|
||||
profile.description = "Динамический TGS-разбор записанной K1-сессии без ML; только наблюдение.";
|
||||
profile.run_definition = {
|
||||
definition_id: "m49-tgs-portable",
|
||||
version: 2,
|
||||
definition_sha256: "4".repeat(64),
|
||||
result_schema: "missioncore.recorded-travel-tgs-review/v2",
|
||||
result_kind: "recorded-source-paced-tgs-shadow",
|
||||
models: [],
|
||||
};
|
||||
profile.source_compatibility.reason = "Запись соответствует требованиям TRAVEL TGS.";
|
||||
profile.executor.reason_code = "m49-executor-release-unsealed";
|
||||
profile.executor.reason = "Immutable executor release не установлен.";
|
||||
profile.preflight.reason = "Переносимый вычислительный контур M4.9T5 пока недоступен.";
|
||||
return profile;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchObservatoryLaboratorySetups,
|
||||
fetchObservatoryPortableLaboratorySetups,
|
||||
preflightObservatoryLaboratorySetup,
|
||||
selectableObservatorySetups,
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observatory/laboratorySetups.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("Observatory setup catalog keeps definition identity separate from executor availability", async () => {
|
||||
const calls = [];
|
||||
const fetcher = async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [setup()],
|
||||
authority,
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
};
|
||||
|
||||
const catalog = await fetchObservatoryLaboratorySetups("source-a", { fetcher });
|
||||
assert.equal(catalog.setups[0].runDefinition.definitionSha256, "b".repeat(64));
|
||||
assert.equal(catalog.setups[0].executor.state, "not-installed");
|
||||
assert.equal(catalog.setups[0].preflight.submissionAllowed, false);
|
||||
assert.equal(catalog.setups[0].preservedResults[0].access, "legacy-lab");
|
||||
assert.equal(
|
||||
calls[0].input,
|
||||
"/api/v1/observatory/laboratory-setups?source_session_id=source-a",
|
||||
);
|
||||
assert.equal(calls[0].init.method, "GET");
|
||||
});
|
||||
|
||||
test("portable profiles report compatible source separately from unavailable Worker", async () => {
|
||||
const calls = [];
|
||||
const selected = (await fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||
source_session_id: "source-a",
|
||||
setups: [portableSetup(), portableM49Setup()],
|
||||
authority,
|
||||
}), { status: 200 });
|
||||
},
|
||||
})).setups[0];
|
||||
|
||||
assert.equal(selected.origin, "portable-definition");
|
||||
assert.equal(selected.compatibility.compatible, true);
|
||||
assert.equal(selected.executor.state, "not-installed");
|
||||
assert.equal(selected.preflight.submissionAllowed, false);
|
||||
assert.deepEqual(selected.runDefinition.models.map((model) => model.name), [
|
||||
"EoMT Cityscapes Large 1024",
|
||||
"DDRNet-39",
|
||||
]);
|
||||
const m49 = (await fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||
source_session_id: "source-a",
|
||||
setups: [portableSetup(), portableM49Setup()],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
})).setups[1];
|
||||
assert.equal(m49.displayName, "M4.9T5 · TRAVEL TGS · CPU-only, без ML");
|
||||
assert.deepEqual(m49.runDefinition.models, []);
|
||||
assert.equal(
|
||||
calls[0].input,
|
||||
"/api/v1/observatory/portable-laboratory-setups?source_session_id=source-a",
|
||||
);
|
||||
|
||||
let preflightRequest;
|
||||
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||
fetcher: async (input, init) => {
|
||||
preflightRequest = { input: String(input), init };
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||
source_session_id: "source-a",
|
||||
setup_id: selected.setupId,
|
||||
definition_sha256: "3".repeat(64),
|
||||
check_sha256: null,
|
||||
outcome: "blocked",
|
||||
submission_allowed: false,
|
||||
checks: [{
|
||||
check_id: "executor",
|
||||
outcome: "fail",
|
||||
reason_code: "eomt-executor-release-unsealed",
|
||||
message: "Immutable executor release не установлен.",
|
||||
}],
|
||||
existing_result_ids: [],
|
||||
executor: portableSetup().executor,
|
||||
authority,
|
||||
}), { status: 200 });
|
||||
},
|
||||
});
|
||||
assert.equal(preflightRequest.input, "/api/v1/observatory/run-preflights");
|
||||
assert.equal(preflightRequest.init.method, "POST");
|
||||
assert.equal(preflight.outcome, "blocked");
|
||||
assert.equal(preflight.submissionAllowed, false);
|
||||
assert.equal(preflight.checkSha256, null);
|
||||
assert.equal(preflight.checks[0].outcome, "fail");
|
||||
});
|
||||
|
||||
test("portable profile accepts a consistent ready projection", async () => {
|
||||
const ready = portableSetup();
|
||||
ready.executor = {
|
||||
contour_id: "worker-006",
|
||||
state: "ready",
|
||||
ready: true,
|
||||
reason_code: null,
|
||||
reason: null,
|
||||
};
|
||||
ready.preflight = {
|
||||
outcome: "ready",
|
||||
action: "check",
|
||||
reason: "Запись готова к постановке в очередь.",
|
||||
submission_allowed: true,
|
||||
existing_result_ids: [],
|
||||
};
|
||||
|
||||
const catalog = await fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||
source_session_id: "source-a",
|
||||
setups: [ready],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
});
|
||||
assert.equal(catalog.setups[0].executor.state, "ready");
|
||||
assert.equal(catalog.setups[0].preflight.outcome, "ready");
|
||||
assert.equal(catalog.setups[0].preflight.submissionAllowed, true);
|
||||
});
|
||||
|
||||
test("portable LAB V1 rejects an unbound existing result projection", async () => {
|
||||
const spoofed = portableSetup();
|
||||
spoofed.existing_results = [{
|
||||
result_id: `legacy-vegetation-${"4".repeat(64)}`,
|
||||
result_kind: "recorded-perception-qualification",
|
||||
created_at_utc: "2026-08-30T18:16:00Z",
|
||||
}];
|
||||
spoofed.preflight = {
|
||||
outcome: "existing",
|
||||
action: "open-existing",
|
||||
reason: "Результат якобы готов.",
|
||||
submission_allowed: false,
|
||||
existing_result_ids: [spoofed.existing_results[0].result_id],
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||
source_session_id: "source-a",
|
||||
setups: [spoofed],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
/значение изменилось|значение не поддерживается|обнаружены неизвестные поля/,
|
||||
);
|
||||
});
|
||||
|
||||
test("portable LAB V1 rejects heavyweight compatibility evidence", async () => {
|
||||
const heavyweight = portableSetup();
|
||||
heavyweight.source_compatibility.evidence = {
|
||||
frame_count: 6830,
|
||||
timeline_start_seconds: 0,
|
||||
timeline_end_seconds: 819,
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||
source_session_id: "source-a",
|
||||
setups: [heavyweight],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
/неизвестные поля/,
|
||||
);
|
||||
});
|
||||
|
||||
function cachedPortableSetup() {
|
||||
const setup = portableSetup();
|
||||
setup.existing_results = [{
|
||||
result_id: "portable-result-001",
|
||||
result_kind: setup.run_definition.result_kind,
|
||||
relation: "exact-recorded-computation",
|
||||
access: "observatory",
|
||||
created_at_utc: "2026-09-03T00:00:00Z",
|
||||
observatory_projection_available: true,
|
||||
identity: {
|
||||
job_id: "observatory-run-001",
|
||||
source_session_id: "source-a",
|
||||
source_catalog_sha256: "a".repeat(64),
|
||||
source_bundle_sha256: "b".repeat(64),
|
||||
source_capability_manifest_sha256: "c".repeat(64),
|
||||
setup_id: setup.setup_id,
|
||||
definition_sha256: setup.run_definition.definition_sha256,
|
||||
package_sha256: "d".repeat(64),
|
||||
artifact_manifest_id: "e".repeat(64),
|
||||
},
|
||||
}];
|
||||
setup.preflight = {
|
||||
outcome: "existing", action: "open-existing",
|
||||
reason: "Точный результат проверен.", submission_allowed: false,
|
||||
existing_result_ids: [setup.existing_results[0].result_id],
|
||||
};
|
||||
return setup;
|
||||
}
|
||||
|
||||
function fetchCachedPortable(setup) {
|
||||
return fetchObservatoryPortableLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-portable-setup-catalog/v2",
|
||||
source_session_id: "source-a", setups: [setup], authority,
|
||||
}), { status: 200 }),
|
||||
});
|
||||
}
|
||||
|
||||
test("portable exact cached result is readable without an installed executor", async () => {
|
||||
const catalog = await fetchCachedPortable(cachedPortableSetup());
|
||||
const setup = catalog.setups[0];
|
||||
assert.equal(setup.executor.state, "not-installed");
|
||||
assert.equal(setup.preflight.outcome, "existing");
|
||||
assert.equal(setup.preflight.submissionAllowed, false);
|
||||
assert.deepEqual(setup.preflight.existingResultIds, ["portable-result-001"]);
|
||||
assert.equal(setup.preservedResults[0].access, "observatory");
|
||||
});
|
||||
|
||||
test("selector contains only compatible uncalculated portable definitions", async () => {
|
||||
const uncached = (await fetchCachedPortable(portableSetup())).setups[0];
|
||||
const cached = (await fetchCachedPortable(cachedPortableSetup())).setups[0];
|
||||
const currentVersion = {
|
||||
...uncached,
|
||||
runDefinition: { ...uncached.runDefinition, version: 2, definitionSha256: "f".repeat(64) },
|
||||
};
|
||||
const catalog = (setups) => ({ sourceSessionId: "source-a", setups });
|
||||
assert.deepEqual(selectableObservatorySetups(null), []);
|
||||
assert.deepEqual(selectableObservatorySetups(catalog([])), []);
|
||||
assert.deepEqual(selectableObservatorySetups(catalog([cached])), []);
|
||||
// Same display label and setup ID, but no verified cache hit for the new definition.
|
||||
assert.deepEqual(selectableObservatorySetups(catalog([currentVersion])), [currentVersion]);
|
||||
assert.deepEqual(selectableObservatorySetups(catalog([
|
||||
cached,
|
||||
{ ...uncached, setupId: "remaining-profile" },
|
||||
])).map((s) => s.setupId), ["remaining-profile"]);
|
||||
assert.deepEqual(selectableObservatorySetups(catalog([
|
||||
{ ...uncached, compatibility: { compatible: false, reasons: [] } },
|
||||
{ ...uncached, origin: "archived-definition" },
|
||||
{ ...uncached, origin: "existing-result" },
|
||||
{ ...uncached, runDefinition: null },
|
||||
])), []);
|
||||
// Worker availability does not masquerade as completed computation.
|
||||
assert.equal(uncached.executor.state, "not-installed");
|
||||
assert.deepEqual(selectableObservatorySetups(catalog([uncached])), [uncached]);
|
||||
// A missing/deleted/unpublished projection is not a completed result.
|
||||
assert.deepEqual(selectableObservatorySetups(catalog([{
|
||||
...uncached, preservedResults: cached.preservedResults,
|
||||
}])).map((s) => s.setupId), [uncached.setupId]);
|
||||
});
|
||||
|
||||
for (const [label, change] of [
|
||||
["another source", (s) => { s.existing_results[0].identity.source_session_id = "source-b"; }],
|
||||
["another setup", (s) => { s.existing_results[0].identity.setup_id = "another-profile"; }],
|
||||
["another version", (s) => { s.existing_results[0].identity.definition_sha256 = "f".repeat(64); }],
|
||||
["bad package digest", (s) => { s.existing_results[0].identity.package_sha256 = "not-a-digest"; }],
|
||||
["unavailable artifact", (s) => { s.existing_results[0].observatory_projection_available = false; }],
|
||||
["missing binding", (s) => { delete s.existing_results[0].identity; }],
|
||||
["unrelated result ID", (s) => { s.preflight.existing_result_ids = ["different-result"]; }],
|
||||
["duplicate result", (s) => {
|
||||
s.existing_results.push(s.existing_results[0]);
|
||||
s.preflight.existing_result_ids.push(s.preflight.existing_result_ids[0]);
|
||||
}],
|
||||
["contradictory action", (s) => { s.preflight.action = "check"; }],
|
||||
["cached but queueable", (s) => { s.preflight.submission_allowed = true; }],
|
||||
]) {
|
||||
test(`portable cached result rejects ${label}`, async () => {
|
||||
const setup = cachedPortableSetup();
|
||||
change(setup);
|
||||
await assert.rejects(fetchCachedPortable(setup));
|
||||
});
|
||||
}
|
||||
|
||||
test("Observatory preflight sends the exact selected definition and never submits a run", async () => {
|
||||
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [setup()],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
})).setups[0];
|
||||
let request;
|
||||
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||
fetcher: async (input, init) => {
|
||||
request = { input: String(input), init };
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||
source_session_id: "source-a",
|
||||
setup_id: selected.setupId,
|
||||
definition_sha256: "b".repeat(64),
|
||||
check_sha256: null,
|
||||
outcome: "blocked",
|
||||
submission_allowed: false,
|
||||
checks: [{
|
||||
check_id: "executor",
|
||||
outcome: "fail",
|
||||
reason_code: "laboratory-runner-adapter-not-installed",
|
||||
message: "Адаптер не установлен.",
|
||||
}],
|
||||
existing_result_ids: [],
|
||||
executor: setup().executor,
|
||||
authority,
|
||||
}), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(request.input, "/api/v1/observatory/run-preflights");
|
||||
assert.equal(request.init.method, "POST");
|
||||
assert.deepEqual(JSON.parse(request.init.body), {
|
||||
schema_version: "missioncore.observatory-run-preflight-request/v1",
|
||||
source_session_id: "source-a",
|
||||
setup_id: "m49-tgs-full-shadow-v1",
|
||||
definition_sha256: "b".repeat(64),
|
||||
});
|
||||
assert.equal(preflight.outcome, "blocked");
|
||||
assert.equal(preflight.submissionAllowed, false);
|
||||
assert.equal(preflight.checkSha256, null);
|
||||
});
|
||||
|
||||
test("Observatory dynamic preflight admits only an explicit queueable response", async () => {
|
||||
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [setup()],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
})).setups[0];
|
||||
|
||||
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||
source_session_id: "source-a",
|
||||
setup_id: selected.setupId,
|
||||
definition_sha256: "b".repeat(64),
|
||||
outcome: "queueable",
|
||||
submission_allowed: true,
|
||||
checks: [{
|
||||
check_id: "durable-queue",
|
||||
outcome: "pass",
|
||||
reason_code: "exact-binding-ready",
|
||||
message: "Точный источник и очередь готовы.",
|
||||
}],
|
||||
existing_result_ids: [],
|
||||
executor: setup().executor,
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
});
|
||||
|
||||
assert.equal(preflight.outcome, "queueable");
|
||||
assert.equal(preflight.submissionAllowed, true);
|
||||
assert.equal(preflight.checkSha256, null);
|
||||
});
|
||||
|
||||
test("Observatory setup contract rejects authority escalation and response drift", async () => {
|
||||
const escalated = setup();
|
||||
escalated.authority = { ...authority, commands_enabled: true };
|
||||
await assert.rejects(
|
||||
fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [escalated],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
);
|
||||
|
||||
const drifted = setup();
|
||||
drifted.unexpected = true;
|
||||
await assert.rejects(
|
||||
fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [drifted],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
);
|
||||
|
||||
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
|
||||
source_session_id: "source-a",
|
||||
setups: [setup()],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
})).setups[0];
|
||||
await assert.rejects(
|
||||
preflightObservatoryLaboratorySetup("source-a", selected, {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-run-preflight/v1",
|
||||
source_session_id: "source-a",
|
||||
setup_id: selected.setupId,
|
||||
definition_sha256: "c".repeat(64),
|
||||
outcome: "blocked",
|
||||
submission_allowed: false,
|
||||
checks: [],
|
||||
existing_result_ids: [],
|
||||
executor: setup().executor,
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
ObservatoryLaboratorySetupContractError,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let fetchObservatoryRecordedJobs;
|
||||
let retryObservatoryRecordedJobPublication;
|
||||
let submitObservatoryRecordedJob;
|
||||
let ObservatoryRecordedJobContractError;
|
||||
|
||||
const authority = {
|
||||
commands_enabled: false,
|
||||
actuation_allowed: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
production_accepted: false,
|
||||
};
|
||||
|
||||
function job(state = "queued") {
|
||||
return {
|
||||
schema_version: "missioncore.observatory-recorded-job/v1",
|
||||
job_id: "observatory-run-0123456789abcdef0123456789abcdef",
|
||||
idempotency_key: "observatory-ui:source-a:m49-tgs:request-a",
|
||||
request_sha256: "1".repeat(64),
|
||||
identity_sha256: "2".repeat(64),
|
||||
submission_receipt_sha256: "3".repeat(64),
|
||||
source: {
|
||||
session_id: "source-a",
|
||||
catalog_sha256: "4".repeat(64),
|
||||
bundle_sha256: "5".repeat(64),
|
||||
capability_manifest_sha256: "6".repeat(64),
|
||||
adapter: {
|
||||
adapter_id: "ravnoves00-m49-source-pack",
|
||||
version: 1,
|
||||
adapter_sha256: "7".repeat(64),
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
setup_id: "m49-tgs",
|
||||
definition_id: "m49-tgs",
|
||||
definition_version: 1,
|
||||
definition_sha256: "8".repeat(64),
|
||||
},
|
||||
executor: {
|
||||
release_id: "m49-tgs-release",
|
||||
release_sha256: "9".repeat(64),
|
||||
image_sha256: "a".repeat(64),
|
||||
model_release_ids: [],
|
||||
learned_models: [],
|
||||
model_manifest_sha256: "b".repeat(64),
|
||||
resource_profile_id: "m49-tgs-cpu",
|
||||
resource_profile_sha256: "c".repeat(64),
|
||||
},
|
||||
checkpoint_policy: {
|
||||
mode: "non-checkpointable",
|
||||
allowed_checkpoints: [],
|
||||
last_checkpoint_id: null,
|
||||
},
|
||||
priority: { class: "recorded", rank: 100, server_owned: true },
|
||||
state,
|
||||
preemption_requested: state === "preemption-pending",
|
||||
restart_from_zero: state === "paused",
|
||||
preemption_receipt_sha256: null,
|
||||
claim_generation: 0,
|
||||
claim_lease: null,
|
||||
result: state === "succeeded"
|
||||
? { result_id: "m49-result", sha256: "d".repeat(64) }
|
||||
: null,
|
||||
publication: {
|
||||
state: "not-required",
|
||||
attempts: 0,
|
||||
error: null,
|
||||
published_at_utc: null,
|
||||
},
|
||||
terminal: state === "failed"
|
||||
? { code: "worker-failed", message: "Worker завершил расчёт с ошибкой." }
|
||||
: null,
|
||||
created_at_utc: "2026-08-31T10:00:00Z",
|
||||
updated_at_utc: "2026-08-31T10:00:01Z",
|
||||
authority,
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
fetchObservatoryRecordedJobs,
|
||||
retryObservatoryRecordedJobPublication,
|
||||
submitObservatoryRecordedJob,
|
||||
ObservatoryRecordedJobContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observatory/recordedJobs.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
test("Observatory reads the exact session/setup queue without polling", async () => {
|
||||
const calls = [];
|
||||
const jobs = await fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-recorded-job-list/v1",
|
||||
items: [job("running")],
|
||||
authority,
|
||||
}), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
calls[0].input,
|
||||
"/api/v1/observatory/runs?source_session_id=source-a&setup_id=m49-tgs&limit=20",
|
||||
);
|
||||
assert.equal(calls[0].init.method, "GET");
|
||||
assert.equal(jobs[0].state, "running");
|
||||
assert.equal(jobs[0].definitionSha256, "8".repeat(64));
|
||||
});
|
||||
|
||||
test("Observatory submits only public identities and accepts every durable state", async () => {
|
||||
const states = [
|
||||
"accepted",
|
||||
"queued",
|
||||
"claimed",
|
||||
"running",
|
||||
"paused",
|
||||
"preemption-pending",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"reconciliation-required",
|
||||
];
|
||||
|
||||
for (const state of states) {
|
||||
let request;
|
||||
const result = await submitObservatoryRecordedJob(
|
||||
"source-a",
|
||||
"m49-tgs",
|
||||
"observatory-ui:source-a:m49-tgs:request-a",
|
||||
null,
|
||||
{
|
||||
fetcher: async (input, init) => {
|
||||
request = { input: String(input), init };
|
||||
return new Response(JSON.stringify(job(state)), { status: 200 });
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.state, state);
|
||||
assert.equal(request.input, "/api/v1/observatory/runs");
|
||||
assert.equal(request.init.method, "POST");
|
||||
assert.deepEqual(JSON.parse(request.init.body), {
|
||||
schema_version: "missioncore.observatory-recorded-run-submit/v1",
|
||||
idempotency_key: "observatory-ui:source-a:m49-tgs:request-a",
|
||||
source_session_id: "source-a",
|
||||
setup_id: "m49-tgs",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("Observatory queue contract rejects authority escalation and response drift", async () => {
|
||||
const escalated = job();
|
||||
escalated.authority = { ...authority, commands_enabled: true };
|
||||
await assert.rejects(
|
||||
fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
|
||||
fetcher: async () => new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-recorded-job-list/v1",
|
||||
items: [escalated],
|
||||
authority,
|
||||
}), { status: 200 }),
|
||||
}),
|
||||
ObservatoryRecordedJobContractError,
|
||||
);
|
||||
|
||||
const drifted = job();
|
||||
drifted.worker_command = "forbidden";
|
||||
await assert.rejects(
|
||||
submitObservatoryRecordedJob(
|
||||
"source-a",
|
||||
"m49-tgs",
|
||||
"observatory-ui:source-a:m49-tgs:request-a",
|
||||
null,
|
||||
{ fetcher: async () => new Response(JSON.stringify(drifted), { status: 200 }) },
|
||||
),
|
||||
ObservatoryRecordedJobContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("portable submission carries the exact definition/check fence", async () => {
|
||||
let request;
|
||||
await submitObservatoryRecordedJob(
|
||||
"source-a",
|
||||
"lab-v1-eomt-ddrnet-portable-v1",
|
||||
"observatory-ui:source-a:lab-v1:request-a",
|
||||
{
|
||||
definitionSha256: "e".repeat(64),
|
||||
checkSha256: "f".repeat(64),
|
||||
},
|
||||
{
|
||||
fetcher: async (input, init) => {
|
||||
request = { input: String(input), init };
|
||||
const response = job("queued");
|
||||
response.setup.setup_id = "lab-v1-eomt-ddrnet-portable-v1";
|
||||
response.setup.definition_sha256 = "e".repeat(64);
|
||||
response.source.session_id = "source-a";
|
||||
return new Response(JSON.stringify(response), { status: 202 });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(JSON.parse(request.init.body), {
|
||||
schema_version: "missioncore.observatory-recorded-run-submit/v1",
|
||||
idempotency_key: "observatory-ui:source-a:lab-v1:request-a",
|
||||
source_session_id: "source-a",
|
||||
setup_id: "lab-v1-eomt-ddrnet-portable-v1",
|
||||
definition_sha256: "e".repeat(64),
|
||||
check_sha256: "f".repeat(64),
|
||||
});
|
||||
});
|
||||
|
||||
test("publication retry never submits a second compute request", async () => {
|
||||
let request;
|
||||
const response = job("succeeded");
|
||||
response.publication = {
|
||||
state: "published",
|
||||
attempts: 2,
|
||||
error: null,
|
||||
published_at_utc: "2026-09-01T12:00:00Z",
|
||||
};
|
||||
const retried = await retryObservatoryRecordedJobPublication(response.job_id, {
|
||||
fetcher: async (input, init) => {
|
||||
request = { input: String(input), init };
|
||||
return new Response(JSON.stringify(response), { status: 200 });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
request.input,
|
||||
`/api/v1/observatory/runs/${response.job_id}/publication/retry`,
|
||||
);
|
||||
assert.equal(request.init.method, "POST");
|
||||
assert.equal(request.init.body, undefined);
|
||||
assert.equal(retried.publication.state, "published");
|
||||
});
|
||||
|
||||
test("queue query and response are fenced to the selected definition", async () => {
|
||||
const oldVersion = job("succeeded");
|
||||
oldVersion.setup.definition_sha256 = "f".repeat(64);
|
||||
const otherSource = job("running");
|
||||
otherSource.source.session_id = "source-b";
|
||||
const otherSetup = job("running");
|
||||
otherSetup.setup.setup_id = "other-setup";
|
||||
let url;
|
||||
const jobs = await fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
|
||||
definitionSha256: "8".repeat(64),
|
||||
fetcher: async (input) => {
|
||||
url = new URL(String(input), "http://localhost");
|
||||
return new Response(JSON.stringify({
|
||||
schema_version: "missioncore.observatory-recorded-job-list/v1",
|
||||
items: [oldVersion, otherSource, otherSetup, job("queued")], authority,
|
||||
}));
|
||||
},
|
||||
});
|
||||
assert.equal(url.searchParams.get("definition_sha256"), "8".repeat(64));
|
||||
assert.deepEqual(jobs.map((j) => j.state), ["queued"]);
|
||||
});
|
||||
|
||||
test("portable submit rejects a successful response for an old definition", async () => {
|
||||
await assert.rejects(submitObservatoryRecordedJob(
|
||||
"source-a", "m49-tgs", "test-request",
|
||||
{ definitionSha256: "f".repeat(64), checkSha256: "e".repeat(64) },
|
||||
{ fetcher: async () => new Response(JSON.stringify(job("succeeded"))) },
|
||||
), ObservatoryRecordedJobContractError);
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let admitObservatoryRecordedRunReview;
|
||||
let fetchObservatoryRecordedRunReview;
|
||||
let observatoryRecordedRunBinding;
|
||||
let ObservatoryRecordedRunContractError;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
({
|
||||
admitObservatoryRecordedRunReview,
|
||||
fetchObservatoryRecordedRunReview,
|
||||
observatoryRecordedRunBinding,
|
||||
ObservatoryRecordedRunContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observatory/recordedRun.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
const sourceSessionId = "20260828T130511Z_viewer_live";
|
||||
const resultId = `lab-v1-vegetation-shadow-${"8".repeat(64)}`;
|
||||
|
||||
function canonicalProvenance() {
|
||||
const identitySha256 = resultId.slice("lab-v1-vegetation-shadow-".length);
|
||||
return {
|
||||
schema_version: "missioncore.canonical-recorded-lab-projection/v1",
|
||||
evidence_identity_sha256: identitySha256,
|
||||
result_document_sha256: "a".repeat(64),
|
||||
replay_capability: {
|
||||
schema_version: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewer_profile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commands_enabled: false,
|
||||
},
|
||||
authority: {
|
||||
commands_enabled: false,
|
||||
navigation_or_safety_accepted: false,
|
||||
actuation_accepted: false,
|
||||
},
|
||||
method: {
|
||||
schema_version: "missioncore.laboratory-method/v1",
|
||||
completeness: "legacy-partial",
|
||||
execution_class: "ai-inference",
|
||||
pipeline_id: "ravnoves004tree-full-eomt-ddrnet-recorded-review/v1",
|
||||
components: [{
|
||||
kind: "source",
|
||||
name: "sealed full-route LAB result",
|
||||
version: "missioncore.lab-v1-vegetation-shadow/v1",
|
||||
role: "immutable Session catalog projection",
|
||||
identity_sha256: identitySha256,
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function lab(overrides = {}) {
|
||||
return {
|
||||
labId: "LAB V1",
|
||||
sourceSessionId,
|
||||
resultKind: "recorded-perception-qualification",
|
||||
resultId,
|
||||
sourceResultId: `lab-v1-vegetation-shadow-${"9".repeat(64)}`,
|
||||
configSha256: null,
|
||||
runCreatedAtUtc: "2026-08-29T18:05:11.329061+00:00",
|
||||
publishedAtUtc: "2026-08-30T12:00:00.000Z",
|
||||
replayCapability: {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v1",
|
||||
kind: "canonical-recorded-rerun",
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
},
|
||||
provenance: canonicalProvenance(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function portableCapability() {
|
||||
return {
|
||||
schemaVersion: "missioncore.observation-lab-replay-capability/v2",
|
||||
kind: "portable-result-review",
|
||||
viewerProfile: "portable-result",
|
||||
timeline: "result-defined",
|
||||
activation: "explicit",
|
||||
commandsEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function portableCapabilityDocument() {
|
||||
return {
|
||||
schema_version: "missioncore.observation-lab-replay-capability/v2",
|
||||
kind: "portable-result-review",
|
||||
viewer_profile: "portable-result",
|
||||
timeline: "result-defined",
|
||||
activation: "explicit",
|
||||
commands_enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function portableLab(portableResultId, definitionSha256) {
|
||||
return lab({
|
||||
labId: "M4.9",
|
||||
resultId: portableResultId,
|
||||
resultKind: "recorded-route-analysis",
|
||||
sourceResultId: null,
|
||||
configSha256: definitionSha256,
|
||||
replayCapability: portableCapability(),
|
||||
provenance: {
|
||||
schema_version: "missioncore.observatory-portable-result-publication/v1",
|
||||
authority: {},
|
||||
calculation_profile: {},
|
||||
calculation_profile_sha256: "b".repeat(64),
|
||||
job: {},
|
||||
source: { session_id: sourceSessionId },
|
||||
run_definition: { definition_sha256: definitionSha256 },
|
||||
result_package: {
|
||||
manifest_sha256: "c".repeat(64),
|
||||
artifact_manifest_id: "d".repeat(64),
|
||||
},
|
||||
replay_capability: portableCapabilityDocument(),
|
||||
storage: {},
|
||||
method: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("Observatory admits only the exact typed canonical recorded run", () => {
|
||||
const binding = observatoryRecordedRunBinding(resultId, lab());
|
||||
assert.deepEqual(binding, {
|
||||
kind: "canonical-recorded-rerun",
|
||||
evidenceSessionId: resultId,
|
||||
sourceSessionId,
|
||||
resultId,
|
||||
viewerProfile: "recorded-session",
|
||||
timeline: "session_time",
|
||||
activation: "explicit",
|
||||
});
|
||||
assert.equal(
|
||||
observatoryRecordedRunBinding(resultId, lab({ replayCapability: null })),
|
||||
null,
|
||||
);
|
||||
assert.throws(
|
||||
() => observatoryRecordedRunBinding("different-session", lab()),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => observatoryRecordedRunBinding(
|
||||
resultId,
|
||||
lab({ sourceSessionId: "RAVNOVES004TREE" }),
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => observatoryRecordedRunBinding(
|
||||
resultId,
|
||||
lab({ configSha256: "a".repeat(64) }),
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => observatoryRecordedRunBinding(
|
||||
resultId,
|
||||
lab({ sourceResultId: "legacy-result" }),
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
const invalidProvenance = canonicalProvenance();
|
||||
invalidProvenance.method.components[0].identity_sha256 = "0".repeat(64);
|
||||
assert.throws(
|
||||
() => observatoryRecordedRunBinding(
|
||||
resultId,
|
||||
lab({ provenance: invalidProvenance }),
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory rechecks the selected source and sealed review before mounting replay", () => {
|
||||
const binding = observatoryRecordedRunBinding(resultId, lab());
|
||||
const review = { sessionId: sourceSessionId, frameCount: 6830 };
|
||||
assert.equal(
|
||||
admitObservatoryRecordedRunReview(
|
||||
binding,
|
||||
sourceSessionId,
|
||||
{ resultId, routeFullReview: review },
|
||||
),
|
||||
review,
|
||||
);
|
||||
assert.throws(
|
||||
() => admitObservatoryRecordedRunReview(
|
||||
binding,
|
||||
"another-session",
|
||||
{ resultId, routeFullReview: review },
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
assert.throws(
|
||||
() => admitObservatoryRecordedRunReview(
|
||||
binding,
|
||||
sourceSessionId,
|
||||
{ resultId, routeFullReview: { ...review, sessionId: "another-session" } },
|
||||
),
|
||||
ObservatoryRecordedRunContractError,
|
||||
);
|
||||
});
|
||||
|
||||
test("Observatory selects and strictly decodes a portable result viewer by capability", async () => {
|
||||
const portableResultId = "portable-result-a";
|
||||
const definitionSha256 = "e".repeat(64);
|
||||
const binding = observatoryRecordedRunBinding(
|
||||
portableResultId,
|
||||
portableLab(portableResultId, definitionSha256),
|
||||
);
|
||||
assert.deepEqual(binding, {
|
||||
kind: "portable-result-review",
|
||||
evidenceSessionId: portableResultId,
|
||||
sourceSessionId,
|
||||
resultId: portableResultId,
|
||||
viewerProfile: "portable-result",
|
||||
timeline: "result-defined",
|
||||
activation: "explicit",
|
||||
definitionSha256,
|
||||
});
|
||||
|
||||
let requestedUrl = null;
|
||||
const review = await fetchObservatoryRecordedRunReview(binding, {
|
||||
selectedSourceSessionId: sourceSessionId,
|
||||
fetcher: async (url) => {
|
||||
requestedUrl = url;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
schema_version: "missioncore.observatory-portable-result-view/v1",
|
||||
result_id: portableResultId,
|
||||
source_session_id: sourceSessionId,
|
||||
result_kind: "recorded-route-analysis",
|
||||
definition_sha256: definitionSha256,
|
||||
viewer_capability: portableCapabilityDocument(),
|
||||
calculation_profile: { name: "M4.9" },
|
||||
artifact_manifest_id: "d".repeat(64),
|
||||
artifacts: [{
|
||||
role: "result-document",
|
||||
media_type: "application/json",
|
||||
sha256: "f".repeat(64),
|
||||
byte_length: 42,
|
||||
}],
|
||||
result_document: { verdict: "reviewed" },
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
requestedUrl,
|
||||
`/api/v1/observatory/portable-results/${portableResultId}`,
|
||||
);
|
||||
assert.equal(review.kind, "portable-result");
|
||||
assert.deepEqual(review.resultDocument, { verdict: "reviewed" });
|
||||
assert.deepEqual(review.artifacts, [{
|
||||
role: "result-document",
|
||||
mediaType: "application/json",
|
||||
sha256: "f".repeat(64),
|
||||
byteLength: 42,
|
||||
}]);
|
||||
});
|
||||
|
||||
test("Observatory run admission remains metadata-only until explicit UI activation", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/core/observatory/recordedRun.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /fetchVegetationShadowResultMetadata/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/fetchVegetationShadowResult\(|advanced-index|resolveObservationSessionReplay|resolveCanonicalLabReplay|RerunViewport|recording\.rrd/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { after, before, test } from "node:test";
|
||||
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server;
|
||||
let productModel;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
appType: "custom",
|
||||
logLevel: "silent",
|
||||
server: { middlewareMode: true },
|
||||
});
|
||||
productModel = await server.ssrLoadModule("/src/productModel.ts");
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
async function read(relativePath) {
|
||||
return readFile(new URL(`../src/${relativePath}`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
test("Observatory is the third independent Polygon workspace", () => {
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
productModel.workspaceById("observatory"),
|
||||
{
|
||||
id: "observatory",
|
||||
root: "polygon",
|
||||
label: "Обсерватория",
|
||||
title: "Проверка компьютерного зрения",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
|
||||
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
|
||||
icon: "eye",
|
||||
kind: "observatory",
|
||||
groups: [],
|
||||
},
|
||||
);
|
||||
assert.equal(productModel.workspaceById("lab-archive").kind, "lab-archive");
|
||||
});
|
||||
|
||||
test("Observatory mounts the one shared canonical replay only after explicit admission", async () => {
|
||||
const [
|
||||
app,
|
||||
workspaceHub,
|
||||
workspace,
|
||||
hook,
|
||||
recordedRun,
|
||||
sharedReplay,
|
||||
legacyReplayWrapper,
|
||||
viewerProfiles,
|
||||
styles,
|
||||
] = await Promise.all([
|
||||
read("App.tsx"),
|
||||
read("workspaces/Workspaces.tsx"),
|
||||
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||
read("core/observatory/useObservatoryCatalog.ts"),
|
||||
read("core/observatory/recordedRun.ts"),
|
||||
read("components/laboratory/CanonicalVegetationRerunReplay.tsx"),
|
||||
read("workspaces/laboratory/CanonicalVegetationRerunReplay.tsx"),
|
||||
read("core/observation/viewerProfile.ts"),
|
||||
read("styles/observatory.css"),
|
||||
]);
|
||||
|
||||
assert.match(app, /activeDefinition\.kind === "observatory"[\s\S]*Только наблюдение/);
|
||||
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
|
||||
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
|
||||
assert.match(workspace, /useObservatoryCatalog/);
|
||||
assert.match(workspace, /Связанных результатов нет/);
|
||||
assert.match(workspace, /не является выводом о качестве/);
|
||||
assert.match(workspace, /presentedEvidence = selectedSession\?\.evidence \?\? \[\]/);
|
||||
assert.doesNotMatch(workspace, /MAX_PRESENTED_EVIDENCE|\.evidence\.slice\(/);
|
||||
assert.match(workspace, /Полнота исторических/);
|
||||
assert.match(workspace, /вне текущего загруженного среза/);
|
||||
assert.match(workspace, /observatory-notice__copy/);
|
||||
assert.match(workspace, /observatory-evidence-card/);
|
||||
assert.match(workspace, /observatory-session-stack/);
|
||||
assert.match(workspace, /observatory-session-summary__facts/);
|
||||
assert.match(workspace, /observatory-evidence-card__copy/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/function evidenceResultSubtitle[\s\S]*calculationProfile\?\.displayName[\s\S]*`\$\{evidence\.label\} · \$\{profileName\}`[\s\S]*: evidence\.label/,
|
||||
);
|
||||
assert.match(
|
||||
workspace,
|
||||
/<span>\{evidenceResultSubtitle\(evidence\)\}<\/span>/,
|
||||
);
|
||||
assert.match(
|
||||
workspace,
|
||||
/evidence\.recordedRun \? \([\s\S]*name="trash"[\s\S]*name="edit"[\s\S]*Открыть визуальный разбор:[\s\S]*name="eye"/,
|
||||
);
|
||||
assert.doesNotMatch(workspace, /observatory-evidence-card[^>]*tone="soft"/);
|
||||
assert.match(workspace, /Открыть визуальный разбор/);
|
||||
assert.match(workspace, /replay\.kind === "ready"[\s\S]*<CanonicalVegetationRerunReplay/);
|
||||
assert.match(workspace, /replay\.review\.kind === "canonical-recorded-rerun"[\s\S]*РЕЗУЛЬТАТ \/ ПРОВЕРЕННЫЙ ДОКУМЕНТ/);
|
||||
assert.match(workspace, /Связанных артефактов: \{replay\.review\.artifacts\.length\}/);
|
||||
assert.match(workspace, /<summary>Документ результата<\/summary>/);
|
||||
assert.doesNotMatch(workspace, /UNIVERSAL VIEWER|content-addressed artifacts/);
|
||||
assert.match(workspace, /Проверяем точную связь результата/);
|
||||
assert.match(workspace, /role="alert"/);
|
||||
assert.match(workspace, /Повторить/);
|
||||
assert.match(workspace, /Закрыть разбор/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/const returnToOverview[\s\S]*closeReplay\(\);[\s\S]*scrollIntoView\(\{ block: "start" \}\)/,
|
||||
);
|
||||
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
|
||||
assert.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/);
|
||||
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
|
||||
assert.match(workspace, /data-observatory-authority="observation-only"/);
|
||||
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
|
||||
assert.doesNotMatch(
|
||||
`${workspace}\n${hook}`,
|
||||
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|resolveCanonicalLabReplay|deleteObservationSession|setInterval|setTimeout|useObservationSessions|useAdvancedLaboratoryCatalog|advanced-index|prefetch|preload/,
|
||||
);
|
||||
assert.equal(sharedReplay.match(/<RerunViewport\b/g)?.length, 1);
|
||||
assert.match(sharedReplay, /recordedSessionRerunProfile/);
|
||||
assert.match(sharedReplay, /timelineStartSeconds/);
|
||||
assert.doesNotMatch(sharedReplay, /live-acquisition|lab-recorded-evidence/);
|
||||
assert.match(
|
||||
legacyReplayWrapper,
|
||||
/export \{ CanonicalVegetationRerunReplay \} from "\.\.\/\.\.\/components\/laboratory\/CanonicalVegetationRerunReplay"/,
|
||||
);
|
||||
assert.match(recordedRun, /fetchVegetationShadowResultMetadata/);
|
||||
assert.doesNotMatch(recordedRun, /advanced-index|recording\.rrd|canonical-replay\.rrd/);
|
||||
assert.doesNotMatch(workspace, /workspaces\/laboratory|LaboratoryRecordedClipPlayer|SimulationViewport/);
|
||||
assert.match(viewerProfiles, /kind: "live-acquisition"/);
|
||||
assert.match(viewerProfiles, /kind: "recorded-session"/);
|
||||
assert.match(viewerProfiles, /kind: "lab-recorded-evidence"/);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.observatory-session-summary \{[\s\S]*grid-template-columns:[\s\S]*\.observatory-evidence-card \{[\s\S]*background: var\(--nodedc-glass-control-bg\);/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.observatory-session-stack \{[\s\S]*container-name: observatory-session;[\s\S]*container-type: inline-size;/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.observatory-workspace \{[\s\S]*grid-auto-rows: max-content;[\s\S]*align-content: start;/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.observatory-session-summary__facts dd \{[\s\S]*font-size: var\(--nodedc-font-size-sm\);/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.observatory-catalog-bar h3,[\s\S]*\.observatory-session-summary h3,[\s\S]*\.observatory-evidence h3 \{[\s\S]*font-size: var\(--nodedc-font-size-lg\);/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container observatory-session \(max-width: 46rem\) \{[\s\S]*\.observatory-session-summary \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container observatory-session \(max-width: 38rem\) \{[\s\S]*\.observatory-session-summary__facts \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
|
||||
);
|
||||
assert.doesNotMatch(styles, /\.observatory-evidence-card[\s\S]*background:\s*(?:#0{3,6}|black|rgb\(0[ ,])/i);
|
||||
assert.doesNotMatch(styles, /nodedc-glass-surface|nodedc-status-badge/);
|
||||
});
|
||||
|
||||
test("Observatory rename and delete use admitted projection mutations and canonical windows", async () => {
|
||||
const [workspace, hook] = await Promise.all([
|
||||
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||
read("core/observatory/useObservatoryCatalog.ts"),
|
||||
]);
|
||||
|
||||
assert.match(
|
||||
workspace,
|
||||
/deleteObservatoryLabProjection,[\s\S]*renameObservatoryLabProjection,[\s\S]*from "\.\.\/\.\.\/core\/observatory\/catalogMutations"/,
|
||||
);
|
||||
assert.match(workspace, /<Window[\s\S]*title="Переименовать лабораторный результат"/);
|
||||
assert.match(workspace, /<TextField[\s\S]*label="Название"[\s\S]*maxLength=\{160\}/);
|
||||
assert.match(workspace, /<WindowFooterActions>[\s\S]*Сохранить/);
|
||||
assert.match(workspace, /<ConfirmationModal[\s\S]*title="Удалить результат из Обсерватории\?"/);
|
||||
assert.match(workspace, /Исходная сессия, запечатанный лабораторный результат и файлы доказательств/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/const result = await renameObservatoryLabProjection\([\s\S]*controller\.applyEvidenceRename\(result\.sessionId, result\.displayName\);[\s\S]*setRenameTarget\(null\);[\s\S]*void controller\.refresh\(\);/,
|
||||
);
|
||||
assert.match(
|
||||
workspace,
|
||||
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*evidence\?\.label === reconciliation\.displayName[\s\S]*setRenameTarget\(null\);/,
|
||||
);
|
||||
|
||||
const deleteFlowStart = workspace.indexOf("const confirmDelete = useCallback");
|
||||
const deleteFlowEnd = workspace.indexOf("}, [closeReplay, controller", deleteFlowStart);
|
||||
assert.ok(deleteFlowStart >= 0 && deleteFlowEnd > deleteFlowStart);
|
||||
const deleteFlow = workspace.slice(deleteFlowStart, deleteFlowEnd);
|
||||
const teardown = deleteFlow.indexOf("closeReplay();");
|
||||
const remove = deleteFlow.indexOf("await deleteObservatoryLabProjection");
|
||||
const tombstone = deleteFlow.indexOf("controller.applyEvidenceDeletion");
|
||||
const refresh = deleteFlow.indexOf("void controller.refresh();");
|
||||
assert.ok(teardown >= 0 && teardown < remove, "active replay must unmount before projection delete");
|
||||
assert.ok(remove < tombstone, "local tombstone must follow the exact empty 204");
|
||||
assert.ok(tombstone < refresh, "reconciliation refresh must follow the local tombstone");
|
||||
assert.match(deleteFlow, /flushSync\(\(\) => \{[\s\S]*closeReplay\(\);[\s\S]*\}\);/);
|
||||
assert.match(
|
||||
deleteFlow,
|
||||
/catch \(error\) \{[\s\S]*const reconciled = await controller\.refresh\(\);[\s\S]*observatoryCatalogConfirmsEvidenceDeletion\([\s\S]*setDeleteTarget\(null\);/,
|
||||
);
|
||||
assert.match(workspace, /mutationError[\s\S]*role="alert"/);
|
||||
assert.match(hook, /activeRequestRef\.current\?\.controller\.abort\(\)/);
|
||||
assert.match(hook, /requestSequenceRef\.current !== id/);
|
||||
assert.match(hook, /const requestMutationRevision = mutationRevisionRef\.current/);
|
||||
assert.match(hook, /reconcileObservatoryCatalogMutationOverlay\(/);
|
||||
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
|
||||
});
|
||||
|
||||
test("Observatory keeps one compact selector axis without the obsolete setup detail", async () => {
|
||||
const [workspace, styles, setupHook, jobsHook] = await Promise.all([
|
||||
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
|
||||
read("styles/observatory.css"),
|
||||
read("core/observatory/useObservatoryLaboratorySetups.ts"),
|
||||
read("core/observatory/useObservatoryRecordedJobs.ts"),
|
||||
]);
|
||||
|
||||
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
|
||||
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
|
||||
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
|
||||
assert.match(workspace, /setupController\.error \? \(/);
|
||||
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
|
||||
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
|
||||
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
|
||||
assert.match(workspace, /useObservatoryRecordedJobs/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/runPreflight\?\.outcome === "queueable"[\s\S]*runPreflight\.submissionAllowed/,
|
||||
);
|
||||
assert.match(
|
||||
workspace,
|
||||
/showCalculate \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
|
||||
);
|
||||
assert.doesNotMatch(workspace, /recordedJobStatus|presentedJobStatus|Расчёт завершён|Результат опубликован|Вычислено ·/);
|
||||
assert.match(workspace, /setupController\.selectableSetups\.map/);
|
||||
assert.match(workspace, /showCalculate = setupController\.selectedSetup !== null/);
|
||||
assert.match(workspace, /disabled=\{!canSubmitRecordedJob\}/);
|
||||
assert.match(workspace, /aria-busy=\{calculationPending\}/);
|
||||
const runBar = workspace.slice(workspace.indexOf('<div className="observatory-catalog-bar__run"'), workspace.indexOf("{queueStatusError ?"));
|
||||
assert.doesNotMatch(runBar, /StatusBadge/);
|
||||
assert.match(setupHook, /fetchObservatoryPortableLaboratorySetups/);
|
||||
assert.doesNotMatch(setupHook, /fetchObservatoryLaboratorySetups|mergeSetupCatalogs|legacyResult/);
|
||||
assert.match(setupHook, /selectableObservatorySetups\(next\)/);
|
||||
assert.match(setupHook, /selectable\[0\]\?\.setupId \?\? ""/);
|
||||
assert.match(setupHook, /selectedSetupId, sourceSessionId, selectedDefinitionSha256/);
|
||||
assert.match(
|
||||
workspace,
|
||||
/preflightCandidate\.definitionSha256[\s\S]*selectedSetup\?\.runDefinition\?\.definitionSha256/,
|
||||
);
|
||||
assert.match(
|
||||
jobsHook,
|
||||
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
|
||||
);
|
||||
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
|
||||
assert.match(jobsHook, /JSON\.stringify\(\[sourceSessionId, setupId, definitionSha256\]\)/);
|
||||
assert.match(jobsHook, /snapshot\.selectionKey === selectionKey \? snapshot : null/);
|
||||
assert.doesNotMatch(jobsHook, /observatory-ui:[^`]*sourceSessionId|\.slice\(0, 160\)/);
|
||||
assert.match(workspace, /recordedJobsController\.refresh\(\)/);
|
||||
assert.match(jobsHook, /POLL_INTERVAL_MS = 1_500/);
|
||||
assert.match(jobsHook, /globalThis\.setTimeout/);
|
||||
assert.doesNotMatch(jobsHook, /setInterval/);
|
||||
assert.match(workspace, /job\.publication\.state === "published"/);
|
||||
assert.match(workspace, /void controller\.refresh\(\);[\s\S]*setupController\.refresh\(\);/);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.observatory-catalog-bar__controls \{[\s\S]*min-width: 0;[\s\S]*flex: 1 1 auto;[\s\S]*flex-wrap: nowrap;[\s\S]*justify-content: flex-end;/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.observatory-catalog-bar__run \{[\s\S]*display: flex;[\s\S]*align-items: center;/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/@container observatory-workspace \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar,[\s\S]*\.observatory-catalog-bar__controls,[\s\S]*flex-direction: column;/,
|
||||
);
|
||||
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
|
||||
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
|
||||
assert.match(setupHook, /preflightRequest\.current !== request/);
|
||||
assert.match(
|
||||
setupHook,
|
||||
/state !== "ready" \|\| !selectedSetup \|\| preflight\.kind !== "idle"[\s\S]*void check\(\)/,
|
||||
);
|
||||
});
|
||||
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
|
||||
assert.equal(workspaceById("datasets").kind, "datasets");
|
||||
assert.deepEqual(
|
||||
workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations"],
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
);
|
||||
assert.equal(
|
||||
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
||||
|
||||
@@ -27,6 +27,19 @@ test("top navigation has no Center and Park owns contour health first", () => {
|
||||
);
|
||||
assert.equal(productModel.workspacesForRoot("fleet")[0]?.id, "contour-health");
|
||||
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory"],
|
||||
);
|
||||
assert.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive");
|
||||
assert.deepEqual(
|
||||
{
|
||||
root: productModel.workspaceById("observatory")?.root,
|
||||
kind: productModel.workspaceById("observatory")?.kind,
|
||||
icon: productModel.workspaceById("observatory")?.icon,
|
||||
},
|
||||
{ root: "polygon", kind: "observatory", icon: "eye" },
|
||||
);
|
||||
});
|
||||
|
||||
test("every laboratory result uses the shared evidence template", async () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ let liveTimelineNeedsSynchronization;
|
||||
let liveRerunReceiverBindingIdentity;
|
||||
let recordedOpenWatchdogTimeoutMs;
|
||||
let rerunViewerInitialSource;
|
||||
let rerunViewerOpenOptions;
|
||||
let resolveRecordedViewerSourceUrl;
|
||||
|
||||
before(async () => {
|
||||
@@ -31,7 +30,6 @@ before(async () => {
|
||||
liveRerunReceiverBindingIdentity,
|
||||
recordedOpenWatchdogTimeoutMs,
|
||||
rerunViewerInitialSource,
|
||||
rerunViewerOpenOptions,
|
||||
resolveRecordedViewerSourceUrl,
|
||||
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
|
||||
});
|
||||
@@ -71,9 +69,21 @@ test("only the canonical digest-bound generation URL reaches the native Rerun re
|
||||
}
|
||||
});
|
||||
|
||||
test("only the live receiver opens on the native following edge", () => {
|
||||
assert.deepEqual(rerunViewerOpenOptions(true), { follow_if_http: true });
|
||||
assert.equal(rerunViewerOpenOptions(false), null);
|
||||
test("one canonical LAB replay generation reaches the same native receiver", () => {
|
||||
const labSource =
|
||||
`/api/v1/laboratory/vegetation-shadow/lab-v1-vegetation-shadow-${"c".repeat(64)}`
|
||||
+ "/canonical-replay.rrd";
|
||||
const descriptor = {
|
||||
sourceUrl: labSource,
|
||||
viewerSourceUrl: `${labSource}?generation=${sha256}`,
|
||||
byteLength: 300_000_000,
|
||||
sha256,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
resolveRecordedViewerSourceUrl(descriptor, "http://mission-core.test"),
|
||||
`http://mission-core.test${descriptor.viewerSourceUrl}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("live presentation waits for the exact receiver to expose a usable range", () => {
|
||||
@@ -216,7 +226,7 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
assert.doesNotMatch(source, /missioncore\/recorded-recording/);
|
||||
assert.doesNotMatch(source, /recordedChannel/);
|
||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||
assert.match(source, /rerunViewerOpenOptions\(followLive\)/);
|
||||
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
|
||||
assert.match(
|
||||
source,
|
||||
/recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createServer } from "vite";
|
||||
let server;
|
||||
let canPublishRecordedPlaybackController;
|
||||
let createRecordedAutoplayGate;
|
||||
let createRecordedInitialSeekGate;
|
||||
let isRecordedPlaybackReady;
|
||||
let isRecordedPlaybackPresentationReady;
|
||||
let isUsableRecordedPlaybackRange;
|
||||
@@ -22,6 +23,7 @@ before(async () => {
|
||||
({
|
||||
canPublishRecordedPlaybackController,
|
||||
createRecordedAutoplayGate,
|
||||
createRecordedInitialSeekGate,
|
||||
isRecordedPlaybackReady,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
isUsableRecordedPlaybackRange,
|
||||
@@ -159,6 +161,31 @@ test("recorded autoplay waits for the full range and then runs exactly once", ()
|
||||
assert.equal(gate.attempted(), true);
|
||||
});
|
||||
|
||||
test("paused recorded replay seeks once to its first presentable frame", () => {
|
||||
const gate = createRecordedInitialSeekGate();
|
||||
const seeks = [];
|
||||
const range = { min: 0, max: 535_717_620_042 };
|
||||
|
||||
assert.equal(gate.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
range,
|
||||
(value) => seeks.push(value),
|
||||
39_215_263_458,
|
||||
), true);
|
||||
assert.equal(gate.attempt(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
range,
|
||||
(value) => seeks.push(value),
|
||||
50_000_000_000,
|
||||
), false);
|
||||
assert.deepEqual(seeks, [39_215_263_458]);
|
||||
assert.equal(gate.attempted(), true);
|
||||
});
|
||||
|
||||
test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => {
|
||||
const gate = createRecordedAutoplayGate();
|
||||
const seeks = [];
|
||||
|
||||
@@ -1,81 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
import makeRerunRuntime from "../vendor/rerun-web-viewer-0.34.1/re_viewer.nodedc.js";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.34.1");
|
||||
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
||||
|
||||
const sha256 = (path) =>
|
||||
createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
test("Mission Core uses the exact upstream Rerun 0.36.3 web package", () => {
|
||||
const application = readJson(resolve(root, "package.json"));
|
||||
const installed = readJson(resolve(packageRoot, "package.json"));
|
||||
|
||||
test("NODE.DC Rerun runtime is the audited 0.34.1 spatial camera build", () => {
|
||||
const manifest = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
assert.equal(manifest.version, "0.34.1");
|
||||
|
||||
const expectedWasm = "ffe7543d28bb3394f289f6299de43d038767eef83d781c2b7f8f5683308a0469";
|
||||
const expectedGlue = "0f7b76c9f24cbd8437021b5d37499894aeadc586183e422ebc82ef556d7b8339";
|
||||
|
||||
assert.equal(sha256(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")), expectedWasm);
|
||||
assert.equal(sha256(resolve(vendorRoot, "re_viewer.nodedc.js")), expectedGlue);
|
||||
assert.equal(sha256(resolve(packageRoot, "re_viewer_bg.wasm")), expectedWasm);
|
||||
assert.equal(sha256(resolve(packageRoot, "re_viewer.js")), expectedGlue);
|
||||
assert.equal(application.dependencies["@rerun-io/web-viewer"], "0.36.3");
|
||||
assert.equal(installed.version, "0.36.3");
|
||||
assert.equal(application.scripts.postinstall, undefined);
|
||||
});
|
||||
|
||||
test("custom JavaScript glue references only exports present in its paired WASM", () => {
|
||||
const wasmPath = resolve(vendorRoot, "re_viewer_bg.nodedc.wasm");
|
||||
const gluePath = resolve(vendorRoot, "re_viewer.nodedc.js");
|
||||
const module = new WebAssembly.Module(readFileSync(wasmPath));
|
||||
const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name));
|
||||
const imports = WebAssembly.Module.imports(module);
|
||||
const glue = readFileSync(gluePath, "utf8");
|
||||
const referencedExports = new Set(
|
||||
[...glue.matchAll(/\bwasm\.([A-Za-z_$][\w$]*)/g)].map((match) => match[1]),
|
||||
);
|
||||
const missingExports = [...referencedExports].filter((name) => !exports.has(name));
|
||||
test("the active application never imports or installs the archived vendor fork", () => {
|
||||
const application = readFileSync(resolve(root, "package.json"), "utf8");
|
||||
const viewport = readFileSync(resolve(root, "src/components/RerunViewport.tsx"), "utf8");
|
||||
|
||||
assert.equal(imports.length, 927);
|
||||
assert.equal(exports.size, 79);
|
||||
assert.deepEqual(missingExports, []);
|
||||
assert.match(glue, /export default function\(\)/);
|
||||
assert.match(glue, /if \(!wasm\) return;/);
|
||||
assert.doesNotMatch(application, /patch-rerun-web-viewer/);
|
||||
assert.doesNotMatch(application, /vendor\/rerun-web-viewer/);
|
||||
assert.doesNotMatch(viewport, /vendor\/rerun-web-viewer/);
|
||||
assert.doesNotMatch(viewport, /0\.34\.1/);
|
||||
});
|
||||
|
||||
test("custom Rerun WASM initializes and grows its externref table", () => {
|
||||
const runtime = makeRerunRuntime();
|
||||
runtime.initSync({
|
||||
module: readFileSync(resolve(vendorRoot, "re_viewer_bg.nodedc.wasm")),
|
||||
});
|
||||
test("upstream WebViewer exposes one three-argument start contract", () => {
|
||||
const declaration = readFileSync(resolve(packageRoot, "index.d.ts"), "utf8");
|
||||
|
||||
assert.equal(typeof runtime.WebHandle, "function");
|
||||
runtime.deinit();
|
||||
});
|
||||
|
||||
test("source patch carries pointer navigation, persistent follow, and camera continuity tests", () => {
|
||||
const patch = readFileSync(resolve(vendorRoot, "NODEDC_ZOOM_TO_CURSOR.patch"), "utf8");
|
||||
|
||||
assert.match(patch, /fn pointer_ray_direction/);
|
||||
assert.match(patch, /fn zoom_orbit_towards_pointer/);
|
||||
assert.match(patch, /near_limit_hands_excess_zoom_to_cursor_directed_dolly/);
|
||||
assert.match(patch, /crossing_near_limit_preserves_unconsumed_scene_scaled_zoom/);
|
||||
assert.match(patch, /remaining_zoom_factor\.ln\(\) \* self\.speed/);
|
||||
assert.match(patch, /off_center_pointer_stays_on_the_same_view_ray/);
|
||||
assert.match(patch, /fn rotate_radians_around_anchor/);
|
||||
assert.match(patch, /orbit_drag_anchor/);
|
||||
assert.match(patch, /minimum_orbital_navigation_speed/);
|
||||
assert.match(patch, /orbital_rotation_keeps_selected_anchor_on_the_same_view_ray/);
|
||||
assert.match(patch, /orbital_navigation_speed_floor_tracks_scene_scale/);
|
||||
assert.match(patch, /NODEDC_PERSISTENT_ORBIT_TRACKING_ENTITY/);
|
||||
assert.match(patch, /nodedc_rig_orbit_tracking_is_persistent/);
|
||||
assert.match(patch, /restore_persistent_orbit_eye_after_blueprint_update/);
|
||||
assert.match(
|
||||
patch,
|
||||
/persistent_rig_follow_restores_the_last_rendered_eye_after_blueprint_update/,
|
||||
declaration,
|
||||
/start\(rrd: string \| string\[\] \| null, parent: HTMLElement \| null, options: WebViewerOptions \| null\): Promise<void>/,
|
||||
);
|
||||
assert.match(patch, /explicit_blueprint_pose_is_not_replaced_by_the_previous_eye/);
|
||||
assert.match(patch, /previous_picking_result/);
|
||||
});
|
||||
|
||||
@@ -124,6 +124,11 @@ test("Compute-contour telemetry remains a bounded system feature slice", async (
|
||||
assert.match(pollIntervalContract, /MIN_TELEMETRY_POLL_INTERVAL_SECONDS\s*=\s*1/);
|
||||
assert.match(telemetryPolling, /normalizeWorkerTelemetryPollMilliseconds/);
|
||||
assert.match(telemetryPolling, /startSequentialPolling/);
|
||||
assert.match(telemetryPolling, /catch \(reason: unknown\)[\s\S]*?setTelemetry\(null\)/);
|
||||
assert.match(computeWorkspace, /sm_clock_mhz/);
|
||||
assert.match(computeWorkspace, /memory_clock_mhz/);
|
||||
assert.match(computeWorkspace, /Ожидает восстановления данных/);
|
||||
assert.doesNotMatch(computeWorkspace, /Очередь свободна; модели остаются загруженными/);
|
||||
assert.doesNotMatch(telemetryPolling, /if\s*\(\s*!enabled\s*\|\|\s*loading\s*\)/);
|
||||
assert.match(pollingScheduler, /A transient failure must not stop polling/);
|
||||
assert.match(styles, /system-telemetry\.css/);
|
||||
|
||||
@@ -451,8 +451,8 @@ test("canonical recorded LAB spatial frame keeps source, SLAM and body identity
|
||||
assert.deepEqual(frame.bodyFrame.originMapXyzM, [33, 4, 1]);
|
||||
});
|
||||
|
||||
test("vegetation realtime LAB and archival benchmark use separate admitted instruments", async () => {
|
||||
const [resultSource, benchmarkSource, m49Source, canonicalSource] = await Promise.all([
|
||||
test("vegetation realtime LAB uses one upstream Rerun clock and keeps archival review separate", async () => {
|
||||
const [resultSource, benchmarkSource, m49Source, canonicalSource, rerunSource, replayStyles] = await Promise.all([
|
||||
readFile(
|
||||
new URL("../src/workspaces/laboratory/VegetationShadowResult.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -469,6 +469,14 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
|
||||
new URL("../src/components/laboratory/CanonicalRecordedLabReplay.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
new URL("../src/components/laboratory/CanonicalVegetationRerunReplay.tsx", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
readFile(
|
||||
new URL("../src/styles/m4-replay-threat.css", import.meta.url),
|
||||
"utf8",
|
||||
),
|
||||
]);
|
||||
assert.doesNotMatch(resultSource, /M48MaskComparisonVisual/);
|
||||
assert.match(resultSource, /M49TgsFullShadowEvidence/);
|
||||
@@ -477,23 +485,54 @@ test("vegetation realtime LAB and archival benchmark use separate admitted instr
|
||||
assert.match(m49Source, /controlLabel: "SEMANTICS"/);
|
||||
assert.equal(resultSource.match(/<LaboratoryEvidence\b/g)?.length, 2);
|
||||
assert.doesNotMatch(resultSource, /RAVNOVES004TREE mixed route review/);
|
||||
assert.match(resultSource, /CANONICAL RECORDED LAB · RAVNOVES004TREE/);
|
||||
assert.match(resultSource, /<M4ReplayThreatVisual/);
|
||||
assert.match(resultSource, /timelineEndpointRoot=\{VEGETATION_TIMELINE_ENDPOINT\}/);
|
||||
assert.match(resultSource, /playbackTransport="segmented"/);
|
||||
assert.match(resultSource, /recoverTimestampStalls/);
|
||||
assert.match(resultSource, /КАНОНИЧЕСКАЯ ЗАПИСАННАЯ LAB · RAVNOVES004TREE/);
|
||||
assert.match(resultSource, /<CanonicalVegetationRerunReplay/);
|
||||
assert.doesNotMatch(resultSource, /RerunViewport/);
|
||||
assert.doesNotMatch(resultSource, /cacheRef|pumpRef|desiredRef/);
|
||||
assert.doesNotMatch(resultSource, /LaboratoryRecordedClipPlayer|M48EvidenceModeRail/);
|
||||
assert.doesNotMatch(resultSource, /assets\.tgs|<img/);
|
||||
assert.match(resultSource, /SOURCE POINTS/);
|
||||
assert.match(resultSource, /TGS COSTMAP/);
|
||||
assert.match(rerunSource, /<RerunViewport/);
|
||||
assert.match(rerunSource, /ИСХ\. ТОЧКИ/);
|
||||
assert.match(rerunSource, /ЛОК\. SLAM/);
|
||||
assert.match(rerunSource, /resolveCanonicalLabReplay/);
|
||||
assert.doesNotMatch(rerunSource, /canonical-overlay\.rrd/);
|
||||
assert.match(rerunSource, /unifiedPerception: splitView/);
|
||||
assert.match(rerunSource, /lockPerceptionCameraInteraction: mediaMode !== null/);
|
||||
assert.match(rerunSource, /!splitView[\s\S]*event\.button !== 0/);
|
||||
assert.match(rerunSource, /if \(!splitView\) stopNativeSplitTracking\(\)/);
|
||||
assert.match(rerunSource, /event\.target instanceof HTMLCanvasElement/);
|
||||
assert.match(rerunSource, /--canonical-rerun-camera-pane/);
|
||||
assert.match(rerunSource, /onPointerDownCapture=\{splitView \? trackNativeSplit : undefined\}/);
|
||||
assert.match(rerunSource, /data-split-view=\{splitView \? "true" : undefined\}/);
|
||||
assert.match(rerunSource, /mediaMode === null[\s\S]*\? 0[\s\S]*: splitView[\s\S]*\? nativeSplitPercentRef\.current[\s\S]*: 100/);
|
||||
assert.match(rerunSource, /isRecordedPlaybackPresentationReady\(viewerStatus, playback\)/);
|
||||
assert.match(rerunSource, /data-presentation-state=\{presentationState\}/);
|
||||
assert.match(rerunSource, /<ActivityIndicator label="Загружаем синхронизированную запись"/);
|
||||
assert.match(rerunSource, /presentationReady && playback && playbackController/);
|
||||
assert.match(
|
||||
replayStyles,
|
||||
/canonical-vegetation-rerun-replay:not\(\[data-presentation-state="ready"\]\)[\s\S]*laboratory-evidence-viewer__controls/,
|
||||
);
|
||||
assert.match(
|
||||
replayStyles,
|
||||
/canonical-vegetation-rerun-replay__timeline \.observation-timeline__playback \{[\s\S]*grid-template-columns: auto auto minmax\(0, 1fr\) auto;/,
|
||||
);
|
||||
assert.match(
|
||||
replayStyles,
|
||||
/canonical-vegetation-rerun-replay__viewport-lock[\s\S]*rerun-viewport__camera-lock \{[\s\S]*width: var\(--canonical-rerun-camera-pane, 100%\);/,
|
||||
);
|
||||
assert.match(
|
||||
replayStyles,
|
||||
/canonical-vegetation-rerun-replay__viewport-lock\[data-split-view="true"\][\s\S]*width: calc\(var\(--canonical-rerun-camera-pane, 46%\) - 0\.75rem\);/,
|
||||
);
|
||||
assert.doesNotMatch(rerunSource, /LaboratoryRecordedClipPlayer|LaboratoryMetricEvidenceScene/);
|
||||
assert.match(resultSource, /point-aligned 3D semantics пока не запечатаны/);
|
||||
assert.match(resultSource, /cellLayerAvailable: false/);
|
||||
assert.match(rerunSource, /value: "tgs", label: "TGS", disabled: true/);
|
||||
assert.match(canonicalSource, /primary=\{mediaPane\}/);
|
||||
assert.match(canonicalSource, /secondary=\{spatialPane/);
|
||||
assert.match(canonicalSource, /missioncore\.canonical-recorded-lab-replay\/v1/);
|
||||
assert.match(canonicalSource, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
|
||||
assert.match(canonicalSource, /separatorLabel="Изменить размер видео\/камеры и 3D\/плана"/);
|
||||
assert.match(canonicalSource, /resizable=\{splitView && !unifiedContent\}/);
|
||||
assert.match(resultSource, /linked canonical M4\.9 TGS evidence/);
|
||||
assert.match(resultSource, /linkedTgsResultId/);
|
||||
assert.match(benchmarkSource, /M48MaskComparisonVisual/);
|
||||
|
||||
@@ -75,13 +75,13 @@ test("recorded session profile owns progressive admission and on-demand layers",
|
||||
assert.equal(profile.perceptionLayers.enabled, false);
|
||||
});
|
||||
|
||||
test("LAB recorded evidence remains outside native Rerun lifecycle", () => {
|
||||
test("the old LAB transport is fenced as explicit legacy comparison only", () => {
|
||||
assert.deepEqual(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE, {
|
||||
kind: "lab-recorded-evidence",
|
||||
clock: "source-sequence",
|
||||
cameraTransport: "generation-bound-fmp4",
|
||||
spatialTransport: "bounded-sealed-artifacts",
|
||||
loadPolicy: "visible-evidence-only",
|
||||
loadPolicy: "explicit-legacy-comparison-only",
|
||||
workerRequired: false,
|
||||
});
|
||||
assert.equal(Object.isFrozen(LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE), true);
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Archived viewer comparison source
|
||||
|
||||
`rerun-web-viewer-0.34.1/` is retained only as rollback-era comparison evidence
|
||||
for ADR 0045. The active application neither imports it nor runs the historical
|
||||
patch scripts during install or build. Canonical Control Station routes use the
|
||||
unmodified `@rerun-io/web-viewer` dependency declared in `package.json`.
|
||||
|
||||
Do not update or reactivate this tree. Remove it with the remaining custom
|
||||
fMP4/Three.js replay implementation after the canonical Rerun migration passes
|
||||
operator visual acceptance.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schema_version": "missioncore.equipment-model-registry/v1",
|
||||
"models": [
|
||||
{
|
||||
"schema_version": "missioncore.equipment-model/v1",
|
||||
"equipment_model_id": "xgrids.lixelkity-k1",
|
||||
"equipment_model_version": 1,
|
||||
"plugin_id": "nodedc.device.xgrids-lixelkity-k1",
|
||||
"vendor": "XGRIDS",
|
||||
"display_name": "XGRIDS LixelKity K1",
|
||||
"category": "mobile-lidar-scanner",
|
||||
"capability_ids": [
|
||||
"recorded.camera.video",
|
||||
"recorded.lidar.point-cloud",
|
||||
"recorded.pose.trajectory"
|
||||
],
|
||||
"authority": {
|
||||
"commands_enabled": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"schema_version": "missioncore.observatory-laboratory-setup-registry/v1",
|
||||
"setups": [
|
||||
{
|
||||
"setup_id": "m49-tgs-full-shadow-v1",
|
||||
"display_name": "M4.9T5 · TRAVEL TGS · CPU-only, без ML",
|
||||
"description": "Сохранённая конфигурация RAVNOVES00: TRAVEL TGS без ML-моделей, 4 489 кадров, causal rolling 1 s, полный визуальный разбор и отдельное доказательство integrated graph.",
|
||||
"origin": "archived-definition",
|
||||
"source": {
|
||||
"session_id": "20260720T065719Z_viewer_live",
|
||||
"label": "RAVNOVES00",
|
||||
"required_modalities": [
|
||||
"point-cloud",
|
||||
"trajectory",
|
||||
"video"
|
||||
]
|
||||
},
|
||||
"run_definition": {
|
||||
"definition_id": "m49-tgs-full-shadow",
|
||||
"version": 1,
|
||||
"work_id": "m49-tgs-full-shadow",
|
||||
"configuration": [
|
||||
{
|
||||
"role": "primary-profile",
|
||||
"path": "config/perception/m49-tgs-full-shadow-v1.json",
|
||||
"sha256": "c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"
|
||||
}
|
||||
]
|
||||
},
|
||||
"executor": {
|
||||
"contour_id": "worker-006",
|
||||
"state": "not-installed",
|
||||
"reason_code": "laboratory-runner-adapter-not-installed",
|
||||
"reason": "Повторный запуск этого сетапа ещё не подключён к общему контуру расчёта."
|
||||
},
|
||||
"preserved_results": [
|
||||
{
|
||||
"result_id": "m49-tgs-full-shadow-ef98de7db7596d48e8c8c0549ce68e6704ee03e87c8c4bcf1e3e748b7ccb032e",
|
||||
"result_kind": "recorded-source-paced-tgs-shadow",
|
||||
"relation": "primary-visual",
|
||||
"access": "legacy-lab",
|
||||
"created_at_utc": "2026-08-26T20:27:19.076865Z"
|
||||
},
|
||||
{
|
||||
"result_id": "m49-tgs-integrated-graph-shadow-75e48fd8acf0246be16613e087fcf26fe909f7834718d217c74785a57f494b24",
|
||||
"result_kind": "source-paced-integrated-graph-shadow",
|
||||
"relation": "compute-successor",
|
||||
"access": "evidence-only",
|
||||
"created_at_utc": "2026-08-27T08:50:05.5622153+00:00"
|
||||
}
|
||||
],
|
||||
"authority": {
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false,
|
||||
"production_accepted": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"setup_id": "lab-v1-ravnoves004tree-final",
|
||||
"display_name": "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
|
||||
"description": "Точный RAVNOVES004TREE result: 6 830 кадров EoMT Cityscapes Large 1024 + DDRNet-39 и синхронный записанный replay; полный TGS и независимый YOLOX отсутствуют.",
|
||||
"origin": "existing-result",
|
||||
"source": {
|
||||
"session_id": "20260828T130511Z_viewer_live",
|
||||
"label": "RAVNOVES004TREE",
|
||||
"required_modalities": [
|
||||
"point-cloud",
|
||||
"trajectory",
|
||||
"video"
|
||||
]
|
||||
},
|
||||
"run_definition": null,
|
||||
"executor": {
|
||||
"contour_id": "worker-006",
|
||||
"state": "not-installed",
|
||||
"reason_code": "durable-dispatch-definition-unavailable",
|
||||
"reason": "Для готового результата не сохранён повторно запускаемый сетап; доступен записанный разбор."
|
||||
},
|
||||
"preserved_results": [
|
||||
{
|
||||
"result_id": "lab-v1-vegetation-shadow-8c8f387599955dd79a16ded2c2d7cfd7f9f308d704f52c42fc26bd39d6da2d60",
|
||||
"result_kind": "recorded-perception-qualification",
|
||||
"relation": "canonical-projection",
|
||||
"access": "observatory",
|
||||
"created_at_utc": "2026-08-29T18:05:11.329061Z"
|
||||
}
|
||||
],
|
||||
"authority": {
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false,
|
||||
"production_accepted": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"schema_version": "missioncore.observatory-m49-recorded-queue-binding/v1",
|
||||
"binding_id": "m49-ravnoves00-recorded-queue-v1",
|
||||
"source": {
|
||||
"session_id": "20260720T065719Z_viewer_live",
|
||||
"label": "RAVNOVES00",
|
||||
"required_modalities": [
|
||||
"point-cloud",
|
||||
"trajectory",
|
||||
"video"
|
||||
],
|
||||
"source_pack": {
|
||||
"artifact_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b",
|
||||
"sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944",
|
||||
"byte_length": 72996000,
|
||||
"media_type": "application/vnd.nodedc.lidar-source-pack+npz",
|
||||
"expected_timeline_frames": 4489,
|
||||
"expected_available_lidar_frames": 3928
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"setup_id": "m49-tgs-full-shadow-v1",
|
||||
"definition_id": "m49-tgs-full-shadow",
|
||||
"definition_version": 1,
|
||||
"definition_sha256": "836a66639e69f7ea00de2a9111c1c6c9c3c00f1abd726f1df596aeb4e3a88ae6"
|
||||
},
|
||||
"source_adapter": {
|
||||
"adapter_id": "ravnoves00-m49-source-pack",
|
||||
"version": 1
|
||||
},
|
||||
"executor": {
|
||||
"release_id": "m49-tgs-full-shadow-worker-release",
|
||||
"artifact_sha256": "5e0ea16c7a5cc760463836718b0cd8b0006ffc4b202e5f706a20a86ef2f912ab",
|
||||
"code_revision": "40c850b167dda366d8aa45d828520168affaf9fd",
|
||||
"service_installed": false,
|
||||
"image_set": {
|
||||
"schema_version": "missioncore.observatory-executor-image-set/v1",
|
||||
"images": {
|
||||
"travel": "7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f",
|
||||
"parity": "ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0"
|
||||
}
|
||||
},
|
||||
"learned_models": []
|
||||
},
|
||||
"resource_profile": {
|
||||
"schema_version": "missioncore.observatory-recorded-resource-profile/v1",
|
||||
"profile_id": "worker006-cpu-single-run-v1",
|
||||
"contour_id": "worker-006",
|
||||
"concurrency": 1,
|
||||
"checkpoint_policy": "non-checkpointable",
|
||||
"restart_from_zero": true,
|
||||
"staging_discard_required": true,
|
||||
"resource_release_receipt_required": true
|
||||
},
|
||||
"authority": {
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false,
|
||||
"production_accepted": false
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"schema_version": "missioncore.recorded-capture-profile-registry/v1",
|
||||
"profiles": [
|
||||
{
|
||||
"schema_version": "missioncore.recorded-capture-profile/v1",
|
||||
"capture_profile_id": "xgrids-k1.viewer-live.fw-3.0.2.v1",
|
||||
"capture_profile_version": 1,
|
||||
"equipment_model_id": "xgrids.lixelkity-k1",
|
||||
"equipment_model_sha256": "c95d7183ffd2e425892fd9ddfc4cd8dcbccc0231289127ddc14db6b52bbb92aa",
|
||||
"plugin_id": "nodedc.device.xgrids-lixelkity-k1",
|
||||
"archive_id": "xgrids-k1.viewer-live.evidence",
|
||||
"firmware_compatibility_profile_id": "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
|
||||
"modalities": [
|
||||
"point-cloud",
|
||||
"trajectory",
|
||||
"video"
|
||||
],
|
||||
"semantic_channels": [
|
||||
"camera.video.recorded",
|
||||
"spatial.point-cloud.recorded",
|
||||
"spatial.pose.recorded"
|
||||
],
|
||||
"camera_media": {
|
||||
"source_id": "sensor.camera.right",
|
||||
"media_type": "video/mp4; codecs=\"avc1.641028\"",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"initialization_sha256": "e2279963e16d84c91d68e7dbb1f7efed840533387dfeb844b7398bff45fbde38"
|
||||
},
|
||||
"calibration": {
|
||||
"slot_id": "camera_1",
|
||||
"sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9"
|
||||
},
|
||||
"media_epoch_policy": "exactly-one-complete-epoch",
|
||||
"seekable": true,
|
||||
"adapter": {
|
||||
"adapter_id": "xgrids-k1-recorded-observatory-v2",
|
||||
"version": 2,
|
||||
"adapter_sha256": "4e12be6d2503e2e237eddb290b28d6a7d16cf983855b6d8e6b4e3b65d2feb0de"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"schema_version": "missioncore.perception-profile-candidate/v1",
|
||||
"profile_id": "k1-perception-ddrnet39-rfdetr-tgs-prototype/v1",
|
||||
"display_name": "K1 Perception — DDRNet-39 + RF-DETR + TGS",
|
||||
"status": "stage-1-candidate-not-installed-not-qualified",
|
||||
"experiment_policy": {
|
||||
"bounded_lab_overload_allowed": true,
|
||||
"retain_profiles_that_fail_current_hardware_realtime": true,
|
||||
"realtime_failure_blocks_experimental_packaging": false,
|
||||
"qualification_scope": ["profile-version", "effective-config", "hardware", "source", "transport"],
|
||||
"preserve_original_source_clock": true,
|
||||
"report_drops_and_staleness": true,
|
||||
"overload_is_realtime_pass": false,
|
||||
"future_onboard_placement_requires_new_measurement": true
|
||||
},
|
||||
"packaging": {
|
||||
"image": "ndc-k1-perception-ddrnet39-rfdetr-tgs:prototype-v1",
|
||||
"image_sha256": null,
|
||||
"single_container": true,
|
||||
"external_model_server_required": false,
|
||||
"host_model_cache_required": false,
|
||||
"runtime_model_download_allowed": false,
|
||||
"model_environment_isolation": "supervised-processes-one-gpu-scheduler",
|
||||
"common_interpreter_required": false
|
||||
},
|
||||
"hardware_baseline": {
|
||||
"gpu": "NVIDIA GeForce RTX 4090",
|
||||
"active_profiles_per_worker": 1,
|
||||
"sources_per_worker": 1,
|
||||
"gpu_inference_policy": "serialized",
|
||||
"other_gpu_workloads_allowed": false
|
||||
},
|
||||
"cpu_execution_policy": {
|
||||
"OPENBLAS_NUM_THREADS": 1,
|
||||
"OMP_NUM_THREADS": 1,
|
||||
"MKL_NUM_THREADS": 1,
|
||||
"reason": "bounded-single-thread-numeric-libraries-before-whole-graph-qualification"
|
||||
},
|
||||
"operating_envelope": {
|
||||
"status": "bounded-latency-reference-not-full-qualification",
|
||||
"checker": "k1link.perception.worker_operating_envelope.operating_envelope_failures",
|
||||
"reference_conditions": {
|
||||
"envelope_id": "worker006-4090-610.47-stock-clock-reference/v1",
|
||||
"gpu_name": "NVIDIA GeForce RTX 4090",
|
||||
"driver_version": "610.47",
|
||||
"cpu_limit_millicores": 8000,
|
||||
"memory_limit_mib": 8192,
|
||||
"minimum_sm_clock_mhz": 2610,
|
||||
"minimum_memory_clock_mhz": 10251,
|
||||
"maximum_snapshot_age_ms": 1000
|
||||
},
|
||||
"check_phase": "post-warmup-before-timed-window-plus-runtime-monitoring",
|
||||
"container_may_set_host_clocks": false,
|
||||
"persistent_host_clock_policy_installed": false,
|
||||
"readiness_is_realtime_qualification": false,
|
||||
"unknown_ownership_or_telemetry_is_pass": false,
|
||||
"failed_envelope_allows_labelled_experiment": true
|
||||
},
|
||||
"output_freshness_contract": {
|
||||
"schema_version": "missioncore.perception-scene-freshness/v1",
|
||||
"required_layers": ["segmentation", "objects", "geometry", "motion", "costmap", "policy"],
|
||||
"retain_original_layer_identity": true,
|
||||
"propagate_required_input_age": true,
|
||||
"recheck_at_publication_receipt_and_use": true,
|
||||
"clock_uncertainty_adds_to_age": true,
|
||||
"unavailable_or_stale_required_layer": "no-go-advisory-only",
|
||||
"history_can_independently_authorize_terrain": false,
|
||||
"transport_streamstart_and_lease_fencing_required_separately": true
|
||||
},
|
||||
"input_contract": {
|
||||
"schema_version": "missioncore.perception-stream-start/v1",
|
||||
"channels": ["camera", "point-cloud", "pose"],
|
||||
"camera": {
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"projection": "KB4",
|
||||
"decoded_pixel_format": "bgr8",
|
||||
"calibration_sha256": "05f3ad9b38b3a4fc95388a8ec83da83c745e217709e51787b3d5aad0969f6fa9",
|
||||
"valid_fov_sha256": "a40cee06b7c6f69b6a09a11563dcfd237f3de833b1ccd31459e66692e528ba63"
|
||||
},
|
||||
"point_cloud": {
|
||||
"representation": "vendor-registered-current-increment",
|
||||
"coordinate_frame": "map",
|
||||
"units": "metres",
|
||||
"scalar_type": "float32-little-endian",
|
||||
"raw_fields_retained": true
|
||||
},
|
||||
"pose": {"parent_frame": "map", "child_frame": "sensor"},
|
||||
"clock_mapping_required": true,
|
||||
"source_end_or_duration_required": false,
|
||||
"full_source_transfer_required": false,
|
||||
"full_source_hash_pass_required": false,
|
||||
"full_decode_or_png_cache_required": false,
|
||||
"previous_perception_artifacts_allowed": false
|
||||
},
|
||||
"stream_policy": {
|
||||
"replay_speed": 1.0,
|
||||
"release_clock": "original-source-timestamps",
|
||||
"inference_stride": 1,
|
||||
"sampling_status": "strict-every-frame-initial-baseline-no-silent-multirate",
|
||||
"first_result_requires_eof": false,
|
||||
"lookahead_allowed": false,
|
||||
"reconnect": "new-epoch-drop-old-backlog-reacquire-codec-keyframe",
|
||||
"overflow": "bounded-drop-with-ledger-degraded-and-failed-runtime-gate",
|
||||
"archive_export_on_critical_path": false,
|
||||
"transport_candidate": "grpc-bidirectional-protobuf-binary-payloads",
|
||||
"transport_qualified": false
|
||||
},
|
||||
"components": {
|
||||
"segmentation": {
|
||||
"model": "ddrnet_39-goose-fine-64",
|
||||
"checkpoint_sha256": "b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6",
|
||||
"precision": "existing-fp32-baseline",
|
||||
"preprocessing": "center-600-square-to-512-rgb-tensor-0-1",
|
||||
"outside_crop": "undefined-not-free",
|
||||
"class_count": 64,
|
||||
"runtime": "python-3.9-pytorch-1.13.1-cu117-super-gradients-3.2.0"
|
||||
},
|
||||
"objects": {
|
||||
"model": "rf_detr_large_native_kb4",
|
||||
"checkpoint_sha256": "0f4e20e19a99c0f8a62b5685f57f6c8b5c371c59081feda6752a0561a79ccf38",
|
||||
"engine_sha256": "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695",
|
||||
"runtime": "tensorrt-11-fp16-native-uint8",
|
||||
"minimum_score": 0.25,
|
||||
"minimum_box_area_pixels": 64,
|
||||
"maximum_box_area_fraction": 0.5,
|
||||
"minimum_valid_fov_fraction": 0.5,
|
||||
"require_center_inside_valid_fov": true,
|
||||
"required_existing_classes": ["person", "cat", "dog"],
|
||||
"fine_static_object_classifier_required": false,
|
||||
"filter_quality_status": "existing-unchanged-near-large-and-small-object-risk-unqualified"
|
||||
},
|
||||
"geometry": {
|
||||
"online_surface": "K1LocalSurfaceShadowEstimator",
|
||||
"prepared_local_surface_allowed": false,
|
||||
"generic_static_class": "static.unknown",
|
||||
"detected_object_range": "median-camera-z-of-owned-current-points/v1",
|
||||
"geometry_only_range": "nearest-euclidean-sensor-distance/v1",
|
||||
"vehicle_clearance_claim": false,
|
||||
"missing_support": "unavailable-not-zero-not-free"
|
||||
},
|
||||
"motion": {
|
||||
"reuse": "BoundedSpatialTemporalProvider+ClassIndependentMotionEstimator",
|
||||
"semantic_class_implies_motion": false
|
||||
},
|
||||
"costmap": {
|
||||
"algorithm": "TRAVEL-TGS",
|
||||
"revision": "95dc2fbd66a343efd9060c45a5711b6307a950a4",
|
||||
"execution": "cpu",
|
||||
"rolling_history_seconds": 1.0,
|
||||
"states": ["UNOBSERVED", "GROUND_SUPPORT", "NONGROUND_OCCUPIED", "UNKNOWN_REJECTED"],
|
||||
"policy_input": true
|
||||
},
|
||||
"policy": {
|
||||
"scope": "rural-prototype-advisory-only",
|
||||
"hard_surface": "ALLOW-candidate-subject-to-geometry-and-freshness",
|
||||
"fine_road_sidewalk_bikeway_distinction_required": false,
|
||||
"effective_material_rules_required": true,
|
||||
"unknown_or_occupied_overrides_material_allowance": true,
|
||||
"sensor_to_vehicle_mount": "simulation-only-until-measured"
|
||||
}
|
||||
},
|
||||
"reference_files": [
|
||||
{"path": "config/perception/rf-detr-large-native-kb4-risk-shadow-v0.json", "sha256": "dbf4da5dbad6c3c22b1280b46ffcad81719bd183c81c263a4859847d829019b6"},
|
||||
{"path": "config/perception/lab-v1-goose-vegetation-benchmark-v1.json", "sha256": "96a427a8baae387b827ec9c0bf7ca42e3fb9114b8fa9a8671bbc9d10877670b9"},
|
||||
{"path": "config/perception/lab-v1-vegetation-provider-label-map-v1.json", "sha256": "f2b69046b6a740fd9532d2d88e7fabae7c20fb662f783c9502adc9026406f352"},
|
||||
{"path": "config/perception/lab-v1-vegetation-mission-policy-v1.json", "sha256": "b75c4ac841d7b4bcc57f7a9c8417ca2317d8ecfa499e72a9af8a8591a2ec0d35"},
|
||||
{"path": "config/perception/m49-tgs-full-shadow-v1.json", "sha256": "c2e07010aaee78259d36c057962d6bfb885349251ff7356d867e5813e632881c"},
|
||||
{"path": "config/perception/m4-geometry-association-v1.json", "sha256": "cc666c9389a5e221957faddec89584709b66918d14abaf646f1832e001421999"}
|
||||
],
|
||||
"reference_usage": "reuse-model-parameters-and-algorithms-only-not-recording-ids-or-precomputed-results",
|
||||
"realtime_contract": {
|
||||
"schema_version": "missioncore.perception-realtime-contract/v1",
|
||||
"budget_status": "engineering-targets-worker-pilot-measured-not-whole-path-qualified-not-safety-approved",
|
||||
"budgets": {
|
||||
"maximum_start_metadata_bytes": 65536,
|
||||
"maximum_chunk_bytes": 1048576,
|
||||
"maximum_inflight_bytes": 16777216,
|
||||
"maximum_pending_camera_frames": 2,
|
||||
"maximum_codec_preroll_ms": 500.0,
|
||||
"maximum_pose_age_ms": 100.0,
|
||||
"maximum_clock_uncertainty_ms": 5.0,
|
||||
"maximum_release_lag_ms": 25.0,
|
||||
"maximum_output_age_p95_ms": 125.0,
|
||||
"maximum_output_age_p99_ms": 125.0,
|
||||
"maximum_layer_age_ms": 250.0,
|
||||
"maximum_first_result_ms": 1000.0,
|
||||
"maximum_warmup_seconds": 120.0,
|
||||
"maximum_stop_seconds": 5.0,
|
||||
"maximum_vram_mib": 22000,
|
||||
"maximum_rss_mib": 8192,
|
||||
"maximum_backlog_growth_ms": 25.0
|
||||
},
|
||||
"budget_origin": "125ms-historical-envelope-retained-as-target-now-applied-to-whole-path; other-limits-explicit-initial-engineering-guards",
|
||||
"capacity_drop_count_max": 0,
|
||||
"unaccounted_input_count_max": 0,
|
||||
"full_input_transfer_before_first_result_allowed": false,
|
||||
"independent_quality_and_physical_live_status": "pending"
|
||||
},
|
||||
"authority": {
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false,
|
||||
"production_accepted": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"schema_version": "missioncore.lab-v1-eomt-ddrnet-portable-profile/v2",
|
||||
"profile_id": "lab-v1-eomt-ddrnet-portable-v2",
|
||||
"lab_id": "LAB-V1",
|
||||
"worker_id": "worker-006",
|
||||
"source_binding": {
|
||||
"mode": "admitted-k1-recording",
|
||||
"camera_source_id": "sensor.camera.right",
|
||||
"camera_timeline": "dynamic",
|
||||
"filesystem_paths": "executor-resolved",
|
||||
"expected_width": 800,
|
||||
"expected_height": 600,
|
||||
"expected_frame_count": "source-derived",
|
||||
"frame_indices": "source-derived-representative",
|
||||
"crop_contract": "center-600-square-to-512; outside-crop-is-undefined"
|
||||
},
|
||||
"effective_config_contract": {
|
||||
"schema_version": "missioncore.lab-v1-goose-vegetation-benchmark/v1",
|
||||
"dynamic_field": "ravnoves",
|
||||
"source_id": "sealed-source-derived",
|
||||
"source_sha256": "sealed-camera-input-derived",
|
||||
"base_m4_result_id": null
|
||||
},
|
||||
"runtime": {
|
||||
"super_gradients_version": "3.2.0",
|
||||
"super_gradients_revision": "54d062ecb1081944a672ce447cf3e96a36708ff9",
|
||||
"python_version": "3.9",
|
||||
"numpy_version": "1.23.0",
|
||||
"cmake_version": "3.31.6",
|
||||
"onnxsim_version": "0.4.36",
|
||||
"opencv_python_version": "4.8.1.78",
|
||||
"pytorch_version": "1.13.1",
|
||||
"torchvision_version": "0.14.1",
|
||||
"pytorch_cuda_version": "11.7",
|
||||
"container_base": "nvidia/cuda:12.8.1-cudnn-devel-ubuntu22.04@sha256:ad6d59a3bbf3e82c1c849c9ac09cfc2a3e0bbb8655042fd899be6681b3fe2a85",
|
||||
"miniconda_installer": "Miniconda3-py39_24.11.1-0-Linux-x86_64.sh",
|
||||
"miniconda_installer_sha256": "3ea8373098d72140e08aac9217822b047ec094eb457e7f73945af7c6f68bf6f5"
|
||||
},
|
||||
"dataset": {
|
||||
"dataset_id": "goose-2d-validation-visible-rgb",
|
||||
"relative_root": "goose-2d/validation",
|
||||
"mapping_relative_path": "goose_label_mapping.csv",
|
||||
"mapping_sha256": "88ae319ba5a3877dd3ae0773f693a6a5fdc283934140de9dfaff029108aefd7f",
|
||||
"image_glob": "images/val/**/*_windshield_vis.png",
|
||||
"expected_pair_count": 962,
|
||||
"input_size": [
|
||||
512,
|
||||
512
|
||||
],
|
||||
"preprocessing": [
|
||||
"center-square-crop",
|
||||
"nearest-neighbor-resize",
|
||||
"rgb-to-tensor-0-1"
|
||||
]
|
||||
},
|
||||
"candidates": {
|
||||
"ddrnet": {
|
||||
"candidate_id": "goose-ddrnet-class-512",
|
||||
"model_names": [
|
||||
"ddrnet_39"
|
||||
],
|
||||
"checkpoint_relative_path": "models/goose/ddrnet_class_512.pth",
|
||||
"checkpoint_size_bytes": 259419077,
|
||||
"checkpoint_sha256": "b99c2838051bcd7b092fd3970aa62a77d5c0bbb809c9b9afb2ff4b0ebdaa4ee6",
|
||||
"published_validation_miou_percent": 46.53
|
||||
}
|
||||
},
|
||||
"vegetation_class_names": [
|
||||
"leaves",
|
||||
"forest",
|
||||
"bush",
|
||||
"moss",
|
||||
"tree_crown",
|
||||
"tree_trunk",
|
||||
"crops",
|
||||
"low_grass",
|
||||
"high_grass",
|
||||
"scenery_vegetation",
|
||||
"hedge",
|
||||
"tree_root"
|
||||
],
|
||||
"visual_case_contract": {
|
||||
"selection_basis": "ground-truth-class-support-only",
|
||||
"case_count": 12,
|
||||
"minimum_focus_pixels": 2048,
|
||||
"strata": [
|
||||
{
|
||||
"class_name": "high_grass",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"class_name": "low_grass",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"class_name": "bush",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"class_name": "tree_trunk",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"class_name": "tree_crown",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"class_name": "hedge",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"class_name": "forest",
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"class_name": "crops",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"error_overlay": {
|
||||
"correct_material_rgba": [
|
||||
34,
|
||||
197,
|
||||
94,
|
||||
72
|
||||
],
|
||||
"missed_vegetation_rgba": [
|
||||
239,
|
||||
68,
|
||||
68,
|
||||
220
|
||||
],
|
||||
"false_vegetation_rgba": [
|
||||
245,
|
||||
158,
|
||||
11,
|
||||
220
|
||||
],
|
||||
"wrong_vegetation_material_rgba": [
|
||||
168,
|
||||
85,
|
||||
247,
|
||||
220
|
||||
]
|
||||
}
|
||||
},
|
||||
"policy_action_colors": {
|
||||
"ALLOW": "#22c55e",
|
||||
"HIGH_COST": "#f59e0b",
|
||||
"NO_GO": "#ef4444"
|
||||
},
|
||||
"invariants": {
|
||||
"one_heavy_candidate_at_a_time": true,
|
||||
"raw_fisheye_is_immutable": true,
|
||||
"outside_center_crop_is_free": false,
|
||||
"missing_or_unknown_is_free": false,
|
||||
"camera_semantics_can_clear_rigid_geometry": false,
|
||||
"navigation_authority": false,
|
||||
"actuation_authority": false,
|
||||
"canonical_triton_mutation_allowed": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"schema_version": "missioncore.m49-tgs-portable-profile/v2",
|
||||
"profile_id": "m49-tgs-portable-v2",
|
||||
"source_binding": {
|
||||
"mode": "admitted-k1-recording",
|
||||
"camera_timeline": "dynamic",
|
||||
"lidar_replay": "dynamic",
|
||||
"trajectory": "dynamic",
|
||||
"frame_counts": "source-derived",
|
||||
"filesystem_paths": "executor-resolved"
|
||||
},
|
||||
"alignment": {
|
||||
"timeline": "recorded-camera-host-arrival",
|
||||
"lidar_selection": "latest-not-newer-than-camera-frame",
|
||||
"maximum_lidar_age_seconds": 1.0,
|
||||
"future_frames_allowed": false
|
||||
},
|
||||
"tgs": {
|
||||
"max_range_m": 80.0,
|
||||
"min_range_m": 1.0,
|
||||
"resolution_m": 8.0,
|
||||
"num_iterations": 3,
|
||||
"num_lowest_representative_points": 5,
|
||||
"minimum_points": 10,
|
||||
"seed_threshold_m": 0.5,
|
||||
"distance_threshold_m": 0.125,
|
||||
"outlier_threshold_m": 0.3,
|
||||
"normal_threshold": 0.94,
|
||||
"weight_threshold": 200.0,
|
||||
"lcc_normal_similarity": 0.03,
|
||||
"lcc_planar_distance_m": 0.1,
|
||||
"obstacle_height_m": 1.0,
|
||||
"refine_mode": true
|
||||
},
|
||||
"rolling_profile": {
|
||||
"history_seconds": 1.0,
|
||||
"local_radius_m": 12.0,
|
||||
"missing_lidar_policy": "all-cells-unobserved"
|
||||
},
|
||||
"costmap": {
|
||||
"coordinate_frame": "map-gravity-local",
|
||||
"cell_size_m": 0.45,
|
||||
"radius_m": 12.0,
|
||||
"state_priority": [
|
||||
"NONGROUND_OCCUPIED",
|
||||
"UNKNOWN_REJECTED",
|
||||
"GROUND_SUPPORT",
|
||||
"UNOBSERVED"
|
||||
]
|
||||
},
|
||||
"invariants": {
|
||||
"aos_allowed": false,
|
||||
"gpu_allowed": false,
|
||||
"missing_support_means_free": false,
|
||||
"missing_lidar_means_unobserved": true,
|
||||
"unobserved_cells_are_emitted": true,
|
||||
"camera_projection_is_authoritative": false,
|
||||
"navigation_or_actuation_allowed": false
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,23 @@ The normalizer exposes a read-only normalized telemetry adapter on
|
||||
Timescale credentials, and Timescale is not published on a host port.
|
||||
The normalizer uses a separate `missioncore_normalizer` database role with only
|
||||
`SELECT`, `INSERT`, `UPDATE`, and bounded retention `DELETE` on the telemetry
|
||||
hypertable. Raw telemetry older than 30 days is removed at most once per day by the
|
||||
normalizer. This stays compatible with the Apache-licensed Timescale image without
|
||||
hypertable. Raw telemetry older than 30 days is removed in batches of at most
|
||||
1000 rows per minute, on a separate connection/thread (2s statement / 250ms lock
|
||||
deadline). Retention never runs inside the MQTT callback. This stays compatible
|
||||
with the Apache-licensed Timescale image without
|
||||
depending on the Timescale License retention scheduler.
|
||||
|
||||
The 1 GiB database container explicitly sets 128 MiB shared buffers and 64 MiB
|
||||
maintenance memory. The HA image's automatic tuning sees the Docker VM instead
|
||||
of this container limit; do not inherit its multi-GiB defaults.
|
||||
|
||||
For a memory-constrained local repair, `compose.source.yaml` mounts the reviewed
|
||||
normalizer source read-only into the **existing** image. Set
|
||||
`MISSIONCORE_NORMALIZER_SOURCE` to the absolute `normalizer.py` path, pass both
|
||||
Compose files and the existing private `--env-file` / `--project-directory`, and
|
||||
use `up -d --no-deps --no-build normalizer`. This is an explicit local source
|
||||
override, not a rebuilt portable image. Normal restart/reboot retains the mount.
|
||||
|
||||
The stack is one deployment contour, not one multi-process container. Keeping broker,
|
||||
normalizer and database in separate containers preserves independent health checks,
|
||||
least-privilege boundaries and rollback while Compose provides one operator lifecycle:
|
||||
@@ -136,6 +149,24 @@ globally unique because Mosquitto ACL ownership is username-based.
|
||||
|
||||
## Worker agent
|
||||
|
||||
MQTT outputs use `startup_error_behavior="retry"`, with a 2000-metric buffer and
|
||||
the configured flush interval. A missing broker at agent startup must not end
|
||||
the service. See [Telegraf's startup policy](https://docs.influxdata.com/telegraf/v1/configuration/plugin-options/).
|
||||
If Docker lost a published listener while the saved LAN address is unchanged,
|
||||
the explicit broker Apply action reconciles that listener; a telemetry GET never
|
||||
restarts infrastructure. Node connectivity does not prove a profile is ready.
|
||||
|
||||
The streaming profile can export one bounded (8 KiB) current observation using
|
||||
`--telemetry-snapshot /telemetry/current.json`, with `/telemetry` mounted to
|
||||
`C:\ProgramData\NDC\MissionCore\telemetry-agent\perception` on Worker. The existing
|
||||
collector reads it with a 5s freshness limit and forwards it over the same MQTT
|
||||
pipeline topic. It overrides the legacy service only when explicitly present;
|
||||
an expired/broken file reports unavailable rather than relabeling a legacy model.
|
||||
The publisher runs independently from heartbeat/inference and never renews a
|
||||
lease, grants GPU ownership, or qualifies real-time. Queue memory includes active
|
||||
input and decoder scratch. Stage timings are unavailable until actually measured.
|
||||
No camera, point cloud, model result, MQTT secret, or motor command goes here.
|
||||
|
||||
Worker 006 uses the official Windows Telegraf distribution as the host service
|
||||
`NDC Mission Core Telemetry Agent`. Install or update it with:
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Local, read-only source override for repairing the existing normalizer without
|
||||
# rebuilding an image on a memory-constrained operator machine. No extra service.
|
||||
services:
|
||||
normalizer:
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${MISSIONCORE_NORMALIZER_SOURCE:?Set the absolute normalizer.py path}
|
||||
target: /app/normalizer.py
|
||||
read_only: true
|
||||
@@ -49,6 +49,8 @@ services:
|
||||
|
||||
timescale:
|
||||
image: timescale/timescaledb-ha:pg16.14-ts2.28.2-all-oss
|
||||
# The HA image auto-tunes for the Docker VM, not this 1 GiB container.
|
||||
command: ["postgres", "-c", "shared_buffers=128MB", "-c", "work_mem=4MB", "-c", "maintenance_work_mem=64MB", "-c", "effective_cache_size=512MB", "-c", "max_parallel_workers_per_gather=0", "-c", "max_connections=32"]
|
||||
container_name: ndc-mission-core-telemetry-timescaledb
|
||||
restart: unless-stopped
|
||||
security_opt:
|
||||
|
||||
@@ -31,7 +31,8 @@ SAFE_IDENTIFIER: Final = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
|
||||
MAX_PAYLOAD_BYTES: Final = 1024 * 1024
|
||||
MAX_TAGS: Final = 256
|
||||
MAX_SERIES_KEY_BYTES: Final = 4096
|
||||
RETENTION_INTERVAL_SECONDS: Final = 86_400
|
||||
RETENTION_INTERVAL_SECONDS: Final = 60
|
||||
RETENTION_BATCH_ROWS: Final = 1000
|
||||
ALLOWED_TELEMETRY_TAGS: Final = frozenset(
|
||||
{
|
||||
"agent_id",
|
||||
@@ -434,6 +435,39 @@ def _start_query_server(
|
||||
return server
|
||||
|
||||
|
||||
def _retention_batch(connection: Any) -> int:
|
||||
"""Preserve the existing 30-day policy without an unbounded startup DELETE."""
|
||||
with connection.transaction(), connection.cursor() as cursor:
|
||||
cursor.execute("SET LOCAL statement_timeout = '2s'")
|
||||
cursor.execute("SET LOCAL lock_timeout = '250ms'")
|
||||
cursor.execute(
|
||||
"""
|
||||
WITH expired AS (
|
||||
SELECT tableoid, ctid FROM contour_telemetry_samples
|
||||
WHERE observed_at < CURRENT_TIMESTAMP - INTERVAL '30 days'
|
||||
ORDER BY observed_at LIMIT %s
|
||||
)
|
||||
DELETE FROM contour_telemetry_samples AS samples USING expired
|
||||
WHERE samples.tableoid = expired.tableoid AND samples.ctid = expired.ctid
|
||||
""",
|
||||
(RETENTION_BATCH_ROWS,),
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def _retention_loop(dsn: str) -> None:
|
||||
import psycopg # type: ignore[import-not-found]
|
||||
|
||||
while True:
|
||||
# Neither a retention timeout nor database recovery blocks MQTT I/O.
|
||||
time.sleep(RETENTION_INTERVAL_SECONDS)
|
||||
try:
|
||||
with psycopg.connect(dsn, connect_timeout=3) as connection:
|
||||
_retention_batch(connection)
|
||||
except psycopg.Error:
|
||||
print("telemetry retention deferred", flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import paho.mqtt.client as mqtt
|
||||
import psycopg # type: ignore[import-not-found]
|
||||
@@ -447,7 +481,9 @@ def main() -> None:
|
||||
password = _required("MISSIONCORE_MQTT_PASSWORD")
|
||||
runtime_health = TelemetryRuntimeHealth()
|
||||
connection = psycopg.connect(dsn, autocommit=True)
|
||||
last_retention_monotonic = 0.0
|
||||
threading.Thread(
|
||||
target=_retention_loop, args=(dsn,), name="telemetry-retention", daemon=True
|
||||
).start()
|
||||
runtime_health.set_database(True)
|
||||
_start_query_server(dsn, runtime_health)
|
||||
client = mqtt.Client(
|
||||
@@ -480,7 +516,7 @@ def main() -> None:
|
||||
runtime_health.set_mqtt(False, f"MQTT disconnected: {reason_code}")
|
||||
|
||||
def on_message(_client: Any, _userdata: object, message: Any) -> None:
|
||||
nonlocal connection, last_retention_monotonic
|
||||
nonlocal connection
|
||||
try:
|
||||
row = _normalize(message.topic, message.payload)
|
||||
except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
@@ -489,19 +525,6 @@ def main() -> None:
|
||||
for attempt in range(2):
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
now_monotonic = time.monotonic()
|
||||
if (
|
||||
now_monotonic - last_retention_monotonic
|
||||
>= RETENTION_INTERVAL_SECONDS
|
||||
):
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM contour_telemetry_samples
|
||||
WHERE observed_at
|
||||
< CURRENT_TIMESTAMP - INTERVAL '30 days'
|
||||
"""
|
||||
)
|
||||
last_retention_monotonic = now_monotonic
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO contour_telemetry_samples (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ContainerName = "ndc-mission-core-perception-worker",
|
||||
[string]$HealthUrl = "http://127.0.0.1:18020/health"
|
||||
[string]$HealthUrl = "http://127.0.0.1:18020/health",
|
||||
[string]$RuntimeSnapshot = "C:\ProgramData\NDC\MissionCore\telemetry-agent\perception\current.json"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -18,6 +19,46 @@ $stageIds = @(
|
||||
)
|
||||
$health = $null
|
||||
$collectorState = "unavailable"
|
||||
# A fresh full-profile export supersedes the historical service. Presence of an
|
||||
# expired/broken export is NOT permission to relabel a legacy model as current.
|
||||
if (Test-Path -LiteralPath $RuntimeSnapshot -PathType Leaf) {
|
||||
$runtime = $null
|
||||
try {
|
||||
$item = Get-Item -LiteralPath $RuntimeSnapshot
|
||||
$age = ([DateTime]::UtcNow - $item.LastWriteTimeUtc).TotalSeconds
|
||||
if ($item.Length -le 8192 -and $age -ge -1 -and $age -le 5) {
|
||||
$stream = [IO.File]::Open($RuntimeSnapshot, 'Open', 'Read', 'ReadWrite')
|
||||
try {
|
||||
$bytes = New-Object byte[] 8193
|
||||
$count = $stream.Read($bytes, 0, $bytes.Length)
|
||||
if ($count -le 8192) {
|
||||
$runtime = [Text.Encoding]::UTF8.GetString($bytes, 0, $count) | ConvertFrom-Json
|
||||
}
|
||||
} finally { $stream.Dispose() }
|
||||
if ($runtime.schema_version -ne 'missioncore.perception-runtime-observation/v1') {
|
||||
$runtime = $null
|
||||
}
|
||||
}
|
||||
} catch { $runtime = $null }
|
||||
$samples = foreach ($stageId in $stageIds) {
|
||||
$sample = [ordered]@{
|
||||
stage_id = $stageId
|
||||
stage_state = 'unavailable'
|
||||
service_state = 'unavailable'
|
||||
collector_state = 'unavailable'
|
||||
}
|
||||
if ($runtime) {
|
||||
$sample.collector_state = 'live'
|
||||
foreach ($name in @('service_state', 'profile_name', 'active_request_id',
|
||||
'input_state', 'wait_reason', 'live_children', 'input_pauses', 'pending_bundles', 'buffer_bytes')) {
|
||||
$sample[$name] = $runtime.$name
|
||||
}
|
||||
}
|
||||
[pscustomobject]$sample
|
||||
}
|
||||
@($samples) | ConvertTo-Json -Compress -Depth 4
|
||||
exit 0
|
||||
}
|
||||
try {
|
||||
$healthJson = docker exec $ContainerName python3 -c `
|
||||
"import urllib.request;print(urllib.request.urlopen('$HealthUrl',timeout=2).read().decode())" `
|
||||
@@ -68,13 +109,13 @@ $completedRuns = if ($health -and $null -ne $health.completed_runs) {
|
||||
[int64]$health.completed_runs
|
||||
}
|
||||
else {
|
||||
[int64]0
|
||||
[int64]-1
|
||||
}
|
||||
$failedRuns = if ($health -and $null -ne $health.failed_runs) {
|
||||
[int64]$health.failed_runs
|
||||
}
|
||||
else {
|
||||
[int64]0
|
||||
[int64]-1
|
||||
}
|
||||
$modelLoadSeconds = if ($health -and $null -ne $health.model_load_seconds) {
|
||||
[double]$health.model_load_seconds
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
[agent]
|
||||
interval = "${MISSIONCORE_TELEMETRY_INTERVAL}"
|
||||
flush_interval = "${MISSIONCORE_TELEMETRY_INTERVAL}"
|
||||
metric_batch_size = 200
|
||||
metric_buffer_limit = 2000
|
||||
round_interval = true
|
||||
omit_hostname = false
|
||||
|
||||
@@ -26,6 +29,7 @@
|
||||
total = true
|
||||
|
||||
[[outputs.mqtt]]
|
||||
startup_error_behavior = "retry"
|
||||
servers = ["tcp://${MISSIONCORE_MQTT_HOST}:${MISSIONCORE_MQTT_PORT}"]
|
||||
topic = "mission-core/v1/contours/${MISSIONCORE_CONTOUR_ID}/agents/${MISSIONCORE_AGENT_ID}/host"
|
||||
username = "${MISSIONCORE_MQTT_USERNAME}"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
[agent]
|
||||
interval = "${MISSIONCORE_TELEMETRY_INTERVAL}"
|
||||
flush_interval = "${MISSIONCORE_TELEMETRY_INTERVAL}"
|
||||
metric_batch_size = 200
|
||||
metric_buffer_limit = 2000
|
||||
round_interval = true
|
||||
omit_hostname = false
|
||||
|
||||
@@ -30,6 +33,7 @@
|
||||
[[inputs.docker]]
|
||||
endpoint = "npipe:////./pipe/docker_engine"
|
||||
container_name_include = []
|
||||
container_state_include = ["running", "exited", "restarting", "paused"]
|
||||
|
||||
[[inputs.http_response]]
|
||||
urls = ["http://127.0.0.1:8000/v2/health/ready"]
|
||||
@@ -59,6 +63,9 @@
|
||||
"current_stage",
|
||||
"active_request_id",
|
||||
"collector_state",
|
||||
"profile_name",
|
||||
"input_state",
|
||||
"wait_reason",
|
||||
]
|
||||
|
||||
[[inputs.tail]]
|
||||
@@ -73,6 +80,7 @@
|
||||
data_type = "string"
|
||||
|
||||
[[outputs.mqtt]]
|
||||
startup_error_behavior = "retry"
|
||||
servers = ["tcp://${MISSIONCORE_MQTT_HOST}:${MISSIONCORE_MQTT_PORT}"]
|
||||
topic = "mission-core/v1/contours/${MISSIONCORE_CONTOUR_ID}/agents/${MISSIONCORE_AGENT_ID}/host"
|
||||
username = "${MISSIONCORE_MQTT_USERNAME}"
|
||||
@@ -83,6 +91,7 @@
|
||||
namedrop = ["missioncore_pipeline", "missioncore_pipeline_event"]
|
||||
|
||||
[[outputs.mqtt]]
|
||||
startup_error_behavior = "retry"
|
||||
servers = ["tcp://${MISSIONCORE_MQTT_HOST}:${MISSIONCORE_MQTT_PORT}"]
|
||||
topic = "mission-core/v1/contours/${MISSIONCORE_CONTOUR_ID}/agents/${MISSIONCORE_AGENT_ID}/pipeline"
|
||||
username = "${MISSIONCORE_MQTT_USERNAME}"
|
||||
|
||||
@@ -172,7 +172,7 @@ On the first live or adapter file-replay session, `RerunBridge`:
|
||||
|
||||
At every session start it resets the trajectory, current point count, metrics,
|
||||
blueprint and session-local visible time, then feeds the new source through the
|
||||
same recording. This process-wide lifecycle is intentional: the Rerun 0.34.1
|
||||
same recording. This process-wide lifecycle is intentional: the upstream Rerun 0.36.3
|
||||
browser receiver can remain connected after canvas teardown, so restarting the
|
||||
native listener on the same port is not a reliable session boundary.
|
||||
|
||||
|
||||
@@ -185,6 +185,15 @@ The evidence slot has two admitted renderers:
|
||||
- `diagnostic-model` — a specialized visual result such as the LAB E28 L2.6
|
||||
surface/timeline/review viewer.
|
||||
|
||||
ADR 0045 amends the full-session `recorded-replay` transport. A canonical
|
||||
recorded LAB uses one unmodified upstream Rerun viewer, recording identity and
|
||||
timeline for camera, semantic and spatial evidence. The source RRD owns pose,
|
||||
point cloud and trajectory; a digest-bound RRD sidecar may add only immutable
|
||||
derived entities which are absent from the source. Model or layer selection is
|
||||
a blueprint/profile change, not a new player or renderer. Missing full-route
|
||||
TGS, semantic 3D, boxes or cuboids remain disabled instead of being inferred
|
||||
from sparse review artifacts.
|
||||
|
||||
Recorded camera clips used for review are a frozen sub-contract of the admitted
|
||||
viewer, `missioncore.laboratory-recorded-clip-viewer/v1`, implemented by
|
||||
`LaboratoryRecordedClipPlayer`. It owns the generation-bound fMP4 manifest,
|
||||
@@ -197,6 +206,10 @@ Forward frame progression may roll an already-buffered segment target; a
|
||||
backward seek or clip loop must perform an explicit decoder seek and remain
|
||||
decoder-ready without exposing a per-frame loader.
|
||||
|
||||
That clip contract remains available to historical bounded clip instruments.
|
||||
It is legacy comparison transport for a migrated full-session LAB and must not
|
||||
be mounted, prefetched or run in parallel with the canonical Rerun profile.
|
||||
|
||||
When recorded camera and frame-indexed spatial evidence are both required for
|
||||
one review question, the shared player presents them simultaneously on the same
|
||||
media clock. The camera remains the clock owner; a bounded experiment-neutral
|
||||
@@ -262,6 +275,96 @@ execution canaries. Once they prove one automatic run-to-evidence path, the LAB
|
||||
canonicalization slice closes and remaining historical work stays `legacy` unless
|
||||
an integrity or product need justifies a targeted migration.
|
||||
|
||||
## Observatory durable recorded queue and Worker dispatch
|
||||
|
||||
Selecting a source and a laboratory setup in Observatory is not itself a run.
|
||||
The exact legacy submission contains only `source_session_id`, `setup_id` and an
|
||||
idempotency key. A portable submission additionally returns the server-owned
|
||||
`definition_sha256` and one content-bound `check_sha256` obtained from preflight;
|
||||
both must be echoed unchanged during submit. Neither request can supply commands,
|
||||
executable text, filesystem paths, container images, model identities, resource
|
||||
limits or priority. The server resolves the allowlisted identities and seals all
|
||||
executable identity into one durable record:
|
||||
|
||||
- the current source-catalog snapshot captured at admission, plus immutable source
|
||||
bundle and source-capability-manifest SHA-256 identities;
|
||||
- source-adapter id, version and SHA-256;
|
||||
- setup RunDefinition id, version and SHA-256;
|
||||
- executor release and image SHA-256 identities;
|
||||
- learned-model manifest and resource-profile identities;
|
||||
- checkpoint policy and the server-owned priority class.
|
||||
|
||||
An exact retry returns the existing job. Reusing an idempotency key for another
|
||||
identity fails closed. The ordinary lifecycle advances through `accepted`,
|
||||
`queued`, `claimed` and `running`, then terminates as `succeeded` or `failed`.
|
||||
`paused`, `preemption-pending` and `reconciliation-required` are explicit safety
|
||||
branches rather than hidden retries. The durable queue serializes one recorded
|
||||
compute owner and does not equate a queued receipt with a Worker execution
|
||||
receipt.
|
||||
|
||||
The only pair currently admitted to the durable queue is the exact `RAVNOVES00`
|
||||
source and `M4.9T5 · TRAVEL TGS · CPU-only, без ML` RunDefinition. It seals an
|
||||
explicitly empty learned-model list because TGS is an algorithmic CPU pipeline,
|
||||
not an unknown model dependency. The binding pins the exact source pack rather
|
||||
than a volatile whole-catalog digest; the server captures and seals the current
|
||||
catalog snapshot into each admitted job.
|
||||
|
||||
Two source-independent definitions are projected by the portable catalog:
|
||||
|
||||
- `LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39` seals the exact
|
||||
model/component/resource identities and the
|
||||
`missioncore.recorded-eomt-ddrnet-review/v2` result contract;
|
||||
- `M4.9T5 · TRAVEL TGS · CPU-only, без ML` v2 seals an explicitly empty model
|
||||
manifest, dynamic source-derived frame counts, causal TGS invariants and the
|
||||
`missioncore.recorded-tgs-costmap-review/v2` result contract.
|
||||
|
||||
Neither portable definition contains a source Session id, label, fixed frame
|
||||
count or filesystem path. Compatibility is derived independently for every
|
||||
setup from the selected Session's real K1 capabilities. A compatible source may
|
||||
therefore report capability `pass` while that setup's executor remains blocked.
|
||||
Blocked definitions stay projectable and do not prevent an unrelated ready
|
||||
definition from entering the durable queue allowlist.
|
||||
|
||||
The server now owns a definition-SHA/check-SHA fenced portable check/submit
|
||||
boundary. Submission still fails closed unless that exact definition has a
|
||||
sealed `ready` executor release and image and is present in the durable queue.
|
||||
Both repository portable definitions currently declare `not-installed`, so
|
||||
their preflight remains blocked; no release or image hash is fabricated. The
|
||||
generic v2 assemblers, verified publisher and Worker transport exist as a
|
||||
dormant fail-closed implementation, but exact executor installation, central
|
||||
storage, authentication, tunnel acceptance and end-to-end canaries remain
|
||||
pending. The old `missioncore.lab-v1-vegetation-shadow/v1` result and the exact
|
||||
RAVNOVES00 M4.9 result remain only in their existing immutable catalogs;
|
||||
neither is reclassified as a result of a portable definition.
|
||||
|
||||
Recorded work has priority rank `100`. A future live K1 lease has rank `0` and
|
||||
closes new recorded claims while it is pending or active. Cooperative executors
|
||||
yield only at an allowlisted checkpoint. A non-checkpointable monolith must be
|
||||
cancelled by the scheduler, discard all staging output and later restart from
|
||||
zero. Cancellation uses a crash-safe two-phase protocol: Mission Core first
|
||||
persists one stable cancellation intent and activates live work only after an
|
||||
identity-bound receipt proves resource release and staging discard. A retry uses
|
||||
the same cancellation identity. A missing callback or receipt remains durably
|
||||
pending for retry; a conflicting or indeterminate receipt enters reconciliation.
|
||||
Every unresolved form blocks live activation and concurrent ownership.
|
||||
An explicit live terminal trigger requeues paused recorded work.
|
||||
|
||||
ADR 0046 records this decision. Durable queue and identity contracts, the
|
||||
authenticated Worker pull protocol and transport-agnostic Worker agent exist as
|
||||
foundation. The production app hard-disables the Worker router even when a valid
|
||||
credential is present until an expiring claim lease and verified result publisher
|
||||
are accepted. Installation of the exact executors, Worker deployment and wiring
|
||||
from the real K1 lifecycle to live-lease triggers remain pending. Therefore a
|
||||
submitted exact legacy M4.9T5 job may honestly wait in `queued` without implying
|
||||
that Worker 006 can execute it yet. Portable LAB V1 and portable M4.9T5 cannot
|
||||
currently be submitted because their executor states are `not-installed` and
|
||||
the authenticated Worker dispatch gate is closed.
|
||||
|
||||
Worker telemetry remains secondary observation evidence. It does not replace the
|
||||
authoritative queue ledger, result validation or common laboratory receipt. K1
|
||||
acquisition and control, the legacy LAB archive, and Simulation/Gaussian runtimes
|
||||
are outside this boundary and are not restarted, migrated or modified by it.
|
||||
|
||||
## Planning discipline
|
||||
|
||||
A planned LAB number is not a placeholder page. Before execution it must name:
|
||||
|
||||
@@ -149,7 +149,8 @@ second mounted source.
|
||||
- [x] Harden the Control Station application boundary before A3: consume the
|
||||
Design Guideline packages as the only visual platform, isolate the LAB
|
||||
feature and CSS, add typed workspace contracts and enforce one-way imports
|
||||
plus composition-size ratchets.
|
||||
across the application layers. Line-count limits were subsequently removed:
|
||||
they are not an architectural invariant.
|
||||
- [x] Validate the real A2 generation:
|
||||
`e30-review-pack-faec915a771022cceaf4ee62bece698afc8018d09b6b0ac7602157216fbb3686`
|
||||
→
|
||||
|
||||
@@ -160,6 +160,15 @@ history of rejected approaches belong in Ops.
|
||||
|
||||
Primary visual evidence is hosted in one reusable viewer frame.
|
||||
|
||||
For canonical full-session recorded replay, ADR 0045 fixes one native upstream
|
||||
Rerun viewer beneath this frame. The accepted product chrome and switching
|
||||
logic remain Mission Core UI, while Rerun alone owns playback time, video
|
||||
decoding, 2D annotations, point-cloud rendering, trajectory and 3D camera.
|
||||
VIDEO/CAMERA, SOURCE POINTS/LOCAL SLAM/TGS COSTMAP/SEMANTICS and 3D/PLAN select
|
||||
entities, visible time ranges and blueprints in that mounted viewer. They never
|
||||
start independent transports or render loops. A LAB may rename model buttons or
|
||||
add an admitted layer button, but it may not change this switching architecture.
|
||||
|
||||
### Frozen Milestone 4 perception instruments
|
||||
|
||||
Milestone 4 admits exactly two operator instruments inside the shared LAB page
|
||||
@@ -234,6 +243,13 @@ The shared player may retain a rolling target only for the same or a later
|
||||
segment. Rewind and clip-loop transitions must seek backward explicitly while
|
||||
keeping the admitted generation and decoder owner mounted.
|
||||
|
||||
The preceding fMP4 rule applies to historical bounded clip instruments. A
|
||||
migrated full-session recorded LAB instead uses the native Rerun `AssetVideo`
|
||||
and `VideoFrameReference` contract from ADR 0045. It must not mount the clip
|
||||
player or a custom Three.js scene alongside Rerun. The visual controls and LAB
|
||||
template are identical in both cases; the canonical profile determines the one
|
||||
active transport.
|
||||
|
||||
### 3D and 2D policy
|
||||
|
||||
Choose the default representation from the operator question:
|
||||
|
||||
@@ -249,8 +249,7 @@ vocabulary and executable contracts. They do not create a second runtime model.
|
||||
- no upward imports from core/components into workspaces or App;
|
||||
- no visual adapter imports from core;
|
||||
- no local vendor icon library or direct Design Guideline source imports;
|
||||
- LAB code and CSS remain outside the central workspace buckets;
|
||||
- central composition files cannot silently return to their previous size.
|
||||
- LAB code and CSS remain outside the central workspace buckets.
|
||||
|
||||
`test/laboratoryProductUi.test.mjs` additionally enforces the versioned LAB
|
||||
report fields and shared result component across bounded LAB modules.
|
||||
@@ -260,8 +259,9 @@ new unclassified experiment branches while allowing a declared bounded
|
||||
experimental adapter. The gate protects core composition; it does not forbid a
|
||||
novel research stack.
|
||||
|
||||
The line limits are ratchets, not quality targets. When a file reaches a limit,
|
||||
split a feature; do not raise the limit to accommodate unrelated behavior.
|
||||
File length is not an architectural boundary and is not enforced. Refactoring
|
||||
is justified by ownership, cohesion, dependency direction, lifecycle or test
|
||||
isolation, not by a line-count threshold.
|
||||
|
||||
From `apps/control-station` run:
|
||||
|
||||
@@ -273,13 +273,15 @@ npm run build
|
||||
|
||||
## Known bounded debt
|
||||
|
||||
- `App.tsx` remains a large shell orchestrator. Its current size is frozen by a
|
||||
ratchet; future shell behavior must extract a controller/hook or panel module.
|
||||
- `App.tsx` remains a large shell orchestrator. Future shell behavior should
|
||||
preserve its orchestration ownership and extract modules only where they
|
||||
acquire an independent responsibility or lifecycle.
|
||||
- `Workspaces.tsx` still contains several established generic workspaces. New
|
||||
domains must be separate modules, and existing ones may be extracted when
|
||||
their behavior changes.
|
||||
- `LaboratoryArchiveWorkspace.tsx` is now physically isolated but at its
|
||||
ratchet. A3 receives its own component/module instead of growing that file.
|
||||
domains should respect the existing dependency direction; extraction is a
|
||||
design decision rather than a response to file length.
|
||||
- `LaboratoryArchiveWorkspace.tsx` is physically isolated. Further LAB work
|
||||
must preserve the feature boundary without imposing a size quota on the
|
||||
implementation.
|
||||
- Design Guideline dependencies are mutable local `file:` links until a
|
||||
portable package/distribution decision is implemented.
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# Observatory — product-surface brief
|
||||
|
||||
Status: accepted by the product owner for the first M5.1 vertical slice on 2026-08-30.
|
||||
|
||||
## Operator and job story
|
||||
|
||||
The operator is an engineer qualifying the perception stack before any control authority is put on
|
||||
an unmanned platform. After a source session has been recorded, the engineer needs one lightweight
|
||||
place to identify that immutable source and see which laboratory results are actually linked to it.
|
||||
The first slice is used repeatedly while recordings and results are being produced; it is not a
|
||||
mission-planning surface and it never sends navigation, braking or actuation commands.
|
||||
|
||||
## Placement decision
|
||||
|
||||
The accepted placement is `Тестировочный контур → Обсерватория`, with the operator-facing
|
||||
description `Сессии и квалификация`. It is a third workspace alongside the existing
|
||||
`Лабораторные контуры` and `Симуляции` entries.
|
||||
|
||||
Alternatives considered:
|
||||
|
||||
1. Replace or refactor `Лабораторные контуры` in place. Rejected for M5.1 because the current LAB
|
||||
archive is a working, documented legacy projector and rollback reference.
|
||||
2. Add an Observatory mode to `Данные → Сессии и записи`. Rejected because that surface owns
|
||||
storage and replay, while Observatory owns the recurring qualification-review job.
|
||||
3. Add a dedicated workspace under `Тестировочный контур`. Selected because it creates a clean
|
||||
composition boundary without changing K1, Simulation or the legacy LAB lifecycle.
|
||||
|
||||
## Entity, evidence and authority
|
||||
|
||||
The source entity is an immutable observation Session. Its visible facts are limited to the
|
||||
validated Session catalog contract: identity, timestamps, status, duration, modalities and
|
||||
readability. Laboratory evidence is linked only by the typed `lab.sourceSessionId` relationship.
|
||||
Source and LAB projections are independent bounded newest-first windows. A LAB result whose source
|
||||
is absent from the loaded source window remains unresolved and visible as `вне среза`; it is never
|
||||
attached heuristically and its absence is not presented as a broken relationship. Reaching the
|
||||
100-item boundary marks historical completeness as unknown until cursor pagination exists.
|
||||
|
||||
The presence of a Session or linked LAB result is not a computer-vision pass and is not evidence of
|
||||
safe navigation. M5.1 does not infer a verdict from provenance, labels or result kind. The workspace
|
||||
is observation-only: command, navigation and safety authority are all absent.
|
||||
|
||||
## Viewer-profile boundary
|
||||
|
||||
The existing source-specific viewer contracts remain separate and unchanged:
|
||||
|
||||
| Source job | Existing profile | Clock / load ownership |
|
||||
| --- | --- | --- |
|
||||
| Live equipment | `live-acquisition` | `stream_time`; live receiver and recovery authority |
|
||||
| Historical Sessions | `recorded-session` | `session_time`; explicit preparation and progressive admission |
|
||||
| Legacy LAB evidence | `lab-recorded-evidence` | `source-sequence`; explicit comparison-only loading |
|
||||
|
||||
Canonical LAB compositions may configure the recorded Rerun engine inside the bounded LAB slice,
|
||||
but that does not make Observatory a LAB or live consumer. The first Observatory slice mounts no
|
||||
viewer while its catalog is opened, refreshed or navigated. Its bounded canary may activate only
|
||||
the historical `recorded-session` profile after a separate explicit action and acceptance proof.
|
||||
It does not add a fourth profile or silently collapse the three existing lifecycles.
|
||||
|
||||
## M5.1 canary: canonical recorded replay
|
||||
|
||||
The accepted viewer canary reuses the stabilized full-route RAV004 composition; it does not create
|
||||
a second viewer or a second recorded-data pipeline. The sealed vegetation result is first projected
|
||||
into the generic Session/LAB catalog through `SessionStore.publish_lab_instance`. Its exact
|
||||
`route_full_review.session_id` is the only source relationship. Labels, `latest`, result prefixes and
|
||||
the advanced LAB index are not valid joins.
|
||||
|
||||
The immutable LAB projection carries a versioned replay capability:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "missioncore.observation-lab-replay-capability/v1",
|
||||
"kind": "canonical-recorded-rerun",
|
||||
"viewer_profile": "recorded-session",
|
||||
"timeline": "session_time",
|
||||
"activation": "explicit",
|
||||
"commands_enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
Catalog entry, Session selection and result selection remain lightweight. Only the explicit
|
||||
`Открыть визуальный разбор` action reads and revalidates the sealed result metadata. Only after that
|
||||
admission does the application mount the shared canonical replay component, which follows the
|
||||
existing `resolveObservationSessionReplay → resolveCanonicalLabReplay → recordedSessionRerunProfile
|
||||
→ RerunViewport` path. Closing the review or changing Session unmounts that component and aborts
|
||||
pending admission. At most one viewer and one `session_time` clock exist.
|
||||
|
||||
The publication command verifies the registered result root, schema, content-addressed identity,
|
||||
artifact hashes, exact RAV004 source Session and all false authority flags before adding an
|
||||
idempotent SQLite projection. The validated source snapshot is compared again inside the same
|
||||
SQLite write transaction, so reconciliation cannot swap the Session between admission and copy.
|
||||
The default v1 catalog continues to hide capability-owned projections from legacy consumers; the
|
||||
explicit v2 LAB catalog returns the typed capability. A narrowly matched rolling migration types
|
||||
the already-published canonical projection and leaves ordinary legacy LAB rows untouched. The
|
||||
command does not run inference, ffmpeg, RRD merge or replay
|
||||
materialization; source/result evidence is not copied or rewritten. Legacy LAB continues to import
|
||||
the same shared replay through a compatibility wrapper.
|
||||
|
||||
## Information hierarchy and states
|
||||
|
||||
1. Catalog authority and refresh state.
|
||||
2. Explicit source Session selection.
|
||||
3. Selected Session identity, timing, modalities and readability.
|
||||
4. Strictly linked immutable LAB evidence.
|
||||
5. Fail-visible bounded-window and unresolved-evidence status.
|
||||
|
||||
The admitted states are initial loading, ready with items, ready empty, refreshing with the last
|
||||
valid snapshot, and unavailable/error with retry. If a selected Session disappears after refresh,
|
||||
selection moves to the first valid source or to the empty state. No demo rows, fabricated progress,
|
||||
placeholder actions or hidden polling are allowed.
|
||||
|
||||
## Session-to-setup configurator slice
|
||||
|
||||
The next bounded slice adds an explicit laboratory Setup selection beside the source Session. A
|
||||
Setup is a versioned, immutable compatibility contract, not a mutable bag of UI parameters. The
|
||||
catalog keeps two independently named historical facts:
|
||||
|
||||
- `M4.9T5 · TRAVEL TGS · CPU-only, без ML` is an archived RunDefinition bound exactly to
|
||||
`20260720T065719Z_viewer_live`. Its configuration references are content-addressed, its primary
|
||||
visual result remains in legacy LAB, and the integrated-graph result is retained as a compute
|
||||
successor rather than presented as a second viewer.
|
||||
- `LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39` is the current exact RAV004 result bound to
|
||||
`20260828T130511Z_viewer_live`. It predates the product RunDefinition contract and therefore keeps
|
||||
`run_definition = null`; the UI identifies it as an existing result and never fabricates a config
|
||||
digest for it.
|
||||
|
||||
Changing Session fetches only the small setup catalog. Changing Setup performs no computation.
|
||||
Compatibility requires the exact source Session identity, the preserved source label and all
|
||||
required modalities. An explicit `Проверить совместимость` action sends the selected source, setup
|
||||
and RunDefinition digest to a read-only preflight. Digest drift fails closed. The preflight may
|
||||
report an exact existing result or a blocked executor, but it cannot enqueue work.
|
||||
|
||||
The calculation action remains absent until a durable dispatcher exists with an idempotent
|
||||
submission key, immutable RunDefinition receipt, lifecycle ledger and exact result publication
|
||||
receipt. The currently preserved bespoke Worker scenario is not silently treated as that adapter.
|
||||
This boundary also keeps live equipment, historical replay and legacy LAB Rerun profiles separate.
|
||||
|
||||
## Design Guideline composition
|
||||
|
||||
The existing `ApplicationShell`, `AdminNavigationPanel` and `ApplicationPanel` composition remains
|
||||
the owner of navigation and workspace framing. The feature reuses canonical `Select`, `Button`,
|
||||
`Icon`, `StatusBadge`, `GlassSurface` and `ActivityIndicator` primitives. The semantic Session and
|
||||
evidence projection belongs to `workspaces/observatory`; feature styles own layout only and do not
|
||||
introduce a parallel control or surface grammar. The registered `eye` icon identifies the new
|
||||
workspace.
|
||||
|
||||
## First-slice acceptance
|
||||
|
||||
- Polygon navigation order is `Лабораторные контуры → Симуляции → Обсерватория`.
|
||||
- Opening Observatory loads only the typed source and laboratory Session catalogs.
|
||||
- Opening Observatory mounts no Rerun, canvas, WebGL, RRD, WebSocket or LAB result route.
|
||||
- Source and LAB records are joined only by `sourceSessionId`; evidence outside the independently
|
||||
bounded source window remains visible as unresolved and never becomes a false integrity verdict.
|
||||
- Reaching either 100-item catalog boundary explicitly marks historical completeness as unknown.
|
||||
- The selected source presents at most six newest linked results while retaining the exact total;
|
||||
the historical tail remains in legacy LAB and is never mounted into a long Observatory DOM.
|
||||
- Loading, empty, refreshing, error/retry and ready states contain no synthetic data.
|
||||
- Linked evidence is never presented as a CV or safety pass.
|
||||
- Existing LAB, Simulation, K1 and Data/Sessions operator behavior remains unchanged.
|
||||
- Live, historical Session and legacy LAB viewer-profile contracts remain distinct.
|
||||
- Session and Setup are independently explicit selections; neither selection submits Worker work.
|
||||
- Both the archived M4.9T5 setup and the current final RAV004 result remain visible and immutable.
|
||||
- A pre-RunDefinition result never receives a fabricated configuration hash.
|
||||
- Preflight is read-only, digest-fenced and reports the missing executor adapter instead of
|
||||
fabricating queued or running states.
|
||||
|
||||
## Canary acceptance
|
||||
|
||||
- RAV004 appears as one exact LAB result linked to `20260828T130511Z_viewer_live` in the Session
|
||||
catalog; Observatory never reads `/api/v1/laboratory/advanced-index`.
|
||||
- Entering Observatory and changing Session perform no replay POST, RRD HEAD/GET, viewer mount or
|
||||
canvas creation.
|
||||
- Explicit open validates the result/source binding, then uses the existing canonical RRD,
|
||||
blueprint, cache, `recorded-session` profile and `session_time` clock.
|
||||
- One `RerunViewport` is mounted; legacy clip/Three renderers, a second clock and a second RRD
|
||||
builder are absent.
|
||||
- Close, Session change and run replacement abort pending work and fully unmount the viewer.
|
||||
- K1, Worker 006/add-worker, Simulation/Gaussian, raw Sessions and legacy LAB behavior remain
|
||||
unchanged.
|
||||
@@ -0,0 +1,545 @@
|
||||
# Observatory: четыре этапа — записанные лаборатории → переносимые профили → борт
|
||||
|
||||
## Актуальное решение владельца — 2026-09-02
|
||||
|
||||
**Этот раздел заменяет прежний порядок и GO-зависимости плана ниже.** Сначала
|
||||
доводим лабораторный продукт на записях, затем экспериментируем с составом
|
||||
Docker-профилей, затем переносим выбранный профиль на подходящий бортовой
|
||||
компьютер. Сетевые 125 ms, Ethernet, LTE и наличие беспилотника больше не
|
||||
являются условиями готовности Observatory. Старый этап 1 и инкременты 1–18
|
||||
сохраняются как выполненная инженерная работа; отрицательные realtime-замеры
|
||||
не переименовываются в PASS.
|
||||
|
||||
Основной путь: **запись → совместимый ещё не рассчитанный профиль → Рассчитать
|
||||
→ фактический прогресс → проверенная публикация → сохранённый просмотр**.
|
||||
Просмотр, Refresh и выбор записи не запускают модели. Готовые результаты всех
|
||||
версий остаются в «Лабораторных доказательствах». Если рассчитаны все текущие
|
||||
совместимые профили, выбор пуст/недоступен, кнопки «Рассчитать» нет, «Обновить»
|
||||
остаётся. Старое имя LAB не доказывает совпадение версии конфигурации.
|
||||
|
||||
### Этап 1 — Идентичность расчёта и переиспользование результата (в работе)
|
||||
|
||||
Первый инкремент: portable submit защищён атомарной проверкой существующей
|
||||
job identity. Другой ключ запроса не создаёт дубликат active/reconciliation
|
||||
или pending/failed-publication job; точный retry идемпотентен, failed compute
|
||||
можно повторить. 88 focused backend/queue/Worker-API/publication tests PASS;
|
||||
Ruff lint и mypy трёх изменённых source files PASS. Полный frontend/GPU-прогон
|
||||
не выполнялся: UI и модели не изменены. Cache projection/селектор/прогресс ещё
|
||||
не реализованы, этап не закрыт. Решение: [ADR 0050](adr/0050-recorded-observatory-first.md).
|
||||
|
||||
Runtime check 23:15 МСК: canonical8000 перезапущен на новом коде, PID55765,
|
||||
health/portable catalog HTTP200,8765 отсутствует. Модельных заданий не создано;
|
||||
durable queue содержит10 failed и1 succeeded/not-required, live leases0.
|
||||
Обнаружен существовавший ДО перезапуска blocker:50000 claim receipts при
|
||||
лимите50000, из них49985 пустых,15 с job. Последний receipt2026-09-01T17:28:39.238Z;
|
||||
новые Worker polls продолжают получать503. Каталог всё ещё рекламирует ready,
|
||||
что не доказывает работоспособность claim-пути. Следующий инкремент этапа1:
|
||||
исправить bounded empty-poll lifecycle и readiness без удаления audit history
|
||||
и нарушения идемпотентности старых claim IDs; затем verified-cache projection.
|
||||
Не повышать бесконечно лимит и не чистить receipts вслепую.
|
||||
|
||||
**Ремонт claim-пути выполнен, 23:54 МСК.** Backend и оба текущих control agents
|
||||
переведены на claim/v3: пустой poll не создаёт receipt; реальная выдача получает
|
||||
durable grant с прежними ownership/retry guards. Все 50000 legacy receipts,
|
||||
11 jobs и остальные старые таблицы сохранены побайтно на уровне canonical row
|
||||
hashes. Новый положительный ledger имеет отдельный конечный лимит50000;
|
||||
старый лимит не поднят, общий storage bound128MiB прежний.16 Worker→Mac idle
|
||||
probes PASS, обычные polls204, v3 rows0, agent restarts0.126 focused tests,
|
||||
Ruff/mypy PASS. Канонический8000/PID57796 работает; модели/UI не запускались.
|
||||
Изменена только schema version в реально импортируемом transport-модуле;
|
||||
compute packages/registries сохранены. Старые agents stopped/restart=no
|
||||
оставлены для rollback. Source of truth транспорта — pinned create declarations
|
||||
отдельного control release, не прежний package launcher.
|
||||
[Отчёт и rollback](../experiments/perception/OBSERVATORY_RECORDED_CLAIM_REPAIR_2026-09-02.md).
|
||||
Следующий шаг: verified published-cache projection. Этап1 по-прежнему открыт;
|
||||
готовый cache/selector/progress и полный расчёт записей ещё не доказаны.
|
||||
|
||||
**Третий инкремент, 2026-09-03: verified cache реализован в коде.** Каталог и
|
||||
preflight принимают только successful/published job с точным source snapshot,
|
||||
RunDefinition, Session provenance и целым пакетом артефактов. Фронтенд читает
|
||||
проверенную привязку вместо прежнего запрета непустых `existing_results`.
|
||||
Готовый результат доступен без dispatch/подготовки raw source; повреждённый
|
||||
не скрывает возможность нового расчёта. Повторный submit и гонка с публикацией
|
||||
защищены внутри queue INSERT transaction; холодная проверка больших файлов
|
||||
требует Refresh и не выполняется под write lock. История не удаляется.
|
||||
|
||||
120 focused backend tests,23 frontend/architecture tests, Ruff, mypy пяти
|
||||
source files и focused TypeScript check PASS. Полная production build и browser
|
||||
QA **пока не выполнены**: Mac memory-pressure=2 при swap~7.6–7.8GiB, чужие постоянные
|
||||
сервисы не остановлены. Canonical8000/PID57796 оставлен на прежнем согласованном
|
||||
backend/frontend release, health200; новый cache ещё не активирован. Worker,
|
||||
модели и живые очереди не изменялись. Полный LAB-run не запускался.
|
||||
[Проверки и следующий шаг](../experiments/perception/OBSERVATORY_PUBLISHED_CACHE_2026-09-03.md).
|
||||
Первоначальный ресурсный blocker выше снят следующим инкрементом. Не повторять
|
||||
уже закрытый ремонт claim/v3 и не возвращаться к сетевым latency canary.
|
||||
|
||||
**Активация cache-инкремента, 2026-09-03.** По разрешению владельца перезапущена
|
||||
старая Docker VM: footprint14GB→1565MB, pressure2→1, swap7722→2746MiB. Браузеры и
|
||||
Little Snitch не трогали. Три прежних Mission Core контейнера healthy;12 Plane
|
||||
restart overrides возвращены, Plane выключен. Состояния45 non-Core контейнеров,
|
||||
image/mount identities сохранены; лимиты Docker и данные не менялись.
|
||||
Полный typecheck,714 frontend tests и production build PASS, последовательно.
|
||||
Согласованная версия на8000/PID67747, health200; UI `/assets/index-DSuuMkq4.js`.
|
||||
Каталоги004TREE/00 совместимы с двумя profiles;01 заблокирован отсутствующим
|
||||
capture attestation. Точных опубликованных cache hits пока нет; legacy
|
||||
succeeded/not-required не принят за новый результат. Все6 queue-table row hashes
|
||||
сохранены. Worker/model jobs не создавались. Browser: каталог, выбор, Refresh,
|
||||
normal/expanded и Escape проверены, console warnings/errors0.
|
||||
|
||||
Этап1 пользовательски ещё не закрыт: текущий селектор смешивает архивные entries
|
||||
и portable definitions, а source/setup-only выбор последней job показывает
|
||||
старое «Расчёт завершён» рядом с новым «Рассчитать». Это не текущий cache hit.
|
||||
Следующий шаг на стыке этапов1–2: exact-definition queue/status binding,
|
||||
селектор только нерассчитанных profiles, прогресс и полный цикл на записях.
|
||||
Положительный publish→cache→view пока доказан synthetic tests, не новым LAB-run.
|
||||
Подробности/rollback в том же отчёте выше; повторять сборку без изменения кода
|
||||
или снова освобождать память не требуется.
|
||||
|
||||
- Сверить каталог, очередь, публикацию и просмотр для текущих M4.9T5 и LAB V1.
|
||||
- Связать готовность с точной исходной записью и immutable RunDefinition,
|
||||
включающей image/model/config/adapter/result-contract identities.
|
||||
- Подавлять повторную постановку активного идентичного расчёта на backend;
|
||||
опубликованный кэш признавать только при проверенной привязке и доступных
|
||||
артефактах. Failed/partial/unpublished не считать готовым результатом.
|
||||
- Отделить архивные результаты от исполняемых профилей и не прятать новую
|
||||
версию профиля из-за старого результата с похожим названием.
|
||||
|
||||
Выход: контрактные тесты для другой записи/версии, повторного клика, гонки,
|
||||
ошибки публикации и отсутствующих артефактов; актуальные документы. Это ещё
|
||||
не полный пользовательский acceptance.
|
||||
|
||||
### Этап 2 — Полный пользовательский цикл записанной лаборатории
|
||||
|
||||
- Единый селектор только совместимых нерассчитанных профилей; все доступные
|
||||
результаты ниже, без скрытого ограничения первыми шестью карточками.
|
||||
- Очередь, реальные счётчики/этапы прогресса, ошибки, повтор публикации без
|
||||
inference, автоматическое обновление после публикации.
|
||||
- Полный расчёт с сохранением результатов и исходной временной шкалы;
|
||||
скорость расчёта может быть ниже скорости записи. Ограниченные очереди и
|
||||
backpressure вместо накопления всей записи в RAM. Разделить режим полноты
|
||||
записанного анализа и строгий realtime-rehearsal: не отключать freshness
|
||||
и drop-политику live глобально и не терять кадры ради wall-clock темпа
|
||||
в режиме полного анализа.
|
||||
- Последовательно проверить текущие два профиля на совместимых записях:
|
||||
публикация, перезапуск приложения, повторный просмотр без модели/GPU,
|
||||
отсутствие дубликатов. Не объявлять LAB V1 полным detector/distance/TGS
|
||||
профилем: сейчас это последовательные EoMT и DDRNet; M4.9T5 — CPU TGS.
|
||||
|
||||
Выход: настоящий путь через canonical8000 и Worker, полный сохранённый
|
||||
результат и воспроизводимый просмотр. До такого proof этап открыт.
|
||||
|
||||
### Этап 3 — Эксперименты с переносимыми Docker-конфигурациями
|
||||
|
||||
- Переиспользовать наработанный streaming runtime и общий контроль исполнения.
|
||||
В первую очередь новый DDRNet + RF-DETR + LiDAR/distance + motion +
|
||||
TGS/costmap + policy-shadow, без зависимости от чужих архивных overlays.
|
||||
- Один Worker006/4090 — один активный профиль. EoMT сохраняется; альтернативы
|
||||
не выполняются параллельно. Понятные profile/image имена с `ndc-` и digest.
|
||||
- Измерять отдельно подготовку, прогрев, Worker compute/full-graph latency,
|
||||
доставку, публикацию и wall time всего задания. Перегрузка — результат
|
||||
эксперимента, а не повод запретить лабораторный расчёт.
|
||||
- Условный прогноз onboard FPS относится только к измеренным железу, образу,
|
||||
настройкам и составу; не выводится из viewer FPS или времени скачивания.
|
||||
|
||||
Выход: сравнимые результаты конфигураций на нескольких записях, известные
|
||||
ошибки, воспроизводимые образы; не обязательный remote realtime PASS.
|
||||
|
||||
### Этап 4 — Отбор профиля и проверка переноса на борт
|
||||
|
||||
- Выбрать удачный immutable образ/config по лабораторной матрице качества и
|
||||
производительности; исключить checkout и скрытые model caches из зависимостей.
|
||||
- Проверить тот же образ и входной контракт на фактическом бортовом компьютере
|
||||
после его появления. Другая архитектура CPU/GPU может потребовать отдельной
|
||||
сборки и новой квалификации; Docker не обещает NVIDIA→Apple Silicon перенос
|
||||
без изменений.
|
||||
- Physical-live, контроллеры/моторы, аварийная остановка и автономная навигация
|
||||
имеют отдельную приёмку. Пока только observation-only/policy-shadow.
|
||||
|
||||
Выход сейчас: пакет кандидата, матрица evidence и явные ограничения; реальный
|
||||
перенос остаётся pending до появления оборудования, не блокируя этапы 1–3.
|
||||
|
||||
## История прежнего realtime-first плана (не текущие команды и зависимости)
|
||||
|
||||
Ниже сохранены прежние измерения, этапы и решения. Все слова «следующий»,
|
||||
«GO», «не открыты» и старые номера этапов в этой истории относятся к состоянию
|
||||
до решения выше. Приоритет имеют четыре актуальных этапа выше.
|
||||
|
||||
Дата: 2026-09-01; обновлено 2026-09-02 22:35 МСК. **Этап1 закрыт; этап2 продолжается. Инкремент18: существенный транспортный хвост локализован ниже Perception-приложения; full-profile latency не пересчитывалась.** Код `6af4c20`: bounded TLS-record witnesses в CPU-only diagnostic. A/B/A без/с/без свидетелей сохраняет RPC p95 96.093/93.895/95.145ms.424/424 encrypted records и192/192 RPC сопоставлены;288 raw clock exchanges пересчитаны. Из13 post-start tails >50ms восемь приходятся на request transit, пять на response transit; начальный TLS/startup sample не получает выдуманного clock mapping.
|
||||
|
||||
Прямой TCP к подтверждённому SSH22 Worker без gRPC/Docker/forwarding тоже даёт67.921–91.142ms пики (7/16 >50ms). Native Worker loopback после первого sample0.240–0.299ms; первый8.229ms сохранён. Оба сетевых интерфейса — Wi-Fi: Mac en0 и Worker Realtek8812BU USB. Конкретная причина среди radio/AP/OS/driver/power policy не доказана; Wi-Fi и866.7Mbps link rate сами по себе не определяют ни usable throughput, ни latency.82 local/82 Worker tests PASS;142 staged files/132 Python exact. [Отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_TLS_ATTRIBUTION_2026-09-02.md),43 artifacts, manifest `abc3ac677b81e0d78f8d5891517022e33ac7fa962166bc399b4009ee48d87d8e`.
|
||||
|
||||
Предыдущий инкремент17: непрерывность короткого LAN-прогона32/32 PASS; latency FAIL. Код `59a74a3` добавляет только bounded clock-only диагностику. CPU A/B/A без моделей и scene: SSH/Tailscale48/96 ready → проверенный LAN94/96, непрерывно после warmup → Tailscale53/96. Все288 обменов независимо пересчитаны; ~100ms хвосты RPC остаются и без scene. Это не доказательство DERP relay, универсального ускорения LAN или конкретной причины transport tails.
|
||||
|
||||
Один полный32-кадровый1× LAN-canary:32/32 byte-exact/reference-exact результата, без WAIT, drops, sync skips и reconnect. Все50 обменов часов пересчитаны; после двух начальных warmup48 ready, uncertainty публикаций3.645–4.771ms при прежнем пределе5ms. p95/p99 source→consumer-ready205.864/213.751ms при бюджете125ms — FAIL. Полностью свежих scene12, доступных sensor pairs17;30 результатов до EOF. PeakVRAM2357MiB, не предел24GiB. Модели/effective config и129 прежних runtime/test source files не менялись; маршрут выбран только для этого запуска, не зашит в Docker и не назначен продуктовым default.245 local/202 Worker tests PASS,2 Worker-only skip локально,131 source hashes совпадают. [Отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_LAN_ROUTE_2026-09-02.md),67 artifacts, manifest `2645a0faacb89b5af5d3756feea05550a12cc64820be926d32f2103b5f225caf`.
|
||||
|
||||
Предыдущий инкремент16: двусторонний старт принят, непрерывный real-time canary FAIL. Код `48835a0`: обе стороны прогревают часы до source anchor; явное подтверждение Worker и data grant обязательны до старта1×. Потерянный ACK не меняет уже предложенный/принятый anchor.241 local/198 Worker tests PASS,2 Worker-only skip локально;129 измеренных source hashes совпадают.
|
||||
|
||||
Один32-кадровый canary теперь принял начало0–5 и после WAIT/resync30–31:8 результатов,1 compute discard,9 source WAIT и14 sync skips, без неучтённых кадров. p95/p99 до consumer-ready215.767ms,5 полных свежих scene,6 результатов до EOF. Все55 raw t1–t6 обменов независимо пересчитаны;41 ready. На работающем источнике uncertainty5.031–5.667ms превысила неизменные5ms; RPC errors0, Worker telemetry33/33 PASS, lease1/PIDs сохранены. PeakVRAM2363MiB, не предел24GiB. [Отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_JOINT_START_2026-09-02.md),53 artifacts, manifest `9cb369fe7ae185d1a92f6050bc1018a878483f5eec169267f100843245428691`.
|
||||
|
||||
Предыдущий инкремент15: полный Mac↔Worker graph подключён к continuous clock/freshness; прежний canary FAIL. Добавлены ответное подтверждение часов Worker, immutable source anchor, uncertainty для всех слоёв/ячеек и WAIT без потери локального владельца. Исправлены EOF ordering, повторное чтение часов внутри одного результата и ранний disconnect до application OPEN.297 local/173 Worker tests PASS; ранний reconnect дополнительно12/12 PASS.
|
||||
|
||||
Финальный32-кадровый1× canary:13 кадров пропущены при ожидании допуска,17 при keyframe/sensor resync, приняты только30–31; оба результата после конца короткого source window,143.881/155.743ms. Все пропуски учтены, но availability/latency/result-before-EOF gates не пройдены.78 clock samples,28 ready; условная uncertainty median5.312ms при неизменном пределе5ms. Peak VRAM2365MiB — не свидетельство предела24GiB. Более ранняя попытка вернула30 exact результатов, но имела2 compute discards и EOF error; её нельзя выдавать за финальный PASS. [Отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_CROSSHOST_GRAPH_2026-09-02.md), код `35b6cd9`, evidence198 artifacts/manifest `7b435cac111b3e0e789aa137ba064651e6d344a5f8fb8fa35b0abbd0e7cb1d74`.
|
||||
|
||||
Предыдущий инкремент14: межмашинная выдача grant и измеренные границы часов. TLS control endpoint доставляет уже выданный Worker-контроллером одноразовый data grant, но не умеет получать/продлевать GPU lease, выбирать epoch, перезапускать модели или actuate. Четыре timestamp дают диапазон remote-minus-local без предположения симметрии сети; до16 наблюдений, expiry2s, нарушение envelope карантинит clock session.
|
||||
|
||||
CPU-only Mac↔Worker006 proof:72/72 timestamp-наблюдения независимо пересчитаны,60/72 соответствуют условному uncertainty≤5ms; min/median/max3.883/4.469/27.118ms. Восемь synthetic32KiB events exact8/8. Обрыв2.202s сохранил PID/lease1, старое clock evidence истекло, новый epoch/grant восстановили вход. Это доказывает механизм WAIT/recovery, но не full-graph cross-host freshness: readiness переключалась и после warmup. Поэтому5ms не ослаблен, а модельный прогон с неподключённым uncertainty gate не запускался. [Отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_CONTROL_CLOCK_2026-09-02.md); evidence manifest SHA-256 `cd479c577fa3da78b1b01a6a90e03e8c742ecb53561e606d939408120a53a622`.
|
||||
|
||||
Предыдущий инкремент13 — полный graph с реальным сетевым входом и выходом внутри одного Worker. CPU source/receiver вынесен в отдельный контейнер без GPU; модельный контейнер не получает запись через mounts. Через gRPC/TLS возвращаются настоящая маска512×512, detections/distance/motion и grid/costmap/policy. Обычный1× прогон:128/128 exact raw к reference, p95/p99 до готового consumer-view106.150/115.349ms, max126.910ms (один кадр выше125ms).76/128 полных свежих scene;52 исходные sensor gaps не скрыты.
|
||||
|
||||
Отдельный обрыв2.2s:102 результатов +26 учтённых пропусков24–49, resume50 с теми же четырьмя PID и lease generation1; одна очистка temporal history, без backlog. p95/p99 принятых результатов107.098/110.817ms; отдельный gap доступности2.725s. Начальный лишний reconnect из-за раннего init исправлен без увеличения idle-timeout и проверен повтором.146 focused tests PASS,2 больших Worker-only теста пропущены локально. Максимальный ответ460248B остаётся внутри1MiB; peak tracked13,217,791B, после закрытия0.
|
||||
|
||||
Граница доказательства: два контейнера одного Worker с проверенным общим Linux monotonic clock, временные штатные частоты2610/10251MHz; **не Mac↔Worker full-graph, не radio/live и не standalone**. История зависимых слоёв после gap закономерно отличается от uninterrupted reference. Все230 опубликованных scene/масок проверены на стороне приёмника и повторно сверены. [Подробный отчёт](../experiments/perception/PERCEPTION_STREAM_STAGE2_NETWORK_2026-09-02.md). Evidence236 artifacts: `.runtime/perception-stage2-network-graph-20260902T1541Z/manifest.json`, SHA-256 `d1bc7e26850a2d9050ee1d8f8a5ece2e6b8c53e2e7dba381cbd4445d68a5ec17`.
|
||||
|
||||
Следующий проверяемый результат этапа2: per-frame source packing/write → ingress/decode/admission → GPU/CPU queues → publication/result read/parse. В исходнике найден отдельный10ms polling готовых ответов: сначала измерить фактическую цену и bounded lifecycle/backpressure, затем проверить event-driven замену. CPU transport attribution уже доказала существенный tail за границами приложения; не менять модели/VRAM ради этого симптома и не вычитать p95 разных выборок. При доступном Ethernet выполнить контроль без обоих Wi-Fi участков; это не блокирует оптимизацию и упаковку runtime при честном network-specific FAIL. Пороги5ms/125ms/source1× прежние, backlog не догонять. Затем controlled gap/slow-consumer, длинный поток и standalone без developer/model mounts внутри этапа2. Этапы3–4/registry/UI cutover не открыты. Временные CPU containers/relays/tunnel18561/key/bootstrap удалены; GPU/сервисные настройки в инкременте18 не менялись. Ollama/Frigate exited/restart=no; Mac8000/Worker telemetry работают.
|
||||
|
||||
Предыдущий инкремент12 (`0310592`, `1916122`): CPU-only gRPC/TLS proof Mac↔Worker,16/16 payload/reply; синтетический source clock и CPU sentinel, моделей0. Same-Mac RTT14.360/23.919/120.815ms min/median/max — не one-way age/FPS.105 local и20 Worker tests PASS, проверены bounded slow-reader/quarantine. Evidence `.runtime/perception-stage2-grpc-20260902T1500Z/manifest.json`, SHA-256 `adf5eaf092feaed6721f66e2adaceded0cdbf55754e1f9953f54623bfb52d331`.
|
||||
|
||||
Предыдущий инкремент11 (`c570f7d`, `2ef8c08`): восстановлены System/Worker telemetry plane и независимый runtime exporter. Телеметрия остаётся отдельным наблюдателем, а не выдающим lease/GPU authority контуром.
|
||||
|
||||
104 Python tests, 703 frontend tests, typecheck и production build PASS. Полный frontend проверен последовательно на CPU Worker из-за Mac memory pressure=2; собранный UI установлен на canonical8000. Визуальная приёмка по `mission-core-product-ui` отложена до освобождения памяти, не объявлена пройденной. Расширен существующий Worker, без новых страниц и без product profile/registry cutover. MQTT outage/startup проверен с сохранением PID9268; CPU canary сохранил lease generation1 и освободил его после завершения. Evidence `.runtime/worker-telemetry-20260902T1422Z/manifest.json`, SHA-256 `825b1d8035dbf907b2b731d35ede4c5742b81e653a6f24670f4775981f853492`.
|
||||
|
||||
Предыдущий модельный замер `61cbdb3`: 404 focused tests PASS, Ruff/format/mypy PASS. Normal exact raw128/128, 50 real observations, p95/p99 91.157/97.946ms. При задержке host reply:102 обработанных +26 учтённых пропусков, resume seq50, прежние PID/lease сохранены; p95/p99 принятых кадров88.643/91.236ms. Между последней старой и первой свежей scene2.719s — отдельная метрика доступности. Оба измерения условны для временных штатных clocks2610/10251MHz, не доказывают overhead regression или общий real-time. В инкременте11 модели не запускались; overhead нового observability exporter пока не измерен на полном графе. Исходные sensor gaps, auto-clock FAIL, непроверенные native-host inventory, network и standalone остаются.
|
||||
|
||||
Текущий evidence: [отчёт этапа 1](../experiments/perception/PERCEPTION_STREAM_STAGE1_2026-09-01.md), [ADR 0049](adr/0049-stream-first-perception-profiles.md), [candidate manifest](../config/perception/k1-perception-ddrnet39-rfdetr-tgs-prototype-v1.json). Запрет full-source preload закреплён в новом контракте; старый batch materializer пока не удалён и продуктовый путь не переключён.
|
||||
|
||||
## Цель и граница завершения
|
||||
|
||||
Собрать отдельный самостоятельный Docker-профиль полного восприятия рига: DDRNet-39 GOOSE + RF-DETR native + LiDAR-ассоциация/метрические расстояния + TGS/costmap + temporal/motion + диагностическая оценка препятствий и mission-policy. Совместимый источник поступает потоком на выделенный Worker; все выходы рассчитываются в этом запуске, без подмешивания ранее рассчитанных масок, детекций, local-surface или threat-ledgers. Запись подаёт наблюдения по исходному времени с темпом 1× и заменяет физический источник, но не меняет вычислительный граф.
|
||||
|
||||
Сценарий владельца: записывать множество маршрутов K1 с камерой, точками и pose; в Observatory последовательно применять разные профили и сравнивать результаты; затем выбрать проверенную версию того же профиля в будущей live-миссии с зарегистрированным оборудованием. Конкретная запись — вход запуска, а не зашитый компонент Docker. Размер коллекции (в том числе 500 записей) не требует 500 приложений или 500 исполняемых адаптеров. Во время будущего движения профиль обрабатывает новые живые наблюдения, а не воспроизводит старые решения для маршрута.
|
||||
|
||||
Первый результат — один полный, измеренный на RTX 4090 прототип с общим runtime и потоковыми результатами, пригодный для последующей проверки на полигоне. Управление моторами, автопилот, построение маршрута и реализация всего конфигуратора миссии в этот прототип не входят. EoMT-L Cityscapes сохраняется как отдельный резервный вариант; его упаковка/оптимизация не блокирует первый профиль и не запускается рядом с DDRNet. Сборка Docker и совпадение digest не являются доказательствами real-time; успешный replay не является допуском к автономному движению.
|
||||
|
||||
Приёмка первого прототипа требует: самостоятельного образа; реального расчёта всех заявленных слоёв; одного live-compatible контракта для replay и будущего физического источника; воспроизведения нескольких совместимых записей без правки кода; bounded latency/queues/memory; явных unknown/degraded состояний; сохранённого отчёта производительности и обнаруженных ошибок. Отсутствующий сейчас беспилотник не блокирует приёмку replay-прототипа, но physical-live, качество в поле и actuation остаются явно непроверенными.
|
||||
|
||||
## Авторитетный контекст и подтверждённое CURRENT
|
||||
|
||||
Рабочий репозиторий: `/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory`. На момент обзора: ветка `codex/m5-1-observatory`, HEAD `7025e173a3370a1b70afaad8579db20903436157`, множество существующих tracked/untracked изменений. Выводы относятся к прочитанному рабочему дереву, не только к HEAD. Чужие изменения сохраняются.
|
||||
|
||||
Источники и их назначение:
|
||||
|
||||
- [Последний отчёт](/Users/dcconstructions/Desktop/MISSING_CORE_OBSERVATORY_DOCKER_LABS_FINAL_STATUS_2026-09-01.md) — история предыдущих запусков и актуализированная цель полного профиля. Исторические измерения не являются новой проверкой текущего состояния Worker.
|
||||
- [Предыдущая архитектурная итерация](/Users/dcconstructions/Desktop/MISSING_CORE_OBSERVATORY_DOCKER_LABS_ARCHITECTURE_2026-09-01.md) — история и идеи, не распоряжения к выполнению и не актуальное подтверждение готовности.
|
||||
- [Правила репозитория](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/AGENTS.md), [существующие gates проекта](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/docs/01_IMPLEMENTATION_PLAN.md) — действующие ограничения. Этот план не открывает навигационные, actuator- или quality/truth-gates проекта.
|
||||
- `/Users/dcconstructions/Desktop/CODEX_V5` — прочитаны все пять документов. Применены разделение CURRENT/TARGET, приоритет цели и доказательств над процедурами, этапы с проверяемым выходом и один поддерживаемый ExecPlan. Шаблоны не копируются поверх правил проекта.
|
||||
- Последние уточнения владельца от 2026-09-01 имеют приоритет: один активный полный профиль, внутри него детектор + выбранная сегментация + LiDAR/TGS; EoMT и DDRNet остаются альтернативами. Не переносить старое ошибочное ограничение «одна модель/AI-процесс» на состав полного профиля.
|
||||
|
||||
Проверенная по локальному коду карта:
|
||||
|
||||
| Участок | CURRENT | Следствие для TARGET |
|
||||
| --- | --- | --- |
|
||||
| Portable definition / preflight | Есть идентичность source/model/executor и проверка совместимости, но нет полного timing-контракта и измеренного real-time допуска; `ready` в основном структурный | Сохранить идентичность и admission, добавить отдельную квалификацию конкретного профиля на конкретном runtime/hardware |
|
||||
| Передача источника / runtime | Полная материализация → выполнение → финальная публикация | Инкрементальный data plane с ограниченными буферами, без обязательной подготовки всей записи |
|
||||
| Installed LAB package | Одноразовые контейнеры: prepare → весь EoMT → весь DDRNet → assemble | Сначала один постоянный полный DDRNet/RF-DETR/LiDAR/TGS runtime; EoMT — отдельный будущий вариант, без зависимости DDRNet от его результата |
|
||||
| Модельные адаптеры | Все кадры, PNG/маски/overlay и итоговые архивы на критическом пути | Обработка поступающих наблюдений и выдача результата до окончания источника; архивирование не задаёт темп inference |
|
||||
| Backend / Worker | В claim есть глобальный запрет второго активного job; Worker 006 зашит в части контракта | Эксклюзивность и fencing на уровне `worker_id`; не глобальный запрет работы независимых Worker |
|
||||
| Frontend | Выбор setup и terminal job/result; нет достаточного контракта потокового состояния | Общие состояния и renderer capabilities, без отдельного микроприложения для каждой модели |
|
||||
| Ранее существовавшие эксперименты | Есть pacing 1×, bounded queues, freshness/age и multirate измерения | Переиспользовать проверенные механизмы, но не совмещать EoMT с DDRNet или разные профили; RF-DETR и DDRNet входят в один полный профиль. Чужие бюджеты автоматически не копируются |
|
||||
|
||||
Опорные файлы для продолжения:
|
||||
|
||||
- [Portable contracts](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/portable_run_definitions.py), [runtime](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/portable_worker_runtime.py), [source transport](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/worker_http_transport.py).
|
||||
- [Queue / ownership](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/recorded_jobs.py), [Worker agent](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/worker_agent.py), [preflight API](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/web/observatory_api.py).
|
||||
- [Installed runner](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/observatory/installed_lab_package_runner.py), [combined package builder](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/experiments/perception/worker/observatory_portable/promote_installed_lab_v1_package.py).
|
||||
- [EoMT adapter](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/experiments/perception/worker/observatory_portable/run_portable_lab_v1_eomt_component.py), [DDRNet adapter](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/experiments/perception/worker/observatory_portable/run_portable_lab_v1_ddrnet_component.py).
|
||||
- [Observation graph](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/perception/graph.py), [recorded source](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/perception/recorded_source.py), [E9 runner](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/experiments/perception/worker/run_e9_multirate_perception.py), [E21 envelope](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/experiments/perception/e21_realtime_envelope_profile.json).
|
||||
- [Equipment/capture compatibility](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/sessions/equipment.py), [Observatory workspace](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx).
|
||||
|
||||
Из последнего отчёта: источник `20260828T130511Z_viewer_live` содержит 6830 кадров за 808.779495667 s, средняя частота ≈8.4448 FPS. Остановленный запуск после примерно 32 min 54 s ещё не дошёл до DDRNet/публикации. Это свидетельство непригодности прежнего полного пути для поставленной задачи, но не изолированный benchmark самой модели.
|
||||
|
||||
Последующий read-only аудит 2026-09-01 проверил Docker inventory, mounts и фактические installed manifests на Worker 006 через strict-pinned `mission-gpu`. Portable M4.9 содержит TGS и `models: []`; LAB V1 выполняет `prepare → EoMT → DDRNet → assemble`, без detector/geometry/threat stages. Legacy perception Worker и Triton существуют отдельно, со значимыми host mounts; это не самостоятельный полный portable-профиль. Никакие контейнеры при аудите не запускались и не останавливались.
|
||||
|
||||
Скриншот M4.9T5 показывает композицию результатов: собственный CPU TGS, отдельный M4 detector/geometry/threat и заранее рассчитанный E47/EoMT. Отдельный [RF-DETR reference runtime](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/src/k1link/perception/m48s_reference_graph_runtime.py:209) реально собирает geometry, temporal, motion, rolling и threat. Сохранённый [M49 integrated result](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/.runtime/m49-integrated-12fps-003/result.json:72) сообщает 4489/4489 кадров, 11.860865 FPS и p95 совместной готовности 46.825886 ms. Это historical RF-DETR + CPU TGS shadow на подготовленных входах, не полный новый профиль: TGS не меняет graph state, EoMT там не вычисляется, `local-surface.npz` подготовлен заранее.
|
||||
|
||||
Ближайший сохранённый [multirate candidate](/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory/config/perception/lab-v1-vegetation-integrated-multirate-phased-shadow-v3.json:12) добавляет DDRNet 6 Hz к RF-DETR/TGS timeline 12 Hz с phase offset 40 ms, но `semantic_output_persisted=false`: это исходная точка исследования нагрузки, а не доказанная сквозная реализация нового продукта.
|
||||
|
||||
Во время read-only проверки на Worker также работали `sentinel-frigate` и `sentinel-ollama` с GPU DeviceRequests; общая занятая VRAM составляла 9256 MiB. Это не доказывает их одновременный inference, но чистое GPU-окно не установлено. Перед измерением требуется согласованное освобождение ресурса; этот план не разрешает самостоятельно останавливать сервисы другого продукта.
|
||||
|
||||
## Жёсткие границы и принятый TARGET
|
||||
|
||||
Уточнение владельца 2026-09-02 (МСК): проект — экспериментальная платформа
|
||||
переносимых профилей, применяемых к разным совместимым записям. Текущий приоритет —
|
||||
снижение задержек всей цепочки, прозрачное измерение и оптимизация runtime,
|
||||
IPC/сети, копирований и оркестрации. Вычислительно тяжёлый профиль не удаляется
|
||||
из-за FAIL на 4090: результат сохраняется вместе с hardware/transport-specific
|
||||
квалификацией. Возможен перенос на более мощный Worker или бортовой компьютер.
|
||||
Локальное размещение устраняет внешний сетевой участок, но не само по себе
|
||||
декодирование, внутренний IPC, очереди и вычисления.
|
||||
|
||||
Лабораторный эксперимент с ограниченной перегрузкой допустим и не блокирует
|
||||
разработку упаковки/общего runtime. Разделяем **функциональную готовность**,
|
||||
**готовность к лабораторным сравнениям** и **real-time qualification на конкретной
|
||||
связке profile/config/hardware/source/transport**. Бюджеты 125 ms и остальные
|
||||
исходные критерии остаются измерительными ориентирами, а FAIL не превращается
|
||||
в PASS. По-прежнему обязательны bounded memory/queues, учёт drops/unknown,
|
||||
исходный clock 1×, отсутствие подмены старых данных новыми и отсутствие actuation.
|
||||
|
||||
- Текущий аппаратный baseline — один Worker 006 с RTX 4090, один активный полный профиль одного источника. RF-DETR и DDRNet являются компонентами этого профиля и могут быть прогреты одновременно; GPU-работа в первом прототипе подчинена одному сериализованному scheduler. CPU-геометрия/TGS и транспорт имеют свои ограниченные очереди, не создавая второй конкурирующий AI-профиль. Нет EoMT+DDRNet вместе, фоновых моделей, обучения или параллельных benchmark. Неактивный профиль не удерживает GPU. Новый профиль не стартует до подтверждённого освобождения предыдущего.
|
||||
- Один беспилотник обслуживается одним независимым Worker. Масштабирование на десять устройств означает десять Worker, а не десять потоков на 4090. Сейчас не строится полноценная система управления флотом; контракты и тесты не должны закрепить глобальный singleton.
|
||||
- Профиль — полный вычислительный граф и его входные/выходные требования, а не одна модель. DDRNet и EoMT — альтернативные сегментационные ветви разных профилей. Общий runtime/SDK и инфраструктурный агент допустимы; профильные backend/frontend приложения — нет. На первом этапе нужен один новый полный Docker-профиль, не обязательная разработка сразу двадцати вариантов.
|
||||
- Переносимы образ/контракт/конфигурация, а не произвольный hardware performance claim. RTX 5090 и Apple Silicon не являются условиями успеха. Для иной GPU/платформы нужна отдельная проверка; CUDA-образ не объявляется автоматически совместимым с Metal.
|
||||
- Запись и live различаются адаптером входа, не реализацией inference. Реальная совместимость определяется объявленными каналами, форматами, временем, калибровкой и capture-параметрами, не названием LAB или единственным session ID.
|
||||
- Работа остаётся в локальном контуре Mission Core + Worker 006; нет Synology/external-server rollout и deploy-canon workflow. План не является командой на запуск Worker или изменение оборудования. На Mac нет модельной нагрузки, тяжёлых параллельных работ и новых временных серверов; канонический сервис 8000 сохраняется.
|
||||
- Существующие записи, raw evidence, секреты, результаты и рабочие изменения сохраняются. Нет управления движением, изменения scanner-протокола или самостоятельного запуска физического сканирования. Старые доказательства не переименовываются в новые успешные испытания.
|
||||
|
||||
Предлагаемая схема имён, согласованная с обязательным namespace `ndc-`:
|
||||
|
||||
| Название профиля в приложении | Docker image | Экземпляр на Worker 006 |
|
||||
| --- | --- | --- |
|
||||
| K1 Perception — DDRNet-39 + RF-DETR + TGS | `ndc-k1-perception-ddrnet39-rfdetr-tgs:prototype-v1` | `ndc-k1-perception-ddrnet39-rfdetr-tgs-worker006` |
|
||||
| K1 Perception — EoMT-L + RF-DETR + TGS, резервный TARGET | `ndc-k1-perception-eomt-rfdetr-tgs:<version>` | `ndc-k1-perception-eomt-rfdetr-tgs-worker006` |
|
||||
|
||||
Машинный profile ID стабилен и связан с display name; runtime выбирается по sealed manifest/image digest, не только по изменяемому tag. Существенное изменение весов, preprocessing, output labels, precision или execution policy меняет версию профиля и требует нового свидетельства. `K1` в названии описывает capture-совместимость, но не должен зашивать K1 в общий runtime.
|
||||
|
||||
Первый прототип поставляется одним самостоятельным образом и запускается одним контейнером: runtime, обе модели, TGS, preprocessing/postprocessing, конфигурация и manifest находятся внутри образа. Нет зависимости от чужого Triton, checkout, скрытого host-cache, готовой LAB или скачивания моделей на первом запуске. Внутренние supervised процессы/изолированные Python environments допустимы, если решают одну задачу восприятия и управляются общим lifecycle; broker, БД и приложение в этот образ не добавляются. Снаружи только Docker/NVIDIA runtime, входной поток/запись, выходы и явно переданные секреты. Shared base layers допустимы для будущих профильных образов. Сохраняются ownership labels `com.nodedc.product`, `com.nodedc.stack`, `com.nodedc.role`, `com.nodedc.managed-by`. Текущие контейнеры не переименовываются этим планом.
|
||||
|
||||
## Контракт первого полного профиля
|
||||
|
||||
| Слой | Обязательный выход | Граница интерпретации |
|
||||
| --- | --- | --- |
|
||||
| DDRNet-39 GOOSE | Semantic mask, существующее coarse material mapping, freshness и исходные class IDs для evidence | В первом сельском прототипе `hard_surface` допустим без разделения асфальта/тротуара/велодорожки; геометрическое препятствие всё равно блокирует |
|
||||
| RF-DETR native | Объекты, class, confidence, bbox и source frame identity | Person/cat/dog и существующие классы; точное название статического препятствия не требуется, дополнительный bollard-классификатор не нужен |
|
||||
| LiDAR + calibration + pose | 3D support, расстояние с определённым estimator/frame, неизвестные геометрические препятствия и связь с детекциями | Нет подходящих точек/калибровки/синхронизации — range unavailable, а не придуманное расстояние или free |
|
||||
| Temporal / motion | Track identity, оценка движения, current/held/stale/unknown | Тип объекта не доказывает движение: стоящая машина и движущийся человек требуют измерения во времени |
|
||||
| TGS + rolling geometry | Ground support, occupied, rejected, unobserved; локальная costmap и свежесть каждой ячейки | Ground support не равен traversable; map может запретить, но сама не выполняет объезд |
|
||||
| Fusion / policy shadow | Общая scene, согласованные слои, объяснимый allowed-candidate/high-cost/blocked/unknown и advisory events | Не direct motor/control commands; unknown/stale не создают разрешение двигаться |
|
||||
|
||||
Входы: camera frames/encoded chunks, registered point increments, pose, intrinsics/extrinsics, coordinate frames и timestamps с clock mapping. Нужно различать sensor→camera калибровку и будущую sensor→vehicle/body калибровку. Для прототипа виртуальный footprint допускается только с явной отметкой simulation; реальный зазор до корпуса не заявляется до измеренного mount/vehicle profile.
|
||||
|
||||
Решение владельца для первого сельского прототипа: используем существующий coarse `hard_surface` (`asphalt`, `sidewalk`, `bikeway`, `cobble`) как допустимый материал. Разделение дорог, тротуаров и велодорожек, городские правила движения, дополнительная сегментационная модель и обязательный новый road-only классификатор не нужны и не блокируют реализацию. Сохранять исходные class IDs полезно для evidence, но создавать отдельную fine-grained policy сейчас не требуется. Остальные материалы определяются явным выбранным preset/config; слово «сельский» само по себе не разрешает все грунты/растительность. Camera semantics никогда не снимает occupied/unknown запрет геометрии.
|
||||
|
||||
Для столбиков и других неподвижных препятствий переиспользуется существующее представление `static_obstacle` / `static.unknown`, уже присутствующее в vocabulary и визуальном слое; точное имя предмета не требуется. Пользователь ссылается на сохранённый RAV004 как имеющий это поведение. Задача нового профиля — воспроизвести этот слой из текущего потока и проверить его, а не изобрести ещё одну taxonomy, обучить распознаватель столбиков или загрузить старые masks вместо вычисления. Геометрическое препятствие сохраняется независимо от того, нашёл ли detector узнаваемое имя.
|
||||
|
||||
Профиль содержит измерительные модели/алгоритмы и возможности policy; выбранное правило миссии, оборудование, footprint и thresholds входят в effective run configuration. Пара `(immutable profile + effective mission/equipment configuration)` одинакова в Observatory и последующем live; изменение значимого параметра требует нового сравнения/квалификации. Запись не содержит «разрешение будущему автопилоту», только воспроизводимые входы и свидетельство проверки.
|
||||
|
||||
## Этап 1 — Полный состав профиля, контракты и технический baseline
|
||||
|
||||
**Цель:** закрыть состав первого полного профиля и оставшиеся технические разрывы до изменения продуктового пути; получить исходную точку для измерений на 4090.
|
||||
|
||||
**Вход:** локальный обзор, оба отчёта, существующие pinned weights и source-paced эксперименты. Новые измерения требуют доступного выделенного Worker без другой AI-нагрузки.
|
||||
|
||||
**Работа:**
|
||||
|
||||
- Завершить матрицу CURRENT → TARGET для backend, frontend, Worker, recording/live adapters, model runtime и хранения. Зафиксировать keep/replace/deprecate, не переписывая устойчивые admission/publication механизмы.
|
||||
- Описать versioned observation/result contracts: source/stream/epoch/sequence, timestamp/clock domain, payload formats, camera/cloud/pose, calibration и coordinate-frame references; на выходе все слои таблицы выше. Для полного первого профиля cloud и pose необходимы. Не привязывать совместимость к одному session ID или байтам media-init, не существенным для capabilities.
|
||||
- Описать lifecycle с одним владельцем Worker, правило переключения профиля, incremental result contract и различие «установлен», «работоспособен», «прошёл real-time квалификацию», «экспериментальный/не прошёл». Состояния availability, qualification и running/busy не сводить в один флаг `ready`.
|
||||
- Зафиксировать до приёмки численные бюджеты каждого профиля: входной поток, требуемая частота результатов, p95/p99 end-to-end age, допустимые пропуски, очередь, startup/warmup, память и stop-time. Не снижать требования задним числом ради зелёного результата. Неопределённые продуктовые компромиссы выносить владельцу, а не скрывать в scheduler.
|
||||
- Разделить reusable online algorithms и recorded-only подготовку. Проследить, чем заменяются чтение готового `local-surface.npz`, заранее подготовленные TGS rolling inputs, E47 masks и M4 ledgers. Vendor-registered исходные точки/pose допустимы как вход K1; собственные perception-результаты должны вычисляться текущим запуском.
|
||||
- Проверить совместимость зависимостей в одном образе: текущий DDRNet использует Python 3.9 / PyTorch 1.13.1 cu117 / super-gradients 3.2.0, RF-DETR — TensorRT 11, TGS — C++. Не объявлять их совместимыми в одном interpreter без проверки. Выбрать минимальную изоляцию внутри одного контейнера либо доказанное преобразование модели с parity check; не менять веса/качество молча ради сборки.
|
||||
- После согласованного освобождения GPU выполнить короткие baseline компонентов первого профиля последовательно и пилот общего расписания. Разделить decode/preprocess, H2D, inference каждой модели, online geometry/TGS, fusion/policy и доставку результата. Отдельно измерить cold startup и warmed steady state; не начинать с полного старого batch-run.
|
||||
- Включить в preflight/qualification фактические GPU/VRAM clocks, power state/limit, driver и CPU/RAM quotas. Один image на auto/fixed clocks не имеет одинакового измеренного operating envelope. Host-wide настройки меняются только явно разрешённым owner-scoped механизмом с rollback; контейнеру не выдаётся неограниченная власть над GPU ради воспроизводимости.
|
||||
- Сохранить существующие EoMT artifacts и точный checkpoint. Его отдельное исследование/упаковка — последующий вариант профиля, не обязательная ветка первого прототипа. Не запускать EoMT во время работ DDRNet-профиля.
|
||||
- Проверить текущие class/score/box-size/FOV фильтры RF-DETR на требования прототипа, включая близкий крупный объект и маленькое животное; наличие имени класса в модели не доказывает достаточное обнаружение. Не менять пороги без новой версии и сравнительного evidence.
|
||||
|
||||
**Результат и evidence:** точный manifest первого полного профиля, карта входов/выходов и owner boundaries, план единого образа с проверенной dependency strategy, численные инженерные критерии и baseline первого состава. Зафиксированы class coverage, distance estimators, unknown policy и отсутствие motor authority.
|
||||
|
||||
**Выход / зависимость:** GO на этап 2 после фиксации состава, существенных контрактных границ и воспроизводимого baseline. По уточнению владельца от 2026-09-02 performance FAIL на текущем железе не является запретом на развитие лабораторной платформы/упаковки и не требует выбрасывать профиль. На 2026-09-01 выполнены карта/reuse, executable contract, candidate manifest, component baseline и совместный causal pilot полного графа. Дальше измеряем и уменьшаем задержки, исправляем preroll/layered freshness и сохраняем отрицательные результаты; real-time статус выдаётся отдельно, только по факту прохождения критериев. Network whole-path и самостоятельная упаковка относятся к следующему runtime и не объявляются проверенными по локальному collector. Диагностический контейнер с mounts не является runtime этапа 2.
|
||||
|
||||
**Gate на 2026-09-02 10:16 МСК: GO (engineering, not qualification).** Shared output freshness и operating-envelope contract зафиксированы, выполнен bounded полный reference pilot. Обязательная стадия исследования состава/baseline завершена. Не следует бесконечно удерживать упаковку на этапе 1 из-за уже явно измеренных overload/source-gap/quality ограничений. В этап 2 перенесены реализация controller lease/continuous readiness, transport, supervisor, per-cell aging и самостоятельная упаковка; положительный real-time допуск по-прежнему требует отдельных измерений. Cat/dog/close-object качество, mount/clearance, другая и полная запись остаются непройденными gates квалификации, а не новым training scope.
|
||||
|
||||
## Этап 2 — Самостоятельный Docker с полным потоковым вычислением
|
||||
|
||||
**Текущий статус, 16:52 МСК:** в работе. Полный pilot получает реальные GPU clocks/driver, Docker image/limits и bounded inventory через отдельный host controller. В контейнере нет Docker socket; response mount read-only и вне writable outputs. Effective config связывает mode/envelope/inventory scope. Пропавшие/задержавшиеся факты не возобновляют local lease и не выгружают модели; подтверждённый конфликт остаётся terminal. Normal и delayed-reply full graph проверены, raw parity сохранена; далее внешний transport/auth/recovery с отдельным availability budget и standalone packaging. GO на этап3 пока нет.
|
||||
|
||||
**Цель:** один самостоятельный контейнер принимает поток и реально рассчитывает DDRNet, детекции, расстояния, motion, TGS/costmap и policy-shadow; ничего не дорисовывается из старых LAB.
|
||||
|
||||
**Вход:** контракты и baseline этапа 1.
|
||||
|
||||
**Работа:**
|
||||
|
||||
- Реализовать общий lifecycle open/warmup/process/flush/stop, stage interfaces и supervisor. RF-DETR и DDRNet загружаются один раз при активации полного профиля. GPU inference сериализован; CPU TGS/geometry и I/O не блокируют GPU через неограниченную очередь. Разные частоты слоёв задаются явно и измеряются; retained mask не считается новым inference.
|
||||
- Подключить recorded adapter с исходными timestamp и pacing 1×; live adapter подаёт тот же тип наблюдений без знания длительности/конца записи. Не требовать полного tar/download, конкатенации всей камеры, полного PNG-cache или полного source hash-pass перед первым результатом. Допустить ограниченный декодерный pre-roll для codec dependencies, не просмотр будущих наблюдений моделью.
|
||||
- Выбрать и проверить бинарный data plane по реальным объёмам video/cloud, CPU cost и latency. Control plane остаётся отдельным. У модели нет произвольного доступа к сети/host; поток приходит через контролируемый proxy/IPC или явно ограниченный transport. Не включать privileged/host-network ради удобства.
|
||||
- Реализовать online local-surface/geometry и causal rolling TGS из поступивших points/pose. Сохранять неизвестные геометрические препятствия без semantic class. Camera–LiDAR association, distance estimator, track/motion и footprint references выдаются явно; отсутствие поддержки не подменяется нулём/бесконечностью.
|
||||
- Связать результаты слоёв в общий scene contract во время исполнения, а не только склеить тайминги после завершения. Привязать semantic labels к текущей геометрии с temporal/coordinate validity и bounded TTL. TGS и semantic policy становятся реальными входами диагностического rule evaluation, не только соседними слоями viewer.
|
||||
- Подключить существующее coarse material mapping и простую advisory policy: `hard_surface` — допустимый материал для первого сельского прототипа; geometry/TGS occupied и missing/stale evidence имеют приоритет над этим допуском. Тротуар и велодорожка не являются отдельными отказными случаями. Не добавлять второй segmenter или новую fine-grained road policy. Допуск поверхности не выбирает объезд и не выдаёт motor commands.
|
||||
- Сделать ограниченные очереди, явные cadence/drop/TTL правила, backpressure и stop/cancel. Завершение lease, смерть процесса и смена владельца должны прекращать старое исполнение; новый профиль не стартует, пока старый GPU-владелец не освобождён. Restart не воспроизводит накопившийся устаревший live backlog.
|
||||
- Для временного input/network gap сохранять прогретые процессы и локального владельца. Replay clock продолжает 1×; очередь не накапливает прошлое. Повторное подключение создаёт отдельную input epoch без смены model activation/lease generation. До resume нужны свежий декодированный keyframe, causal pose/points, сброшенные tracking/motion/rolling TGS/costmap и повторная проверка свежести. Деградация телеметрии допускает ожидание без выдачи qualified результатов; подтверждённый ownership/runtime fault не маскируется как сетевой лаг. Физический stop/hold и политика возобновления ровера — отдельный будущий onboard safety gate.
|
||||
- Выдавать результаты и технические метрики до EOF; хранение/overlay/video export не блокируют inference. Для сохранения evidence тоже задаётся ограничение ресурсов и честное состояние при переполнении. Не выдавать повтор предыдущей маски за новое измерение.
|
||||
- Упаковать первый полный образ с pinned model assets, TGS/runtime и нужными библиотеками. Проверить запуск без developer checkout, host model cache, внешнего Triton и model downloads. Не добавлять backend/БД/broker в контейнер. EoMT остаётся сохранённым отдельным вариантом вне активного первого профиля.
|
||||
|
||||
**Результат и evidence:** короткий replay 1× выдаёт все заявленные результаты до конца источника, память и очереди ограничены. Каждый слой имеет trace от inputs этого запуска; старые E47/M4/local-surface artifacts не предоставляются. Проверяются person/cat/dog, существующее static-obstacle представление без точного имени, допустимый `hard_surface` и препятствие поверх него, missing LiDAR, stale mask, burst/gap/out-of-order/cancel/lease-loss. Synthetic tests на Mac не загружают тяжёлые модели; реальные модельные случаи проверяются на Worker.
|
||||
|
||||
**Выход / зависимость:** GO на этап 3 после подтверждения работоспособности самостоятельного полного образа, streaming lifecycle и GPU ownership. Если полный граф не проходит короткий budget, сначала локализуется причина внутри этапа; FPS одного DDRNet и добавление UI не закрывают этот gate.
|
||||
|
||||
## Этап 3 — Сквозное подключение backend и frontend без LAB-микроприложений
|
||||
|
||||
**Цель:** приложение выбирает совместимый профиль и показывает его работу через общие контракты.
|
||||
|
||||
**Вход:** полный standalone runtime и образ первого профиля из этапа 2.
|
||||
|
||||
**Работа:**
|
||||
|
||||
- Расширить generic registry/preflight/claim: требуемые input capabilities, pinned executor identity, актуальная доступность Worker и соответствующее performance evidence. Совместимость, установленность и real-time квалификация проверяются отдельно; несовпадение capture/weights/runtime/hardware делает прежний допуск неприменимым.
|
||||
- Привязать leases, fencing, ограничения активности и live/recorded ownership к конкретному `worker_id`. Исключить двойной запуск на одном Worker. Независимые Worker не блокируют друг друга глобальным SQL/константой. Проверить эту независимость лёгкими fake-worker тестами, не параллельными GPU-запусками.
|
||||
- Интегрировать incremental results/progress/cancel и последующую immutable publication. Потоковое отображение не ожидает terminal archive. Существующие provenance, digest validation и восстановление публикации сохраняются.
|
||||
- В едином Observatory показать полный состав профиля и понятное название, совместимость источника, effective mission-rule, занятость Worker и qualification status. Один recording можно запускать на разных версиях профиля, один профиль — на разных совместимых recordings. Наличие двадцати профилей не создаёт двадцать приложений.
|
||||
- Расширить общие renderer/result capabilities: semantic mask, boxes/class/track, LiDAR support/ranges, TGS/costmap, motion и policy-shadow с явной свежестью. Все слои ссылаются на текущий run; исторический overlay разрешён только как явно выбранное сравнение, не скрытый fallback. EoMT и DDRNet не считаются одной ontology.
|
||||
- Сохранять run matrix с recording/capture/profile/effective mission configuration, техническими результатами и инженерной оценкой ошибок. Подготовить versioned profile reference для будущего mission configurator: он должен выбирать тот же immutable image/config, а не другой «live вариант» с тем же названием. Сам конфигуратор миссии и управление оборудованием в этом этапе не реализуются.
|
||||
- Применить UI skill и действующие архитектурные/UI документы перед UI-реализацией. Технические counters/p95/drop reasons размещать в соответствующих деталях, не превращать основное рабочее место в консоль отладки.
|
||||
|
||||
**Результат и evidence:** несколько совместимых recordings последовательно запускаются через приложение на одном полном профиле без правки кода; до конца записи видны все слои текущего run. Несовместимый источник и второй профиль на занятом Worker отклоняются. Видимый qualification-status совпадает с evidence; результат и сравнение версий сохраняются после перезапуска. Второй реальный модельный профиль не требуется придумывать ради этого gate: независимость каталога проверяется контрактными fixtures.
|
||||
|
||||
**Выход / зависимость:** GO на этап 4 после интеграционных, контрактных и последовательных browser-проверок общего пользовательского пути. Успешный UI smoke ещё не означает full-session real-time acceptance.
|
||||
|
||||
## Этап 4 — Квалификация полного прототипа на записях и подготовка к полигону
|
||||
|
||||
**Цель:** доказать вычислительную работоспособность полного профиля на 4090, найти его ошибки на записях и передать воспроизводимый прототип для последующего полигона, не объявляя готовую автономию.
|
||||
|
||||
**Вход:** сквозной путь этапа 3; численные критерии заморожены до испытаний.
|
||||
|
||||
**Работа:**
|
||||
|
||||
- Перед каждым профилем/испытанием проверить единственного владельца GPU, версии, питание/ограничения и warmup. Запуски строго последовательные. Сначала bounded canary; полный recording только после его прохождения. Не повторять заведомо неуспешный длинный EoMT-run ради завершения плана.
|
||||
- Провести full-session replay 1× всего профиля DDRNet + RF-DETR + geometry/motion + TGS + fusion/policy с учётом всех наблюдений. Проверить отсутствие растущего отставания, cadence/age/drop/memory budgets каждого слоя и общего выхода до приложения. Отдельно измерить startup, steady state и export; prepared geometry/masks не выдаются за online вычисление.
|
||||
- Проверить смысловые сценарии: допустимый `hard_surface` без препятствия и с препятствием, static-объект без уточнения имени, человек/животное, движение и неподвижность, отсутствие LiDAR/pose, истёкшая маска и конфликт семантики с геометрией. Тротуар/велодорожка относятся к принятой coarse категории, а не к отрицательным road-only случаям. Это инженерная проверка сельского прототипа, не общая accuracy и не городской автопилот. Новая разметка/обучение не являются условием первого запуска.
|
||||
- Проверить другую совместимую запись без изменения прикладного кода и отрицательные случаи: отсутствующий канал, неверная калибровка/формат, изменённые веса, другой hardware. Если второй источник отсутствует, соответствующее доказательство остаётся pending. Проверка другой машины/платформы не симулируется заявлением «это Docker».
|
||||
- Проверить interruption/reconnect, медленный consumer, burst/loss, stop, lease-loss, restart и публикацию. Проверить live-compatible вход с неизвестной заранее длительностью, без чтения будущего, на том же runtime. Физический live через K1/роутер/беспилотник проводится позже при доступности оборудования и согласованного окна; его отсутствие не блокирует replay-прототип. Целевые 300–500 Mbps не являются доказанным каналом: физический gate потребует uplink/jitter/loss/clock alignment/end-to-end age.
|
||||
- После приёмки нового пути убрать combined EoMT→DDRNet setup из активного real-time каталога, затем ограниченно вывести устаревшие adapters/микроприложения. Исторические записи/результаты и проверенный legacy M4.9 не удалять. Изменять конкретные declarations; для каждой runtime-миграции иметь точный predecessor и восстановление, без массового docker rename/delete.
|
||||
|
||||
**Результат и evidence:** один standalone image полного профиля, его manifest и инструкция запуска; таблица проверенных recordings/условий/ошибок; честный статус replay-prototype-qualified либо blocked. Подтверждены sequential exclusivity, переносимость на совместимые записи и регрессии сохранённых результатов. EoMT сохранён отдельно, не потерян и не включён в нагрузку первого профиля.
|
||||
|
||||
**Выход:** закрыть scope первого прототипа только при прохождении его replay/standalone gates. Отдельно оставить `physical-live-pending`, `independent-quality-pending`, `vehicle-integration-pending`, `actuation-disabled`. Ни новый известный маршрут, ни прошлый успешный recording, ни хороший FPS не включают автономию. Будущие EoMT/другие профили проходят те же gates отдельно, без расширения текущего этапа до бесконечного поиска моделей.
|
||||
|
||||
## Как измеряем и принимаем результат
|
||||
|
||||
Для каждого испытания сохраняются точный profile/image/weights/config digest, effective mission policy, equipment/capture/calibration identity, Worker/hardware, драйверы/runtime, source identity, интервалы и численные критерии, cold/warm режим, исходные counters и итоговый verdict. Изменение значимого измерительного контекста требует новой квалификации, а не ручного `ready=true`. FPS измеряется для полного output contract; отсутствие обязательного слоя не считается ускорением профиля.
|
||||
|
||||
Считаются source cadence, released/received/selected/inferred/emitted/dropped/expired/failed observations, queue depth, release lag, decode/preprocess/inference/postprocess, online geometry/TGS/fusion/policy и доставка результата до приложения, RSS/VRAM и объём передачи. Все входы должны быть учтены по однозначным терминальным категориям; processed FPS, successful-result cadence и fresh-result cadence — разные величины. У общего scene-result сохраняются возраст и source sequence каждого вложенного слоя, а не только свежий timestamp оболочки.
|
||||
|
||||
P95/p99 model-time не заменяет end-to-end age. Для разных машин нельзя вычитать несогласованные часы: clock mapping и его погрешность входят в evidence; внутрипроцессные интервалы измеряются monotonic clock. Startup/warmup не скрывается, но не смешивается с steady-state FPS. Устройство/модель не считается ready до завершения warmup.
|
||||
|
||||
Разрешённые пропуски определяются контрактом до запуска. К примеру, 6830/808.779495667/5 ≈1.689 результата/s: прежнее требование EoMT ≥1.8 FPS несовместимо со stride 5 на этом среднем исходном потоке. Требуется согласованная cadence-политика, а не механический перенос прежнего профиля. Quality-check для оптимизированных весов/precision/preprocessing отдельный: этот рефакторинг не создаёт ground truth и не даёт навигационную безопасность.
|
||||
|
||||
Проверки идут от дешёвых схем/negative/unit tests к isolated Worker canary, затем integration/full replay и доступному physical live. На Mac запускаются только ограниченные релевантные проверки; никаких model benchmark или full stress suite. После каждого этапа фиксируются выполненный результат, ссылки на evidence, известные ограничения и GO/PAUSE/BLOCKED. Следующий этап не начинается при незакрытом обязательном критерии предыдущего.
|
||||
|
||||
## EoMT: проверенные внешние сведения и предел вывода
|
||||
|
||||
EoMT — семейство ViT-моделей сегментации изображений; архитектура применяется к semantic, instance и panoptic segmentation. Наш конкретный checkpoint — Cityscapes semantic EoMT-L 1024, а не универсальное обещание качества на любой камере/домене. Это следует из [карточки конкретной модели](https://huggingface.co/tue-mps/cityscapes_semantic_eomt_large_1024) и [исходной статьи](https://arxiv.org/html/2503.19108v1).
|
||||
|
||||
В [официальном DINOv2 model zoo](https://github.com/tue-mps/eomt/blob/master/model_zoo/dinov2.md) для Cityscapes EoMT-L 1024×1024 указаны 25 FPS; условия таблицы — NVIDIA H100 с default `torch.compile`, если не оговорено иное. Это не показатель RTX 4090 и не end-to-end скорость нашей системы. Поэтому оснований объявить всё семейство «не real-time» нет, но и оснований квалифицировать наш профиль по этой цифре нет. Сохранение EoMT — отдельный исследовательский/резервный профиль, без обязательства неограниченно его ускорять.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-09-02 22:35 МСК: `6af4c20`, инкремент18. CPU-only TLS A/B/A:96 обменов/192 RPC на case, p95 96.093/93.895/95.145ms; relay не устранил tail.424/424 ciphertext records exact,192 RPC matched,288 clocks reconstructed.13 post-start B tails >50ms локализованы к inter-edge request8/response5; startup mapping unknown сохранён. Прямой TCP SSH22 без forwarding/gRPC/Docker:16/16 успех,7 скачков67.921–91.142ms; Worker loopback после первого0.240–0.299ms. Mac en0 и Worker Realtek8812BU USB — оба Wi-Fi.82 local/82 Worker PASS,142 staged files/132 Python exact,43 artifacts/manifest `abc3ac677b81e0d78f8d5891517022e33ac7fa962166bc399b4009ee48d87d8e`. Нет новых GPU/full-profile запусков или runtime fix; следующий шаг — per-frame timing/10ms reply polling и доступный wired comparison. Временные ресурсы удалены, durable IDs/policies сохранены; два прежних сервиса автономно увеличили restart counts, не объявлены починенными.
|
||||
|
||||
- 2026-09-02 22:06 МСК: `59a74a3`, инкремент17. Bounded clock-only probe +10ms scheduling witness, без lease/grant/source/model authority. CPU A/B/A ready48/96 →94/96 →53/96; LAN после warmup непрерывен, ~100ms RPC tails сохраняются. Один полный LAN-canary32/32 exact без WAIT/drop/reconnect,30 результатов до EOF,12 полностью fresh из17 доступных sensor pairs. p95/p99 205.864/213.751ms —125ms FAIL; четыре resident PID/lease1 сохранены. Все338 raw clock exchanges (288 CPU +50 full) независимо пересчитаны.245 local/202 Worker tests PASS,2 Worker-only skip локально;131 source hashes, прежние129 без изменений.67 artifacts/manifest `2645a0faacb89b5af5d3756feea05550a12cc64820be926d32f2103b5f225caf`. Результат не standalone и не изменение постоянного маршрута. Ресурсы освобождены, сервисы/auto clocks восстановлены.
|
||||
|
||||
- 2026-09-02 21:12 МСК: `48835a0`, инкремент16. Joint startup PASS; 1× source начинается с0, anchor принят обеими сторонами. Full canary8/32 FAIL: compute discard6, source WAIT7–15, sync16–29, результаты0–5/30–31. Raw receipt exact8/8; до EOF6, полностью fresh5. Clock exchanges55/55 пересчитаны, readiness41/55; running uncertainty5.031–5.667ms, host telemetry33/33 без ошибок. Lease1/4PID пережили WAIT, после End всё освобождено.241 local/198 Worker PASS;129 source hashes.53 artifacts/manifest `9cb369fe7ae185d1a92f6050bc1018a878483f5eec169267f100843245428691`. Следующий gate — CPU-only control/data scheduling attribution, не ещё один слепой GPU retry.
|
||||
|
||||
- 2026-09-02 20:37 МСК: `35b6cd9`, инкремент15. Full Mac↔Worker graph использует acknowledged clock bounds, uncertainty для слоёв/ячеек, WAIT и immutable source anchor. Исправлены EOF ordering, repeated clock read и ранний disconnect до OPEN;297 local/173 Worker/12 repeated reconnect PASS. Финальный canary2/32,13 source WAIT+17 sync skips, оба результата после EOF,143.881/155.743ms; real-time FAIL, пороги не ослаблены. Следующий пункт — совместный pre-start clock readiness обеих сторон, затем короткий повтор. Все попытки сохранены;198 artifacts, manifest `7b435cac111b3e0e789aa137ba064651e6d344a5f8fb8fa35b0abbd0e7cb1d74`. Temporary resources удалены, leases released, stock-clock lock снят, четыре сервиса восстановлены,8000/telemetry работают. Этап2 остаётся открытым, stages3–4 не начаты.
|
||||
|
||||
- 2026-09-02 19:42 МСК: `d7b8989`, инкремент14. TLS Poll выдаёт pending grant с activation binding без GPU/model authority; four-timestamp mapping с conditional500ppm/50us envelope,16 samples/expiry2s. CPU-only Mac↔Worker:72 probes,60 внутри5ms,8/8 exact echoes, gap2.202s сохраняет PID9/lease1 и22 локальных renewal. Mapping истёк и восстановился; readiness менялась также после warmup.252 local PASS/2 Worker-only skips,65 Worker PASS; Ruff/mypy PASS.81 source hashes сверены; manifest18 artifacts SHA-256 `cd479c577fa3da78b1b01a6a90e03e8c742ecb53561e606d939408120a53a622`. Full-graph mapping/freshness integration остаётся впереди; модели не запускались. Временные ресурсы/секреты удалены, owner released,8000 и телеметрия работают. Старый perception-worker self-restart26→27 зафиксирован, не объявлен исправленным.
|
||||
|
||||
- 2026-09-02 15:56 МСК: `70927ea`, full-graph recovery. CPU normal128/gap76 и два повторения full normal128/gap76; 383 tests PASS. Четыре PID сохранены, reset stores 92/16/6/291 →0 и507/42/6/358 →0; stale history/cells отсутствуют. Final normal p95/p99 85.775/89.385ms, gap83.163/89.729ms при SM2610/mem10251. First fresh scene после fault1476.169/3857.475ms, seq30/110,52 intentional skips учтены. Первая GPU попытка прервана до моделей: ошибочный новый idle threshold512MiB был ниже известного baseline534MiB, факт530MiB; сохранена, проверка исправлена, пустой owned lease удалён. 330 artifacts,236 Worker hashes,114 current files verified; `.runtime/perception-stage2-resume-worker-20260902T1545MSK/manifest.json`, SHA-256 `a9449fa4c012e30ce8a06025790c0e110eeebab7c1859fdebf56d5e785961ea3`. Clocks/services восстановлены; legacy restart loops не исправлялись, product/vehicle scope не расширен.
|
||||
- 2026-09-02 15:30 МСК: `c31c46c`, resumable lifecycle/input epoch, recoverable readiness, decoder/sensor reset. 375 focused tests PASS, Ruff/format 14 files и mypy 7 modules PASS. Worker CPU-only три input epochs, EOF-паузы 150 ms / 2.2 s, 3/44 synthetic source ticks во время пауз, 54 local renewals; PID decoder/sentinel неизменны, BGR exact 3/3. Peak input 5,275,461 bytes, final input/children=0, released lease; temporary container удалён. Один реальный H.264 fixture + synthetic timestamps/sensors/temporal reset, НЕ полный graph/GPU recovery. 73 code hashes сверены, 85 evidence artifacts; `.runtime/perception-stage2-continuity-worker-20260902T1518MSK/manifest.json`, SHA-256 `fbd327d129c88caa13b7183b1ae2c4767e877a6f5472c0f430875cfd7401ed46`. Сервисы/clocks не менялись; прежние legacy restart loops не исправлялись. Старый terminal-readiness эксперимент ниже сохранён как история; новый TARGET по решению владельца — wait/resync для временной недоступности.
|
||||
- 2026-09-02 14:52 МСК: `92625bf`, readiness/lifecycle increment. 349 focused tests PASS; Worker Linux CPU-only 4/4 scenarios, stop 10.676–63.994 ms, 0 residual bytes, terminal fixture lease generation 4 released. Expired inventory остановил runtime через 1043.960 ms после последнего refresh, несмотря на 13 heartbeat renewals; late refresh/result rejected. Allowed overload сохранил violation после восстановления clocks. 70 measured code files verified, 86 evidence artifacts; `.runtime/perception-stage2-readiness-worker-20260902T1450MSK/manifest.json`, SHA-256 `4588995d763fe82d896b42689e981f1d00feedaf9d6aab29f4579f771d03da3a`. GPU facts synthetic, no model timing or collector qualification. Service IDs/StartedAt/restart counts неизменны до/после; у legacy agents уже были restart counts 21/2/2 — состояние running не объявляется устойчивой готовностью. Clock setters, service mutations, network listeners, registry/UI отсутствуют.
|
||||
- 2026-09-02 14:22 МСК: `380b6ea` / `f39f1ff`. CPU probe (NumPy 1.26.4, Worker x86) воспроизвёл две ошибки range_m через quaternion address 8 mod 16; aligned copy сохраняет bytes и прежние norm/projection результаты. Удалены четыре full-frame copies на связке decoder→parent→DDRNet; binary framing, source clock, TTL, lease checks не менялись. A–B–B–A, 4×128 без drops: контроль p95/p99 84.702/92.961 и 83.930/91.759 ms; кандидат 85.113/91.219 и 82.425/90.530 ms. Кандидат raw parity 128/128 дважды, available/fresh 76/128; global qualification=false. CPU oracle 128/128 BGR/sensors для обоих вариантов, hung decoder cleanup PASS. 279 focused tests PASS. Manifest 305 artifacts, SHA-256 `9c72a93833a1dda51c4787dca13fb9791a1bf2e31c763b7740e1b21406703707`, `.runtime/perception-stage2-ipc-worker-20260902T1410MSK/manifest.json`. Частоты auto и сервисы восстановлены; registry/UI/image не менялись.
|
||||
|
||||
- 2026-09-02 13:55 МСК: `221e429` — supervised decoder RPC (250 ms deadline, 1 MiB input / 1,440,000-byte caller-owned BGR), общий causal sensor cache и atomic reservation → mailbox transfer. CPU oracle 32/32 exact до полного запуска и после review; native-hang injection учтён как failed cameras 0/1/2, child/lease/input освобождены. Полный binary 1×: 384/384 событий, 128/128 сцен без drops, BGR/segmentation/proposals/tracks/threats/material/raw costmap exact; два range_m отличаются на 1.388e-17 m, strict parity=false. p95/p99 161.974/191.806 ms; 76 available sensor pairs, 74 fresh scenes, seq106/107 дополнительно stale. 271 focused tests, Ruff/format/mypy PASS. Evidence `.runtime/perception-stage2-binary-graph-worker-20260902T1335MSK/manifest.json`: 161 artifacts, SHA-256 `e224de4e9f2c5fd419fe0fb0b9be6e01ec267628568e104795ae211ec803bce4`. Один GPU-прогон, без изменений clocks/quotas/models; сервисы восстановлены. Standalone/network/product cutover не заявлены.
|
||||
|
||||
- 2026-09-02 10:16 МСК: `097e450` — six-layer shared ABI и read-only Worker operating-envelope checker. Два sequential PyTorch auto-clock runs, 128/128 без drops; финальный C p95/p99 122.11/141.40 ms. 27 свежих полных scenes, 52 missing sensor pairs, 49 stale costmaps; пять дополнительных отказов после receipt aging. Функциональные model/geometry/motion/TGS/material outputs точны к reference 128/128. 142 focused tests + 42 adjacent regressions PASS, 1 исторический evidence-root test FAIL из-за существующих symlinks в соседний checkout; данные и guard не менялись. Этап 1 закрыт как engineering baseline; этап 2 допущен, не реализован. Evidence manifest `3b0a2ad32f6b60d3da48a4bcafdf6e11bc91209abc9d1f65da54b0eb36c52d45`.
|
||||
|
||||
- 2026-09-01 18:15 UTC: локальный обзор backend/frontend/Worker и двух документов выполнен в предыдущей итерации; ограничения и отсутствие свежей Worker-проверки перенесены в этот план.
|
||||
- 2026-09-01 18:15 UTC: прочитаны все пять документов CODEX_V5; уточнение владельца о взаимоисключающих профилях внесено в TARGET. Проверены первичные источники EoMT. Создан план ровно из четырёх этапов.
|
||||
- 2026-09-01, последующий аудит: прочитаны M4.9T5/E47/M4 и integrated artifacts; через SSH выполнены только read-only Docker inventory/inspect, чтение installed manifests и GPU status. Подтверждено расхождение между viewer composition, reusable graph и portable package; выявлены другие GPU-capable сервисы.
|
||||
- 2026-09-01 18:48 UTC: по уточнённому сценарию владельца план переработан под один полный standalone Perception-профиль DDRNet + RF-DETR + geometry/motion + TGS + policy-shadow. Два обязательных сегментационных образа больше не являются целью первого прототипа. Road-only/coarse-material разрыв и dependency compatibility включены в этап 1.
|
||||
- 2026-09-01, следующее решение владельца: fine-grained road-only исключён из первого прототипа; coarse `hard_surface` принят, сельская среда — текущий контекст. Static-obstacle представление переиспользуется без распознавания отдельных видов предметов. Предыдущее требование road-vs-sidewalk gate снято; dependency compatibility остаётся технической задачей.
|
||||
- 2026-09-01 19:15–19:58 UTC, этап 1: по прямому разрешению владельца остановлены Ollama/Frigate, Docker restart=no закреплён в runtime и Compose, добавлены manual-only profiles. Данные/модели/записи сохранены. После замеров эти два сервиса не восстанавливаются; остальные временно остановленные Mission Core Worker-контейнеры восстановлены.
|
||||
- 2026-09-01 19:31–19:57 UTC: выполнены последовательные 64-sample baseline DDRNet, RF-DETR, TGS core и synthetic online local-surface. Ограничение BLAS/OMP/MKL до одного потока снизило mean synthetic local-surface с 198.0 до 63.2 ms без изменения алгоритмических порогов. Полный профиль этими цифрами не измерен.
|
||||
- 2026-09-01 19:49–19:57 UTC: построен локальный диагностический image с Python 3.9 DDRNet environment, TensorRT 11/native Triton base, Python 3.12 geometry и C++ TGS. Все четыре исполнились отдельными последовательными probes одного image ID; DDRNet masks совпали на 64 кадрах. Модели ещё монтируются явно, общего supervisor/IPC нет; standalone/full-profile статус не присвоен.
|
||||
- 2026-09-01: добавлены ADR 0049, transport-neutral stream/freshness/measurement contracts и pinned candidate manifest без image digest/active registry mutation. 78 focused tests прошли (51 новых contract + 27 существующих live-ingress/synchronizer/shadow); Ruff и mypy новых контрактов прошли. Модельные baseline и нагрузочные проверки на Mac не выполнялись.
|
||||
- 2026-09-01 20:51 UTC: совместный граф выполнен на исходных camera/point/pose arrivals 1×. Исправлена bounded causal body history для motion/threat и векторизован costmap lookup. До оптимизации 120/128, 8 drops, p95/p99 353.98/401.19 ms; после — 128/128, 0 drops, 174.09/190.62 ms. Fresh input у 75/128 кадров; остальные явно unavailable. 86 focused tests прошли. Все результаты и отрицательные gates сохранены в отчёте этапа 1.
|
||||
- 2026-09-02 00:20–00:41 МСК: уточнение владельца о долгосрочном экспериментариуме внесено в план, ADR, Desktop status и candidate `experiment_policy`. Тяжёлые профили сохраняются; functional, quality и hardware/source/transport-specific performance статусы независимы.
|
||||
- 2026-09-02: добавлены CUDA-event/host-IPC timing и экспериментальные варианты DDRNet layout/CUDA Graph; оба не показали устойчивого выигрыша полного графа и не стали defaults. Затем реализован `--schedule overlap-cpu`: один serial GPU worker, один chronological CPU consumer, общий бюджет двух pending кадров, единый bounded учёт входов. Первый fixed-slot вариант потерял один кадр и сохранён как отрицательный результат; dynamic shared-slot вариант дважды дал 128/128 без drops. Маски, proposals, metric observations, tracks, threats, costmap и non-timing TGS output совпали с reference на всех 128 кадрах; age-based policy проверяется отдельно.
|
||||
- 2026-09-02: 91 focused tests PASS (12 pilot, 52 contract, 27 existing live-ingress/synchronizer/shadow), Ruff PASS, Python 3.9 child lint PASS. Семь последовательных Worker runs и версии кода сохранены в `.runtime/perception-latency-20260902T0020MSK/manifest.json`. Worker-сервисы восстановлены, Triton ready=200, local 8000=200; Frigate/Ollama exited/restart=no. Никаких продуктовых defaults/registry cutover.
|
||||
- 2026-09-02 01:06 МСК: по запросу владельца сохранены все 122 изменённых/новых исходных файла семью тематическими коммитами; чистая контрольная точка `ffd6be5`. Коммиты: `d655d69` equipment/capture, `a945d66` backend lifecycle, `62d5520` Worker packaging, `5a78c99` portable UI, `4e04d06` real-time contracts, `bfe6ef4` streaming pilot, `ffd6be5` plan/evidence. Push не выполнялся; raw/weights/runtime не включены. Отдельный `05c99ef` исправляет девять legacy test failures: исторические fixtures отделены от новых installed-package pins, старый image явно отвергается; production digest checks не ослаблены.
|
||||
- 2026-09-02: следующий source-binding increment отделяет первый preroll от текущих LiDAR increments, сохраняет history-only timestamps/points в rolling/TGS и добавляет поэлементные причины unavailable/stale/held. Metadata audit: первый кадр получает 4,787 current points, 7,207 preroll points остаются history-only; остальные 127 admissions и increment identities неизменны. 384 focused Python tests и 62 выбранных frontend architecture/Observatory tests PASS. Нового GPU run, frontend build, deployment или product cutover в этой итерации не было.
|
||||
- 2026-09-02 01:22–01:33 МСК: preroll fix проверен тремя последовательными 128-frame Worker-прогонами полного графа. Во всех трёх кадр 0 valid и TGS рассчитан; model outputs на 127 общих последовательностях совпали с прежним control. Каждый прогон потерял ровно один кадр из-за `handoff-overflow`: A — seq 84, B/C — seq 38. p95 = 127.18/125.19/120.84 ms, p99 = 143.22/138.02/135.46 ms. Ничего не продвинуто в defaults: zero-drop и 125 ms gate FAIL. Evidence: `.runtime/perception-preroll-worker-20260902T0122MSK/manifest.json`.
|
||||
- 2026-09-02 01:33 МСК: временно остановленные Triton, perception Worker и два Observatory agent возвращены в исходное состояние; Triton healthy/ready=200, оба agent running, canonical Mac 8000=200, Mac 8765 закрыт. Legacy perception Worker возобновил существовавший до замера HTTP 404 restart loop (running, restart count 4 на контрольном снимке); новый пилот от него не зависел. Ollama/Frigate сохранены exited/restart=no. Модельные запуски были строго последовательными; EoMT и посторонняя GPU-нагрузка не запускались.
|
||||
- 2026-09-02 01:43–01:50 МСК: `2945859` заменил ложную buffered reservation на synchronous rendezvous, только когда consumer уже ждёт. 109 focused tests и Ruff PASS. Два последовательных Worker-повтора: 128/128, 0 drops, unaccounted=0; p95 = 128.75/122.69 ms, p99 = 151.67/157.10 ms. Повторная функциональная parity всех 128 outputs PASS. Evidence: `.runtime/perception-handoff-worker-20260902T0143MSK/manifest.json`, SHA-256 `d68af383c42eb3686ef199afeebd4625f27d573b77109e7e8ff8c85e811a9003`.
|
||||
- 2026-09-02 01:50 МСК: Worker-сервисы снова возвращены в исходное состояние; Triton healthy/ready=200, оба Observatory agent running, active pilot containers=0, canonical Mac 8000=200, Mac 8765 закрыт. Legacy perception Worker возвращён в прежнюю HTTP 404 restart-loop конфигурацию; Ollama/Frigate exited/restart=no.
|
||||
- 2026-09-02 01:57–02:47 МСК: surface reuse `d9ea7c1` сохранил functional parity 128/128, surface p99 19.44/21.87 ms. NVML/no-telemetry и cadence 50/100 ms не дали повторяемого latency PASS. Unified Triton с DDRNet/RF-DETR исполняет модели последовательно; NumPy preprocessing точен 128/128, но TensorRT mask отличается на 0.01195% пикселей и меняет одну ячейку grass→hard_surface / NO_GO→ALLOW_candidate. Backend не продвинут в default. Измерены пять whole-graph Triton variants, p99 137.58–151.37 ms; CUDA Graph/busy-wait проблему не закрыли. 119 focused tests PASS. Evidence: `.runtime/perception-unified-triton-worker-20260902T0227MSK/manifest.json`, SHA-256 `ed81475e71b7d02af03dbc65ef628065baaec80bf4877b83e80cc6b3d682a330`.
|
||||
- 2026-09-02 02:47 МСК: pilot containers=0, Triton healthy, два Observatory agent running, local 8000=200, 8765 закрыт. Legacy perception Worker восстановлен в прежнюю HTTP404 restart-loop конфигурацию; Ollama/Frigate exited/restart=no. Нет product cutover/deployment, raw/weights/runtime не добавлены в Git.
|
||||
- Git checkpoint: `d9ea7c1` — surface cache reuse; `34f13ba` — bounded Triton/cadence/NVML/preprocess diagnostics и regression tests. Документы и выводы фиксируются отдельно; push не выполнялся.
|
||||
- 2026-09-02 09:11–09:40 МСК: численная диагностика frame 115 связала большую часть mismatch с TF32, а critical policy flip — с sigmoid ties и ничьей двух ground votes. `c6c42c2` переводит tie в unknown/NO_GO; 210 cell×frame значений на 41 кадре изменились только консервативно. 122 focused tests PASS. Masks/detector/geometry/motion outputs не изменились от этой правки.
|
||||
- 2026-09-02 09:31–09:37 МСК: по явному разрешению владельца A/B stock clock lock (requested SM 2610 / memory 10501 MHz; observed CUDA 2610/10251) без изменения 450 W или Docker limits. Auto B: p95/p99 123.14/138.16 ms; locked C/D: 64.08/70.46 и 63.97/67.77 ms; pinned PyTorch E: 71.80/77.02 ms; auto-restored F: 116.71/144.88 ms. Все шесть новых полных проб 128/128, 0 drops; функциональная parity clock A/B 128/128. Это повторяемый bounded latency PASS при указанном envelope, не whole-product qualification. Evidence: `.runtime/perception-parity-pacing-worker-20260902T0911MSK/manifest.json`, SHA-256 `eac303c218ab391ed5dc2cd8d94be6f1eeecec2effc123da7585135a8b63f2dd`.
|
||||
- 2026-09-02 09:40 МСК: clock resets успешны, GPU снова auto/P8 210/405 MHz, power limit 450 W; pilot containers=0. Четыре Mission Core сервиса восстановлены в прежнюю конфигурацию, Triton healthy/ready=200; прежние сторонние restart-loop дефекты не исправлялись. Ollama/Frigate exited/restart=no, Mac 8000=200, 8765 закрыт. Raw/weights не в Git, push/deployment не выполнялись.
|
||||
- 2026-09-02 10:16 МСК: этап 1 закрыт как engineering baseline (`097e450`, `adcca7a`), не qualification. Six-layer ABI и Worker envelope закреплены; final auto C: 128/128, 0 drops, p95/p99 122.11/141.40 ms, 27 fresh scenes. Подробности и известный historical baseline test FAIL сохранены в отчёте.
|
||||
- 2026-09-02 11:00 МСК: этап 2 начат. `bcacb02` переносит queue/GPU scheduler в common perception, ограничивает drop-history и проверяет владение входом при release/cancel. `1f8101e` добавляет адресную свежесть costmap; core tests и полный GPU-пилот используют тот же код. 202 focused tests, Ruff, mypy четырёх изменённых core modules и diff-check PASS.
|
||||
- Два последовательных Worker runs: whole-scene p95/p99 125.01/130.31 ms, fresh 25/128; per-cell 123.91/136.17 ms, fresh 76/128. В обоих 128/128 без drops; raw functional parity 128/128, missing pairs 52. Per-cell блокирует 2195 cell×frame ground appearances (1994 при publication, ещё 201 при receipt); это не 2195 уникальных физических препятствий. Все 256 derived receipts повторно проверены по payload hashes/ages/policy.
|
||||
- Продуктовый backend/frontend path и registry не переключены. Этап 2 остаётся незавершённым: общий supervisor/lease fencing, binary live ingress без знания EOF и standalone package ещё не реализованы. Этапы 3–4 не начаты. Следующий bounded шаг — lifecycle/ownership + неизвестная длина входного потока; старый batch materializer не ослаблять.
|
||||
- Evidence первого инкремента этапа 2: `.runtime/perception-stage2-cells-worker-20260902T1055MSK/manifest.json`, 125 artifacts, SHA-256 `1a951fd9e807099e500fa66f8a40b1076071f96e4c8e0be4ab3094caaa33bf09`. Четыре сервиса восстановлены, Triton ready=200; Ollama/Frigate exited/restart=no, pilots=0, GPU auto P8, Mac 8000=200 и 8765 закрыт. Модели/сырые outputs не попали в Git.
|
||||
- 2026-09-02 12:00 МСК: `6acf468` / `ac69e3b` — общий subprocess lifecycle и cooperative Worker-local lease/fencing подключены к полному пилоту. 223 focused tests PASS. Отдельные контейнеры подтвердили contention rejection, clean generation succession и crash quarantine. Global product queue не менялась; canonical persistent control root и backend integration не установлены.
|
||||
- Normal pilot 128/128 без drops, p95/p99 124.98/136.12 ms, fresh 76/128. Intentional expiry A: 52 released = 51 complete + 1 GPU terminal drop; expiry trace B: 50 released = 50 complete. Оба `execution_complete=false`, reason=`lease-lost`, unaccounted=0, все процессы/входы освобождены. Final trace: stop-request deadline +45.74 ms, retirement +4109.82 ms, receipts at/after deadline=0. Все 229 опубликованных raw outputs exact к reference prefix; clocks/quotas/models не менялись.
|
||||
- Evidence: `.runtime/perception-stage2-lifecycle-worker-20260902T1146MSK/manifest.json`, 259 artifacts, SHA-256 `00bd091a80845e50489a34ce4df2cbf3d2cd60d9ecb13d1d97accd03d44fdba4`. Временные probe containers и два test volumes удалены после сохранения owner records. Четыре сервиса восстановлены; Triton ready=200, Ollama/Frigate exited/restart=no, Mac 8000=200, 8765 закрыт. Этап 2 остаётся в работе; дальше binary ingress, постоянная controller/recovery integration и standalone package.
|
||||
|
||||
- 2026-09-02 12:35 МСК: третий инкремент (`da60fef`, `a937400`, `79ce55d`), 276 focused tests PASS. Worker raw IPC: 103/103 exact, каждый data channel до End, peak 553,991 bytes; negative EOF: incomplete=1, delivered=0, release=true. Evidence `.runtime/perception-stage2-binary-ingress-worker-20260902T1232MSK/manifest.json`, 42 artifacts, SHA-256 `2a5492080027a86de9f176f051c9265e29f377d1d9207c86ac93cdff7632505a`. Попытки 1230 (неполный diagnostic code pack) и 1231 (гонка финального timestamp journal) сохранены как rejected; их manifests связаны из принятого. Никакой повторной GPU-квалификации этим probe не выдано.
|
||||
|
||||
## Surprises / открытые вопросы
|
||||
|
||||
- Инкремент18: «LAN» не означал Ethernet — обе стороны работают по Wi-Fi, Worker через USB Realtek8812BU. Прямой TCP connect воспроизводит большой tail без нашего pipeline; reported866.7Mbps link rate не квалифицирует latency. Это не доказательство вины конкретного адаптера/AWDL/роутера или отсутствия других затрат в коде. Независимое clock/record evidence локализует контрольный tail, но не измеряет ещё крупные camera/scene buffers. First TLS startup без mapping сохранён отдельно; ICMP no-reply не выдан за общую потерю пакетов.
|
||||
|
||||
- Инкремент17: clock readiness срывается в A/A2 даже при0 моделях/scene, поэтому общий scene event loop не является единственным достаточным объяснением. LAN улучшил короткий интервал ready, но не все RPC tails: Poll p95 A/B/A2 88.838/92.403/89.950ms, LAN ACK p95 98.266ms. Tailscale preflight показал direct LAN endpoint, а не доказанный DERP. Выбор маршрута относится к connection configuration, не к весам/профилю.32/32 выходов не равны32 свежим scene; локальные Worker accounting gates остаются unknown/false для внешнего источника, отдельный joined verifier доказывает отсутствие потерь. Нельзя складывать/вычитать p95 разных timing boundaries как стоимости независимых стадий.
|
||||
|
||||
- Инкремент16: согласованный старт и отсутствие RPC errors не означают непрерывную готовность часов. Running bounds вышли за5ms без GPU/owner failure; это условная uncertainty при500ppm budget, не измеренный физический drift. Offline добавление всех probes в прежних2s всё ещё даёт FAIL на тех же девяти samples. Для следующего изменения нужен timing trace scheduling/control/data, а не автоматическое расширение history или бюджета.
|
||||
|
||||
- 2026-09-02 13:55 → 14:22 МСК: два старых range_m расхождения 1.3877787807814457e-17 m воспроизведены и исправлены. Quaternion начинался на byte 24 общего pose buffer; адрес 8 mod 16 менял norm на один ULP в текущем NumPy runtime. Offsets 0/16/32/48 возвращают reference, 8/24/40/56 точно воспроизводят оба отклонения. Теперь quaternion — отдельная immutable 32-byte копия с проверенным 16-byte alignment; формулы и comparator прежние. Полный граф подтвердил 128/128 exact дважды. Это квалификация numeric layout текущего pinned runtime, не универсальное обещание bitwise равенства на любом CPU/NumPy.
|
||||
- Четыре удалённые full-frame copies дают небольшой измеримый эффект в IPC, не объясняют разницу 192→91 ms. При одинаковых фиксированных частотах контроль тоже проходил 125-ms gate до оптимизации. Новый p95 колеблется внутри межпрогонного разброса; p99 немного ниже в обоих повторах. CPU-only кандидат имел native-decode outlier и худший total p99, хотя IPC-minus-decode снизился; отрицательный результат сохранён. Автоматический runtime не должен обещать fixed-clock qualification без фактического envelope и не должен сам менять host clocks без отдельной authority.
|
||||
- В том же запуске auto memory clocks переходят 10,251 → 405–810 MHz. По последнему 0.5-s sample перед receipt high/low cohorts имеют p95 88.269/172.079 ms (39/89 кадров). Это корреляция внутри одного прогона, не controlled A/B и не отдельная qualification. Общий p95/p99 161.974/191.806 ms остаётся FAIL. Decoder p95/p99 10.871/11.606 ms, RPC+bundle 18.053/38.991 ms, очередь 44.284/84.492 ms, DDRNet RPC 73.068/77.479 ms; CPU throttled delta=0. Нельзя приписать всю разницу с предыдущими 124.98/136.12 ms новому транспорту или сделать вывод об исчерпании 24 GiB VRAM.
|
||||
|
||||
- 2026-09-02 13:09 МСК: decoder-инкремент `350366b` / `f102712` принят отдельно от full graph. Evidence `.runtime/perception-stage2-decoded-ingress-worker-20260902T1310MSK/manifest.json`, 43 artifacts, SHA-256 `612295eabbc8f203ad9b037a69ac9cf97b2df3134b087d9e51a9786738423f2f`; связан с отдельными 128-frame demux/direct/reference manifests. Все 128 BGR совпадают; 32-camera binary canary и negative EOF освобождают весь input budget. Четыре Mission Core сервиса не останавливались; модели и GPU clocks не менялись.
|
||||
|
||||
- Повторный `av.open(init + fragment)` не является полнофайловым барьером, но повторяет stream probing на каждом кадре. Принят узкий проверяемый ISO-BMFF layout → один AVCC sample → persistent codec; NumPy импортируется до readiness. p95 первого потокового варианта 19.927 ms → 11.156 ms. Это bounded CPU A/B без модели; не вычитать выигрыш из старого full-graph p99. Codec/parser не импортируют session store или materializer; archive API сохраняет прежние exception contracts. Иной codec/layout, B-frames, multi-sample, пропуск DTS или не-keyframe start требуют нового явно квалифицированного профиля/epoch, а не скрытого fallback.
|
||||
|
||||
- Исторический decode-only probe 13:09 не доказывал causal scene/calibration admission. В инкременте 13:55 supervised child, causal cut и full-graph handoff реализованы с прежней реальной калибровкой полного профиля; готовые MP4/masks/geometry не подмешиваются. Multi-recording calibration admission и field quality по-прежнему не проверены.
|
||||
|
||||
- 2026-09-02 12:00 МСК: истечение lease и освобождение GPU разделены. После crash OS lock свободен, но durable `active` не допускает новый запуск. Recovery намеренно отсутствует до trusted resource-release proof; нельзя удалять production marker ради продолжения. В пробах удалены только два заведомо временных experiment-owned volume после сохранения записей и остановки всех probe containers.
|
||||
|
||||
- Свежая camera/cloud/pose пара не равна свежему полному scene. У TGS last-seen разрешающих ячеек может быть старее camera anchor; новый guard сохраняет этот возраст. В финальном C пять scenes истекли за 1–4 ms между publisher guard и приёмом, хотя раньше выглядели бы свежими. Пока одна такая ячейка запрещает весь scene; per-cell expiry — следующий runtime increment без ослабления 250-ms TTL. 52 source gaps нельзя исправить GPU-ускорением.
|
||||
- New operating-envelope evaluator — проверяемый контракт readiness, не уже подключённый orchestrator и не host clock manager. Power/P-state/limit остаются измеряемыми фактами; continuous monitoring и авторитетный lease должен предоставить общий runtime. Low GPU utilization не доказывает владение, а snapshot PASS не квалифицирует целый run.
|
||||
|
||||
- Малое численное расхождение backend не обязательно семантически безвредно. Critical cell flip устранён fail-closed tie rule, но PyTorch vs TensorRT material по-прежнему совпадает 127/128 кадров. Без ground truth и явной numeric acceptance нельзя заменить reference автоматически.
|
||||
- Автоматический downclock GPU/VRAM — подтверждённый A/B источник существенной части tail latency. CUDA Graph capture был реальным; CPU quota не троттлила. Server intervals всё ещё не являются CUDA kernel trace, а остаточные IPC/CPU/network затраты не исчезли. Две fixed-clock пробы пройдут 125-ms gate, возвращённый auto — нет; квалификация должна описывать условия Worker, а не только image digest.
|
||||
- Глобальная блокировка очереди противоречит независимости будущих Worker; нужно перенести ownership scope, а не просто оставить `max_concurrency=1` во всех слоях.
|
||||
- Старые combined-package manifests сохраняют batch-семантику и не исполняют detector/geometry/TGS/threat. Красивый общий viewer использует отдельные сохранённые результаты; это не готовый профиль.
|
||||
- FPS исходного recording и старые sampling/min-FPS требования математически расходятся. Численные product budgets должны быть закрыты в этапе 1; пользователь не задал их в этом уточнении.
|
||||
- Чистое GPU-окно для ограниченных component probes получено: после остановки сторонних и старых Mission Core GPU-процессов 0% utilization и около 529–531 MiB системного/display VRAM. Ollama/Frigate отключены постоянно из автозапуска по явному разрешению владельца. Source uplink, clock alignment и долгий эксклюзивный full-profile run не подтверждены; 300–500 Mbps и будущее hardware остаются гипотезами до проверки.
|
||||
- Существующий `static_obstacle` достаточен для неподвижных препятствий; точные semantic имена не являются пробелом scope первого прототипа. Динамика определяется temporal evidence, не классом объекта.
|
||||
- Потеря различия дорога/тротуар/велодорожка в `hard_surface` — осознанно принятый владельцем компромисс сельского прототипа, не блокер. Геометрический запрет остаётся выше material allowance.
|
||||
- Самостоятельный полный образ ещё не построен. Совместные lifecycle/IPC, resident модели и последовательный GPU schedule проверены в bounded пилоте с mounts, но performance gate FAIL. Экспорт нового образа заблокирован отсутствующим cached Triton blob / NVCR 403. Будущая поставка обязана включить веса и явно задать scratch/cache; рабочий mounted probe не равен standalone. В Triton base отсутствуют Python grpc/protobuf/cv2/TensorRT SDK: native trtexec не означает их наличия. Внутренний RF-DETR HTTP adapter позволил выполнить пилот без них.
|
||||
- CPU online local-surface — выявленный latency risk даже после ограничения BLAS threads. TGS core около 1 ms на prepared clouds не включает online rolling preparation/costmap; нельзя подставлять эту цифру как стоимость всего геометрического слоя.
|
||||
|
||||
## Decision log
|
||||
|
||||
- 2026-09-02 16:52 МСК: `61cbdb3`, real Worker host-control increment. 404 tests; normal128/128 exact raw, 50 observations без ошибок; delayed reply2200ms → два expired requests, WAIT, resume seq50 с прежними PID/lease и очищенными stores.102 completed+26 intentional skips; no backlog/unaccounted. Scope `docker-gpu-access` не равен `host-compute`: Windows PID4/Insufficient Permissions не скрывается как доказанная host-wide эксклюзивность. Read-only canary проверил3 последовательных обновления и существующего GPU-capable конкурента. Первый warmup abort из-за PowerShell `$null`/File.Replace и неправильной startup WAIT трактовки сохранён; обе причины исправлены, freshness1s не расширена. Manifest475 artifacts,363 Worker source hashes/115 current files, SHA-256 `aac125bd1b182d164c95d18089793fa54bf9797478bce1afe3de2a1d9093f6ef`, `.runtime/perception-stage2-worker-control-20260902T1630MSK/manifest.json`. Host collector не меняет clocks/limits/services; временные setters применяет только разрешённый measurement harness с восстановлением.
|
||||
|
||||
- 2026-09-02, уточнение владельца: временная потеря входной сети/потока не завершает resident AI-профиль. Отдельно сохраняем local Worker lease, ждём свежий поток без backlog и заново синхронизируем temporal state. Unknown/stale GPU telemetry — не доказательство чужого владельца; подтверждённый конфликт/авария остаются terminal. Автоматическое восстановление inference не означает разрешение возобновить физическое движение.
|
||||
- 2026-09-01, решение владельца: EoMT и DDRNet не считаются вместе на Worker 006. Первоначальная трактовка «никаких двух моделей внутри профиля» отменена последующим уточнением: RF-DETR и DDRNet нужны внутри одного полного профиля; запрет сохраняется для альтернативных профилей и EoMT+DDRNet.
|
||||
- 2026-09-01, решение владельца: один дрон — один Worker; текущий baseline RTX 4090. Следствие: worker-scoped exclusivity, без GPU-multiplexing и без зависимости от покупки hardware.
|
||||
- 2026-09-01, решение владельца: EoMT сохранить отдельно, даже если не проходит real-time на текущей карте. Следствие: отрицательный benchmark оформляется как честный статус, а не повод блокировать DDRNet или бесконечно оптимизировать.
|
||||
- 2026-09-01, рабочее решение плана: сначала контракты и baseline, затем runtime, затем продуктовая интеграция, затем квалификация и ограниченная миграция. Ровно четыре этапа; уточнения ведутся внутри них в этом же документе.
|
||||
- 2026-09-01, решение владельца: первым нужен самостоятельный Docker с DDRNet, объектной детекцией, LiDAR-расстояниями и TGS для проверки многих записей K1 и последующего полигона. Следствие: цель — полный Perception-профиль, а не голая сегментация; нет зависимости от прежних вычисленных LAB результатов.
|
||||
- 2026-09-01, решение владельца: беспилотника и motor integration сейчас нет. Следствие: текущий scope заканчивается проверенным perception/policy-shadow прототипом; actuator commands, autonomy acceptance и полный mission configurator — будущие отдельные gates, не скрытая часть этой реализации.
|
||||
- 2026-09-01, рабочее решение прототипа: в одном контейнере допускаются несколько внутренних runtime-процессов одной задачи для сохранения модельной совместимости; все имеют одного supervisor и одного GPU scheduler. Изолированные среды не превращаются в отдельно устанавливаемые LAB-сервисы.
|
||||
- 2026-09-01, решение владельца: никаких новых классов для столбиков/подобных предметов; существующего static-object / obstacle достаточно. Следствие: reuse текущего слоя и regression на RAV004, не новая модель/разметка перед первым прототипом.
|
||||
- 2026-09-01, решение владельца: coarse `hard_surface` достаточен в сельской среде, включая тротуары/велодорожки. Следствие: прежние обязательные road-only/fine-class требования отменены; один segmenter DDRNet, без urban autonomy scope. Геометрия и unknown/freshness сохраняют приоритет.
|
||||
- 2026-09-01, решение владельца: Ollama и Frigate больше не нужны в постоянной нагрузке. Остановить сейчас, убрать автозапуск после reboot, не восстанавливать после benchmark; сохранить данные для ручного использования. Выполнено и проверено по Docker/Compose, без физического reboot системы.
|
||||
- 2026-09-01, инженерное решение: стартовый replay stride=1, source clock=1×, ≤16 MiB inflight и два pending camera frames; whole-path p95/p99 ≤125 ms остаются preregistered candidate. Перегрузка должна быть видна и проваливать strict gate; нельзя скрыто замедлить источник/сменить stride/подменить слой старым результатом.
|
||||
- 2026-09-02, решение владельца: разрешены временный A/B stock clock lock с возвратом и расширение Docker resource limits при необходимости. Clock A/B выполнен, все настройки восстановлены; CPU/RAM quotas и power limit не изменялись. Постоянный автозапуск фиксированных частот этим испытанием не вводится.
|
||||
|
||||
## Восстановление и остановки
|
||||
|
||||
Для активного профиля transient input/telemetry loss означает waiting/resync с сохранением моделей/lease. Unknown inventory запрещает новый admission, но не доказывает конфликт текущего владельца. Подтверждённая утрата local lease, конфликт, child/decoder failure и cancel остаются terminal. Новый режим включён и измерен в полном binary pilot; прежние one-shot diagnostics сохраняются. Motion resume — отдельный будущий onboard safety gate, не следствие восстановления inference.
|
||||
|
||||
При неудаче короткого canary остановить только принадлежащий испытанию профиль, сохранить диагностику и проверить освобождение GPU; не убивать посторонние процессы и не продолжать full-session. Неясный владелец GPU или потеря lease требуют fail-closed остановки нового запуска, а не захвата ресурса поверх старого процесса. Переключение на другой профиль — отдельная последовательная операция, не автоматический GPU fallback.
|
||||
|
||||
Новые схемы/декларации вводятся версионно. Возврат к точному предыдущему образу/конфигурации не превращает старый batch-путь в real-time; при откате сохраняется честный статус unavailable/not-qualified. Общую старую очередь нельзя ослаблять без worker-scoped fencing. Опубликованная история и исходники записи не переписываются для прохождения проверок.
|
||||
|
||||
Новый scope, изменение аппаратной/операторской границы, необходимость физического действия, отсутствие нужного доступа или существенный выбор latency/quality — повод остановить соответствующую часть и запросить решение владельца. Обычные локальные исправления внутри согласованных границ не требуют переписывания плана. Настоящий прогресс, evidence и решения обновляются здесь, без новых параллельных планов на каждую проверку.
|
||||
|
||||
## Outcomes / retrospective
|
||||
|
||||
Этап1 завершён; этап2 продолжается. Инкремент18 локализовал один существенный источник длинных задержек ниже Perception-приложения: между TLS edges, с независимым воспроизведением при TCP connect без gRPC/Docker/SSH forwarding. Оба конца на Wi-Fi; конкретный radio/AP/driver/OS механизм остаётся неизвестным, Ethernet comparison возможен при физическом подключении.424 encrypted records/192 RPC exact,288 clocks reconstructed;82 local/82 Worker tests PASS,142 staged files exact,43 artifacts. Модели/GPU/production runtime не запускались и не менялись; full-graph статус по-прежнему из инкремента17:32/32 continuous, p95/p99 205.864/213.751ms FAIL,12 fresh scene. Весь runtime не объявляется невиновным: далее per-frame очереди/сериализация/выдача, отдельный10ms polling и bounded lifecycle. Отсутствие Ethernet не блокирует экспериментальную разработку runtime/standalone. Этапы3–4/registry/UI/actuation не открыты. Временные ресурсы закрыты, IDs/restart policies сохранены; существующие autonomous service restarts отмечены отдельно, не исправлены. Mac8000 PID33360/telemetry работают,8765/18561 отсутствуют; Ollama/Frigate exited/restart=no.
|
||||
|
||||
После этапа 4 здесь будут перечислены digest самостоятельного полного образа, измеренные статусы и ошибки по recordings, реально проверенные source/hardware/config комбинации, состояние сохранённого EoMT-варианта и оставшиеся physical-live/quality/vehicle-integration ограничения. Готовый Docker и готовая автономия не отождествляются.
|
||||
@@ -0,0 +1,112 @@
|
||||
# ADR 0045: Upstream Rerun as the canonical recorded-LAB pipeline
|
||||
|
||||
Date: 2026-08-30
|
||||
Status: accepted; RAVNOVES004TREE is the first migrated full-route LAB
|
||||
|
||||
## Context
|
||||
|
||||
The accepted LAB product composition was repeatedly rebuilt over independent
|
||||
camera, semantic, point-cloud and TGS transports. The resulting implementation
|
||||
had several clocks, LAB-specific caches, a browser MediaSource decoder and a
|
||||
separate Three.js spatial renderer. A camera could continue while segmentation
|
||||
or the cloud stopped; rewind could expose evidence from different source
|
||||
sequences; switching TGS could block both panes. Low host utilization did not
|
||||
make that architecture correct: the bottleneck was duplicated admission,
|
||||
decoding, scheduling and state ownership.
|
||||
|
||||
The product owner requires the existing LAB UI and interaction grammar to stay
|
||||
unchanged. VIDEO/CAMERA, SOURCE POINTS/LOCAL SLAM/TGS COSTMAP/SEMANTICS and
|
||||
3D/PLAN remain the canonical controls. Models and evidence providers may
|
||||
change, but a LAB may not create another player, clock, splitter, spatial
|
||||
renderer, window or status grammar.
|
||||
|
||||
The repository state before this migration is retained by the annotated Git
|
||||
tag `baseline/custom-legacy-before-canonical-rerun-2026-08-30`. Its Russian
|
||||
stage name is **«Этап перехода от самописного legacy-контура к каноническому
|
||||
шаблонному Rerun-пайплайну»**.
|
||||
|
||||
## Decision
|
||||
|
||||
Recorded LAB replay uses the unmodified upstream Rerun SDK and web viewer. The
|
||||
first accepted dependency is exactly `rerun-sdk==0.36.3` and
|
||||
`@rerun-io/web-viewer@0.36.3`. Mission Core does not patch the package, vendor a
|
||||
viewer fork or depend on private viewer source. Product controls are an outer
|
||||
adapter which requests an ordinary Rerun blueprint.
|
||||
|
||||
One native Rerun viewer owns:
|
||||
|
||||
- one `session_time` playback clock;
|
||||
- the recorded camera and semantic image-space evidence;
|
||||
- `/world/points`, `/world/sensor_pose` and `/world/trajectory`;
|
||||
- native 3D orbit and top-down plan presentation;
|
||||
- seek, play/pause and frame synchronization.
|
||||
|
||||
The canonical K1 RRD remains the source of pose, source points, bounded Local
|
||||
SLAM accumulation and trajectory. A LAB may publish one immutable normalized
|
||||
RRD sidecar containing only derived evidence absent from that recording, such
|
||||
as camera video, semantic masks and diagnostic 2D boxes. The base recording and
|
||||
sidecar must have the same application id, recording id and timeline. A sidecar
|
||||
does not copy, rotate or re-own world geometry.
|
||||
|
||||
RAVNOVES004TREE uses a digest-bound sidecar cache. Its sealed fMP4 fragments are
|
||||
verified, concatenated and transcoded once to an upstream-compatible H.264
|
||||
`AssetVideo`. Source PTS are preserved. A fragment without a decodable sample
|
||||
holds the latest preceding frame; decoded samples are never renumbered to a
|
||||
synthetic fixed-rate clock. Each semantic mask and `VideoFrameReference` is
|
||||
logged at the exact immutable LAB `session_time`.
|
||||
|
||||
Profiles control loading rather than creating different viewers:
|
||||
|
||||
- source points use zero accumulation;
|
||||
- Local SLAM uses a native five-second visible time range;
|
||||
- TGS COSTMAP and 3D SEMANTICS are enabled only when full-route immutable
|
||||
artifacts exist and share the recording clock;
|
||||
- semantic model buttons select an entity path in the same sidecar;
|
||||
- 3D/PLAN changes native eye controls, never point coordinates;
|
||||
- layers missing from an immutable result stay visibly disabled and fail
|
||||
closed; they are not reconstructed from sparse review anchors.
|
||||
|
||||
The previous fMP4/Three.js LAB transport remains source-retained only for
|
||||
explicit legacy comparison. No canonical route selects it, preloads it or lets
|
||||
it start background work. Removal is allowed after migrated results pass the
|
||||
same acceptance checks and the rollback tag is no longer operationally needed.
|
||||
|
||||
## Acceptance
|
||||
|
||||
A migrated recorded LAB is accepted only when:
|
||||
|
||||
1. the base RRD identity and sidecar identity match exactly;
|
||||
2. camera, semantics, point cloud, pose and trajectory follow one Rerun clock;
|
||||
3. play, pause, forward seek and backward seek do not remount the viewer;
|
||||
4. SOURCE POINTS and Local SLAM are native views of the same sealed geometry;
|
||||
5. unavailable TGS or semantic 3D evidence is disabled rather than simulated;
|
||||
6. first materialization is cached by source/result/renderer digests and a
|
||||
cache hit performs no decode or inference;
|
||||
7. the existing LAB page, selectors, report mode, controls and expand behavior
|
||||
remain unchanged;
|
||||
8. Data replay and live Rerun profiles continue to use their own load policies;
|
||||
9. the integrated application remains on `127.0.0.1:8000` and no second Mission
|
||||
Core service is introduced.
|
||||
|
||||
The isolated renderer materialized the first real RAVNOVES004TREE sidecar in
|
||||
79.5 seconds. Under the live operator service, cold materialization completed
|
||||
in approximately seven minutes and produced a 393,203,594-byte RRD; this is too
|
||||
slow to treat as an interactive open and should be moved to publication-time
|
||||
preparation. With a full SHA-256 recheck on every cache hit, the warm product
|
||||
endpoint returned headers in 0.89 seconds and streamed the complete local
|
||||
artifact in 2.48 seconds. These measurements establish the local cache behavior,
|
||||
not a realtime inference or navigation claim.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Mission Core keeps its product UI without owning media or spatial playback.
|
||||
- Rerun can be upgraded through ordinary dependency updates and regression
|
||||
tests instead of reapplying a local patch.
|
||||
- New models publish entities and annotations into the same recording contract;
|
||||
they do not add LAB-specific viewers.
|
||||
- SLAM clouds and trajectories stay visible through standard Rerun components.
|
||||
- Useful native boxes/cuboids may be added as ordinary entity layers when their
|
||||
immutable full-route evidence exists.
|
||||
- The migration does not improve DDRNet quality, prove terrain traversability
|
||||
or grant navigation/actuation authority. Those remain separate model and
|
||||
safety acceptance questions.
|
||||
@@ -0,0 +1,209 @@
|
||||
# ADR 0046: Durable Observatory recorded queue and live K1 priority
|
||||
|
||||
Date: 2026-08-31
|
||||
Status: accepted; portable LAB V1 admission and Worker pull foundations implemented;
|
||||
production executor, claim lease, result publication, Worker deployment and
|
||||
live-trigger wiring pending
|
||||
|
||||
Amended on 2026-09-01 by ADR 0048. Capability-aware claims, renewable leases,
|
||||
verified result transport and the durable publication lifecycle are now
|
||||
implemented; physical Worker installation and live-trigger wiring remain outside
|
||||
that source increment.
|
||||
|
||||
## Context
|
||||
|
||||
Observatory must let an operator apply an admitted laboratory setup to a saved
|
||||
session without turning the browser into an execution client. The current compute
|
||||
host is a shared, finite Worker rather than an elastic pool. Recorded experiments
|
||||
may wait, but a future live K1 perception process must acquire that compute
|
||||
immediately and exclusively when its real acquisition lifecycle begins.
|
||||
|
||||
Historical LAB results are not automatically executable definitions. In
|
||||
particular, the existing LAB V1 result proves a recorded EoMT and DDRNet analysis,
|
||||
but it does not retain one independently versioned, durable executor contract that
|
||||
can safely be reconstructed from its display metadata. Conversely, M4.9T5 has an
|
||||
exact accepted RAVNOVES00 source pack and CPU-only TRAVEL TGS release which can be
|
||||
sealed without inventing learned-model dependencies.
|
||||
|
||||
The queue must also survive process restarts and ambiguity around preemption. A
|
||||
database flag alone cannot prove that a non-checkpointable Worker process released
|
||||
CPU, memory and staging storage. Starting live K1 work before physical cancellation
|
||||
is confirmed would permit two owners of the same constrained resource.
|
||||
|
||||
## Decision
|
||||
|
||||
Mission Core owns a bounded SQLite-backed recorded-job queue. The browser submits
|
||||
only:
|
||||
|
||||
- `source_session_id`;
|
||||
- `setup_id`;
|
||||
- one idempotency key.
|
||||
|
||||
The request contains no command, script, path, environment variable, image,
|
||||
model, resource limit or priority. The server resolves an allowlisted source/setup
|
||||
pair and seals the following identities into the accepted job:
|
||||
|
||||
1. the current source-catalog snapshot SHA-256 captured at admission;
|
||||
2. immutable source-bundle SHA-256;
|
||||
3. source-capability-manifest SHA-256;
|
||||
4. source-adapter id, version and SHA-256;
|
||||
5. setup RunDefinition id, version and SHA-256;
|
||||
6. executor release and container-image SHA-256;
|
||||
7. learned-model release list and model-manifest SHA-256;
|
||||
8. resource-profile SHA-256 and checkpoint policy.
|
||||
|
||||
The queue owns idempotency. An exact retry returns the original job and receipt.
|
||||
The same key with changed identity is a conflict. Jobs advance through `accepted`,
|
||||
`queued`, `claimed` and `running`, then terminate as `succeeded` or `failed`.
|
||||
`paused`, `preemption-pending` and `reconciliation-required` expose safety-relevant
|
||||
branches. Mission Core admits only one active recorded compute owner.
|
||||
|
||||
The queue remains the authoritative orchestration and evidence boundary. A queued
|
||||
job proves durable admission, not remote execution or eventual success. An
|
||||
authenticated, versioned Worker pull protocol is now implemented as a separate
|
||||
boundary. The configured Worker contour may claim one server-sealed job and
|
||||
advance it through start, checkpoint, succeed or fail receipts. The caller cannot
|
||||
supply commands, paths, environment variables, container images, model identities,
|
||||
resource policy or priority. This protocol and its tests are foundation only.
|
||||
The production app hard-disables the Worker router even when a valid private
|
||||
credential exists. A token must not turn an incomplete transport into a production
|
||||
execution surface before an expiring claim lease and verified result publisher
|
||||
are implemented and accepted. The gate remains isolated from K1, Simulation and
|
||||
legacy LAB startup.
|
||||
|
||||
The transport receipt is not result publication. A Worker `succeed` transition
|
||||
records the returned result id and SHA-256 against the claim, but a separate
|
||||
publisher must still validate the generic result contract, immutable artifacts,
|
||||
source/job/definition/model identities and observation-only authority before the
|
||||
result enters the Observatory catalog.
|
||||
|
||||
## Current setup admission
|
||||
|
||||
The current executable matrix contains one exact pair:
|
||||
|
||||
- source: `RAVNOVES00`;
|
||||
- setup: `M4.9T5 · TRAVEL TGS · CPU-only, без ML`;
|
||||
- learned-model releases: `[]`;
|
||||
- checkpoint policy: `non-checkpointable`.
|
||||
|
||||
The empty model list is affirmative provenance: this TGS configuration is a
|
||||
CPU-only algorithmic pipeline. It is not a placeholder for unidentified weights.
|
||||
Admission still reads and seals the current source-catalog snapshot and verifies
|
||||
the pinned source bundle and capability manifest before the job is accepted. The
|
||||
binding deliberately does not pin a digest of the whole mutable catalog: the
|
||||
exact compute input is the immutable source pack, while the catalog snapshot is
|
||||
job-specific provenance.
|
||||
|
||||
`LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39` now has a source-independent
|
||||
portable RunDefinition. Its identity contains the exact EoMT and DDRNet model
|
||||
artifacts, algorithm/configuration components, resource profile, observation-only
|
||||
authority and the generic `missioncore.recorded-eomt-ddrnet-review/v2` result
|
||||
contract. It contains no source session id, source label, host path, command,
|
||||
environment or caller-selected priority. The old
|
||||
`missioncore.lab-v1-vegetation-shadow/v1` result is not an exact or existing
|
||||
result of this generic v2 definition, including for its original RAV004 source.
|
||||
It remains available only through the immutable legacy LAB catalog; the portable
|
||||
catalog neither maps nor projects it.
|
||||
|
||||
Compatibility is decided by capability admission against the immutable selected
|
||||
Session, not by `RAVNOVES*` naming. The source must satisfy the admitted K1
|
||||
plugin/archive, point-cloud/trajectory/video modalities, RIGHT-camera semantic
|
||||
channel, exact recorded media profile, single seekable media epoch and sealed
|
||||
calibration identity. Catalog compatibility uses a lightweight `probe` result
|
||||
containing only the selected Session/catalog identity, source-adapter identity and
|
||||
camera segment count. It neither prepares/restores a recorded-media sidecar nor
|
||||
builds a source bundle or capability manifest. A later explicit admission performs
|
||||
the full immutable read, emits content-addressed path-free documents and rejects
|
||||
source or definition drift before writing contracts or a queue intent.
|
||||
|
||||
This portable definition is intentionally not executable yet. Its executor state
|
||||
is `not-installed`; no executor release or image identity is sealed. Conversion to
|
||||
the durable queue definition therefore fails before source persistence or queue
|
||||
mutation. A compatible recording, including RAV004, has only a successful
|
||||
source-capability probe, not an existing portable result or runnable LAB V1 job.
|
||||
The legacy RAV004 result remains independently viewable in the legacy catalog.
|
||||
The same fail-closed rule applies to every other session/setup combination.
|
||||
|
||||
The implemented Worker 006 agent core accepts only path-free queue projections,
|
||||
validates their sealed identities and resolves execution through a local
|
||||
four-digest allowlist: executor release, image, model manifest and resource
|
||||
profile. Network polling cadence, model/runtime installation and deployment are
|
||||
outside that core and are not inferred from its presence in the repository.
|
||||
|
||||
## Priority and preemption protocol
|
||||
|
||||
Priority is server-owned:
|
||||
|
||||
- live K1 lease: rank `0`;
|
||||
- recorded replay job: rank `100`.
|
||||
|
||||
A pending or active live lease blocks new recorded claims. A running cooperative
|
||||
job yields at an allowlisted checkpoint and moves to `paused` before live
|
||||
activation. A non-checkpointable job cannot pretend to pause. Its transition is:
|
||||
|
||||
1. persist a stable cancellation intent bound to the job claim, executor release,
|
||||
image, resource profile and live trigger;
|
||||
2. invoke the scheduler-owned physical cancellation boundary;
|
||||
3. persist an identity-bound cancellation receipt proving resource release,
|
||||
staging discard and restart-from-zero;
|
||||
4. only then activate the live K1 lease.
|
||||
|
||||
The cancellation identity is reused after a crash or retry. A missing callback or
|
||||
receipt leaves the durable intent pending for retry; a conflicting or
|
||||
indeterminate receipt moves the record to reconciliation. Both forms block live
|
||||
activation instead of automatically launching duplicate work. When an
|
||||
explicit live terminal trigger completes, fails or cancels the lease, paused
|
||||
recorded jobs return to the queue. A non-checkpointable job starts again from
|
||||
zero; partial staging never becomes evidence.
|
||||
|
||||
## Acceptance boundary
|
||||
|
||||
This decision accepts the durable queue core and its identity, lifecycle,
|
||||
idempotency and preemption contracts; the source-independent LAB V1
|
||||
RunDefinition; capability-based recorded-source admission; the fail-closed bridge
|
||||
from portable identities to the durable queue; the authenticated Worker pull
|
||||
protocol; and the transport-agnostic Worker agent core. It does not claim that the
|
||||
complete compute loop is deployed. The following remain implementation gates:
|
||||
|
||||
- sealing and installing a production LAB V1 executor release and container image
|
||||
with the exact EoMT/DDRNet artifacts on Worker 006;
|
||||
- implementing and accepting the generic v2 result assembler, validator and
|
||||
idempotent Observatory publisher rather than trusting a terminal Worker receipt;
|
||||
- replacing the current one-shot claim receipt with an expiring, renewable claim
|
||||
lease whose loss prevents stale execution and terminal acknowledgement;
|
||||
- deploying, credentialing and physically validating the Worker pull agent and
|
||||
its local four-digest executor allowlist;
|
||||
- installation of the exact M4.9T5 executor on the Worker;
|
||||
- binding real K1 acquisition start/terminal events to live-lease triggers;
|
||||
- physical acceptance that cancellation releases the required Worker resources
|
||||
and that recorded work resumes only after the live lease terminates.
|
||||
|
||||
Until those gates close, an M4.9T5 submission may remain honestly `queued`. LAB V1
|
||||
cannot be submitted. The UI must project source compatibility independently from
|
||||
executor readiness: a compatible recording may show capability `pass` while the
|
||||
`not-installed` executor keeps preflight blocked. The portable v2 setup always
|
||||
reports no existing result; its historical vegetation result stays in legacy LAB.
|
||||
Even a future `ready` executor cannot make this projector expose enqueue until
|
||||
server-side definition-SHA and check-SHA fenced check/submit endpoints are
|
||||
implemented and accepted. No current UI state may promise or expose LAB V1
|
||||
enqueue.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The UI stays a bounded selector and read-only preflight surface; executable
|
||||
authority remains on the server, and LAB V1 enqueue stays absent while its
|
||||
executor is `not-installed`.
|
||||
- Source compatibility and executor readiness are separate product facts; neither
|
||||
labels nor an existing result upgrades a compatible Session into an executable
|
||||
definition.
|
||||
- Every accepted job is reproducible from sealed source, setup, executor, model
|
||||
and resource identities rather than mutable host paths.
|
||||
- Live K1 can receive hard priority without silently losing or concurrently
|
||||
running recorded work.
|
||||
- Non-checkpointable replay pays the deliberate cost of discard and
|
||||
restart-from-zero after preemption.
|
||||
- K1 acquisition/control principles, the legacy LAB archive and
|
||||
Simulation/Gaussian remain unchanged; this foundation adds no viewer profile,
|
||||
data migration, equipment command or lifecycle coupling to those contours.
|
||||
- Adding a setup or source requires an admitted adapter and RunDefinition; a
|
||||
display name or historical result is insufficient.
|
||||
@@ -0,0 +1,126 @@
|
||||
# ADR 0047: Verified portable Observatory result publication
|
||||
|
||||
Date: 2026-08-31
|
||||
Status: accepted as a backend foundation; production validators, transport and
|
||||
executor wiring remain blocked
|
||||
|
||||
Amended on 2026-09-01 by ADR 0048. Exact built-in validators, package transport,
|
||||
application publication wiring, durable publication status and a typed portable
|
||||
result viewer are now implemented. Installed generic package executors and a
|
||||
physical Worker 006 cutover remain separate acceptance work.
|
||||
|
||||
## Context
|
||||
|
||||
The durable Observatory queue deliberately treats a Worker `succeed` call as a
|
||||
transport acknowledgement. Its `result_id` and SHA-256 do not prove that an
|
||||
artifact exists, that it was computed from the admitted source, that it obeys
|
||||
the selected RunDefinition, or that it has observation-only authority. Publishing
|
||||
that acknowledgement directly as a LAB session would let incomplete, corrupt or
|
||||
mislabelled output enter the same catalog as immutable evidence.
|
||||
|
||||
The current canonical LAB V1 result is a separate preserved legacy result. It
|
||||
must not be reinterpreted as a portable v2 result, rewritten with new provenance,
|
||||
or used as evidence that the portable executor exists.
|
||||
|
||||
## Decision
|
||||
|
||||
Portable results cross a new backend-only verification boundary implemented in
|
||||
`k1link.observatory.portable_result_contract` and
|
||||
`k1link.observatory.portable_result_publisher`. The boundary does not change the
|
||||
queue, the source-admission service, Session API, setup projection, UI, K1,
|
||||
Simulation, or the legacy canonical publisher.
|
||||
|
||||
A Worker-side assembler must produce one directory whose basename is the
|
||||
SHA-256 of its canonical `manifest.json`. The manifest schema is
|
||||
`missioncore.observatory-portable-result-package/v1`; JSON bytes are canonical
|
||||
UTF-8 with sorted keys and no insignificant whitespace. Its separately hashed
|
||||
identity binds:
|
||||
|
||||
1. queue `job_id`, request identity, execution identity, submission receipt and
|
||||
claim generation;
|
||||
2. source Session, catalog snapshot, source bundle, capability manifest and
|
||||
source-adapter identities;
|
||||
3. the complete portable RunDefinition identity, including source requirements,
|
||||
components, models, resource profile, result contract, executor and authority;
|
||||
4. result id, result schema, result kind and result-contract SHA-256;
|
||||
5. a canonical, role-sorted artifact list with confined relative paths, media
|
||||
types, byte lengths and SHA-256 identities;
|
||||
6. observation-only authority.
|
||||
|
||||
Exactly one non-empty `result-document` JSON artifact is required. Package roots,
|
||||
the manifest, every path component and every artifact are checked without
|
||||
following symlinks outside the package. The queue's terminal result SHA must equal
|
||||
the canonical manifest SHA and the package directory name.
|
||||
|
||||
Before catalog publication, the server also:
|
||||
|
||||
- resolves the exact `(setup_id, definition_sha256)` in the portable registry and
|
||||
proves every recorded queue field equals the resulting RunDefinition;
|
||||
- rejects not-installed executors and the reserved legacy
|
||||
`lab-v1-vegetation-shadow-<sha256>` namespace;
|
||||
- rechecks the current SessionStore catalog snapshot;
|
||||
- reads both source-admission documents from
|
||||
`observatory-portable-source-contracts/<sha256>.json`, verifies their bytes,
|
||||
schemas, cross-links, adapter, source and authority;
|
||||
- invokes a validator registered by exact result-contract SHA-256;
|
||||
- copies the package manifest and all output artifacts into the central immutable
|
||||
content-addressed artifact store and records its manifest id;
|
||||
- publishes one idempotent `LabSessionBinding` through `SessionStore`.
|
||||
|
||||
There is intentionally no generic “JSON looks plausible” validator. An unknown
|
||||
result contract fails before artifact-store or SessionStore mutation. A validator
|
||||
must understand the exact result schema and determine that the result document
|
||||
and supporting artifacts are accepted evidence.
|
||||
|
||||
## Calculation-profile provenance
|
||||
|
||||
The publisher does not read a browser selection or infer a profile from a result
|
||||
name. It requires a server-owned policy bound to the exact definition id, version
|
||||
and SHA-256. The resulting immutable provenance contains
|
||||
`missioncore.observatory-calculation-profile/v1` with:
|
||||
|
||||
- `setup_id`;
|
||||
- full display name;
|
||||
- origin `archived-definition`;
|
||||
- definition id, version and SHA-256.
|
||||
|
||||
It also stores a SHA-256 of that calculation-profile document. A later Session API
|
||||
projection can therefore read `calculation_profile` from the result's provenance
|
||||
instead of reporting whichever setup happens to be selected now.
|
||||
|
||||
No replay capability is invented. The portable result package is preserved in
|
||||
the central artifact store, while a result-schema-specific viewer/replay adapter
|
||||
must be accepted separately before the catalog binding can claim visual replay.
|
||||
|
||||
## Current fail-closed blockers
|
||||
|
||||
The publisher and focused contract tests are implemented, but the end-to-end
|
||||
production loop remains unavailable for concrete reasons:
|
||||
|
||||
1. neither `recorded-eomt-ddrnet-review-v2` nor
|
||||
`m49-tgs-portable-review-v2` has an installed exact result-contract validator;
|
||||
2. the current Worker protocol returns only `result_id` and manifest SHA-256; it
|
||||
has no accepted package upload/CAS handoff that gives Mission Core the matching
|
||||
content-addressed directory;
|
||||
3. portable executor releases that emit this package contract are not installed
|
||||
and physically accepted on Worker 006;
|
||||
4. the application has not registered definition-bound calculation-profile
|
||||
publication policies or wired the publisher into the terminal Worker flow;
|
||||
5. no result-schema-specific replay-capability adapter has been accepted.
|
||||
|
||||
These are explicit blockers. The backend must not fabricate a package, reuse a
|
||||
legacy result, trust a Worker success receipt, guess model/profile provenance, or
|
||||
expose an enqueue/result promise to bypass them.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Queue success and Observatory publication remain distinct evidence states.
|
||||
- Exact retry is safe: content-addressed artifact publication and the immutable
|
||||
SessionStore binding are idempotent; a conflicting result id fails closed.
|
||||
- Source, definition, model, resource-profile and calculation-profile identities
|
||||
remain available in one portable provenance document.
|
||||
- Artifact-store writes may leave harmless immutable unreferenced objects if the
|
||||
final SessionStore transaction detects a conflict; they cannot overwrite an
|
||||
existing identity.
|
||||
- The legacy canonical LAB V1 result and its admission logic remain byte-for-byte
|
||||
outside this publisher.
|
||||
@@ -0,0 +1,138 @@
|
||||
# ADR 0048: Independent installed LAB packages and portable result lifecycle
|
||||
|
||||
Date: 2026-09-01
|
||||
Status: accepted and implemented as source contracts; no Worker deployment or image migration
|
||||
|
||||
## Context
|
||||
|
||||
The first portable Observatory path still composed M4.9 and LAB V1 through
|
||||
profile-specific builders and a combined Worker release. A Worker could claim a
|
||||
job whose exact executor was not installed, execution success could become
|
||||
terminal before catalog publication, and the UI could open only one hard-coded
|
||||
legacy replay family. Adding another LAB therefore still risked changes across
|
||||
the Worker coordinator, backend publication and frontend viewer.
|
||||
|
||||
The target remains observation-only. This decision does not authorize a build,
|
||||
deployment, Docker installation, Worker 006 mutation, K1 command or safety use.
|
||||
|
||||
## Decision
|
||||
|
||||
### Installed package boundary
|
||||
|
||||
A Worker-local `InstalledLabPackageRegistry` binds one independently installed
|
||||
package to the exact RunDefinition, RuntimeCandidate and four-digest executor
|
||||
identity. Its manifest contains only reviewed container images, argv, dependency
|
||||
topology, fixed in-container mount targets and immutable asset IDs. It cannot
|
||||
contain a host path, secret, environment, Docker socket or job-provided command.
|
||||
|
||||
Every package uses one stable container I/O contract:
|
||||
|
||||
- read-only source at `/missioncore/input/source`;
|
||||
- read-only canonical plan at `/missioncore/input/run-plan.json`;
|
||||
- result package at `/missioncore/output`;
|
||||
- ephemeral work at `/missioncore/work`.
|
||||
|
||||
`single-container` and `fixed-stack` are package properties. Exactly one
|
||||
container owns the portable result. All images must already be admitted by the
|
||||
bound RuntimeCandidate, and the package asset inventory must exactly equal that
|
||||
candidate's reusable asset inventory.
|
||||
|
||||
Worker composition now exposes one generic package executor factory. A Worker
|
||||
may install any non-empty subset of server-ready definitions. It advertises only
|
||||
the resulting four-digest identities; it is not required to implement every LAB
|
||||
known by the backend. Existing profile-specific builders remain a compatibility
|
||||
path until their images adopt the common package I/O contract.
|
||||
|
||||
The generic executor runs a deterministic dependency graph of hardened one-shot
|
||||
containers through the local Docker Engine API. Every container is pinned by
|
||||
image SHA-256, has no network, a read-only root filesystem, no Linux
|
||||
capabilities or privilege escalation, and receives only the declared read-only
|
||||
inputs plus the single shared result output. Memory, CPU, PID, shared-memory,
|
||||
tmpfs, GPU-count and timeout limits are explicit package fields. Exactly one
|
||||
`result-writer` step must transitively depend on every compute step, after which
|
||||
the Worker verifies the declared result manifest, file lengths, digests and the
|
||||
absence of links or undeclared files before publication can begin.
|
||||
|
||||
Worker-local asset bindings translate reviewed controller paths to Docker-host
|
||||
paths. Those host paths never enter the package manifest or queued job. The
|
||||
current package contract intentionally admits ordered offline steps only; a
|
||||
future LAB that genuinely requires simultaneous services needs an explicit new
|
||||
contract instead of silently weakening the isolation boundary.
|
||||
|
||||
### Capability-aware dispatch
|
||||
|
||||
Worker claim protocol v2 sends a bounded, canonical snapshot of installed
|
||||
four-digest executor identities. The queue selects the oldest queued job that
|
||||
matches one of those identities inside the same transaction that creates the
|
||||
claim. An empty snapshot claims nothing. The snapshot is included in the claim
|
||||
request digest, so an idempotency key cannot be replayed with different Worker
|
||||
capabilities.
|
||||
|
||||
Claim v1 remains accepted only as a rolling compatibility path and cannot send a
|
||||
capability field. New Worker code always uses v2.
|
||||
|
||||
### Execution and publication are separate durable lifecycles
|
||||
|
||||
Verified upload completion atomically seals execution as `succeeded` and creates
|
||||
a publication outbox entry. Publication has its own state:
|
||||
|
||||
- `not-required` for the compatibility path;
|
||||
- `pending` after verified execution completion;
|
||||
- `failed` with bounded error evidence and attempt count;
|
||||
- `published` with timestamp.
|
||||
|
||||
A publisher failure no longer rewrites or loses the successful execution. The
|
||||
authenticated Worker API returns the durable job with HTTP 202 and exposes an
|
||||
idempotent publication retry endpoint. Exact retries reuse content-addressed
|
||||
artifacts and the immutable SessionStore projection; they do not rerun the model.
|
||||
|
||||
### Contract-driven result viewing
|
||||
|
||||
Portable publications receive an explicit v2 viewer capability
|
||||
`portable-result-review / portable-result / result-defined`. The public viewer
|
||||
service resolves that capability, immutable publication provenance and central
|
||||
artifact manifest, then rechecks the JSON result document's length and SHA-256.
|
||||
The Control Station selects either the legacy canonical replay adapter or the
|
||||
portable result adapter from the typed capability. No portable setup ID, LAB ID,
|
||||
source session ID or result-name family selects the viewer.
|
||||
|
||||
The UI polls while execution or publication is pending, refreshes the catalog
|
||||
after publication, and keeps legacy and portable setup catalogs independently
|
||||
usable if either endpoint is temporarily unavailable. Catalog merge identity is
|
||||
`setup_id`, never a display name.
|
||||
|
||||
### Validator extension
|
||||
|
||||
Result validators are registered and selected by exact result-contract SHA-256.
|
||||
The built-in LAB V1 and M4.9 functions remain compatibility registrations. The
|
||||
composition core no longer requires both setup IDs or branches on them, so a
|
||||
future contract can supply another server-owned registration without changing
|
||||
the queue or Worker router.
|
||||
|
||||
## Compatibility and migration boundary
|
||||
|
||||
This source increment does not claim that the currently installed M4.9 or LAB V1
|
||||
images implement the new common container I/O surface. No synthetic package
|
||||
manifest is checked in for an image that has not been rebuilt and smoked against
|
||||
that surface. The old builders continue to work, and M4.9 startup no longer
|
||||
requires LAB V1 receipt/release environment values.
|
||||
|
||||
The next physical migration is deliberately per profile:
|
||||
|
||||
1. make one image emit the existing portable result package through the common I/O contract;
|
||||
2. seal its independent installed package manifest and local asset bindings;
|
||||
3. run contract composition and a short offline smoke;
|
||||
4. advertise only that executor identity in claim v2;
|
||||
5. perform a reversible canary without changing another profile.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A Worker cannot take an unsupported job merely because it is earlier in the queue.
|
||||
- Adding a conforming LAB changes its definition, runtime candidate, validator
|
||||
registration and installed package, not the queue protocol or frontend routing.
|
||||
- Successful compute and successful catalog publication are both visible and
|
||||
recoverable facts.
|
||||
- Portable result review is generic JSON/artifact evidence; richer visual viewers
|
||||
can be added as new typed capabilities without adding setup-name conditionals.
|
||||
- The generic package boundary is implemented, while image conformance and
|
||||
physical Worker 006 acceptance remain explicit, unclaimed work.
|
||||
@@ -0,0 +1,931 @@
|
||||
# ADR 0049 — Stream-first full perception profiles
|
||||
|
||||
> Product scope/order partially superseded on 2026-09-02 by
|
||||
> [ADR 0050](0050-recorded-observatory-first.md): recorded calculation and cached
|
||||
> review come first. Live-stream contracts and historical measurements below
|
||||
> remain intact; remote realtime is no longer the Observatory release gate.
|
||||
|
||||
Date: 2026-09-01; updated 2026-09-02 19:42 MSK. Status: stage-1 engineering
|
||||
baseline complete; stage-2 has full graph container-network proof plus authenticated
|
||||
cross-host grant delivery and bounded clock intervals. Continuous clock readiness is
|
||||
not yet connected to full-graph freshness. Cross-host full-graph qualification,
|
||||
native-host GPU inventory,
|
||||
standalone image and product cutover remain open. This is not physical-live acceptance.
|
||||
Execution order remains the four stages in
|
||||
[the ExecPlan](../OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
|
||||
|
||||
## Decision and owner intent
|
||||
|
||||
Observatory LABs test the real-time behavior of a complete perception profile,
|
||||
not just the quality of an offline export. A recording replaces the physical
|
||||
source, preserving original release timing at 1×. It must not enable a different
|
||||
batch implementation. The first profile contains DDRNet-39 GOOSE, RF-DETR native,
|
||||
online LiDAR geometry/ranges, temporal/motion, CPU TRAVEL TGS/costmap and advisory
|
||||
policy. One dedicated RTX 4090 owns one active full profile.
|
||||
|
||||
The first rural prototype accepts existing coarse `hard_surface`, including
|
||||
asphalt, sidewalk, bikeway and cobble. Existing `static_obstacle`/`static.unknown`
|
||||
is sufficient. No new bollard classifier, extra segmenter, road/sidewalk taxonomy
|
||||
or annotation campaign precedes this prototype. Other material rules remain an
|
||||
explicit effective mission configuration; rural context alone is not permission
|
||||
for all vegetation or soil. Geometry/unknown/freshness overrides material allowance.
|
||||
|
||||
EoMT is preserved as a separate future profile. It is not loaded with DDRNet.
|
||||
RF-DETR and DDRNet are two required components of the same first profile: one
|
||||
supervisor and serialized GPU scheduling, not competing profile jobs.
|
||||
|
||||
## CURRENT → TARGET / ownership
|
||||
|
||||
| Boundary | Current evidence | Decision / stage |
|
||||
| --- | --- | --- |
|
||||
| Portable definitions | `portable_run_definitions.py` owns pins, resources and admission | KEEP identity/sealing; EXTEND stream capabilities and qualification in stage 3 |
|
||||
| Source requirements | Recorded-only requirements include seekability, one media epoch and init digest | REPLACE as live admission criteria with channel/format/calibration/clock requirements; keep init digest as per-stream integrity |
|
||||
| Job queue | `recorded_jobs.py` has durable claims but global single-active-job scope | KEEP durable queue; EXTEND worker-scoped fencing, not unbounded GPU concurrency |
|
||||
| Worker agent | Blocking `execute(job)` and final publication | EXTEND incremental lifecycle and cancellation; no per-LAB agent |
|
||||
| Runtime | `portable_worker_runtime.py:735` materializes before execution | NEW stream runtime in stage 2; never call this materializer in the real-time path |
|
||||
| Source transport | Whole camera archive download/hash/extraction before use | REPLACE startup barrier with bounded metadata, chunks and incremental integrity |
|
||||
| Installed LAB V1 | prepare → complete EoMT → complete DDRNet → assemble | Legacy evidence only; not the new profile and not real-time qualified |
|
||||
| Camera/source replay | `recorded_source.py` has useful pacing and bounded prefetch, but source-specific timeline and paths | REUSE mechanisms; generic unknown-duration source adapter, no route-sized prefetch |
|
||||
| Existing live ingress | `LivePerceptionIngress`, `LiveIngressEvent.wire_bytes`, `LiveSensorSynchronizer` already provide raw-first modality queues, hashes, epochs and bounded binding | REUSE, not a second acquisition system; extend worker/profile fencing and aggregate byte limits; map existing envelopes to the selected transport |
|
||||
| Local surface | `RecordedGeometryStore` reads prepared NPZ | REUSE `K1LocalSurfaceShadowEstimator.process` and `lidar_local_surface_geometry`; feed current points/pose |
|
||||
| Geometry/ranges | Current point association, exclusive ownership and geometry-only clusters exist | REUSE algorithms; remove recorded-store dependency and expose estimator/frame identity |
|
||||
| Temporal/motion | `BoundedSpatialTemporalProvider`, `ClassIndependentMotionEstimator` | REUSE with current-run inputs and reset on epoch/lease change |
|
||||
| TGS | Core algorithm reusable; old runners read a full schedule, some require exactly 4489 frames | REUSE TGS parameters/core; REPLACE schedule/file CLI with incremental input |
|
||||
| Fusion | M49 TGS can be a neighboring shadow with `tgs_modifies_reference_graph_state=false` | TGS and semantics must influence the current advisory scene, not only viewer composition |
|
||||
| Publication | Verified immutable result/publication/recovery exist | KEEP asynchronous final sealing; live results do not await it |
|
||||
| Frontend | Setup/job/result surfaces exist, historical M49 viewer links other results | EXTEND common renderer capabilities with current-run layer references and freshness in stage 3 |
|
||||
| Recording archive | Source identities and raw evidence must be retained | KEEP; create metadata/index/integrity during acquisition or incrementally, not via preflight full scan |
|
||||
|
||||
No changes to the active registry, old queue, source endpoint, UI or canonical
|
||||
local server are made by this ADR or the stage-1 validators.
|
||||
|
||||
## Versioned handshake and observations
|
||||
|
||||
### Current host observation adapter (2026-09-02 16:52, `61cbdb3`)
|
||||
|
||||
`WorkerControlChannel` / `WorkerControlPump` supply the common readiness monitor
|
||||
with actual GPU name/driver/clocks and Docker image/configured resource limits /
|
||||
GPU-access inventory from `collect_worker_host.ps1` on the trusted Windows host.
|
||||
Only the host collector executes fixed read-only Docker/NVIDIA queries. The AI
|
||||
container has no Docker socket, host setters or network access. Request and reply
|
||||
directories are separate; the reply mount must be read-only and cannot also be
|
||||
reachable through the writable output mount. This local filesystem authority is
|
||||
NOT authentication for external sensor/GCS traffic.
|
||||
|
||||
One pending random nonce, activation digest, sequence and a16KiB reply bound
|
||||
prevent history/queue growth. The collector reads facts AFTER receiving that
|
||||
challenge. Observation time is the local Linux request-start monotonic value,
|
||||
not the reply receipt or Windows timestamp. Delayed replies stay old; missing,
|
||||
malformed or different-nonce replies cannot refresh readiness. A known matching
|
||||
reply with changed container/image/owner or competitors fences execution.
|
||||
Owner, effective config and warmup remain local-controller facts, never accepted
|
||||
from the sensor stream or inferred from utilization. The pump does not renew the
|
||||
lease; its I/O runs outside lifecycle locks and the heartbeat thread.
|
||||
|
||||
Scope is part of the sealed effective configuration: `docker-gpu-access` means
|
||||
observed Docker GPU-capable containers, NOT all native host/WSL GPU processes.
|
||||
It cannot satisfy an envelope requiring `host-compute`. Windows reports PID4 /
|
||||
Insufficient Permissions; native host coverage stays explicitly unproved.
|
||||
Incomplete inventory or unavailable GPU telemetry cannot be waived even by
|
||||
`labelled-experiment`; measured low clocks can be logged as overload conditions.
|
||||
|
||||
Bootstrap acquires the local lease and attaches readiness before spawning any
|
||||
model. Warmup telemetry lag blocks further startup without destroying already
|
||||
loaded children or invalidating an input epoch that has not started. Active
|
||||
stream lag pauses input, keeps models and local lease, then requires the existing
|
||||
fresh-keyframe/sensor resynchronization and temporal reset. The bounded pilot's
|
||||
outer watchdog remains; this is not yet a durable production controller.
|
||||
|
||||
Measured normal graph:128/128 exact raw outputs,50 real observations, p95/p99
|
||||
91.157/97.946ms. Delaying a host reply2200ms:102 completed,26 intentional omissions,
|
||||
two expired requests, same four resident PIDs/lease; resume seq50, old temporal
|
||||
stores/cells absent, first scene fresh. Result receipt gap2.719s is NOT latency
|
||||
from the Windows fault start (clock domains are not subtracted). Both trials
|
||||
use the temporary authorized stock-clock envelope, not general qualification.
|
||||
404 focused tests; evidence SHA-256
|
||||
`aac125bd1b182d164c95d18089793fa54bf9797478bce1afe3de2a1d9093f6ef`.
|
||||
The first warmup abort and Windows atomic-replacement correction are retained
|
||||
in the experiment journal. Historical readiness notes below retain their dates.
|
||||
|
||||
`missioncore.perception-stream-start/v1` is represented by `StreamStart` in
|
||||
`src/k1link/perception/realtime_contract.py`. It contains run/source/worker/epoch,
|
||||
lease generation, profile/image/effective-config/calibration identities, clock
|
||||
domain, input mode and channel declarations. The first profile requires camera,
|
||||
point-cloud and pose. Temporary absence of a sample is an explicit modality
|
||||
outcome, not removal of a required capability.
|
||||
|
||||
It deliberately has no total source bytes, frame count, total duration, whole
|
||||
archive, command, environment, complete member inventory or required EOF. The
|
||||
initial metadata budget is 64 KiB. Large constant model assets and FOV masks
|
||||
belong inside the image; calibration references resolve to bounded metadata.
|
||||
|
||||
The stage-2 wire mapping must carry:
|
||||
|
||||
- Run/epoch/lease binding on every message; source/channel sequence, capture
|
||||
timestamp and clock domain; independently verifiable payload length/hash.
|
||||
- Camera codec initialization and then incremental encoded access units, or
|
||||
bounded fragments thereof. Native decoded representation is BGR8 800×600 KB4.
|
||||
Do not send an entire fMP4 epoch as one observation or turn all frames into PNGs.
|
||||
- Current vendor-registered map point increments with point IDs, coordinate
|
||||
frame, units, scalar layout and retained raw attributes; map→sensor pose and
|
||||
calibration identity. The adapter owns vendor decoding; the profile is not a
|
||||
new K1 protocol implementation and cannot command the scanner.
|
||||
- Explicit unavailable/gap/end/cancel messages. End is a termination event,
|
||||
never a prerequisite for first inference. Final recording inventory/hash can
|
||||
be sealed after consumption; per-chunk integrity is verified before use.
|
||||
- Integer nanoseconds use protobuf integer fields; any JSON projection uses
|
||||
decimal strings for 64-bit times rather than lossy JavaScript numbers.
|
||||
|
||||
Maximum payload fragment: 1 MiB; total application inflight bytes: 16 MiB;
|
||||
pending decoded camera frames: 2. Fragment reassembly is included in that budget,
|
||||
with a deadline and incomplete-chunk accounting. Decoded image/tensor, cloud,
|
||||
costmap and archive sinks need their own measured bounds inside the RSS budget.
|
||||
Transport flow control alone is not a memory bound or a freshness policy.
|
||||
|
||||
gRPC bidirectional streaming is the first transport candidate, not an already
|
||||
measured implementation. HTTP control-plane operations remain. The browser does
|
||||
not connect directly to model RPCs. One authenticated data-plane endpoint feeds
|
||||
the supervised profile; model processes have no arbitrary external network.
|
||||
Protocol/codec selection must be measured on actual payload sizes in stage 2;
|
||||
changing an archive POST to gRPC without changing execution is not acceptance.
|
||||
Existing `missioncore.live-perception-wire/v1` and result wire v2 are migration
|
||||
inputs: reuse their semantics and raw-first producer hooks. Their modality count
|
||||
caps alone permit more than the proposed 16 MiB aggregate inflight budget; an
|
||||
explicit byte bound is required. No duplicate new scanner feed is introduced.
|
||||
|
||||
## Time and lifecycle
|
||||
|
||||
Stage-2 full-graph recovery increment, 2026-09-02 15:56 (`70927ea`):
|
||||
`--recover-input` connects lifecycle to PilotController, RecordingSource,
|
||||
BinaryGraphBridge, GPU/CPU calls and result binding. Source clock mapping is
|
||||
fixed before ingress; fault windows never restart/slow it. Bounded diagnostic
|
||||
`--input-gap sequence:milliseconds` is explicit in effective config. Reconnect
|
||||
polls quiescence without queuing old events; complete accounting distinguishes
|
||||
skipped from processed input. Historical one-shot defaults remain available.
|
||||
|
||||
The receiver waits for an independently decodable fragment and fresh causal
|
||||
sensor pair. Decoder-local index resets; original source sequence/timestamps
|
||||
do not. Actual decoder keyframe validation remains. The graph recreates CPU
|
||||
surface/geometry/temporal/motion/rolling/threat providers using the same configs
|
||||
and clears cached segmentation. RF-DETR backend, DDRNet/decoder/TGS children
|
||||
remain. TGS C++ creates an estimator per request; costmap arrays are per-call,
|
||||
so no native temporal reset command or restart is needed. Rolling input window
|
||||
resets independently. GPU/CPU/result gates bind the input epoch, not activation.
|
||||
Obsolete results release their input rather than terminating the profile;
|
||||
review removes a lingering collector tuple reference to prior input too.
|
||||
|
||||
Two normal/faulted full-profile pairs pass on Worker006. Normal raw parity is
|
||||
128/128; final p95/p99 85.775/89.385ms. Faulted runs preserve four PIDs and clear
|
||||
nonempty temporal stores, resuming on seq30/110 after gaps at seq16/72. All
|
||||
post-resume histories and observed costmap timestamps belong to the new epoch;
|
||||
first scenes have fresh six-layer evidence and new DDRNet inference. No result
|
||||
was published during pauses. GPU calls were idle at injection; mid-compute
|
||||
interruption is covered by focused tests, not new real-GPU cancellation proof.
|
||||
|
||||
Recovery availability differs from accepted-frame processing latency: 150ms gap
|
||||
takes1.476s to next complete scene; 2.2s gap takes3.857s, including waiting for
|
||||
keyframe/sensor alignment. Faulted output is76 processed +52 skipped cameras,
|
||||
zero unaccounted; none is replayed. Its p95/p99 83.163/89.729ms describes accepted
|
||||
frames only. Timings use temporary stock clocks2610/10251MHz; whole-route
|
||||
availability, auto-clock and network timing remain unqualified. Real collector,
|
||||
authentication, cross-host clock mapping, standalone and onboard safety are open.
|
||||
|
||||
383 tests pass. Evidence330 artifacts,236 Worker snapshot hashes/114 current
|
||||
files verified; `.runtime/perception-stage2-resume-worker-20260902T1545MSK/manifest.json`,
|
||||
SHA-256 `a9449fa4c012e30ce8a06025790c0e110eeebab7c1859fdebf56d5e785961ea3`.
|
||||
Initial preflight abort (512MiB threshold below known534MiB idle baseline) is
|
||||
preserved; no models/clocks ran in it. Auto clocks/four scoped services restored,
|
||||
empty/retired fixture volumes and containers removed; legacy restart loops not
|
||||
fixed. The earlier CPU-only increment below is historical, not latest status.
|
||||
No full-source transfer, new model, UI or actuator path is introduced.
|
||||
|
||||
Owner clarification and stage-2 continuity increment, 2026-09-02 15:30
|
||||
(`c31c46c`): temporary input/network loss must not unload the resident models.
|
||||
Unknown/stale GPU telemetry is not proof of a competing owner. The desired
|
||||
response is waiting and resynchronization; verified owner conflict, actual
|
||||
local lease loss, child/model/decoder failure and explicit cancellation remain
|
||||
terminal. This supersedes the blanket terminal-readiness TARGET below; previous
|
||||
terminal fault probes are retained as historical evidence of their opt-in mode.
|
||||
|
||||
Two lifetimes are explicit. The resident activation keeps its original
|
||||
`StreamStart`, local Worker lease and lease generation. Reconnecting input gets
|
||||
a new `StreamStart.epoch_id`, bound by existing wire v2 on every packet/result;
|
||||
acquisition session/generation remains independently checked. A network/GCS
|
||||
heartbeat is not the local ownership heartbeat. The trusted resident controller
|
||||
continues renewing its own lease while the input waits. No second profile may
|
||||
claim the GPU during that wait, and telemetry alone cannot renew/release a lease.
|
||||
|
||||
`StreamingLifecycle(recover_input=True, source_clock_ns=...)` requires a trusted
|
||||
source-to-local-clock mapping and, if monitoring is configured, a
|
||||
`WorkerReadinessMonitor(recoverable=True)`. Both choices belong in the sealed
|
||||
effective config. Their defaults preserve the historical one-shot diagnostic;
|
||||
the current full PilotController/BinaryGraphBridge has not been switched. This
|
||||
compatibility default is not the final product's desired outage policy.
|
||||
|
||||
The input phases are active, waiting and synchronizing. Resident GraphState may
|
||||
remain RUNNING while input is waiting; that means the processes remain loaded,
|
||||
not that results or actuation are ready. EOF without End, an IPC read deadline,
|
||||
connection loss or a declared/source-sequence gap pauses the enabled adapter.
|
||||
Pending input is discarded; active borrowed buffers remain owned until their
|
||||
callback drains, and its old result cannot pass the epoch/phase gate. A new
|
||||
connection waits for the old receiver, active callbacks and mailbox-owned work
|
||||
to drain. No unbounded queue, replay catch-up, future evidence or new model
|
||||
process is introduced. Explicit End terminates the source; Cancel remains a
|
||||
real cancellation. Malformed data and actual decoder failure are not relabelled
|
||||
as a recoverable transport outage. External network error handling remains an
|
||||
unqualified adapter boundary, not proved by the IPC socket tests.
|
||||
|
||||
Resume requires a new init and a successfully decoded random-access H.264 frame,
|
||||
fresh pose/points from the new epoch, and a temporal-state reset. The measured
|
||||
decoder resets codec references/DTS inside the same child process; sensor-window
|
||||
reset retains its bounded allocation but clears rolling state. `ResumeEvidence`
|
||||
must be derived by the trusted profile adapter, not accepted as a client Boolean.
|
||||
Every timestamp is at/after reconnect cutoff and no later than the camera;
|
||||
camera age ≤250 ms, pose/newest-points age ≤100 ms, oldest-points age ≤250 ms and
|
||||
pose/points skew ≤100 ms. The lifecycle rechecks evidence after reset and cannot
|
||||
resume if another pause arrived meanwhile. Full graph tracking/motion/TGS/costmap
|
||||
reset and CPU-consumer old-result disposal are required next, not yet proved by
|
||||
the diagnostic callback. Replay source time must keep advancing at 1× through
|
||||
the outage; the CPU fixture proves this only with a synthetic tick source.
|
||||
|
||||
Recoverable readiness maps expired/missing inventory, incomplete warmup and
|
||||
strict-envelope performance unavailability to waiting, not process destruction.
|
||||
Fresh facts permit beginning resynchronization, not automatic reuse of the old
|
||||
epoch. Confirmed wrong owner/generation/worker/image/config, known competing GPU
|
||||
clients and invalid trusted clock/snapshot ordering still fence the activation.
|
||||
Labelled performance experiments keep their recorded violations and remain
|
||||
unqualified. The runtime does not manipulate GPU clocks, other services or a
|
||||
vehicle. Eventual onboard stop/hold, stale-command rejection and motion-resume
|
||||
policy are independent future safety gates; restored inference is not permission
|
||||
to move, and GCS heartbeat is not proof of fresh perception.
|
||||
|
||||
375 focused tests pass, including repeated reconnects, stale input/results,
|
||||
pause during reset, telemetry recovery and retained hard-fault fencing. Worker
|
||||
CPU-only proof: three epochs, 150-ms and 2.2-s outages, unchanged decoder/sentinel
|
||||
PIDs, identical BGR 3/3, peak input 5,275,461 bytes and complete cleanup. It uses
|
||||
one real H.264 fragment and synthetic timestamps/pose/points; the sentinel is not
|
||||
a GPU model, and temporal reset is a diagnostic fixture. No full-graph recovery,
|
||||
new GPU latency, real inventory collector, external network or standalone-image
|
||||
qualification follows. Evidence: 85 artifacts, 73 verified code hashes;
|
||||
`.runtime/perception-stage2-continuity-worker-20260902T1518MSK/manifest.json`,
|
||||
SHA-256 `fbd327d129c88caa13b7183b1ae2c4767e877a6f5472c0f430875cfd7401ed46`.
|
||||
|
||||
Stage-2 controller-readiness increment, 2026-09-02 14:52 (`92625bf`):
|
||||
`WorkerReadinessMonitor` consumes existing `WorkerOperatingEnvelope` and
|
||||
`WorkerSnapshot` under the same lifecycle lock as admission. No host I/O occurs
|
||||
under this lock. The trusted controller, not a sensor payload, provides the
|
||||
snapshot in a locally comparable Worker monotonic clock domain and seals the
|
||||
mode/envelope into its effective configuration. Snapshot arrival is not a lease
|
||||
renewal, and renewing a lease does not refresh inventory. Identity, exclusive
|
||||
ownership, known inventory and freshness are mandatory in both modes.
|
||||
|
||||
`strict-envelope` rejects post-warmup performance conditions outside the
|
||||
preregistered envelope. `labelled-experiment` permits these conditions, including
|
||||
unknown performance facts, but records violations and never grants real-time
|
||||
qualification. It cannot waive unknown/competing GPU clients, stale inventory,
|
||||
changed worker/image/config/owner or missing warmup. Low clocks during STARTING
|
||||
do not prohibit model warmup; transition to RUNNING checks warmup explicitly.
|
||||
Freshness is checked before spawn/admission/compute/result/heartbeat and in the
|
||||
50-ms watchdog. A late telemetry refresh cannot resurrect expired authority;
|
||||
old StreamStart updates are rejected without stopping the current owner.
|
||||
|
||||
Readiness loss enters STOPPING and stops only owned child groups. A new owner
|
||||
still requires verified child/thread/callback/input release and a new generation
|
||||
and epoch. The monitor cannot clear an active durable lease, force recovery,
|
||||
start a replacement model, change host clocks/quotas or stop other services.
|
||||
It retains one snapshot and bounded reason sets, not unlimited telemetry history.
|
||||
|
||||
The existing diagnostic PilotController has no continuous real collector yet;
|
||||
unconfigured runtimes expose `worker_readiness.enabled=false` and cannot claim
|
||||
monitored readiness. No authentication or host-clock translation is implemented
|
||||
by this in-process method. External inventory collection, control-channel
|
||||
delivery, production admission/recovery and standalone packaging remain stage 2.
|
||||
349 focused tests and four sequential Worker Linux CPU-only scenarios pass.
|
||||
The latter use synthetic GPU facts, zero GPU devices/models and real child
|
||||
processes: stop 10.676–63.994 ms, zero residual input, released fixture generation
|
||||
4. These are not new GPU graph timings. Evidence manifest SHA-256:
|
||||
`4588995d763fe82d896b42689e981f1d00feedaf9d6aab29f4579f771d03da3a`.
|
||||
|
||||
Stage-2 raw IPC increment, 2026-09-02 (`da60fef`, `a937400`, `79ce55d`):
|
||||
`streaming_wire.py` maps existing `LiveIngressEvent` fields into
|
||||
`missioncore.live-perception-wire/v2`. The v1 producer hooks, class and legacy
|
||||
serialization remain unchanged. A bounded length-prefixed JSON header carries
|
||||
decimal uint64 projections; payloads remain binary. This is a controller-supplied
|
||||
IPC socket candidate, not a selection/replacement of the intended gRPC network
|
||||
transport. No listener, arbitrary endpoint, scanner feed or command path is added.
|
||||
|
||||
Open compares the entire expected StreamStart and acquisition session binding;
|
||||
every fragment/control carries a digest of that full binding. A hash is integrity,
|
||||
not authentication. Fragment size is at most 1 MiB, event caps reuse the existing
|
||||
1 MiB camera / 2 MiB LiDAR/pose limits. Headers are capped at 64 KiB. One
|
||||
observation assembles at a time with exact offsets, fragment and whole-event
|
||||
SHA-256, per-channel source identity/sequence/clock checks. End, Cancel and
|
||||
gap/unavailable notices are explicit. EOF without End, timeout, malformed,
|
||||
cross-binding or incomplete input fails the bound stream; an unbound wrong Open
|
||||
is rejected without stopping the current owner. Camera frames require init;
|
||||
camera gap invalidates init. Actual codec/keyframe/IDR validation is NOT yet here.
|
||||
|
||||
`StreamingSender` owns no queue and fails on its bounded write deadline rather
|
||||
than stretching replay time. `StreamingIngress` is a lifecycle-tracked thread;
|
||||
idle reads poll the lease and cannot keep accepting an expired owner. Raw/header
|
||||
and reassembly reservations charge the SAME StreamMailbox 16 MiB input budget as
|
||||
queued/active bundles. They survive cancellation until the borrowing callback
|
||||
returns and buffers are dropped. Reservations are count-bounded too. Trusted
|
||||
decoder adapters must reserve scratch BEFORE allocation and must not retain raw
|
||||
callback bytes outside that ownership contract. Python metadata object overhead,
|
||||
socket/kernel buffers, source adapter memory and model tensors additionally need
|
||||
RSS/OS resource bounds; this application counter alone is not a total-RSS claim.
|
||||
|
||||
Worker evidence uses a separate source process, original camera init/fMP4 segments
|
||||
and existing normalized point/pose increments paced at original 1x times. All
|
||||
103 events (32 camera + init + 34 LiDAR + 36 pose) match exact bytes/metadata, each
|
||||
data modality reaches the hash-only consumer before End, and incomplete EOF
|
||||
delivers no observation. Normal peak input is 553,991 bytes. This validates raw
|
||||
IPC, NOT decoder output, the full graph on this ingress, model performance,
|
||||
authenticated network behavior or a standalone image. The next adapter must
|
||||
feed incremental decode and causal sensors into the same graph without using
|
||||
the old concatenated camera file or precomputed geometry. Product source queues,
|
||||
backend routes and the browser are not switched by this increment.
|
||||
|
||||
Stage-2 decoder increment, 2026-09-02 (`350366b`, `f102712`):
|
||||
`media_fragments.py` now owns the existing bounded ISO-BMFF timing parser.
|
||||
Archive inspection delegates to it and preserves `SessionIntegrityError` at its
|
||||
boundary. `perception.streaming_decoder.FragmentDecoder` imports neither the
|
||||
session store nor its materializer. It receives only bounded init/fragment bytes;
|
||||
source filenames, recording length and EOF are not decoder inputs.
|
||||
|
||||
The measured subset is H.264, one 800×600 frame per fragment, one video track,
|
||||
explicit moof-relative sample size/offset and no composition offset. The first
|
||||
fragment must be random-access and the decoded frame must be keyframe; DTS must
|
||||
remain contiguous. One persistent codec context, one slice thread, no flush or
|
||||
future packet: each call must yield exactly one frame with the same PTS/raster.
|
||||
Reordered/B-frame streams, unknown layouts, discontinuities and invalid output
|
||||
fail closed. Other valid camera formats require explicit qualification, not a
|
||||
hidden decoder fallback. These are current profile bounds, not a new K1 protocol.
|
||||
|
||||
PyAV 18.0.0 is pinned to wheel SHA-256
|
||||
`ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017`.
|
||||
It is installed only into a private experiment target and mounted read-only;
|
||||
standalone packaging must include it. The public
|
||||
[PyAV codec documentation](https://pyav.basswood.io/docs/stable/api/codec.html)
|
||||
describes persistent packet decoding and thread modes; compatibility claims here
|
||||
come from the actual 18.0.0 Worker probe, not from documentation version alone.
|
||||
|
||||
Sequential CPU-only 128-frame 1× comparisons produced exact BGR parity against
|
||||
the previous pinned OpenCV decoder. Direct validated sample extraction replaces
|
||||
opening/demuxing init+fragment on every frame; NumPy loads before readiness.
|
||||
Measured new-decoder p95 improves 19.927→11.156 ms, p99 20.920→18.675 ms.
|
||||
This is one bounded component comparison, not a full-profile speedup claim.
|
||||
The reference full-MP4 path exists only in the separate pixel-comparison process;
|
||||
neither new decoder path mounts/reads that MP4.
|
||||
|
||||
Combined IPC/decoder probe accepts 103 raw events and yields 32 exact BGR frames.
|
||||
31 frames arrive before the source End marker; the final frame drains before
|
||||
the receiver handles End. First decoded callback is 3.071 s before the marker.
|
||||
Input reservations peak at 5,727,751 bytes including conservative decoder scratch
|
||||
and init/extradata retention; normal and truncated-input cleanup return to zero.
|
||||
Native codec/DPB allocations are bounded separately by the CPU-only container;
|
||||
application payload counters alone do not constrain native allocation or execution
|
||||
time. Before full-graph integration, native decoding must run in a supervised CPU
|
||||
child with bounded RPC and explicit decoded-bundle ownership transfer.
|
||||
|
||||
Point/pose are preserved raw in this probe, not causally fused into a scene;
|
||||
calibration identity is explicitly non-scene/synthetic. No full graph, GPU model,
|
||||
network, physical live feed, vehicle control or product cutover is qualified.
|
||||
Next: preserve the existing causal sensor cut at camera release, connect decoded
|
||||
bundles to the same full graph, then repeat full-profile parity/timing. The last
|
||||
full-profile p95/p99 remains 124.98/136.12 ms, not this decoder's timing.
|
||||
|
||||
Stage-2 full binary graph increment, 2026-09-02 13:55 MSK (`221e429`):
|
||||
the preceding decoder-only boundary is now connected to the existing full graph.
|
||||
The recording adapter alone reads bounded current fMP4 fragments and normalized
|
||||
map point/pose rows. It emits original timestamps at 1× through the existing
|
||||
binary socket ABI; the consumer and decoder receive neither source paths nor
|
||||
recording length. The diagnostic container still mounts source/code/weights and
|
||||
a private pinned PyAV target: this is not standalone packaging or a network test.
|
||||
|
||||
Native decoding runs in a lifecycle-owned CPU child with a 1-GiB address-space
|
||||
limit. POSIX pipe RPC reserves header scratch, accepts ≤1 MiB input and writes
|
||||
exactly 1,440,000 BGR bytes into the caller's pre-reserved buffer. A 250-ms request
|
||||
deadline, malformed reply or lost lease terminates the stream; no implicit retry
|
||||
across predictive codec state. Process/native memory remains under container
|
||||
limits, separately from the 16-MiB application payload budget.
|
||||
|
||||
`streaming_sensors` owns the existing causal selection rules. Its rolling and
|
||||
fresh caches are each bounded by 64,000 points/64 increments; camera arrival
|
||||
freezes the original-time cut before decode, never selecting future pose/points.
|
||||
The legacy pilot delegates to the same selection rules. A separate CPU oracle
|
||||
compares the old producer's complete points/rolling/times/pose/lineage bundles.
|
||||
`admit_reserved` transfers the decoded allocation atomically to the common
|
||||
mailbox; rejection retains caller ownership until buffers are discarded.
|
||||
The global two-pending limit and active-input protection remain unchanged.
|
||||
|
||||
Worker evidence: 384/384 input events, 128/128 full graph outputs, no drops,
|
||||
126 outputs before source End; first result 583 ms. BGR, masks, proposals,
|
||||
tracks, threats, material and raw TGS costmap match the reference on 128/128.
|
||||
Two `metric_geometry.range_m` values differ by 1.3877787807814457e-17 m
|
||||
(seq13/observation3 and seq29/observation2): **strict raw parity remains false**.
|
||||
No rounding/tolerance was added; the numerical cause remains unreproduced.
|
||||
The geometry distance-estimator definition and all thresholds are unchanged.
|
||||
|
||||
Full p95/p99 161.974/191.806 ms FAIL the unchanged 125-ms target. Available
|
||||
camera/sensor pairs remain 76/128; 74 scenes are fresh at receipt, with seq106/107
|
||||
additionally expiring. Auto GPU memory clocks fall from 10,251 to 405–810 MHz;
|
||||
this correlates with longer DDRNet/queue times, but is not a controlled transport
|
||||
A/B. Peak input 8,883,895 bytes; remaining input/children/lease zero after stop.
|
||||
VRAM peak 2,367 MiB is not GPU utilization or a compute-capacity percentage.
|
||||
Normal 32-frame CPU oracles and a synthetic hung-decoder cleanup probe pass.
|
||||
|
||||
The full GPU sample preserves its exact measured code snapshot. Subsequent
|
||||
review bounds finite-check scratch, closes sockets/cache after constructor or
|
||||
pre-start failure, and removes CPU-harness heartbeat shutdown noise; those three
|
||||
files pass a separate Worker CPU oracle, not a second GPU performance sample.
|
||||
271 focused tests, Ruff/format and five-module mypy pass. Four temporarily stopped
|
||||
Mission Core services are restored; Ollama/Frigate remain exited/restart=no.
|
||||
Manifest: 161 artifacts, SHA-256
|
||||
`e224de4e9f2c5fd419fe0fb0b9be6e01ec267628568e104795ae211ec803bce4`,
|
||||
`.runtime/perception-stage2-binary-graph-worker-20260902T1335MSK/manifest.json`.
|
||||
Next: explain numerical reproducibility, measure/tune remaining pipeline tails,
|
||||
then complete controller/network/standalone boundaries within stage 2.
|
||||
No quality, physical-live, vehicle-control or real-time qualification is granted.
|
||||
|
||||
Stage-2 numeric/IPC increment, 2026-09-02 14:22 MSK (`380b6ea`, `f39f1ff`):
|
||||
the two preceding raw range differences are resolved, not tolerated. A bounded
|
||||
CPU-only probe on Worker NumPy 1.26.4 reproduces both exact deviations using
|
||||
identical quaternion values at addresses 8 mod 16. The wire quaternion starts at
|
||||
byte 24 of a combined pose buffer; its norm changes by one ULP on that layout.
|
||||
Offsets 0/16/32/48 restore reference norm/rotation/depth, while 8/24/40/56 reproduce
|
||||
the deviations. The adapter now owns an immutable 32-byte quaternion copy and
|
||||
checks 16-byte alignment. Input bytes, projection algorithm and tolerances are
|
||||
unchanged. Other CPU/NumPy combinations still require numerical qualification.
|
||||
|
||||
Synchronous local IPC now borrows contiguous image buffers. Decoder and parent
|
||||
no longer call BGR `tobytes()` for these handoffs; the sender writes framing and
|
||||
the borrowed body separately instead of concatenating a full message copy.
|
||||
Partial writes loop with explicit progress validation; noncontiguous buffers
|
||||
fail before output. The owner retains the image until send completes. This removes
|
||||
four application-level BGR copies across decoder→parent→DDRNet, not kernel copies,
|
||||
network bytes, or all intermediate allocations. Framing and lease/deadline checks
|
||||
are unchanged; a hung-decoder probe still fails and releases all owned resources.
|
||||
|
||||
Preregistered sequential A–B–B–A at authorized temporary stock clock locks:
|
||||
all samples observed SM2610/memory10251 MHz, unchanged 8 CPU/8 GiB/power limit.
|
||||
Every run completes 128/128 without drops. Control p95/p99 84.702/92.961 and
|
||||
83.930/91.759 ms; candidate 85.113/91.219 and 82.425/90.530 ms. Both candidates
|
||||
match reference BGR, masks, objects, geometry/ranges, tracks, threats, materials,
|
||||
lineage/binding and raw costmap exactly on 128/128. Baselines still reproduce the
|
||||
two old float deviations. Median decoder RPC-minus-decode falls 1.642–1.825 to
|
||||
1.100–1.118 ms; DDRNet RPC-minus-component 2.109–2.217 to 1.558–1.613 ms.
|
||||
This is a modest IPC improvement; full p95 does not show an unambiguous win.
|
||||
The previous auto-clock 191.806-ms p99 cannot be compared as a copy-only baseline.
|
||||
|
||||
Local 125-ms timing passes at this fixed operating envelope, but only 76/128
|
||||
scenes are fresh because the same 52 input sensor gaps remain. No overall or
|
||||
auto-clock/network/physical-live qualification is granted. Auto clocks and four
|
||||
Mission Core services are restored; Ollama/Frigate remain disabled. 279 focused
|
||||
tests pass. Manifest: 305 artifacts, SHA-256
|
||||
`9c72a93833a1dda51c4787dca13fb9791a1bf2e31c763b7740e1b21406703707`,
|
||||
`.runtime/perception-stage2-ipc-worker-20260902T1410MSK/manifest.json`.
|
||||
Next implementation boundary is the controller/envelope/network integration and
|
||||
standalone package. Host clock management is not granted implicitly to that controller.
|
||||
|
||||
Open → validate bounded metadata/claim → warm models → Ready → start the replay
|
||||
clock → observations and incremental scenes → stop/drain bounded state → close
|
||||
and asynchronously seal the immutable receipt. A live source already producing
|
||||
data does not queue its entire warmup history: readiness begins at a current
|
||||
decodable keyframe, with the skipped interval recorded.
|
||||
|
||||
Replay release uses original source intervals, not a convenient fixed 12 Hz and
|
||||
not the processing speed. A slow consumer must not stretch source time to make
|
||||
inference look real-time. Overload may drop bounded work to preserve liveness,
|
||||
but the dropped observations remain in the ledger and fail the strict initial
|
||||
performance target. Initial baseline is every camera frame, stride 1. Choosing
|
||||
multirate later changes the explicit profile contract; a retained mask is not a
|
||||
new inference and cannot receive a fresh capture timestamp.
|
||||
|
||||
UTC identifies the run; monotonic clocks measure local durations. Cross-host
|
||||
age requires a clock mapping and its uncertainty. No direct subtraction of
|
||||
unrelated host monotonic clocks. The common source timeline is separate from
|
||||
worker wall-clock time. Causality is bounded by the released-observation
|
||||
watermark at decision time, not by whatever later samples exist in the file.
|
||||
Each layer retains its own channel/sequence/time; camera anchors and pose/cloud
|
||||
association times remain separately visible. Future file look-ahead and offline
|
||||
nearest-pose lookup across unreleased data are forbidden.
|
||||
|
||||
Lease expiry, cancellation and epoch changes fence the previous producer. No
|
||||
new profile may take the GPU until old processes and work have stopped. A
|
||||
reconnect creates a new epoch, discards stale backlog, resets temporal/rolling
|
||||
state and reacquires codec initialization/keyframe. It never resends a stale
|
||||
motor action; this profile has no motor actions at all.
|
||||
|
||||
## Outputs and interpretation
|
||||
|
||||
Every scene accounts for segmentation, objects, geometry, motion, costmap and
|
||||
policy exactly once, with current/held/stale/unavailable state and current-run
|
||||
payload identity. Existing source/object/obstacle/temporal/map/threat contracts
|
||||
remain the domain vocabulary. `LayerEvidence` adds freshness/completeness checks,
|
||||
not a second semantic ontology. In stage 2, layer manifests also carry their full
|
||||
input reference sets; a single anchor does not replace camera/cloud/pose lineage.
|
||||
|
||||
Stage-1 implementation update, 2026-09-02 (`097e450`):
|
||||
`missioncore.perception-scene-freshness/v1` in `realtime_scene.py` extends
|
||||
`LayerEvidence` with oldest-required-input time and checks the six-layer dependency
|
||||
graph. A derived result cannot refresh its input age. Decimal int64 JSON strings
|
||||
preserve timestamps/sequence exactly; transport-neutral Python objects use integers.
|
||||
The pilot validates payload hashes and assesses freshness again at receipt, and
|
||||
must reassess at later use. Missing/stale output remains inspectable but cannot
|
||||
retain permissive advisory policy. This envelope is NOT a replacement for
|
||||
StreamStart identities, complete input lineage or controller lease fencing.
|
||||
|
||||
The stage-1 pilot propagated the oldest last-seen permissive TGS cell into the
|
||||
costmap/policy age and suppressed the entire scene on expiry. Stage-2 increment
|
||||
`1f8101e` adds `missioncore.costmap-cell-freshness/v1`: bounded (8192 cells),
|
||||
index-aligned original support timestamps, decimal int64 strings/null, included
|
||||
in the costmap payload digest together with the guard mode. Unobserved support
|
||||
is never assigned a timestamp. The pilot now defaults to `per-cell`; the old
|
||||
`whole-scene` mode remains an explicit comparison control, not a product cutover.
|
||||
|
||||
Both publication and receipt remove permission from expired ground cells and
|
||||
mark them rejected/NO_GO. Occupied cells retain prohibition. The remaining
|
||||
permissions propagate their original support age AND the mandatory segmentation,
|
||||
geometry and motion dependencies. A missing/stale mandatory layer still blocks
|
||||
the whole policy. A derived consumer view has new costmap/policy hashes while
|
||||
preserving source identity/timestamps; the original published bytes stay immutable.
|
||||
Reassessment cannot restore a suppressed action or move the observer clock back.
|
||||
The enclosing StreamStart must bind the grid/profile/epoch/clock; this descriptor
|
||||
alone does not establish those identities or authenticate a remote producer.
|
||||
|
||||
In the measured 128-frame window, raw model/geometry/motion/TGS/material outputs
|
||||
remain identical to pinned PyTorch. Effective costmap states and permissions
|
||||
change deliberately on expiry. A fresh six-layer envelope can still contain
|
||||
rejected/unknown cells; it is not a claim of full free-space coverage, semantic
|
||||
correctness, vehicle clearance or physical safety. Recheck again at actual use.
|
||||
|
||||
`bcacb02` promotes the proven mailbox and serial GPU stage to common perception
|
||||
modules, with the pilot importing compatibility aliases rather than owning
|
||||
another scheduler. Pending ingress + completed GPU outputs share two slots;
|
||||
active inputs share 16 MiB and are released by their owning stage. Cancellation
|
||||
discards pending work, a timed-out callback retains ownership, and CPU-owned
|
||||
input is not freed by GPU shutdown. Diagnostic drop history is capped at 256
|
||||
entries with exact per-reason totals; it is not the durable terminal ledger.
|
||||
These primitives are NOT the controller supervisor, a cross-process GPU lease,
|
||||
StreamStart fencing or the binary data plane. Those stage-2 boundaries remain open.
|
||||
|
||||
Stage-2 lifecycle increment, 2026-09-02 (`6acf468`, `ac69e3b`):
|
||||
`StreamingLifecycle` now supplies the subprocess-backed profile supervisor using
|
||||
the existing `GraphState` and full `StreamStart` identity. `WorkerLease` uses a
|
||||
stable POSIX file lock on one controller-selected, private directory per Worker,
|
||||
plus an atomic/fsynced ownership record. All managed profile containers must use
|
||||
that SAME directory/volume. Neither the directory nor child commands come from
|
||||
an incoming source/job. This cooperative local fence supplements, not replaces,
|
||||
the backend claim and the trusted inventory of unmanaged GPU clients.
|
||||
|
||||
Open acquires ownership before process spawn/warmup; Ready enables admission.
|
||||
Renewal cannot resurrect expired ownership. Admission, each GPU/CPU lane and
|
||||
result publication/receipt validate run/source/worker/epoch/generation and pinned
|
||||
image/profile/config/calibration/clock identities. A mismatched client is rejected
|
||||
without cancelling the current owner. Each compute lane remains single-owner.
|
||||
The watchdog fences an expired owner even without a new frame and terminates
|
||||
only its dedicated child process groups. Cleanup keeps ownership while callbacks,
|
||||
tracked threads, pending/active payloads or child groups remain. Clean retirement
|
||||
persists `released` before unlocking; a higher generation and new epoch may then
|
||||
activate. A controller crash leaves `active` even when the kernel unlocks, so
|
||||
the next owner stays quarantined. No force/unverified recovery API exists.
|
||||
|
||||
Cross-container contention/clean succession/crash quarantine and full-profile
|
||||
lease expiry were exercised on Worker 006. The final timed expiry trace had no
|
||||
receipt at/after the lease deadline, stop requested after 45.74 ms and retirement
|
||||
after 4109.82 ms. This is bounded evidence, not a hard-real-time scheduler guarantee.
|
||||
The holder has a 2 s lease and receives local pilot heartbeats every 250 ms;
|
||||
those settings and the 50 ms watchdog poll are NOT network/vehicle safety limits.
|
||||
|
||||
Current integration remains explicitly diagnostic: `pilot_lifecycle.py` acts as
|
||||
a local controller, legacy GPU services are quiesced by the authorized launcher,
|
||||
the named volume is an isolated test volume, and the image still has code/model
|
||||
mounts. Mounted-code hashes remain in the experiment manifest; an image digest
|
||||
alone cannot seal those overrides. Backend claims/production-wide canonical root,
|
||||
trusted post-crash resource-release recovery, continuous GPU inventory/envelope
|
||||
enforcement, network authentication/heartbeat and binary live ingress are NOT
|
||||
installed or qualified by this increment. Standalone packaging must remove the
|
||||
developer mounts and retain the same lifecycle boundaries.
|
||||
|
||||
`worker_operating_envelope.py` checks a trusted post-warmup snapshot against
|
||||
preregistered hardware/driver/resource/clock conditions and StreamStart identities,
|
||||
ownership and client inventory. Unknown or expired facts fail readiness. The
|
||||
candidate records the previously measured 4090 fixed-clock envelope; a compatible
|
||||
auto-clock experiment cannot inherit its latency result. The evaluator performs
|
||||
no I/O, host clock changes, lease acquisition or qualification; collection and
|
||||
continuous enforcement belong to the common controller/runtime in stage 2.
|
||||
|
||||
DDRNet currently crops the center 600×600 to 512×512; outside that ROI is undefined,
|
||||
not hard_surface/free. RF-DETR accepts the full 800×600 native raster with its
|
||||
pinned valid-FOV handling. Their coordinates must be related explicitly.
|
||||
|
||||
RF-DETR's existing filter emits person/cat/dog, but minimum box area 64 px,
|
||||
maximum box fraction 0.5, FOV fraction 0.5 and required valid center create known
|
||||
small-animal/close-large-object edge cases. They are retained and documented,
|
||||
not silently retuned or presented as proven field quality.
|
||||
|
||||
Detected-object range is the median camera-Z of owned current support points;
|
||||
geometry-only range is nearest Euclidean distance from the sensor. Neither is
|
||||
automatically clearance from the physical vehicle body. Missing/ambiguous
|
||||
support produces unavailable range, not zero/infinity. Generic static obstacles
|
||||
survive absence of a detector class name.
|
||||
|
||||
TGS keeps ground/occupied/rejected/unobserved separate. Ground support alone is
|
||||
not a traversability or actuation decision. Policy can produce an advisory
|
||||
allowed-candidate/high-cost/blocked/unknown outcome with explanation. No planner,
|
||||
physical motor command, autonomous driving acceptance or mission configurator
|
||||
implementation is introduced.
|
||||
|
||||
## Package/dependency decision
|
||||
|
||||
The candidate manifest is
|
||||
`config/perception/k1-perception-ddrnet39-rfdetr-tgs-prototype-v1.json`.
|
||||
It is not installed into the active portable registry and has no invented image
|
||||
digest. Model/checkpoint/config identities are pinned; source session IDs and old
|
||||
derived output paths in reference experiments are not inherited requirements.
|
||||
|
||||
Select process/environment isolation inside one future image: Python 3.12
|
||||
supervisor and geometry, RF-DETR TensorRT 11 runtime, existing Python 3.9 / Torch
|
||||
1.13.1 cu117 / super-gradients 3.2.0 environment, and CPU C++ TGS. Exchange bounded
|
||||
shared-memory/IPC payload references; one supervisor serializes GPU work. This
|
||||
avoids a forced dependency upgrade or checkpoint conversion in stage 1. Image
|
||||
assembly of the final standalone package and the full schedule pilot remain
|
||||
explicit implementation evidence. A temporary common-base image passed separate
|
||||
DDRNet, TensorRT, Python 3.12 geometry and C++ TGS execution probes; DDRNet masks
|
||||
matched on 64 frames. This proves bounded ABI/execution compatibility in that
|
||||
image, not jointly resident models, the supervisor/IPC or a complete profile.
|
||||
The probes still mount pinned assets explicitly; they are not standalone proof.
|
||||
|
||||
The temporary Triton base lacks Python grpc/protobuf, cv2 and TensorRT bindings;
|
||||
native trtexec/server are present. The actual supervisor/transport environment
|
||||
must explicitly include and validate its dependencies. Numeric-library thread
|
||||
limits are pinned to one after the bounded synthetic local-surface comparison
|
||||
(mean 198.0 ms default versus 63.2 ms with limits); algorithm thresholds were not
|
||||
changed. Real-cloud timing, output parity and whole-graph scheduling remain gates.
|
||||
|
||||
The initial DDRNet image-only probe failed before inference: the installed image
|
||||
does not contain the checkpoint at its logical asset path. The bounded baseline
|
||||
therefore uses an explicit read-only pinned checkpoint and runner mount. This is
|
||||
valid component measurement, but fails the target's standalone packaging claim.
|
||||
Super-gradients also needs a writable log directory; a bounded temporary log
|
||||
mount was required. The new image must declare scratch areas explicitly without
|
||||
requiring a writable host home, code checkout or implicit weight cache.
|
||||
|
||||
## Preregistered engineering acceptance
|
||||
|
||||
Owner clarification, 2026-09-02 MSK: bounded laboratory overload is an admissible
|
||||
experimental outcome. Keep useful profiles that fail the current 4090 target;
|
||||
do not block reusable runtime/packaging work solely on that performance result.
|
||||
Functional execution, experimental availability, quality and real-time
|
||||
qualification are separate dimensions. Qualification belongs to a measured
|
||||
profile/config/hardware/source/transport combination, not permanently to a
|
||||
model name. More powerful Worker/onboard placement is a future retest target,
|
||||
not a claim that existing latency or internal IPC disappears automatically.
|
||||
The original budgets and failed measurements below remain unchanged. No slowed
|
||||
source clock, hidden drops, stale-as-current evidence or actuation is authorized.
|
||||
|
||||
The manifest freezes an initial engineering candidate: p95/p99 full output age
|
||||
≤125 ms, required-layer age ≤250 ms, release lag ≤25 ms, first incremental result
|
||||
≤1 s after source admission, warmup ≤120 s, stop ≤5 s, VRAM ≤22000 MiB, RSS ≤8192
|
||||
MiB. First/last-window backlog growth ≤25 ms and no capacity drops, failures,
|
||||
expired selected frames or unaccounted observations. These are prototype targets,
|
||||
not physical braking/safety limits or claims of achieved performance.
|
||||
|
||||
`ReplayMeasurements`/`realtime_failures` reject full-source preload, EOF-only
|
||||
results, slowed replay, incomplete accounting, hidden missing layers and budget
|
||||
violations. Declared source gaps must be verified from the input ledger; they
|
||||
produce explicit degraded scenes and are not counted as fully fresh perception.
|
||||
At least one fully evaluable scene is required. A component FPS figure cannot
|
||||
populate this whole-path receipt. A quality comparison or successful export is
|
||||
not a substitute for the real-time gate.
|
||||
|
||||
Every observation receives a terminal ledger outcome. Track ingress/release,
|
||||
selection, decode, inference, completion, emission, drop/expiry/failure and source
|
||||
gaps separately. Store startup separately from steady state; record payload bytes,
|
||||
queue high-water marks, clock error, RSS/VRAM, image/weights/config/source/equipment
|
||||
identities. Export and UI encoding cannot block inference through an unlimited
|
||||
queue. A receiver-side render/receipt timestamp, not just Worker completion, ends
|
||||
the end-to-end interval.
|
||||
|
||||
## Evidence and remaining stage-1 work
|
||||
|
||||
### Operator telemetry is not runtime authority (2026-09-02)
|
||||
|
||||
Reuse System/Worker and the existing Telegraf → MQTT → Timescale route for
|
||||
operator observability. A profile may export one bounded 8 KiB current snapshot
|
||||
at 1 Hz to the host collector, independently from controller heartbeat/model
|
||||
work. Missing or stale observations are unavailable, never fabricated idle,
|
||||
loaded-model readiness, per-stage timing or real-time qualification. Agent
|
||||
restart/reconnection and database retention must not block inference. The faster
|
||||
Worker-local readiness/inventory channel remains separate; MQTT/Timescale/UI
|
||||
cannot renew a lease or authorize GPU/vehicle activity. This observability
|
||||
integration does not constitute the external camera/points binary data plane.
|
||||
|
||||
See `experiments/perception/PERCEPTION_STREAM_STAGE1_2026-09-01.md` for measured
|
||||
component results, bounded validation, Worker maintenance and remaining gates.
|
||||
Neither this ADR nor passing synthetic contract tests marks the new runtime ready.
|
||||
|
||||
## Stage 2 increment 12: external gRPC/TLS candidate (2026-09-02)
|
||||
|
||||
`streaming_grpc.py` exposes `/missioncore.perception.v1.BinaryStream/Exchange`.
|
||||
It is an optional `perception-stream` dependency, locked grpcio1.83.1; it is not
|
||||
auto-started by the backend or installed into the product LAB path. Both sides
|
||||
require TLS; the client verifies its supplied trust root and server name. The
|
||||
local controller alone issues a256-bit random single-use capability with30s
|
||||
admission expiry, bound to the complete StreamStart and acquisition session.
|
||||
The endpoint retains only its digest. Invalid/duplicate/stale credentials cannot
|
||||
reserve a stream, stop its owner, renew a lease, launch a model or create an epoch.
|
||||
One active stream is admitted; reconnect needs a controller-approved new epoch
|
||||
and, separately, real decoder/causal-sensor resumption proof. Network disconnect
|
||||
is not explicit End or Cancel. Production grant delivery is still an integration
|
||||
gate; the CPU probe transfers ephemeral files only through authenticated SSH.
|
||||
|
||||
The generic gRPC method uses identity byte serialization, not generated protobuf
|
||||
messages. Request messages contain≤64KiB pieces of the unchanged MCI2 wire;
|
||||
StreamingIngress still validates metadata, fragments, hashes and epoch/source
|
||||
binding. No archive, duration, file path or executable command is sent. Response
|
||||
MCR3 is only an envelope over existing domain bytes: magic4, uint64 sequence8,
|
||||
binding SHA25632, payload SHA25632, then1..1MiB payload. It does not invent a new
|
||||
scene ontology or claim that full-graph scene/layer serialization is connected.
|
||||
The actual profile's larger outputs require reviewed fragmentation, not a silent
|
||||
cap increase. Result age validation and the full result receipt ledger remain
|
||||
with the graph/application adapter, not the transport envelope.
|
||||
|
||||
There are two pending latest replies and one writer/reader per direction; no
|
||||
background source queue/retry or event-loop callback backlog. Python bridge
|
||||
storage is reserved inside the existing16MiB mailbox budget. Fixed1MiB HTTP/2
|
||||
lookahead, disabled BDP growth/retries, bounded metadata/messages/IO are explicit;
|
||||
native gRPC/TLS/socket memory also needs measured RSS, not a claimed byte-perfect
|
||||
mapping into mailbox accounting. Public/multi-client DoS hardening is not proved
|
||||
by this private Worker experiment. A250ms event deadline covers the complete
|
||||
source event. Even final error-status sending is deadline-bound; otherwise an
|
||||
unread response can delay cleanup. Late trusted callbacks are quarantined, with
|
||||
no replacement grant or early byte release until they return. Pending reply
|
||||
drops are counted; they are not equivalent to end-to-end delivery receipts.
|
||||
|
||||
Evidence: initial default64KiB lookahead timed out on1,048,595B. Fixed1MiB passed
|
||||
two16/16 cross-host samples without changing250ms. Final min/median/max RTT
|
||||
14.360/23.919/120.815ms on the Mac includes SSH/echo overhead, not clock-subtracted
|
||||
one-way age.2.202s wait retained the same CPU sentinel PID40 and lease; renewals
|
||||
32→54. Source clock and resume evidence are explicitly synthetic, model count0.
|
||||
Peak mailbox6,750,559B; Linux RSS57,244KiB.20 Worker transport tests include1MiB
|
||||
output, a real slow reader and late-callback cleanup;105 focused Mac tests pass
|
||||
with the two larger probes intentionally skipped. Final cleanup leaves0bytes,
|
||||
no temporary listener/container/tunnel/private key/grant. No GPU setters or
|
||||
durable services changed. Frigate/Ollama remain disabled, Telegraf is Running.
|
||||
Manifest SHA256 `adf5eaf092feaed6721f66e2adaceded0cdbf55754e1f9953f54623bfb52d331`
|
||||
at `.runtime/perception-stage2-grpc-20260902T1500Z/manifest.json` (16 artifacts).
|
||||
|
||||
Design references: [gRPC flow control](https://grpc.io/docs/guides/flow-control/)
|
||||
warns that a completed write need not mean network delivery;
|
||||
[authentication](https://grpc.io/docs/guides/auth/) documents TLS credentials;
|
||||
[channel options](https://github.com/grpc/grpc/blob/master/include/grpc/impl/channel_arg_names.h)
|
||||
documents the fixed lookahead window. Source and actual behavior were checked;
|
||||
these references alone are not a performance qualification.
|
||||
|
||||
## Stage 2 increment 13: full graph over container networking (2026-09-02)
|
||||
|
||||
The same `BinaryGraphInput` decoder and causal sensor cut now feed the full
|
||||
graph from `NetworkGraphBridge`. Source and receiver execute in a separate
|
||||
CPU-only container. No camera/index/sensor recording mount exists in the GPU
|
||||
container. Resident DDRNet/RF-DETR/decoder/TGS children and the local Worker
|
||||
lease remain owned by the existing controller; remote ingress cannot restart
|
||||
models or renew the lease.
|
||||
|
||||
The return payload carries the existing scene plus the actual uint8 segmentation
|
||||
plane. Grid coordinates join the costmap digest. Result1MiB, JSON512KiB and plane
|
||||
512KiB caps are checked before parsing/retention; total framing also fits1MiB.
|
||||
The receiver verifies raw mask and all layer hashes and independently derives
|
||||
freshness/policy. Network arrival, integrity-validation completion and complete
|
||||
consumer-view readiness are recorded separately.
|
||||
|
||||
The pilot verifies a common Linux boot and zero monotonic namespace offsets.
|
||||
Its grant file is trusted local control, read-only to the source; source status
|
||||
uses a separate mount. It is **not** the production grant delivery or cross-host
|
||||
clock mapping implementation. A foreign clock cannot be silently subtracted.
|
||||
|
||||
Final normal128b:128/128 byte-exact received scenes, raw model/geometry/motion/
|
||||
costmap parity to the prior reference, no queue drops, one input epoch, no resets.
|
||||
Consumer-ready p95/p99/max106.150/115.349/126.910ms. One frame exceeds125ms;76/128
|
||||
scenes have complete six-layer freshness, with52 original missing sensor pairs.
|
||||
Gap128b:2.2s disconnect,102 completed +26 omissions24–49, fresh keyframe/pair
|
||||
resume50, exactly one temporal reset, same four PIDs and lease generation1.
|
||||
Consumer-ready p95/p99/max107.098/110.817/117.317ms; availability gap2.725s.
|
||||
Post-gap history-dependent outputs are not asserted equal to uninterrupted
|
||||
history. All230 mask/scene receipts and their derived views were rechecked.
|
||||
|
||||
These runs use temporary stock2610/10251MHz clocks,450W,8CPU/8GiB model and
|
||||
2CPU/1GiB source envelopes. They prove container-network behavior on one
|
||||
Worker, **not** Mac↔Worker/radio/live latency. Observed result maximum460248B,
|
||||
tracked peak13,217,791B, residual0. Full-model slow consumer and cross-host
|
||||
clock/grant integration precede standalone packaging; product cutover remains
|
||||
closed. Diagnostic startup retry is not a production recovery service.
|
||||
|
||||
Source init originally preceded the delayed start by2s and triggered a spurious
|
||||
idle timeout. The source now connects100ms before release, without relaxing
|
||||
the timeout. Final normal/gap have zero/one intended recovery. Missing runner,
|
||||
read-only mountpoint and launcher ExitCode failures are retained as rejected
|
||||
attempts.146 focused tests pass;2 larger Worker-only tests skipped on Mac.
|
||||
|
||||
Evidence236 artifacts at
|
||||
`.runtime/perception-stage2-network-graph-20260902T1541Z/manifest.json`, SHA256
|
||||
`d1bc7e26850a2d9050ee1d8f8a5ece2e6b8c53e2e7dba381cbd4445d68a5ec17`.
|
||||
See [the detailed report](../../experiments/perception/PERCEPTION_STREAM_STAGE2_NETWORK_2026-09-02.md).
|
||||
Four prior services and automatic clocks restored; transient containers,
|
||||
networks, lease volumes, keys and grants removed. Ollama/Frigate stay disabled.
|
||||
Mac8000 and Worker telemetry remain available; no8765 or external deployment.
|
||||
|
||||
## Stage 2 increment 14: cross-host clock/control foundation (2026-09-02)
|
||||
|
||||
Application-side TLS Poll may deliver only a pending data grant already issued by the
|
||||
trusted Worker controller. It cannot acquire/renew the local lease, reset/start models,
|
||||
choose an epoch or cancel. Data admission remains single-use and independently scoped.
|
||||
Four timestamps bound remote-minus-local offset without equal-path assumptions; recent
|
||||
intervals are intersected, aged under an explicit rate/error envelope and expire.
|
||||
Expired/excess/contradictory evidence means unavailable/WAIT, not a wider hidden budget.
|
||||
|
||||
CPU-only Mac↔Worker proof delivers both grants and8/8 exact echoes. A2.202s gap retains
|
||||
the CPU sentinel and lease1, expires the old mapping and resumes with a new input epoch.
|
||||
Of72 measured observations,60 meet conditional≤5ms; min/median/max uncertainty are
|
||||
3.883/4.469/27.118ms and readiness changes after warmup. Consequently this increment
|
||||
does not claim full-graph cross-host freshness and does not cherry-pick a passing sample.
|
||||
The next full-graph run must consume continuous mapping state and carry uncertainty to
|
||||
the receiving freshness decision. [Detailed report](../../experiments/perception/PERCEPTION_STREAM_STAGE2_CONTROL_CLOCK_2026-09-02.md).
|
||||
|
||||
## Stage 2 increment 15: acknowledged clocks in the full cross-host graph (2026-09-02)
|
||||
|
||||
The Worker validates a one-use echoed Poll challenge before accepting responder-side
|
||||
clock evidence and the immutable source anchor. Clock validity is checked continuously,
|
||||
including idle periods, input admission and GPU/CPU/publication boundaries. Unknown or
|
||||
excess uncertainty enters WAIT without releasing resident models/local ownership.
|
||||
Upper age bounds govern source cutoff; scene and cell freshness include uncertainty.
|
||||
Each assessment uses one immutable clock snapshot, and EOF acknowledgement precedes
|
||||
data-channel retirement. Early transport loss after grant admission but before OPEN
|
||||
must also enter WAIT; malformed unbound data cannot stop the owner.
|
||||
|
||||
The final full-graph Mac source/receiver canary is **not qualified**:13 source WAIT skips,
|
||||
17 synchronization skips,2/32 receipts (frames30–31), both after source-window EOF.
|
||||
Latency143.881/155.743ms; no hidden losses, no model reload, sampled VRAM2365MiB,
|
||||
not a24GiB limit.297 local and173 Worker tests PASS;
|
||||
early disconnect additionally12/12 across six repeats. Source-first clock readiness
|
||||
and a fixed2s lead do not establish Worker readiness. Next gate separates two-sided
|
||||
clock warmup from source-anchor activation before1x starts; after start the source
|
||||
timeline remains immutable through outages. No gate widening, backlog replay,
|
||||
standalone or Stage3 promotion. [Evidence and rejected attempts](../../experiments/perception/PERCEPTION_STREAM_STAGE2_CROSSHOST_GRAPH_2026-09-02.md).
|
||||
|
||||
## Stage 2 increment 16: joint startup before immutable 1x activation (2026-09-02)
|
||||
|
||||
Pre-start clock reports carry a null anchor and warm both observation windows.
|
||||
Version2 replies bind responder bounds and accepted anchor to the issued challenge.
|
||||
Source checks both current intervals, ages peer evidence through ACK transit, obtains
|
||||
explicit anchor acceptance and a data grant before startup. A lost ACK cannot create
|
||||
a different anchor; missing the agreed start fails rather than silently retiming input.
|
||||
The existing5ms/2s/500ppm/50us budgets and lease/data authority separation remain intact.
|
||||
|
||||
The single32-frame GPU canary now admits frame0, but is not realtime-qualified:
|
||||
8 results,1 input-gap discard,9 WAIT and14 synchronization skips. All55 six-timestamp
|
||||
exchanges independently reconstruct; running uncertainty5.031–5.667ms causes a real
|
||||
clock WAIT without an RPC error, GPU telemetry loss or model restart. More samples
|
||||
inside the same2s horizon would not remove the measured WAIT.241 local/198 Worker
|
||||
tests PASS,129 measured source hashes match code48835a0. Next investigate control-loop
|
||||
scheduling/transport before another full-graph qualification, without budget widening.
|
||||
[Detailed evidence](../../experiments/perception/PERCEPTION_STREAM_STAGE2_JOINT_START_2026-09-02.md).
|
||||
|
||||
## Stage 2 increment 17: transport-specific clock readiness (2026-09-02)
|
||||
|
||||
Clock-only A/B/A separates route effects from scene processing: SSH/Tailscale48/96,
|
||||
strictly host-key-pinned SSH over confirmed LAN94/96 (only initial2 unready), return
|
||||
to Tailscale53/96. All288 exchanges reconstruct; application handler/loop delays do
|
||||
not alone explain ~100ms request tails. LAN is an observed connection option, not
|
||||
a new model dependency, universal speedup or permanent product/default route change.
|
||||
|
||||
One full LAN canary returns32/32 byte-exact/reference-exact scenes at1x with no WAIT,
|
||||
drop or reconnect;50 clock exchanges reconstruct,48 after initial warmup remain ready.
|
||||
All129 prior runtime source files and the model/effective config remain unchanged.
|
||||
Latency205.864/213.751ms p95/p99 still fails125ms; only12/32 scenes are fully fresh.
|
||||
Do not equate complete output accounting with usable geometry/policy or autonomous
|
||||
readiness. Next instrument data-plane stage boundaries before optimizing remaining
|
||||
tails; no gate widening.245 local/202 Worker tests PASS,131 source hashes matched.
|
||||
[Detailed route and full-profile evidence](../../experiments/perception/PERCEPTION_STREAM_STAGE2_LAN_ROUTE_2026-09-02.md).
|
||||
|
||||
## Stage 2 increment 18: application-edge transport attribution (2026-09-02)
|
||||
|
||||
Optional bounded TLS-record witnesses belong to isolated diagnostic tooling, not
|
||||
the runtime route or model profile. A/B/A keeps the~100ms tail with and without
|
||||
the witnesses;424 ciphertext records match between edges and192 serial RPCs
|
||||
match their handlers.13 post-start long calls localize to request(8)/response(5)
|
||||
inter-edge transit, not local JSON/handler work. Startup without admitted clock
|
||||
mapping stays unqualified; timestamps are application receipt/drain, not wire ACKs.
|
||||
|
||||
Direct TCP connects to the known Worker SSH port reproduce67.9–91.1ms peaks without
|
||||
gRPC, Docker or SSH forwarding. Both active links are Wi-Fi; native Worker loopback
|
||||
after startup is0.240–0.299ms. The specific radio/AP/OS/driver mechanism is unproved.
|
||||
Do not weaken5ms/125ms gates or replace models to address an external transport
|
||||
symptom. Conversely, this does not exonerate all application costs or qualify
|
||||
large scene buffers. Continue per-frame queue/serialization/publication attribution;
|
||||
an available wired comparison should remove both radio legs but is not a prerequisite
|
||||
for experimental runtime/standalone work. No product transport or GPU workload changed.
|
||||
82 local/82 Worker tests PASS;142 staged files exact; previous full-graph latency FAIL
|
||||
stands. [Detailed evidence](../../experiments/perception/PERCEPTION_STREAM_STAGE2_TLS_ATTRIBUTION_2026-09-02.md).
|
||||
@@ -0,0 +1,132 @@
|
||||
# ADR 0050 — Recorded Observatory first; portable profiles then onboard
|
||||
|
||||
Date: 2026-09-02. Decision accepted by the owner; implementation in progress.
|
||||
This supersedes ADR 0049's realtime-first product gate and execution order,
|
||||
not its live-stream integrity, ownership or safety contracts.
|
||||
|
||||
## Decision
|
||||
|
||||
The primary operator workflow is a complete calculation of a compatible K1
|
||||
recording with a selected immutable profile, followed by publication and cached
|
||||
review. Processing may be slower than acquisition. Network latency is evidence,
|
||||
not a prerequisite for using the laboratory. After this workflow works, compare
|
||||
Docker configurations; later qualify the selected configuration on actual
|
||||
onboard hardware. No vehicle or remote-realtime acceptance is claimed now.
|
||||
|
||||
Keep the existing source admission, queue, artifact store, publication recovery,
|
||||
streaming runtime and common viewer. Do not introduce another per-LAB application.
|
||||
One Worker executes one active profile; alternative profiles remain sequential.
|
||||
M4.9T5 currently supplies CPU TGS; LAB V1 supplies sequential EoMT/DDRNet, not the
|
||||
complete future detector/distance/motion/TGS/policy profile.
|
||||
|
||||
## Recorded analysis versus realtime rehearsal
|
||||
|
||||
Recorded analysis preserves source timestamps and causal ordering, uses bounded
|
||||
backpressure, and accounts for every required scheduled input/result. Its elapsed
|
||||
wall time need not match the source clock. Source sensor gaps remain unknown.
|
||||
Do not disguise live drop/resync as a complete recorded result. Introducing this
|
||||
execution mode requires an explicit versioned contract, not silently weakening
|
||||
the existing 1× live freshness, deadline or ownership gates.
|
||||
|
||||
Realtime rehearsal remains an independent qualification. Historical network
|
||||
canaries retain their original FAIL results and thresholds. Neither cached video
|
||||
playback FPS nor end-to-end upload/export time is a Worker compute benchmark.
|
||||
Measure preparation, warmup, processing, delivery and publication separately;
|
||||
onboard estimates are conditional on matching hardware and effective config.
|
||||
|
||||
## Result identity and UI
|
||||
|
||||
Only compatible, not-yet-calculated current profile versions belong in the
|
||||
calculation selector. Published results of every version remain below as evidence.
|
||||
All calculated means no Calculate action, an empty/disabled selector, and Refresh.
|
||||
Refresh, source selection and cached review must never start inference.
|
||||
|
||||
Reuse requires the exact admitted source fingerprint and immutable definition
|
||||
(including model, image, configuration, adapter and result-contract identities).
|
||||
An old LAB name, a legacy overlay, or compute success without publication is not
|
||||
a matching cache. Missing/corrupt artifacts must not suppress a valid retry.
|
||||
Publication failure retries publication, not computation, while the sealed package
|
||||
is recoverable. Changed profile versions become new calculations.
|
||||
|
||||
## First implementation increment
|
||||
|
||||
Portable queue admission now opts into an atomic duplicate-computation guard.
|
||||
It uses the existing job identity, not a second cache key ontology, and rejects a
|
||||
different request while the identical job is active, requires reconciliation, or
|
||||
has a sealed result pending/failed publication. The check and INSERT use one SQLite
|
||||
transaction. Exact retries retain the original idempotency semantics. A genuinely
|
||||
failed compute can be retried with a new request key. Legacy queue callers retain
|
||||
their existing policy; no schema migration or Worker/image change is introduced.
|
||||
|
||||
Published-cache verification, catalog projection, selector changes, progress and
|
||||
complete recorded-analysis execution are still pending. This increment does not
|
||||
mark stage 1 or the full workflow complete. Tests cover concurrent admission,
|
||||
durability, changed source/profile, failure retry and HTTP 409 duplicate handling.
|
||||
|
||||
## Second increment: bounded non-binding idle claims
|
||||
|
||||
Claim/v3 is active on the backend and both current Worker006 control agents.
|
||||
Idle polls do not persist receipts; actual grants are durable and share the
|
||||
request-ID namespace with preserved v2 history. Repeating a non-binding v3 idle
|
||||
request may later acquire work, unlike an old persisted v2 empty receipt.
|
||||
The 50,000-row legacy ledger remains intact; a separate 50,000-positive-grant
|
||||
budget does not change the shared 128 MiB database/WAL/SHM storage bound.
|
||||
|
||||
Only the imported gateway's request schema literal changes in two pinned child
|
||||
images. Compute package identities and model images remain unchanged. The exact
|
||||
control-agent create declarations, separate from executor package declarations,
|
||||
are now the durable runtime source; old agents remain stopped for rollback.
|
||||
126 focused tests and 16 real empty-capability Worker-to-backend probes passed.
|
||||
All pre-existing queue row hashes are unchanged. This repairs dispatch polling,
|
||||
not published-cache verification or full recorded-workflow acceptance.
|
||||
See the [repair evidence and rollback boundary](../../experiments/perception/OBSERVATORY_RECORDED_CLAIM_REPAIR_2026-09-02.md).
|
||||
|
||||
## Third increment: exact published-result cache (2026-09-03)
|
||||
|
||||
Implemented and activated as a matching backend/frontend release on canonical8000
|
||||
on 2026-09-03 after an owner-approved Docker VM restart recovered memory headroom.
|
||||
Full typecheck,714 frontend tests and production build passed sequentially;
|
||||
API catalogs and in-app UI were checked without creating inference jobs.
|
||||
All queue row hashes are preserved. This does not claim a completed recorded run.
|
||||
|
||||
The portable catalog now joins successful **published** queue receipts to the
|
||||
current per-source catalog snapshot, immutable definition, Session publication
|
||||
binding, calculation profile, package contract, central manifest and every
|
||||
artifact. No matching by LAB name, operator label, or the newest 100-job page.
|
||||
The queue receives two indexes, with no deletion or rewriting of history.
|
||||
The existing computation identity remains the only computation key.
|
||||
|
||||
An exact usable result projects `existing / open-existing`, its result ID and
|
||||
path-free source/definition/package identities. It disables new submission but
|
||||
does not require Worker dispatch or raw-source preparation for cached review.
|
||||
Missing/corrupt artifacts do not count as coverage; the older evidence remains.
|
||||
The newest verified exact candidate is returned; other versions are not hidden
|
||||
from the evidence catalog or mistaken for the current calculation.
|
||||
|
||||
Large immutable result objects are hashed in 1 MiB chunks on first use or file
|
||||
metadata change. A bounded 4096-entry memo rechecks dev/inode/size/mtime/ctime;
|
||||
normal refresh does not reread unchanged large objects. This is result-integrity
|
||||
IO, not source upload, source decoding, model inference, or an FPS measurement.
|
||||
The queue INSERT fence rechecks the published candidate atomically. It never
|
||||
streams large files while holding the write lock: cold/concurrent/changed
|
||||
verification returns HTTP409 requesting Refresh. Exact idempotency retries still
|
||||
return the original job. Verified corruption permits a new request.
|
||||
|
||||
The frontend's existing portable decoder now accepts only bound, internally
|
||||
consistent cached results. No per-LAB page, renderer or selector implementation
|
||||
was introduced. Full selector/progress/product acceptance remains stage 2.
|
||||
Browser QA confirms that source/setup-only job selection can still display a
|
||||
legacy completion status against a current definition. Exact-version status
|
||||
binding and separating archives from executable choices remain required;
|
||||
the activated backend cache does not prove those UI changes complete.
|
||||
See [verification and activation status](../../experiments/perception/OBSERVATORY_PUBLISHED_CACHE_2026-09-03.md).
|
||||
|
||||
## Boundaries
|
||||
|
||||
No Synology deployment, hardware actuation, motor integration, new capture,
|
||||
silent model substitutions or deletion of recordings/results. A Docker image's
|
||||
portability does not promise that a CUDA build runs unchanged on Apple Silicon.
|
||||
Physical-live and onboard/vehicle acceptance require the actual target hardware.
|
||||
|
||||
The only maintained execution plan is
|
||||
[the four-stage ExecPlan](../OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
|
||||
@@ -6,7 +6,59 @@ Scope: Mission Core recorded LAB replay, RAVNOVES004TREE, OPS perception state
|
||||
|
||||
Excluded: Gaussian/simulation workers and their artifacts
|
||||
|
||||
## Outcome
|
||||
Status: the diagnostic findings and perception conclusions remain evidence, but
|
||||
the custom fMP4/Three.js implementation described below is superseded by
|
||||
ADR 0045. It is retained here as the failure audit, not as the current replay
|
||||
contract.
|
||||
|
||||
## Current outcome after ADR 0045
|
||||
|
||||
RAVNOVES004TREE now uses one unmodified upstream Rerun 0.36.3 viewer for camera,
|
||||
semantic masks, diagnostic boxes, source points, bounded Local SLAM, trajectory,
|
||||
3D/PLAN and playback. The canonical source RRD owns world geometry; a verified
|
||||
immutable RRD sidecar adds only LAB image-space evidence with the same recording
|
||||
id and `session_time`. Source PTS are preserved, so missing decodable video
|
||||
samples hold the previous frame instead of shortening the route or drifting
|
||||
from masks.
|
||||
|
||||
The accepted LAB controls and layout remain unchanged. Full-route TGS and
|
||||
point-aligned 3D semantics are still absent and therefore remain visible but
|
||||
disabled. The former fMP4/Three.js route is comparison-only legacy and does not
|
||||
load on the canonical RAV004 route.
|
||||
|
||||
The isolated renderer materialized the sidecar in 79.5 seconds. The canonical
|
||||
live service cold-path took approximately seven minutes and produced
|
||||
393,203,594 bytes, so publication-time preparation remains required before this
|
||||
profile is called immediately openable. With a complete SHA-256 check on every
|
||||
cache hit, the warm endpoint returned headers in 0.89 seconds and streamed the
|
||||
local artifact in 2.48 seconds. This is a replay/cache measurement, not a
|
||||
realtime inference claim.
|
||||
|
||||
## Current validation after ADR 0045
|
||||
|
||||
- frontend unit suite: 661 passed;
|
||||
- TypeScript typecheck and production Vite build: passed;
|
||||
- migration-scoped backend/API suite: 60 passed;
|
||||
- live canonical blueprint: HTTP 200, 86,343 bytes, 0.185 seconds;
|
||||
- warm integrity-checked sidecar: HTTP 200, byte-range `RRF2` confirmed;
|
||||
- canonical service restarted and healthy on `127.0.0.1:8000`; no Mission Core
|
||||
listener exists on `8765`.
|
||||
|
||||
The repository-wide Python suite completed with ten failures in pre-existing K1
|
||||
camera-recovery/scenario-reset tests. No K1 runtime or test file differs from
|
||||
the rollback tag in this migration. Nine failures are in the existing active
|
||||
acquisition camera-restart contract; one is an existing expected-document
|
||||
mismatch after the runtime added reset timing. These do not invalidate the
|
||||
Rerun-specific checks, but the repository-wide suite is not represented as
|
||||
green.
|
||||
|
||||
Automated in-app visual QA could not attach to the local address because the
|
||||
browser surface rejected the localhost URL under its URL policy. No alternate
|
||||
browser-control bypass was used. The live HTTP/data plane, build and contracts
|
||||
were accepted; an operator visual pass remains required for the exact layout,
|
||||
seek and toggle experience.
|
||||
|
||||
## Superseded implementation outcome
|
||||
|
||||
RAVNOVES004TREE no longer owns a custom LAB viewer. It supplies recording and
|
||||
model configuration to the same `M4ReplayThreatVisual` and
|
||||
|
||||
@@ -72,6 +72,49 @@ waits until `bootout` has fully removed the old label, bootstraps the new
|
||||
declaration and accepts only the exact Mission Core health document. Any
|
||||
failure restores the previous plist and repeats the same health acceptance.
|
||||
|
||||
### Explicit worktree migration
|
||||
|
||||
A plan from a different checkout is rejected by default. To migrate the
|
||||
canonical service between these two exact worktrees, authorize the installed
|
||||
predecessor explicitly on both plan and apply:
|
||||
|
||||
```bash
|
||||
cd /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory
|
||||
|
||||
uv run python scripts/manage_mission_core_launch_agent.py plan \
|
||||
--repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory \
|
||||
--expected-current-repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE
|
||||
```
|
||||
|
||||
The plan is admissible only when `current_working_directory` is exactly
|
||||
`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE`,
|
||||
`desired_working_directory` is exactly
|
||||
`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory`
|
||||
and `changes.repository_migration` is `true`. The same plan must report
|
||||
`preserved_data_directory` and `changes.preserved_data_directory` as exactly
|
||||
`/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE/.runtime/mission-core`,
|
||||
with `changes.data_directory_preserved=true`. The desired plist then carries
|
||||
that exact path as `MISSIONCORE_DATA_DIR`; an already configured nonblank,
|
||||
private canonical `MISSIONCORE_DATA_DIR` is retained instead. Copy the two
|
||||
exact hashes from that same output:
|
||||
|
||||
```bash
|
||||
uv run python scripts/manage_mission_core_launch_agent.py apply \
|
||||
--repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE_m5_observatory \
|
||||
--expected-current-repository-root /Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_MISSION_CORE \
|
||||
--expected-current-sha256 <current-sha256> \
|
||||
--expected-desired-sha256 <desired-sha256>
|
||||
```
|
||||
|
||||
The migration option is not a general cross-repository override. The planner
|
||||
resolves it to one exact path and rejects any installed `WorkingDirectory`
|
||||
that differs. Apply recomputes the same path-bound plan and still requires both
|
||||
plist hashes, so a changed predecessor or candidate must be planned again.
|
||||
Do not symlink either worktree's `.runtime/mission-core` to the other. The
|
||||
checkout-local singleton lock requires a real private directory; durable data
|
||||
continuity is expressed only by the path-bound `MISSIONCORE_DATA_DIR` in this
|
||||
migration plan.
|
||||
|
||||
Read-only status:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# Portable Observatory Worker 006 operational boundary
|
||||
|
||||
## Scope and current state
|
||||
|
||||
This runbook covers only recorded, observation-only Observatory jobs. It does
|
||||
not change K1 acquisition/control, Simulation/Gaussian, legacy LAB execution,
|
||||
navigation or safety authority.
|
||||
|
||||
The server queue, renewable claim lease, verified source/result transport and
|
||||
result publisher exist, but the production Worker API remains deliberately
|
||||
disabled. All three application gates stay `False` until exact executors are
|
||||
installed and a complete transport smoke has passed:
|
||||
|
||||
- `OBSERVATORY_WORKER_CLAIM_LEASE_READY`;
|
||||
- `OBSERVATORY_WORKER_VERIFIED_RESULT_PUBLISHER_READY`;
|
||||
- `OBSERVATORY_WORKER_PRODUCTION_API_ENABLED`.
|
||||
|
||||
The canonical central artifact store is already declared in the installed
|
||||
Mission Core LaunchAgent as:
|
||||
|
||||
```text
|
||||
MISSIONCORE_ARTIFACT_STORE_ROOT=/Volumes/docker/nodedc-mission-core/artifact-store
|
||||
```
|
||||
|
||||
On 2026-08-31 `/Volumes/docker` was not mounted. This is a fail-closed
|
||||
preflight failure, not permission to create a checkout-local substitute. The
|
||||
portable Worker server composition now requires `central_status=ready` before
|
||||
it can be constructed.
|
||||
|
||||
The installed `com.nodedc.mission-core.local` LaunchAgent was running from the
|
||||
M5 Observatory feature worktree while retaining the established Mission Core
|
||||
data directory in the main checkout. It declares the central artifact-store
|
||||
root and cache limits, but not the two portable storage roots below. Do not
|
||||
silently edit or restart this hybrid local service; carry the environment and
|
||||
working-directory change through one separately reviewed, hash-gated service
|
||||
transition after the code release is sealed.
|
||||
|
||||
Large portable inputs and in-flight results have no Mission Core data-directory
|
||||
fallback. The server requires both roots through environment-only
|
||||
configuration:
|
||||
|
||||
```text
|
||||
MISSIONCORE_OBSERVATORY_WORKER_SOURCE_CAS_ROOT=/Volumes/docker/nodedc-mission-core/observatory-worker/source-cas
|
||||
MISSIONCORE_OBSERVATORY_WORKER_RESULT_STAGING_ROOT=/Volumes/docker/nodedc-mission-core/observatory-worker/result-staging
|
||||
```
|
||||
|
||||
Both directories must already exist, be canonical non-symlink directories,
|
||||
remain inside `/Volumes/docker/nodedc-mission-core`, and be disjoint from each
|
||||
other and from `artifact-store`. Mission Core never creates these configured
|
||||
roots. If `/Volumes/docker` is absent or is only a local directory rather than
|
||||
a mounted volume, composition fails closed before queue or Worker API exposure.
|
||||
Provision the directories only through the reviewed server deployment after
|
||||
the SMB mount preflight succeeds.
|
||||
|
||||
Read-only Worker evidence on the same date:
|
||||
|
||||
- strict-pinned `ssh -o BatchMode=yes mission-gpu` succeeds;
|
||||
- Worker host identity remains `DESKTOP-OPJ8J04`;
|
||||
- Windows OpenSSH `sshd` is running;
|
||||
- `AllowTcpForwarding` and `GatewayPorts` use OpenSSH defaults: forwarding is
|
||||
allowed and remote listeners are not exposed beyond loopback;
|
||||
- Worker loopback port `18080` had no listener.
|
||||
|
||||
## Network shape
|
||||
|
||||
Mission Core remains the only backend on Mac loopback port `8000`. The Mac
|
||||
owns one reverse SSH tunnel through the existing strict-pinned `mission-gpu`
|
||||
alias:
|
||||
|
||||
```text
|
||||
Worker 006 process
|
||||
-> http://127.0.0.1:18080
|
||||
-> encrypted SSH reverse forwarding
|
||||
-> Mac http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
The exact forwarding declaration is:
|
||||
|
||||
```text
|
||||
-R 127.0.0.1:18080:127.0.0.1:8000
|
||||
```
|
||||
|
||||
It neither opens a LAN listener nor sends a credential in process arguments.
|
||||
`ObservatoryWorkerHttpGateway` independently rejects plaintext HTTP to any
|
||||
non-loopback host. `worker_tunnel_launchd.py` builds a pure, hashable launchd
|
||||
plan; `scripts/plan_observatory_worker_tunnel.py` prints that plan and performs
|
||||
no installation.
|
||||
|
||||
## Bearer credential
|
||||
|
||||
The server reads one credential from the fixed private data path:
|
||||
|
||||
```text
|
||||
<MISSIONCORE_DATA_DIR>/worker-auth/observatory-worker.token
|
||||
```
|
||||
|
||||
The installed Worker release receives the same secret through its own private,
|
||||
runner-managed file and passes only its path as
|
||||
`MISSIONCORE_OBSERVATORY_WORKER_TOKEN_FILE`. The token is ASCII, 32–512 bytes,
|
||||
has no newline, is never stored in Git, an artifact, a plist, an environment
|
||||
value, a command line or Ops plaintext, and is read with no-follow semantics.
|
||||
The admitted service runtime is POSIX and requires mode `0600` or narrower;
|
||||
native Windows ACL handling is intentionally not guessed.
|
||||
|
||||
The Worker service accepts only these non-executable settings:
|
||||
|
||||
```text
|
||||
MISSIONCORE_OBSERVATORY_WORKER_BASE_URL=http://127.0.0.1:18080
|
||||
MISSIONCORE_OBSERVATORY_WORKER_TOKEN_FILE=<absolute-private-path>
|
||||
MISSIONCORE_OBSERVATORY_WORKER_WORK_ROOT=<absolute-private-D-backed-path>
|
||||
MISSIONCORE_OBSERVATORY_WORKER_IDLE_POLL_SECONDS=1
|
||||
MISSIONCORE_OBSERVATORY_WORKER_TRANSPORT_BACKOFF_SECONDS=5
|
||||
MISSIONCORE_OBSERVATORY_WORKER_MAX_TRANSPORT_FAILURES=12
|
||||
```
|
||||
|
||||
There is no configurable module, command, image or executable entrypoint.
|
||||
|
||||
## Install-time executor seam
|
||||
|
||||
`compose_installed_observatory_worker_service` is called only by a reviewed
|
||||
Worker release. That release injects a constructed
|
||||
`ObservatoryWorkerExecutorRegistry` directly in memory. Before opening the
|
||||
HTTP gateway, service composition resolves the four-digest identity of every
|
||||
portable RunDefinition whose executor state is `ready`:
|
||||
|
||||
- executor release SHA-256;
|
||||
- executor image SHA-256;
|
||||
- model manifest SHA-256;
|
||||
- resource profile SHA-256.
|
||||
|
||||
No ready definitions, an empty registry, or one missing identity stops the
|
||||
service before its first claim. Blocked candidates cannot be selected through
|
||||
configuration and are not silently registered.
|
||||
|
||||
## Staged deployment and smoke sequence
|
||||
|
||||
No step below was applied by this implementation increment.
|
||||
|
||||
1. Seal each executor release and installation receipt. Change a portable
|
||||
RunDefinition to `ready` only when its exact release/image identities and
|
||||
local adapter admission agree.
|
||||
2. Mount the existing canonical SMB artifact store. Require Mission Core
|
||||
artifact status `central_status=ready`; do not initialize a local surrogate.
|
||||
Through the reviewed server deployment, provision the two disjoint portable
|
||||
roots above, set both environment values, and verify they resolve inside the
|
||||
same mounted `/Volumes/docker/nodedc-mission-core` boundary.
|
||||
3. Provision one bearer credential through the deployment-owned secret path
|
||||
on both Mac and the admitted POSIX Worker service runtime. Verify file type,
|
||||
no-link handling and private permissions without printing the value.
|
||||
4. Generate the reverse-tunnel launchd plan, review its SHA-256 and exact
|
||||
arguments, then install it through a separate hash-gated local-service
|
||||
change. Accept only Worker-side `127.0.0.1:18080/api/health` reaching the
|
||||
canonical Mac service; no second backend is started.
|
||||
5. Install the exact Worker release. Its fixed entrypoint loads the sealed
|
||||
RunDefinition registry and its in-memory executor registry, then calls
|
||||
`compose_installed_observatory_worker_service`. The process must refuse a
|
||||
missing token, non-loopback plaintext URL, unsafe work root, absent ready
|
||||
definition or executor coverage gap.
|
||||
6. Restart the canonical Mission Core process once with CAS and server token
|
||||
available. Keep the Worker route disabled and verify that K1,
|
||||
Simulation/Gaussian and legacy LAB surfaces are unchanged.
|
||||
7. In a separate reviewed source gate, flip the claim-lease, verified-publisher
|
||||
and production-API flags together. Restart only the canonical port `8000`
|
||||
service. An authenticated empty claim must return `204`; missing/wrong bearer
|
||||
and wrong contour identity must remain `401/403`.
|
||||
8. Submit one short recorded K1 canary for each profile. Require exact source
|
||||
materialization, lease heartbeat, result-package digest validation, central
|
||||
artifact publication, immutable calculation-profile provenance and a
|
||||
reopenable Observatory result.
|
||||
9. During a bounded recorded canary, start the existing live K1 priority
|
||||
transition. Require the recorded job to pause/defer and resume only after
|
||||
live K1 releases the single Worker resource. This test never grants control
|
||||
or navigation authority.
|
||||
|
||||
## Remaining blockers
|
||||
|
||||
- canonical SMB artifact store is currently unmounted on the Mac;
|
||||
- portable source CAS and result-staging directories/environment values are not
|
||||
provisioned;
|
||||
- the installed Mission Core LaunchAgent has not received a sealed
|
||||
working-directory/environment transition for this release;
|
||||
- the shared bearer credential has not been provisioned;
|
||||
- the reverse tunnel plan has not been installed or smoked;
|
||||
- the Worker polling entrypoint has not been packaged into an admitted POSIX
|
||||
Worker release;
|
||||
- both exact executor releases/install receipts still need their own seals;
|
||||
- production flags and the server route remain off by design;
|
||||
- no end-to-end result has yet crossed Worker 006 -> central store ->
|
||||
Observatory under this new path.
|
||||
|
||||
The Synology root-owned `nodedc-deploy` registry has no
|
||||
`mission-core-worker` component. Older Mission Core Worker shadow artifacts
|
||||
explicitly declare that they are outside that registry. Do not route a Windows
|
||||
Worker install through an unrelated NAS component or weaken the deploy canon;
|
||||
the durable Worker release needs its own exact reviewed installation transition
|
||||
and rollback evidence.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Observatory published-cache increment — 2026-09-03
|
||||
|
||||
## Status and scope
|
||||
|
||||
Implemented, contract-tested and **activated on canonical8000** on 2026-09-03.
|
||||
Full product acceptance remains open; see the activation evidence below.
|
||||
Implementation commit: `1e4ddc2` on `codex/m5-1-observatory` (no push).
|
||||
This is stage1 source/provenance/cache work, not model or recorded-run evidence.
|
||||
No Worker containers, model profiles, raw recordings, result history, capture,
|
||||
network settings or physical controls were changed. No Synology deployment.
|
||||
|
||||
## Contract
|
||||
|
||||
- Exact key: admitted source snapshot + immutable RunDefinition (including
|
||||
executor image/release, models, adapter, resource and result contracts).
|
||||
- Candidate: successful queue job with durable `publication_state=published`.
|
||||
Legacy `succeeded/not-required`, pending or failed publication is not a hit.
|
||||
- Verify full job/source/definition/profile provenance, Session binding, sealed
|
||||
package and CAS manifest identity, exact artifact inventory and file hashes.
|
||||
- Return the newest usable exact result with `existing / open-existing`,
|
||||
`submission_allowed=false` and path-free evidence identities. Different source,
|
||||
changed snapshot or profile version remains a distinct computation.
|
||||
- Existing catalog/preflight/viewer contracts are reused. No per-LAB UI branch.
|
||||
Full selector filtering and progress are deliberately the next product stage.
|
||||
- Cached preflight does not prepare raw data or call a Worker; read-only result
|
||||
review is independent of current source compatibility/dispatch availability.
|
||||
Source snapshot comparison reads the catalog, not all source media.
|
||||
- Corrupt/missing/symlinked result objects do not suppress a fresh calculation.
|
||||
History is retained. Cached review is not a navigation/safety acceptance.
|
||||
|
||||
## IO and concurrency
|
||||
|
||||
First use or changed metadata streams result objects in 1MiB chunks. Content
|
||||
checks use a bounded4096-entry fingerprint memo; each lookup rechecks file
|
||||
device/inode/size/mtime_ns/ctime_ns. Contracts/manifests remain independently
|
||||
parsed and checked. Warm refresh does not reread unchanged large result objects.
|
||||
Cold verification can cost result-store IO and is not advertised as constant-time.
|
||||
|
||||
Two additive queue indexes support exact published-source and computation lookup.
|
||||
Publication/INSERT races are fenced in the existing transaction. Its verifier
|
||||
does not re-enter the queue DB or hash large files under the write lock. A cold,
|
||||
concurrent or changed verification requests Refresh with HTTP409, never silently
|
||||
creates another job. Confirmed corruption permits retry with a new key; the
|
||||
original idempotency key always refers to the original job.
|
||||
|
||||
## Verification
|
||||
|
||||
- 120 focused backend tests PASS: portable publisher/cache, setup API/projector,
|
||||
queue binding, recorded queue, Worker API, publication retry/reconciliation.
|
||||
- 23 focused frontend tests PASS: portable catalog/decoder and application
|
||||
architecture, sequential Node runner with192MiB heap cap. Peak RSS~108MiB.
|
||||
- Focused strict TypeScript check of the portable decoder/fetch contract PASS,
|
||||
192MiB heap cap; peak process RSS~366MiB including compiler/native memory.
|
||||
- Ruff on all changed Python files PASS; mypy five changed core/API modules PASS.
|
||||
- Tests use synthetic filesystem/SQLite packages, not historical LAB outputs as
|
||||
an inference substitute. They cover complete publish→cache→view, missing and
|
||||
same-size corruption with restored mtime, symlink substitution, changed central
|
||||
manifest/provenance/source/version, publication race, idempotent retry and no
|
||||
source preparation/model submission when an exact result is available.
|
||||
- First fixture run correctly rejected legacy `succeed()` as no publication
|
||||
outbox. Fixture now exercises `complete_for_publication()` followed by actual
|
||||
publisher and durable `mark_published()`; the production gate was not relaxed.
|
||||
- Existing FastAPI/httpx test deprecation warning remains; dependencies unchanged.
|
||||
- Legacy SQLite migration is covered: indexes are created after publication
|
||||
columns are added, preserving the pre-existing job and idempotency identity.
|
||||
|
||||
Reproduce backend tests with `.venv/bin/pytest -o addopts='' -q` and these files:
|
||||
`tests/test_observatory_portable_result_publisher.py`,
|
||||
`tests/test_observatory_portable_setup_api.py`,
|
||||
`tests/test_observatory_portable_setup_projection.py`,
|
||||
`tests/test_observatory_portable_queue_binding.py`,
|
||||
`tests/test_observatory_recorded_jobs.py`, `tests/test_observatory_worker_api.py`,
|
||||
`tests/test_observatory_publication_retry_api.py`,
|
||||
`tests/test_observatory_publication_reconciler.py`.
|
||||
|
||||
Frontend, from `apps/control-station`:
|
||||
`NODE_OPTIONS=--max-old-space-size=192 node --test --test-concurrency=1 test/observatoryLaboratorySetups.test.mjs test/applicationArchitecture.test.mjs`.
|
||||
|
||||
## Initial activation hold — historical, resolved below
|
||||
|
||||
Mac pressure rose from1 to2 during the turn, swap~7.6–7.8GiB; no temporary heavy job
|
||||
remains. Existing operator Docker services were inspected, not stopped. The
|
||||
resource gate in `mission-core-product-ui` defers full build and browser QA.
|
||||
Canonical8000 remains PID57796 on the previous matched release, `/api/health`
|
||||
HTTP200. No listeners on8765/4173; unrelated launcher5173 is not this project.
|
||||
Do not claim that the new cache is already live in the operator interface.
|
||||
|
||||
After memory pressure normalizes: sequential production typecheck/tests/build,
|
||||
activate the matching backend/frontend on canonical8000, verify health/catalog
|
||||
and browser decoder behavior without creating inference jobs, then update this
|
||||
status. Do not start a second backend or replay process. Stage2 then implements
|
||||
the selector/progress/full recorded calculation and sequential M4.9T5/LAB V1
|
||||
acceptance. Neither this report nor a cached viewer FPS proves onboard realtime.
|
||||
|
||||
## Resource recovery and activation — 2026-09-03
|
||||
|
||||
The earlier memory gate was real, but browser RSS was not a sufficient cause
|
||||
diagnosis. After the owner stopped unrelated containers, the23-day-old Docker
|
||||
VM retained a14GB footprint (about13GB accounted swapped/compressed), while
|
||||
its guest had about12.5GiB available. This was not14GB resident physical RAM
|
||||
or a model workload. Chrome, Arc and Little Snitch were not touched.
|
||||
|
||||
With explicit owner approval, saved all48 container identities/states/policies,
|
||||
temporarily changed the12 stopped Plane `always` policies to `no`, and restarted
|
||||
Docker Desktop gracefully. VM PID9403→66297; footprint14GB→1565MB, pressure2→1,
|
||||
used swap7722.12→2746.19MiB. Only the same3 Mission Core telemetry containers
|
||||
resumed, all healthy. Restored Plane policies to `always` without starting Plane.
|
||||
All45 non-Core containers retain their state/StartedAt. Images and mount
|
||||
identities are unchanged (Docker reordered some Mounts arrays). No containers,
|
||||
volumes or data deleted; Docker MemoryMiB14336/SwapMiB4096 unchanged. Plane can
|
||||
start on a future daemon restart: this was only a temporary restart barrier.
|
||||
|
||||
Sequential validation under `mission-core-product-ui`:
|
||||
|
||||
- Architecture4/4; full typecheck; frontend714/714 unit tests; production build
|
||||
PASS. Typecheck5.47s/peak RSS902152192bytes, tests18.76s/242204672bytes,
|
||||
build15.38s/2255650816bytes. Node heap caps1024/512/2048MiB respectively.
|
||||
Pressure remained1; swap did not grow. Existing large-chunk warnings remain.
|
||||
- Built in a fresh ignored staging directory; preserved the old dist for
|
||||
rollback, then replaced the matching frontend/backend via the existing
|
||||
LaunchAgent. Backend PID67747, UI `/assets/index-DSuuMkq4.js`, index SHA256
|
||||
`eb8c47d373a2ce1077557d348e097aa354556ddf2a529e3d6c07b4d5fe91922f`.
|
||||
Health200/reconciler ready. No alternate backend8765 or preview4173.
|
||||
- Portable catalogs HTTP200:004TREE/00 compatible with both current profiles;
|
||||
01 remains blocked by missing capture attestation, not a cache failure.
|
||||
No exact published cache hits in current real data. Both new indexes exist.
|
||||
- In-app browser: actual new build, source/evidence catalog, portable selection,
|
||||
Refresh, normal/expanded window, dropdown Escape PASS; console warnings/errors0.
|
||||
Initial navigation hit the short backend startup gap; a fresh app tab worked.
|
||||
No heavy replay/model run. Automatic retention of the QA tab was declined by
|
||||
browser action review; no alternate retention mechanism was used.
|
||||
- All6 queue-table canonical row hashes unchanged before/after activation and
|
||||
UI checks:11 jobs,50000 legacy claims,1 reconciliation,0 live leases/v3 grants/
|
||||
preemptions. The sole succeeded job is still `not-required`, not a new cache
|
||||
hit. Positive publish→cache→view remains synthetic contract-test evidence.
|
||||
|
||||
Private snapshots/logs and previous frontend: `.runtime/docker-vm-recovery-z1636o/`.
|
||||
No raw recording content was collected into this diagnostic evidence.
|
||||
|
||||
## Remaining product acceptance
|
||||
|
||||
The cache increment is active, but the complete workflow is not accepted.
|
||||
Browser QA reproduced the next-stage UI mismatch: archived entries and portable
|
||||
definitions are mixed in the selector; current M4.9T5 can show an old completion
|
||||
label next to Calculate. `useObservatoryRecordedJobs` selects by source/setup,
|
||||
then `jobs[0]`, without an exact definition filter; `ObservatoryWorkspace`
|
||||
renders `succeeded/not-required` as completed. That is not a current cache hit.
|
||||
|
||||
Next: exact-version queue/status binding, selector filtering, publication
|
||||
progress, complete recorded calculation and sequential M4.9T5/LAB V1 acceptance.
|
||||
Stage1 user-facing reconciliation and the full stage2 workflow remain open.
|
||||
Do not repeat the memory recovery or claim/v3 repair, or treat the old realtime
|
||||
FAIL as a new gate. No per-LAB UI branch or automatic inference on Refresh.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Observatory recorded claim repair — 2026-09-02
|
||||
|
||||
## Результат и граница
|
||||
|
||||
Восстановлен control-путь очереди для обоих текущих Worker006 agents. Backend
|
||||
и агенты используют versioned claim/v3: пустой опрос не создаёт receipt,
|
||||
фактическая выдача остаётся долговечной и идемпотентной. Это второй инкремент
|
||||
нового этапа 1, **не** завершение cache/selector/progress или полного LAB-run.
|
||||
|
||||
Модели, compute packages, записи, результаты, их identities и mounts не менялись.
|
||||
Модельных заданий не создано. Оба агента — диспетчеры; два работающих агента
|
||||
не означают два параллельных вычислительных профиля. GPU authority не добавлена.
|
||||
|
||||
Код: `cabd308` (queue/API/client), `0041e9f` (actual import-path guard),
|
||||
`43f1cdc` (exact Docker layer). Plan: [актуальные четыре этапа](../../docs/OBSERVATORY_REALTIME_PROFILES_EXECPLAN.md).
|
||||
|
||||
## Причина и контракт
|
||||
|
||||
Старый журнал достиг 50 000 receipts: 49 985 пустых опросов и 15 выдач.
|
||||
Каждый новый idle poll занимал запись; quota проверялась до поиска работы.
|
||||
Работающие v2-агенты получали 503 и периодически перезапускались после
|
||||
исчерпания своего transport-failure budget.
|
||||
|
||||
- v2 semantics сохранены, включая прежний NULL-result для старого request ID.
|
||||
- v3 idle — non-binding observation; после idle тот же ID может позднее
|
||||
получить работу. Явный пустой capability tuple гарантированно не получает job.
|
||||
- Выданный grant сохраняется в новой `observatory_recorded_claim_grants_v3`.
|
||||
Повтор возвращает тот же grant либо stale-claim error; другая capability
|
||||
inventory/claimant с тем же выданным ID отвергается.
|
||||
- Старый и новый журналы проверяются в одной сериализованной транзакции:
|
||||
новый протокол не обходится повтором через старый.
|
||||
- Старый лимит 50 000 не увеличен, строки не удалены. Введён **отдельный**
|
||||
конечный бюджет 50 000 реальных v3 grants. Общий storage bound SQLite/WAL/SHM
|
||||
остаётся 128 MiB. Это не бесконечное хранилище: заполнение положительного
|
||||
журнала требует отдельного архивного lifecycle, а не слепой чистки.
|
||||
- Authenticated GET `/api/v1/worker/observatory/recorded-jobs/claim-readiness`
|
||||
показывает оба бюджета, job-state counts и open live leases. Это готовность
|
||||
очереди, не доказательство GPU/model readiness. UI-проекция ещё не менялась.
|
||||
|
||||
## Точный Worker cutover
|
||||
|
||||
Обновлён только реально импортируемый файл:
|
||||
`/opt/nodedc/installed-lab/src/k1link/observatory/worker_http_transport.py`.
|
||||
|
||||
Before SHA: `c3d82ffd482a29b17c68d1fb5603e82924766abc0080137d3a0a6130f92ccdce`.
|
||||
After SHA: `81bdc27f374cd91358f0eb58603e3e1f53e1f3017bf158d269e75affa4567293`.
|
||||
Содержательное изменение — одна строка schema version v2→v3.
|
||||
|
||||
| Контрольный агент | Parent image | Новый transport image |
|
||||
| --- | --- | --- |
|
||||
| installed LAB | `5ad7d95baac6…` | `1b1e335916c1c3d77888b7537331e9d775d078957c4ac0f82e91852301725390` |
|
||||
| M4.9 | `d00274a2a76e…` | `7aa6ccd2ddba4ebb0c07d793e5331539a2e13b07961c269439d5b79762328194` |
|
||||
|
||||
Полные parent digests, container IDs, create bodies, mounts, argv, environment,
|
||||
resource/restart policies и rollback pointers сохранены в release evidence.
|
||||
Каждый новый image содержит точные прежние RootFS layers плюс один слой с
|
||||
изменённым transport-файлом и metadata его каталогов. Model/executor images
|
||||
в package registries остаются прежними; транспортный образ не выдаётся за
|
||||
новую версию вычислительного профиля.
|
||||
|
||||
Два первых apply были остановлены до service cutover:
|
||||
1. Первоначальный путь указывал на неиспользуемую v1-копию в базовом слое
|
||||
`/opt/nodedc/mission-core/src`. Хеш совпадал с этой копией, но её protocol
|
||||
literal — нет. Проверка фактического `module.__file__` подтвердила v2 в
|
||||
installed-lab overlay. Ошибочная промежуточная гипотеза о работающем v1
|
||||
опровергнута; реальные контейнеры до финального apply не заменялись.
|
||||
2. Замена одного байта с сохранением size/mtime не попала в Docker snapshot
|
||||
diff. После обновления file mtime обнаружен ровно ожидаемый файл.
|
||||
Добавлен регрессионный тест. Ни пустой diff, ни неверный import path не
|
||||
принимались как успешный образ.
|
||||
|
||||
## Приёмка
|
||||
|
||||
- 126 focused tests PASS: legacy/v3 queue, full quota, concurrent retries,
|
||||
expired grants, capability mismatch, API auth, gateway, Worker service,
|
||||
recorded submit, portable catalog, publication/retry и migration guards.
|
||||
- Ruff PASS на изменённом коде/тестах; mypy PASS на трёх source modules.
|
||||
Единственный warning — существующая Starlette/httpx deprecation.
|
||||
- 16 настоящих v3 пустых запросов: по 8 из каждого нового агента через его
|
||||
установленный gateway → локальный proxy → Worker tunnel → canonical8000.
|
||||
Capabilities пустые; job/model execution невозможны в этих probes.
|
||||
- Последние100 фоновых Worker polls после cutover:100×204, ошибок0; оба новых
|
||||
контейнера running, restart count 0, restart=unless-stopped, GPU requests=null.
|
||||
- Queue snapshot в 23:54 МСК: legacy=50 000, v3=0, jobs=11 (10 failed,
|
||||
1 succeeded/not-required), live=0. Все строки пяти прежних таблиц
|
||||
совпали с before snapshot по SHA-256. Ни выдачи, ни jobs, ни reconciliation
|
||||
history не потеряны.
|
||||
- Canonical8000 обновлён, PID57796; `/api/health` HTTP200. Начальный health
|
||||
polling ошибочно обращался к несуществующему `/api/v1/health`; это ошибка
|
||||
диагностического URL, не отказ сервиса. Правильный endpoint проверен отдельно.
|
||||
- GPU/model benchmarks, frontend build и полный recorded run не выполнялись.
|
||||
Ollama/Frigate остаются exited/restart=no. Временные containers и один
|
||||
диагностический image удалены; остановленные rollback agents сохранены.
|
||||
|
||||
## Evidence и воспроизводимость
|
||||
|
||||
Worker release:
|
||||
`D:\NDC_MISSIONCORE\runtime\services\observatory-claim-transport\ec3bf17908c6d0c167156ad93e2edb3d8a3bd914a0b0a66aa5b0de6957bf2496`.
|
||||
|
||||
Plan SHA: `ec3bf17908c6d0c167156ad93e2edb3d8a3bd914a0b0a66aa5b0de6957bf2496`.
|
||||
Migration script SHA: `4b07693fec563ef755002d29345528e501f703974b5c7090470962d51eb460b2`.
|
||||
Worker receipt file SHA: `c8f72fc5aad9d7aae6a12391304ec268ee8c0d0f80a1100e41e4638a0d56b771`.
|
||||
Installed-agent declaration SHA:
|
||||
`9a4832496a7b9de1d079eda31d1d9c67d1543a266808df3d8e69bca1cb1f4a40`.
|
||||
M49-agent declaration SHA:
|
||||
`644ed2a4ef6daac0efd9023c77c94d9f2081cd8895fc49019b11922a9c70f1d8`.
|
||||
|
||||
Local evidence (не Git): `.runtime/observatory-claims-v3-l9ock9/`.
|
||||
Before SQLite backup SHA:
|
||||
`428783a40ba000676da3a2de270cbfb3b2fb0685e7f489303b0e05a6ee4bf9f9`.
|
||||
After SQLite snapshot SHA:
|
||||
`934a28e890a318077e25184c5e1f6ff7efc7c1acb8da6a12a9600fea99d490f1`.
|
||||
Legacy rows SHA:
|
||||
`a5291155f9c74e552f05a8a856cd120270f5b5132e7ac9a5ed53605f4a743bbe`.
|
||||
Job rows SHA:
|
||||
`34205296fe1e335657de9d70890e28ccc16eec5120f6d1ffa1fde15f60d58a0f`.
|
||||
SQLite file hashes различаются ожидаемо из-за добавленной таблицы; row hashes
|
||||
прежних таблиц совпадают. Backup содержит реальные operational records и не
|
||||
должен попадать в Git.
|
||||
|
||||
## Durable runtime и откат
|
||||
|
||||
Новый источник истины для **контрольных агентов** — точные
|
||||
`*-declaration.json` в release выше: полный Docker create body и pinned image.
|
||||
Package manifests остаются источником истины для вычислений. Прежний
|
||||
`Start-ObservatoryInstalledLabWorker.ps1` с предположением agent image =
|
||||
package executor image не использовать для замены transport overlay: он
|
||||
потребует reconciliation. Docker restart policy уже сохраняет новые контейнеры
|
||||
при обычном перезапуске; реальный reboot в этом инкременте не выполнялся.
|
||||
|
||||
Предшественники сохранены:
|
||||
`ndc-observatory-installed-lab-worker-agent-v2-rollback-810212622de9`,
|
||||
`ndc-observatory-m49-worker-agent-v2-rollback-b30b41d9e2e2`;
|
||||
оба stopped/restart=no. Не запускать их рядом с новой версией. Перед откатом
|
||||
повторно доказать отсутствие активного/queued/reconciliation/live work, остановить
|
||||
только соответствующий новый агент, проверить точный predecessor ID из declaration,
|
||||
вернуть имя и прежнюю restart policy. v2 снова упрётся в заполненный legacy ledger:
|
||||
это аварийный возврат прежнего состояния, не работоспособное решение.
|
||||
|
||||
Не восстанавливать SQLite backup поверх новых jobs/grants. Backend rollback
|
||||
не должен игнорировать появившиеся v3 grants: сначала отдельная reconciliation,
|
||||
сохранение актуальной БД и контроль владельцев. Автоматического отката со
|
||||
стиранием поздней работы нет.
|
||||
|
||||
Следующий инкремент этапа 1: verified published-cache projection по exact source
|
||||
и definition, затем селектор/прогресс этапа 2. Реальный полный расчёт профиля и
|
||||
повторный просмотр без inference остаются обязательной отдельной приёмкой.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
# Stage 2 increment 14 — authenticated grant delivery and cross-host clock bounds
|
||||
|
||||
Date:2026-09-02. Result: **the application-side control/clock candidate works;
|
||||
continuous full-graph cross-host freshness is not yet qualified.** This increment
|
||||
contains no model/GPU run. It does not change the four-stage plan or open stage3.
|
||||
|
||||
## Objective and architecture boundary
|
||||
|
||||
The source must receive a short-lived data-stream grant without gaining authority
|
||||
to acquire/renew the Worker GPU lease, restart models, select an epoch or actuate.
|
||||
Mac and Worker process monotonic clocks also need an explicit error interval before
|
||||
cross-host scene age is accepted. A low RTT or one successful sample is not clock
|
||||
synchronization. A temporary network/clock failure means WAIT and fresh input epoch;
|
||||
the local lease and resident processes remain owned by the Worker controller.
|
||||
|
||||
`StreamControl/Poll` is a small TLS unary method served beside the binary bidirectional
|
||||
stream. One per-activation bootstrap capability authenticates polling. It returns the
|
||||
controller's already-issued pending data grant and four clock timestamps. Messages are
|
||||
8KiB bounded, closed-schema, rate-limited to40/s and limited to one outstanding client
|
||||
probe. The control endpoint has no lifecycle/model method. Data admission remains
|
||||
single-use and independently authenticated by `Exchange`.
|
||||
|
||||
Clock evidence bounds remote-minus-local offset without assuming equal forward and
|
||||
return delay. Up to16 recent intervals are intersected, widened by controller-supplied
|
||||
relative-rate and timestamp-error envelopes, and expire after2s. Contradictory samples
|
||||
quarantine the clock session. All integer nanoseconds remain exact beyond2^53. This is
|
||||
a bounded application mapping, not an OS-clock setter or PTP/NTP implementation. The
|
||||
four-timestamp basis follows the standard exchange model described by
|
||||
[RFC5905](https://www.rfc-editor.org/rfc/rfc5905.html); the candidate deliberately does
|
||||
not use the midpoint/equal-path estimate as proof.
|
||||
|
||||
During regression, gRPC aio with a native maximum of2 marked the next accepted Poll
|
||||
over-limit while Exchange and a completing Poll overlapped. Its implementation checks
|
||||
the next request slot before awaiting that request. The combined server therefore has
|
||||
three bounded native RPC slots: one active Exchange, one Poll, one accept slot. The
|
||||
application still enforces exactly one data owner; this is not GPU parallelism. The
|
||||
behavior is covered by the upstream [gRPC aio server limiter source](https://github.com/grpc/grpc/blob/v1.83.0/src/python/grpcio/grpc/_cython/_cygrpc/aio/server.pyx.pxi#L808-L825).
|
||||
|
||||
## Real route method
|
||||
|
||||
- Existing authenticated SSH/Tailscale route from Mac to Worker006; TLS gRPC tunneled
|
||||
through it. No firewall/listener persisted.
|
||||
- Worker isolated `runc` container:1CPU,512MiB, no NVIDIA runtime, zero models,
|
||||
pinned diagnostic image
|
||||
`sha256:664824aa25de1db178f177d67a81b01541a938b479812b9383ddbf03f6b59dbe`.
|
||||
- Exactly72 sequential real monotonic probes. Conditional envelope:500ppm relative
|
||||
rate,50us per timestamp,2s maximum age,5ms admission uncertainty.
|
||||
- Two data phases, four32KiB synthetic events each. Between them:2.2s disconnect;
|
||||
local lease heartbeat continues. Source clock and resume proof are synthetic and
|
||||
never presented as sensor freshness.
|
||||
- Bootstrap ticket/certificate copied once by SSH; no stream grant file copied.
|
||||
Both stream grants arrived via authenticated Poll. Secrets are absent from evidence.
|
||||
|
||||
## Results
|
||||
|
||||
| Observation | Result |
|
||||
| --- | ---: |
|
||||
| Clock probes independently verified |72/72 |
|
||||
| Conditional uncertainty min / median / max |3.883 /4.469 /27.118ms |
|
||||
| Samples inside5ms |60/72 |
|
||||
| Readiness state transitions |10 |
|
||||
| Payload/reply SHA-256 exact |8/8 |
|
||||
| Reply RTT min / median / max |15.796 /22.408 /85.344ms |
|
||||
| Controlled network gap |2.202s |
|
||||
| Resident CPU sentinel / lease across gap |same PID9 / generation1 |
|
||||
| Local lease renewals across gap |136→158 |
|
||||
| Peak tracked input / final residual |4,718,905B /0B |
|
||||
|
||||
Old clock evidence expired during the2.3s client pause. The new epoch used a new data
|
||||
grant; the control bootstrap remained scoped to the same activation and could not renew
|
||||
the lease. Mapping recovered after new probes. False→true and true→false clock-ready
|
||||
transitions also occurred after warmup, so continuous checking is required. It would be
|
||||
invalid to pick one of the60 passing samples and call the whole run qualified.
|
||||
|
||||
No full model run followed: the control/clock foundation is proved, but the current
|
||||
full-graph adapter does not yet propagate uncertainty into every result freshness
|
||||
decision or pause/resynchronize on mapping expiry. Running it now would produce a
|
||||
mislabelled cross-host latency claim. The5ms gate was not widened.
|
||||
|
||||
## Validation and retained limits
|
||||
|
||||
Local expanded regressions:252PASS,2 intentional Worker-only skips. Final Worker:
|
||||
65PASS including1MiB result and real slow-reader flow control. Ruff/format, strict mypy
|
||||
and diff checks pass. A first expanded local command named two nonexistent test files
|
||||
and ran zero tests; that XML is retained and excluded from acceptance. A first Worker
|
||||
invocation omitted the Worker-only flag (63PASS/2skips); the explicit final rerun is the
|
||||
accepted65PASS evidence.
|
||||
|
||||
The Worker recorded one pre-existing perception-service self-restart26→27 while the
|
||||
probe ran; same container ID/config, and this harness issued no service-control command.
|
||||
It is not hidden and not attributed to the CPU probe. Canonical8000 telemetry remained
|
||||
reachable/identity-matched; Frigate/Ollama remain exited/restart=no. Final owned
|
||||
containers/listeners/private keys/bootstrap capabilities are zero. Owner record is
|
||||
released. No K1, recording, UI, LAB registry, motor/autopilot, Synology or external
|
||||
deployment was touched.
|
||||
|
||||
Evidence:`.runtime/perception-stage2-control-clock-20260902T1635Z/manifest.json`,18
|
||||
artifacts, SHA-256 `cd479c577fa3da78b1b01a6a90e03e8c742ecb53561e606d939408120a53a622`.
|
||||
Candidate archive SHA-256
|
||||
`980c98fc280f593e470a187870f2b1b08d013386db87c70f688336dc1389d7e3`.
|
||||
|
||||
## Next stage-2 gate
|
||||
|
||||
Wire refreshed clock bounds into the existing Mac source, Worker controller and result
|
||||
receiver. On expiry/excess uncertainty: stop admitting freshness, WAIT, discard backlog,
|
||||
obtain a new input epoch and decoder/sensor proof while retaining local models/lease.
|
||||
Carry uncertainty into consumer-side scene age. Then run one sequential full-profile
|
||||
Mac↔Worker canary plus bounded slow-consumer case. Standalone packaging remains after
|
||||
that; product cutover remains stage3.
|
||||
@@ -0,0 +1,163 @@
|
||||
# Stage 2 increment 15 — full graph with acknowledged cross-host clocks
|
||||
|
||||
2026-09-02, 20:37 MSK. **Implementation/regressions accepted; real-time canary FAIL.**
|
||||
Stage 2 remains open. No stage 3 cutover, standalone release or actuation is claimed.
|
||||
Code commit: `35b6cd9`.
|
||||
|
||||
## Objective and retained architecture
|
||||
|
||||
Connect the existing Mac recording source and real result receiver to the single
|
||||
Worker006 full graph: DDRNet-39 GOOSE, RF-DETR, online LiDAR geometry/distance,
|
||||
temporal/motion, TRAVEL TGS/costmap and advisory policy. The source supplies original
|
||||
camera fragments, normalized point increments and poses incrementally at 1x.
|
||||
No whole recording upload, predecode, precomputed perception layers or per-recording
|
||||
application is introduced. Original source timestamps never change during a run.
|
||||
|
||||
The Worker model container has no recording/index/camera/sensor-archive mount. This
|
||||
is still a diagnostic dependency image with explicit code/model mounts, **not** the
|
||||
portable standalone profile. Mac only reads/releases raw events and validates replies.
|
||||
Masks and full scene/grid payloads return over gRPC/TLS through the existing authenticated
|
||||
SSH/Tailscale route. This is not the prospective rover radio or an onboard deployment.
|
||||
|
||||
## Implemented contracts
|
||||
|
||||
- `StreamControl/ReportClock` echoes the last issued Poll challenge once, including the
|
||||
source receipt timestamp. The Worker checks nonce, both clock identities and its own
|
||||
receive/send timestamps; an acknowledgement must arrive within 500ms. The control
|
||||
capability cannot renew a GPU lease, restart models, select epochs or actuate.
|
||||
- `ClockReceipt` derives the responder-side offset interval from the original causal
|
||||
exchange, not a reversed/symmetric-delay shortcut. The source anchor is immutable.
|
||||
The explicit conditional envelope remains 500ppm relative drift, 50us per timestamp,
|
||||
2s expiry and uncertainty <=5ms. These assumptions are not hardware clock certification.
|
||||
- The Worker checks current clock validity at ingress, GPU/CPU work, publication and
|
||||
while idle. The source checks it during sending and receipt. Unavailable mapping means
|
||||
WAIT, bounded discard, a new controller-owned input epoch and fresh keyframe/sensors.
|
||||
Lease renewal/model ownership remains local and independent of the network.
|
||||
- Source cutoff uses the upper mapped age bound. Scene and per-cell expiry include
|
||||
uncertainty; the Mac receiver measures elapsed source-to-consumer time entirely on its
|
||||
own monotonic clock. Worker-local mapped timing is diagnostic, not the acceptance clock.
|
||||
- One clock snapshot is used throughout each publication/receipt assessment. A concurrent
|
||||
clock update cannot change the timestamp midway through cell/policy assessment.
|
||||
- Source completion is acknowledged before data EOF can retire the control endpoint.
|
||||
Clock warmup precedes initial source-anchor assignment; once assigned, it is never
|
||||
shifted to recover skipped observations.
|
||||
- An admitted transport disconnect before the application OPEN now enters WAIT too.
|
||||
Previously the one-use grant could be consumed while the runtime remained active with
|
||||
no input, preventing a replacement epoch. An unbound malformed packet still cannot
|
||||
stop the owner; obsolete peers cannot pause a replacement epoch.
|
||||
|
||||
## Method and sequential results
|
||||
|
||||
Each model attempt used one profile and the same serialized GPU schedule. Dedicated
|
||||
RTX4090, 8 CPU/8GiB limit, pids256, shm256MiB; temporary stock-clock reference
|
||||
2610/10251MHz and unchanged 450W power limit. No EoMT, Frigate or Ollama inference.
|
||||
Four pre-existing Mission Core services were quiesced and restored per attempt.
|
||||
|
||||
All attempts remain separately identified; none is overwritten or omitted:
|
||||
|
||||
| Attempt | Outcome | Accepted interpretation |
|
||||
| --- | --- | --- |
|
||||
| canary32a | Missing candidate manifest before model startup | Packaging failure, no measurement |
|
||||
| canary32b | Missing pinned DDRNet runner | Packaging failure, no source processed |
|
||||
| canary32c, code-v1 | 30 results, 2 compute discards; source EOF acknowledgement timed out | Full graph/receipt evidence, not accepted real-time |
|
||||
| canary32d, code-v2 | 0 results, clean bounded completion | 3 source WAIT skips +29 synchronization skips |
|
||||
| canary32e, code-v3 | Source command incorrectly named the index directory, not index.jsonl | Operator-command failure, no source data; explicitly ended via the pilot watchdog handler |
|
||||
| canary32f, code-v4 | 2 results, clean bounded completion | Final canary FAIL:13 source WAIT skips +17 synchronization skips |
|
||||
|
||||
The launcher now checks the manifest, pinned runner hash and all bind sources before
|
||||
quiescing services. Source command generation checks exact input files before activation.
|
||||
The pinned runner is unchanged (`b18ad60f277eea69a240a28f290611b94627fb9707faf1bb3e6e22102dad67c1`).
|
||||
The rejected early source command was stopped by signalling the confirmed diagnostic
|
||||
main PID7 through its existing SIGALRM handler, allowing ordinary cleanup/lease release.
|
||||
|
||||
### First full cross-host observation: canary32c
|
||||
|
||||
32 source frames ->32 accepted inputs ->30 byte-exact scene receipts +2 explicit
|
||||
`input-gap` compute discards (sequences17,27). No reply drops or unaccounted frames.
|
||||
The discarded calculations coincided with the old repeated-clock-read assessment path;
|
||||
the run did not retain enough fine-grained tracing to prove that as their sole cause.
|
||||
All30 received raw model/geometry/temporal/costmap reference comparisons match;28 replies
|
||||
arrived before source EOF. Twelve scenes are fully fresh; missing/aged source geometry
|
||||
does not become permissive policy.
|
||||
|
||||
Measured source-to-consumer-ready p95/p99:208.197/211.873ms, range90.877–211.873ms.
|
||||
The125ms latency gate fails even apart from discards/EOF error. Published clock
|
||||
uncertainty:3.487–4.543ms. Peak reply440373B; tracked input14640679B, below16MiB.
|
||||
These figures belong to the rejected code-v1 attempt, not the final candidate.
|
||||
|
||||
### Final candidate: canary32f
|
||||
|
||||
Source released all32 frames at1x. Sequences0–12 were omitted while waiting for
|
||||
admission. Sequences13–29 were skipped for fresh sensor/keyframe synchronization.
|
||||
The full graph resumed at30; frames30 and31 returned, byte/hash validated. Both arrived
|
||||
after the bounded source window ended. Therefore neither complete delivery nor the
|
||||
result-before-EOF gate passed. This was startup WAIT followed by synchronization,
|
||||
**not** two successful initial frames followed by a late outage.
|
||||
|
||||
The two source-to-consumer-ready delays were143.881 and155.743ms. Two observations
|
||||
do not establish meaningful p95/p99 or sustainable FPS. Both receipts are fresh under
|
||||
the explicit250ms freshness envelope; this does not make2/32 availability acceptable.
|
||||
Segmentation/detector output matches the reference for both frames. History-dependent
|
||||
geometry/motion/costmap differs after the omitted prefix and reset; full-scene reference
|
||||
parity is not claimed.
|
||||
|
||||
Clock loop:78 observations,28 marked ready; uncertainty min/median/max
|
||||
3.915/5.312/30.808ms. Published uncertainty4.517–4.562ms. The gate was never widened.
|
||||
One input epoch, one lease generation and four resident children; no model reload
|
||||
while awaiting admission. Peak reply403572B, tracked input10479771B, final buffers0.
|
||||
Final run sampled VRAM peak2365MiB and cgroup peak3313.4MiB; Mac source RSS59.9MB.
|
||||
This is not evidence of a24GiB capacity limit. Sensor archive rows are read incrementally,
|
||||
not fully loaded or transferred.
|
||||
|
||||
## Validation and retained limitations
|
||||
|
||||
- Final local:297PASS,2 intentionally Worker-only skips. Worker:173PASS including
|
||||
the native large-result/slow-reader tests. Early-open cancellation: six additional
|
||||
sequential repeats, two cases each,12/12PASS. No timeout was increased.
|
||||
- The previous intermittent reconnect test was reproduced three times. Diagnostics
|
||||
found `opened=false`, drained transport and still-active continuity; the correction
|
||||
covers cancellation both before and after OPEN. An initial overly broad failure
|
||||
handler was rejected by four existing unbound-peer tests and corrected before GPU use.
|
||||
- Ruff, format, six-file strict mypy and diff checks pass.128 staged Python files match
|
||||
measured commit35b6cd9, including the pinned runner mapping and final test-only overlays.
|
||||
Subsequent formatting in two files is independently AST-identical; no new model result
|
||||
is attributed to a changed algorithm.
|
||||
- `verify.py` independently reconciles source/compute/synchronization/receipt counts,
|
||||
raw scene bytes, mask/layer hashes, Mac receipt ages and clock-bound widening arithmetic.
|
||||
This campaign retained aggregate clock bounds, not every raw five-timestamp exchange;
|
||||
raw clock reconstruction remains a next diagnostic, not a claimed proof here.
|
||||
- No full-session, controlled cross-host full-graph outage or full-graph slow-consumer
|
||||
acceptance was performed after the negative canary. The earlier same-Worker results
|
||||
retain their original narrower scope. Product UI/registry was not changed.
|
||||
|
||||
## Decision and next Stage 2 gate
|
||||
|
||||
The graph works over this real route, but its current start/readiness protocol does
|
||||
not provide stable availability. The source can first pass its clock bound before
|
||||
the Worker has accumulated equivalent responder-side evidence; its fixed2s lead does
|
||||
not prove joint readiness. Missing the initial keyframe/sensor prefix then magnifies
|
||||
a short admission delay. This is a protocol/startup finding, not a GPU sizing result.
|
||||
|
||||
Next: separate two-sided clock warmup from immutable source-anchor activation. Confirm
|
||||
both peers' current readiness before starting the1x source, then keep the source clock
|
||||
running unchanged through real faults. Retain raw exchange/ack timing and state-change
|
||||
ledgers to distinguish route latency, scheduling and protocol overhead. Repeat one short
|
||||
normal canary; only after that pass, run controlled outage/slow-consumer and longer windows.
|
||||
Do not weaken the5ms gate, replay backlog or infer an onboard/radio result from SSH.
|
||||
Standalone packaging remains later within Stage2; Stage3–4 remain unopened.
|
||||
|
||||
## Cleanup and evidence
|
||||
|
||||
All temporary containers, lease volumes, host collectors and port18561/tunnel are gone.
|
||||
Owner records released; four service container IDs restored, Triton200, Telegraf Running.
|
||||
GPU automatic210/405MHz,980MiB,0%,450W at final sample. Existing legacy service defects
|
||||
are not declared fixed. Ollama/Frigate remain exited/restart=no. Canonical Mac8000 PID33360
|
||||
and identity-matched agent-MQTT telemetry remain available;8765 absent. No physical K1,
|
||||
motor/autopilot, recording, Synology or external deployment mutation.
|
||||
|
||||
Private TLS keys/bootstrap capabilities were removed on both hosts; public certificates
|
||||
and raw non-secret diagnostics retained. Session:
|
||||
`.runtime/perception-stage2-crosshost-20260902T1705Z/`,198 artifacts,
|
||||
manifest SHA-256 `7b435cac111b3e0e789aa137ba064651e6d344a5f8fb8fa35b0abbd0e7cb1d74`.
|
||||
Final code-v4 archive SHA-256:
|
||||
`866d709baa785101d10b32b31a17a1b97b15e6de4de932bfd2e79c00825911ab`.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Stage 2 increment 16 — joint source activation and measured clock WAIT
|
||||
|
||||
2026-09-02, 21:12 MSK. Code `48835a0`. **Joint startup PASS; full real-time canary FAIL.**
|
||||
Stage 2 remains open; no Stage 3/4 cutover, standalone release or actuation.
|
||||
|
||||
## Change and invariants
|
||||
|
||||
Clock warmup no longer starts the recording. The Mac acknowledges every issued probe,
|
||||
including pre-start probes with a null source anchor. Worker accumulates responder
|
||||
evidence and returns typed bounds plus its accepted anchor. Both sides must satisfy
|
||||
the current clock gate before proposing a start; Worker must explicitly accept it.
|
||||
Mac additionally waits for the pending data grant before returning from startup.
|
||||
|
||||
ReportClock uses closed `missioncore.stream-clock-report/v2` and
|
||||
`missioncore.stream-clock-receipt/v2` documents; legacy/malformed responses fail closed.
|
||||
Nanoseconds, including signed clock offsets, remain exact canonical decimal strings.
|
||||
Client checks activation, nonce, clock identities, envelope and accepted anchor.
|
||||
Peer bounds are aged to the latest possible Worker time at source use, including ACK
|
||||
transit; a cached readiness boolean cannot authorize a later observation.
|
||||
|
||||
The one-second lead is only for bounded grant delivery/setup AFTER joint readiness.
|
||||
Lost ACK retains the exact same proposal because Worker may already have accepted it.
|
||||
Explicit nonacceptance permits another pre-start proposal; an accepted anchor never
|
||||
changes. Missing the accepted start fails startup rather than retiming the recording.
|
||||
Source input paths are checked before clock activation. Receipt diagnostics retain
|
||||
bounded raw t1–t6, with one evaluation timestamp per reported readiness snapshot.
|
||||
|
||||
Unchanged: 1x original source clock, every-frame candidate, <=16MiB tracked inputs,
|
||||
two pending cameras, serialized GPU, 125ms p95/p99 whole-path gate, <=5ms conditional
|
||||
clock uncertainty, 500ppm relative-rate budget, 50us timestamp-error budget, 2s expiry
|
||||
and 16 recent samples. These are conditional assumptions, not a measurement that the
|
||||
physical clocks actually drift by 500ppm. WAIT preserves models/local ownership;
|
||||
resync discards backlog and requires a new epoch/keyframe/current sensors.
|
||||
|
||||
## One full-graph canary
|
||||
|
||||
Session `.runtime/perception-stage2-joint-start-20260902T1805Z`, run `joint32-worker`.
|
||||
Actual source start 18:04:32.831227Z; session ID is an identifier, not the run timestamp.
|
||||
Mac incrementally reads the existing raw recording and receives real scene/mask payloads;
|
||||
Worker006 runs DDRNet-39 GOOSE, RF-DETR, LiDAR/distance, motion, TRAVEL TGS/costmap/policy.
|
||||
Model container has no recording mount. One GPU profile, 8 CPUs/8GiB, temporary
|
||||
2610/10251MHz stock-clock reference, unchanged 450W. No alternate model run or retry.
|
||||
Pinned dependency image plus developer/model mounts remains **not standalone**.
|
||||
Transport is gRPC/TLS through SSH/Tailscale, not a measured rover radio/onboard link.
|
||||
|
||||
| Original camera sequences | Observed outcome |
|
||||
| --- | --- |
|
||||
| 0–5 | Six results; initial camera/keyframe and sensor prefix admitted |
|
||||
| 6 | Accepted, then explicitly discarded as `input-gap` |
|
||||
| 7–15 | Nine source WAIT skips |
|
||||
| 16–29 | Fourteen synchronization skips while waiting for a fresh keyframe/sensor pair |
|
||||
| 30–31 | Two results after recovery; both arrive after the short source window ends |
|
||||
|
||||
Ledger: 32 released =8 results +1 compute discard +9 WAIT skips +14 sync skips.
|
||||
No unaccounted frame, reply drop, source error or control/data RPC error. Source remains
|
||||
1x; release-lag p95/p99 3.062/17.286ms, max36.725ms. Joint startup admitted frame0,
|
||||
unlike increment15's final attempt; this does not establish continuous availability.
|
||||
|
||||
Eight Worker/Mac scene payloads are byte-exact; all masks, detections, material,
|
||||
lineage and sensor bindings match the uninterrupted reference. Geometry/tracks/threats
|
||||
and raw TGS state match on the six pre-gap results, not on the two post-resync results:
|
||||
their temporal history was intentionally reset. Do not claim eight uninterrupted
|
||||
full-graph reference matches. Five received scenes are fully fresh; six arrive before EOF.
|
||||
|
||||
Same-Mac source-due → consumer-ready: min101.345ms, median144.951ms,
|
||||
p95/p99/max215.767ms, n=8. This tiny failing sample is not a stable throughput estimate.
|
||||
DDRNet GPU-model mean12.895ms, detector mean12.862ms; these are component intervals,
|
||||
not end-to-end latency or evidence of spare realtime capacity. Peak sampled VRAM2363MiB,
|
||||
cgroup3341.08MiB, Mac source RSS61,767,680B, tracked inputs12,055,035B, reply419,194B.
|
||||
The result does not indicate exhaustion of24GiB GPU memory.
|
||||
|
||||
## Independently reconstructed clock evidence
|
||||
|
||||
All55 exchanges retained t1(source send), t2/t3(Worker receive/send), t4(source receipt),
|
||||
t5(Worker receipt of report), t6(source ACK return). Independent integer calculations
|
||||
reproduce BOTH published interval windows and all55 readiness decisions;41 are ready.
|
||||
The sole accepted source anchor and a ready observation precede initial data connect.
|
||||
|
||||
After startup, source samples27–35 are unavailable: uncertainty5.031–5.667ms at the
|
||||
unchanged5ms limit. Sample36 returns to4.867ms. Worker continuous WAIT lasts864.139ms;
|
||||
keyframe recovery magnifies the resulting output gap. Source uncertainty min/median/max
|
||||
4.217/4.620/32.053ms includes initial warmup; the maximum is not running-source drift.
|
||||
Probe-cycle min/median/max8.420/13.991/101.253ms; report/ACK-cycle7.392/13.269/104.500ms.
|
||||
These intervals include application scheduling and cannot alone attribute delay to
|
||||
radio, TCP, SSH, gRPC or event-loop contention.
|
||||
|
||||
An offline diagnostic retaining EVERY sample within the same2s horizon also exceeds
|
||||
5ms throughout these nine unavailable samples. Increasing the16-sample count alone
|
||||
would not remove this WAIT in the trace. No runtime window/threshold was changed.
|
||||
Worker host-control observations33/33 accepted, no expiry or envelope violations;
|
||||
the observed WAIT was clock admission, not missing GPU telemetry or owner loss.
|
||||
Two input epochs retain lease generation1 and resident PIDs11/40/48/49. Recovery clears
|
||||
temporal stores with all children alive; final orderly completion releases all resources.
|
||||
|
||||
## Validation, evidence and next gate
|
||||
|
||||
241 focused local tests PASS,2 Worker-only cases skipped locally;198 Worker CPU tests
|
||||
PASS, including real TLS joint startup, malformed/legacy receipts, lost accepted-anchor
|
||||
ACK, one-sided/stale bounds, missing grant, unchanged anchor after recovery and existing
|
||||
ingress/lifecycle/backpressure cases. Ruff/check-format and typed clock modules mypy PASS.
|
||||
129 measured Python source files match the local committed code and code-v2 archive.
|
||||
Code-v1 CPU evidence remains retained; v2 only makes diagnostic readiness use its
|
||||
recorded timestamp. Only v2 ran the GPU canary.
|
||||
|
||||
Archive SHA256 `52e9778cdd54aab33b9e347081ae67ebcc53968773717b9c708174381f2d6eed`.
|
||||
53 retained artifacts; manifest SHA256
|
||||
`9cb369fe7ae185d1a92f6050bc1018a878483f5eec169267f100843245428691`.
|
||||
`verify.py`, `verify_clocks.py`, `clock-reconstruction.json`, `review.json` and
|
||||
`acceptance.json` reproduce receipt accounting, freshness, timing and closed resources.
|
||||
|
||||
Next: CPU-only attribution/A/B of clock/control scheduling versus concurrent scene
|
||||
transfer/receipt work, preserving the existing route and admission budgets. Determine
|
||||
what is application scheduling and what is transport; then optimize the proven cause.
|
||||
Do not add artificial outages or start a long GPU qualification on this failing baseline.
|
||||
After a stable short full-graph canary: controlled gap/slow-consumer, longer runs and
|
||||
standalone packaging within Stage2. Product integration stays in later stages.
|
||||
|
||||
Temporary model/test containers, collector, lease volume, tunnel18561, key/bootstrap
|
||||
are gone; owner released, four exact prior service IDs restored, Triton200, Telegraf
|
||||
Running. Clock-lock reset commands succeeded; subsequent automatic210/405MHz observed,
|
||||
450W unchanged. Ollama/Frigate remain exited/restart=no. Canonical Mac8000 PID33360 and
|
||||
identity-matched Worker telemetry work;8765 absent. Existing legacy-service defects
|
||||
are not declared fixed. No K1, motor, autonomous-driving or external/Synology deployment.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user