fix(k1): admit live rerun only after real data
This commit is contained in:
@@ -780,8 +780,8 @@ export default function App() {
|
|||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
) : activeDefinition.kind === "spatial" ? (
|
) : activeDefinition.kind === "spatial" ? (
|
||||||
<div className="observation-header-tools">
|
<div className="observation-header-tools">
|
||||||
<StatusBadge tone={runtime.state?.sourceMode === "live" ? "success" : "neutral"}>
|
<StatusBadge tone={["live", "replay"].includes(runtime.state?.sourceMode ?? "") ? "success" : "neutral"}>
|
||||||
{runtime.state?.sourceMode === "live" ? "Эфир" : "Ожидание эфира"}
|
{runtime.state?.sourceMode === "live" ? "Эфир" : runtime.state?.sourceMode === "replay" ? "Повтор записи" : "Ожидание эфира"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -275,6 +275,40 @@ export function isUsableRecordedPlaybackRange(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live receiver is presentable only after the exact browser store exposes
|
||||||
|
* real timeline data that the backend has also confirmed publishing.
|
||||||
|
* `WebViewer.start()` and a non-null active recording id are transport setup,
|
||||||
|
* not evidence that the spatial scene can render.
|
||||||
|
*/
|
||||||
|
export function isLiveRerunPresentationReady(
|
||||||
|
viewerStarted: boolean,
|
||||||
|
rangeNs: { min: number; max: number } | null,
|
||||||
|
backendActivitySequence: number | null,
|
||||||
|
): boolean {
|
||||||
|
return viewerStarted &&
|
||||||
|
Number.isSafeInteger(backendActivitySequence) &&
|
||||||
|
(backendActivitySequence ?? 0) > 0 &&
|
||||||
|
isUsableRecordedPlaybackRange(rangeNs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Key the native receiver to its data-plane binding. Recovery authority is a
|
||||||
|
* retry fence projected from changing supervisor snapshots; it must not tear
|
||||||
|
* down a healthy WebViewer while this acquisition and URL remain unchanged.
|
||||||
|
*/
|
||||||
|
export function liveRerunReceiverBindingIdentity(
|
||||||
|
sourceUrl: string,
|
||||||
|
liveStreamId: string | null,
|
||||||
|
followLive: boolean,
|
||||||
|
): string {
|
||||||
|
return JSON.stringify([
|
||||||
|
followLive ? "live" : "recorded",
|
||||||
|
sourceUrl.trim(),
|
||||||
|
followLive ? liveStreamId?.trim() ?? "" : "",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/** Describe progressive archive availability without gating first rendering. */
|
/** Describe progressive archive availability without gating first rendering. */
|
||||||
export function recordedPlaybackBufferState(
|
export function recordedPlaybackBufferState(
|
||||||
rangeNs: { min: number; max: number } | null,
|
rangeNs: { min: number; max: number } | null,
|
||||||
@@ -938,6 +972,11 @@ export function RerunViewport({
|
|||||||
presentationGate,
|
presentationGate,
|
||||||
recordedArtifact !== null,
|
recordedArtifact !== null,
|
||||||
);
|
);
|
||||||
|
const liveReceiverBindingIdentity = liveRerunReceiverBindingIdentity(
|
||||||
|
sourceUrl,
|
||||||
|
liveStreamId,
|
||||||
|
followLive,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => subscribeToLiveViewerBuildFence(() => {
|
useEffect(() => subscribeToLiveViewerBuildFence(() => {
|
||||||
uiBuildStaleRef.current = true;
|
uiBuildStaleRef.current = true;
|
||||||
@@ -949,7 +988,7 @@ export function RerunViewport({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||||
}, [followLive, liveRecoveryAuthorityIdentity, liveStreamId, sourceUrl]);
|
}, [liveReceiverBindingIdentity]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalizedSource = sourceUrl.trim();
|
const normalizedSource = sourceUrl.trim();
|
||||||
@@ -1152,9 +1191,10 @@ export function RerunViewport({
|
|||||||
recoveryAttempt: liveRecoveryRef.current.attempts || null,
|
recoveryAttempt: liveRecoveryRef.current.attempts || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const currentRecoveryAuthorityIdentity = liveRecoveryAuthorityRef.current;
|
||||||
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current, {
|
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current, {
|
||||||
activeAuthorityIdentity: liveRecoveryAuthorityRef.current,
|
activeAuthorityIdentity: currentRecoveryAuthorityIdentity,
|
||||||
expectedAuthorityIdentity: liveRecoveryAuthorityIdentity,
|
expectedAuthorityIdentity: currentRecoveryAuthorityIdentity,
|
||||||
disposed,
|
disposed,
|
||||||
});
|
});
|
||||||
liveRecoveryRef.current = recovery.state;
|
liveRecoveryRef.current = recovery.state;
|
||||||
@@ -1245,7 +1285,7 @@ export function RerunViewport({
|
|||||||
stalledForMs: Math.round(observed.stalledForMs),
|
stalledForMs: Math.round(observed.stalledForMs),
|
||||||
recoveryAttempt: Math.min(
|
recoveryAttempt: Math.min(
|
||||||
liveRecoveryRef.current.attempts + 1,
|
liveRecoveryRef.current.attempts + 1,
|
||||||
liveRecoveryAuthorityIdentity
|
liveRecoveryAuthorityRef.current
|
||||||
? Number.MAX_SAFE_INTEGER
|
? Number.MAX_SAFE_INTEGER
|
||||||
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||||
),
|
),
|
||||||
@@ -1356,17 +1396,9 @@ export function RerunViewport({
|
|||||||
) return;
|
) return;
|
||||||
recordingOpened = true;
|
recordingOpened = true;
|
||||||
if (!isRecordedSource) {
|
if (!isRecordedSource) {
|
||||||
diagnosticLifecycle.markAdmitted();
|
// Store discovery only establishes a candidate. Admission is
|
||||||
if (liveRecoveryRef.current.awaitingRecovery) {
|
// committed below after this recording exposes a usable live
|
||||||
diagnosticLifecycle.post({
|
// range backed by an actual published frame.
|
||||||
eventCode: "live_receiver_recovered",
|
|
||||||
streamId: liveStreamIdRef.current,
|
|
||||||
backendActivitySequence: liveActivitySequenceRef.current,
|
|
||||||
viewerRangeMaxNs: latestLiveRangeMaxNs,
|
|
||||||
recoveryAttempt: liveRecoveryRef.current.attempts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
recordedBlueprintUrl &&
|
recordedBlueprintUrl &&
|
||||||
@@ -1479,7 +1511,11 @@ export function RerunViewport({
|
|||||||
setRecordingBufferProgress(recordedBuffer.bufferProgress);
|
setRecordingBufferProgress(recordedBuffer.bufferProgress);
|
||||||
}
|
}
|
||||||
const readyToRender = followLive
|
const readyToRender = followLive
|
||||||
? viewerStartResolved
|
? isLiveRerunPresentationReady(
|
||||||
|
viewerStartResolved,
|
||||||
|
rangeNs,
|
||||||
|
liveActivitySequenceRef.current,
|
||||||
|
)
|
||||||
: isRecordedPlaybackReady(viewerStartResolved, artifactVerified, recordedBuffer);
|
: isRecordedPlaybackReady(viewerStartResolved, artifactVerified, recordedBuffer);
|
||||||
const presentationReady = presentationGateRef.current === "ready";
|
const presentationReady = presentationGateRef.current === "ready";
|
||||||
if (!followLive && (!readyToRender || !presentationReady) && playing) {
|
if (!followLive && (!readyToRender || !presentationReady) && playing) {
|
||||||
@@ -1531,6 +1567,25 @@ export function RerunViewport({
|
|||||||
});
|
});
|
||||||
if (readyToRender && !readyPublished) {
|
if (readyToRender && !readyPublished) {
|
||||||
readyPublished = true;
|
readyPublished = true;
|
||||||
|
if (followLive) {
|
||||||
|
diagnosticLifecycle.post({
|
||||||
|
eventCode: "live_receiver_active_store_admitted",
|
||||||
|
streamId: liveStreamIdRef.current,
|
||||||
|
backendActivitySequence: liveActivitySequenceRef.current,
|
||||||
|
viewerRangeMaxNs: rangeNs?.max ?? null,
|
||||||
|
});
|
||||||
|
diagnosticLifecycle.markAdmitted();
|
||||||
|
if (liveRecoveryRef.current.awaitingRecovery) {
|
||||||
|
diagnosticLifecycle.post({
|
||||||
|
eventCode: "live_receiver_recovered",
|
||||||
|
streamId: liveStreamIdRef.current,
|
||||||
|
backendActivitySequence: liveActivitySequenceRef.current,
|
||||||
|
viewerRangeMaxNs: rangeNs?.max ?? null,
|
||||||
|
recoveryAttempt: liveRecoveryRef.current.attempts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||||
|
}
|
||||||
if (!followLive) clearRecordedAdmissionWatchdog();
|
if (!followLive) clearRecordedAdmissionWatchdog();
|
||||||
if (!followLive) setRecordingBufferProgress(1);
|
if (!followLive) setRecordingBufferProgress(1);
|
||||||
recordedSceneAdmitted = true;
|
recordedSceneAdmitted = true;
|
||||||
@@ -1563,11 +1618,6 @@ export function RerunViewport({
|
|||||||
// Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its
|
// Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its
|
||||||
// recording_open event to the JavaScript wrapper. The active store
|
// recording_open event to the JavaScript wrapper. The active store
|
||||||
// is the authoritative fallback and avoids hiding a ready canvas.
|
// is the authoritative fallback and avoids hiding a ready canvas.
|
||||||
diagnosticLifecycle.post({
|
|
||||||
eventCode: "live_receiver_active_store_admitted",
|
|
||||||
streamId: liveStreamIdRef.current,
|
|
||||||
backendActivitySequence: liveActivitySequenceRef.current,
|
|
||||||
});
|
|
||||||
admitRecording({
|
admitRecording({
|
||||||
application_id: "nodedc_mission_core_spatial",
|
application_id: "nodedc_mission_core_spatial",
|
||||||
recording_id: recordingId,
|
recording_id: recordingId,
|
||||||
@@ -1716,10 +1766,8 @@ export function RerunViewport({
|
|||||||
autoplayWhenReady,
|
autoplayWhenReady,
|
||||||
expectedTimelineEndSeconds,
|
expectedTimelineEndSeconds,
|
||||||
expectedTimelineStartSeconds,
|
expectedTimelineStartSeconds,
|
||||||
followLive,
|
liveReceiverBindingIdentity,
|
||||||
initialPlaybackStartSeconds,
|
initialPlaybackStartSeconds,
|
||||||
liveStreamId,
|
|
||||||
liveRecoveryAuthorityIdentity,
|
|
||||||
onPlaybackChange,
|
onPlaybackChange,
|
||||||
onPlaybackControllerChange,
|
onPlaybackControllerChange,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
@@ -1729,7 +1777,6 @@ export function RerunViewport({
|
|||||||
recordedArtifact?.sourceUrl,
|
recordedArtifact?.sourceUrl,
|
||||||
recordedArtifact?.viewerSourceUrl,
|
recordedArtifact?.viewerSourceUrl,
|
||||||
retryNonce,
|
retryNonce,
|
||||||
sourceUrl,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ function SpatialWorkspace({
|
|||||||
const [showDetections2d, setShowDetections2d] = useState(false);
|
const [showDetections2d, setShowDetections2d] = useState(false);
|
||||||
const [showSegmentation, setShowSegmentation] = useState(false);
|
const [showSegmentation, setShowSegmentation] = useState(false);
|
||||||
const [showCuboids3d, setShowCuboids3d] = useState(false);
|
const [showCuboids3d, setShowCuboids3d] = useState(false);
|
||||||
const recordedSource = state?.sourceMode === "replay" || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
const recordedSource = Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
||||||
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
||||||
? recordedSessionAdmission?.phase ?? "loading"
|
? recordedSessionAdmission?.phase ?? "loading"
|
||||||
: "ready";
|
: "ready";
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ let server;
|
|||||||
let claimExclusiveLiveViewer;
|
let claimExclusiveLiveViewer;
|
||||||
let createRecordedOpenWatchdog;
|
let createRecordedOpenWatchdog;
|
||||||
let createReentrantViewerDisposer;
|
let createReentrantViewerDisposer;
|
||||||
|
let isLiveRerunPresentationReady;
|
||||||
|
let liveRerunReceiverBindingIdentity;
|
||||||
let recordedOpenWatchdogTimeoutMs;
|
let recordedOpenWatchdogTimeoutMs;
|
||||||
let rerunViewerInitialSource;
|
let rerunViewerInitialSource;
|
||||||
let rerunViewerOpenOptions;
|
let rerunViewerOpenOptions;
|
||||||
@@ -23,6 +25,8 @@ before(async () => {
|
|||||||
claimExclusiveLiveViewer,
|
claimExclusiveLiveViewer,
|
||||||
createRecordedOpenWatchdog,
|
createRecordedOpenWatchdog,
|
||||||
createReentrantViewerDisposer,
|
createReentrantViewerDisposer,
|
||||||
|
isLiveRerunPresentationReady,
|
||||||
|
liveRerunReceiverBindingIdentity,
|
||||||
recordedOpenWatchdogTimeoutMs,
|
recordedOpenWatchdogTimeoutMs,
|
||||||
rerunViewerInitialSource,
|
rerunViewerInitialSource,
|
||||||
rerunViewerOpenOptions,
|
rerunViewerOpenOptions,
|
||||||
@@ -70,6 +74,31 @@ test("only the live receiver opens on the native following edge", () => {
|
|||||||
assert.equal(rerunViewerOpenOptions(false), null);
|
assert.equal(rerunViewerOpenOptions(false), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("live presentation waits for the exact receiver to expose a usable range", () => {
|
||||||
|
assert.equal(isLiveRerunPresentationReady(false, { min: 1, max: 2 }, 1), false);
|
||||||
|
assert.equal(isLiveRerunPresentationReady(true, null, 1), false);
|
||||||
|
assert.equal(isLiveRerunPresentationReady(true, { min: 2, max: 1 }, 1), false);
|
||||||
|
assert.equal(isLiveRerunPresentationReady(true, { min: 1, max: 2 }, 0), false);
|
||||||
|
assert.equal(isLiveRerunPresentationReady(true, { min: 1, max: 1 }, 1), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("live receiver binding stays stable across recovery authority projections", () => {
|
||||||
|
const sourceUrl = "rerun+http://127.0.0.1:9877/proxy";
|
||||||
|
const streamId = "acq-001";
|
||||||
|
assert.equal(
|
||||||
|
liveRerunReceiverBindingIdentity(sourceUrl, streamId, true),
|
||||||
|
liveRerunReceiverBindingIdentity(` ${sourceUrl} `, streamId, true),
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
liveRerunReceiverBindingIdentity(sourceUrl, streamId, true),
|
||||||
|
liveRerunReceiverBindingIdentity(sourceUrl, "acq-002", true),
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
liveRerunReceiverBindingIdentity(sourceUrl, streamId, true),
|
||||||
|
liveRerunReceiverBindingIdentity(sourceUrl, streamId, false),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("recorded admission watchdog is size-aware and exits an incomplete load", () => {
|
test("recorded admission watchdog is size-aware and exits an incomplete load", () => {
|
||||||
const smallDelay = recordedOpenWatchdogTimeoutMs(4);
|
const smallDelay = recordedOpenWatchdogTimeoutMs(4);
|
||||||
const currentDelay = recordedOpenWatchdogTimeoutMs(246_331_680);
|
const currentDelay = recordedOpenWatchdogTimeoutMs(246_331_680);
|
||||||
@@ -184,7 +213,19 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
/viewer\.get_active_recording_id\(\)[\s\S]*eventCode: "live_receiver_active_store_admitted"[\s\S]*admitRecording\(\{[\s\S]*application_id: "nodedc_mission_core_spatial"/,
|
/viewer\.get_active_recording_id\(\)[\s\S]*admitRecording\(\{[\s\S]*application_id: "nodedc_mission_core_spatial"/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/const readyToRender = followLive\s*\? isLiveRerunPresentationReady\(/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/if \(readyToRender && !readyPublished\)[\s\S]*eventCode: "live_receiver_active_store_admitted"[\s\S]*diagnosticLifecycle\.markAdmitted\(\)/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/\], \[followLive, liveRecoveryAuthorityIdentity, liveStreamId, sourceUrl\]\);/,
|
||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
source,
|
source,
|
||||||
@@ -221,6 +262,25 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
|
|||||||
source,
|
source,
|
||||||
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
|
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/const recordedSource = Boolean\(recordedReplay\) \|\| \/\\\.rrd/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
source,
|
||||||
|
/recordedSource = state\?\.sourceMode === ["']replay["']/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the spatial header reports raw replay as an active source", async () => {
|
||||||
|
const source = await readFile(
|
||||||
|
new URL("../src/App.tsx", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
source,
|
||||||
|
/sourceMode === "replay"[\s\S]*\? "Повтор записи"[\s\S]*: "Ожидание эфира"/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# K1 operator flow: incremental acceptance
|
# K1 operator flow: incremental acceptance
|
||||||
|
|
||||||
Status: working acceptance ledger, 2026-08-14.
|
Status: working acceptance ledger, updated 2026-08-20.
|
||||||
|
|
||||||
This is the short regression anchor for changes to the existing K1 operator
|
This is the short regression anchor for changes to the existing K1 operator
|
||||||
flow. The authoritative connection and safety model remains
|
flow. The authoritative connection and safety model remains
|
||||||
@@ -71,7 +71,19 @@ Current violations observed on 2026-08-14:
|
|||||||
policy again admits Scan plus read-only configured-device observation.
|
policy again admits Scan plus read-only configured-device observation.
|
||||||
- `K1-P0-RERUN-ADMISSION`: backend decoded and published PCL, but the browser
|
- `K1-P0-RERUN-ADMISSION`: backend decoded and published PCL, but the browser
|
||||||
never admitted the active Rerun store; camera/counters were visible over an
|
never admitted the active Rerun store; camera/counters were visible over an
|
||||||
empty point scene. Not fixed in the checkpoint increment.
|
empty point scene. Offline fix and reducer are green: store discovery is only
|
||||||
|
a candidate, presentation requires a usable range plus a backend-published
|
||||||
|
frame, and recovery-authority churn no longer remounts the same receiver.
|
||||||
|
Production raw replay of `20260814T145329Z_viewer_live` rendered the real
|
||||||
|
point scene with `Визуализатор готов` and `Повтор записи`. One live K1
|
||||||
|
READY → START → point cloud/camera → STOP acceptance remains required; the
|
||||||
|
2026-08-20 attempt was blocked before discovery by the unceased checkpoint
|
||||||
|
described below.
|
||||||
|
- `K1-P0-RESET-CHECKPOINT`: an explicit local scenario reset resolved a
|
||||||
|
zero-dispatch START as `not-dispatched`, but left its exact recovery
|
||||||
|
checkpoint revision 17 in `prepared`. No device/network command was sent;
|
||||||
|
the stale checkpoint blocks the next START and requires a local atomic
|
||||||
|
settlement fix before live acceptance continues.
|
||||||
- `K1-P1-STOP-STABILITY`: STOP authority visibly oscillated before the operator
|
- `K1-P1-STOP-STABILITY`: STOP authority visibly oscillated before the operator
|
||||||
clicked. Not fixed in the checkpoint increment.
|
clicked. Not fixed in the checkpoint increment.
|
||||||
- `K1-P1-PENDING-FEEDBACK`: reconnect/search/select/provision transitions replace
|
- `K1-P1-PENDING-FEEDBACK`: reconnect/search/select/provision transitions replace
|
||||||
|
|||||||
Reference in New Issue
Block a user