fix(k1): admit live rerun only after real data
This commit is contained in:
@@ -780,8 +780,8 @@ export default function App() {
|
||||
</StatusBadge>
|
||||
) : activeDefinition.kind === "spatial" ? (
|
||||
<div className="observation-header-tools">
|
||||
<StatusBadge tone={runtime.state?.sourceMode === "live" ? "success" : "neutral"}>
|
||||
{runtime.state?.sourceMode === "live" ? "Эфир" : "Ожидание эфира"}
|
||||
<StatusBadge tone={["live", "replay"].includes(runtime.state?.sourceMode ?? "") ? "success" : "neutral"}>
|
||||
{runtime.state?.sourceMode === "live" ? "Эфир" : runtime.state?.sourceMode === "replay" ? "Повтор записи" : "Ожидание эфира"}
|
||||
</StatusBadge>
|
||||
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
||||
<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. */
|
||||
export function recordedPlaybackBufferState(
|
||||
rangeNs: { min: number; max: number } | null,
|
||||
@@ -938,6 +972,11 @@ export function RerunViewport({
|
||||
presentationGate,
|
||||
recordedArtifact !== null,
|
||||
);
|
||||
const liveReceiverBindingIdentity = liveRerunReceiverBindingIdentity(
|
||||
sourceUrl,
|
||||
liveStreamId,
|
||||
followLive,
|
||||
);
|
||||
|
||||
useEffect(() => subscribeToLiveViewerBuildFence(() => {
|
||||
uiBuildStaleRef.current = true;
|
||||
@@ -949,7 +988,7 @@ export function RerunViewport({
|
||||
|
||||
useEffect(() => {
|
||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||
}, [followLive, liveRecoveryAuthorityIdentity, liveStreamId, sourceUrl]);
|
||||
}, [liveReceiverBindingIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedSource = sourceUrl.trim();
|
||||
@@ -1152,9 +1191,10 @@ export function RerunViewport({
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts || null,
|
||||
});
|
||||
}
|
||||
const currentRecoveryAuthorityIdentity = liveRecoveryAuthorityRef.current;
|
||||
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current, {
|
||||
activeAuthorityIdentity: liveRecoveryAuthorityRef.current,
|
||||
expectedAuthorityIdentity: liveRecoveryAuthorityIdentity,
|
||||
activeAuthorityIdentity: currentRecoveryAuthorityIdentity,
|
||||
expectedAuthorityIdentity: currentRecoveryAuthorityIdentity,
|
||||
disposed,
|
||||
});
|
||||
liveRecoveryRef.current = recovery.state;
|
||||
@@ -1245,7 +1285,7 @@ export function RerunViewport({
|
||||
stalledForMs: Math.round(observed.stalledForMs),
|
||||
recoveryAttempt: Math.min(
|
||||
liveRecoveryRef.current.attempts + 1,
|
||||
liveRecoveryAuthorityIdentity
|
||||
liveRecoveryAuthorityRef.current
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
),
|
||||
@@ -1356,17 +1396,9 @@ export function RerunViewport({
|
||||
) return;
|
||||
recordingOpened = true;
|
||||
if (!isRecordedSource) {
|
||||
diagnosticLifecycle.markAdmitted();
|
||||
if (liveRecoveryRef.current.awaitingRecovery) {
|
||||
diagnosticLifecycle.post({
|
||||
eventCode: "live_receiver_recovered",
|
||||
streamId: liveStreamIdRef.current,
|
||||
backendActivitySequence: liveActivitySequenceRef.current,
|
||||
viewerRangeMaxNs: latestLiveRangeMaxNs,
|
||||
recoveryAttempt: liveRecoveryRef.current.attempts,
|
||||
});
|
||||
}
|
||||
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
|
||||
// Store discovery only establishes a candidate. Admission is
|
||||
// committed below after this recording exposes a usable live
|
||||
// range backed by an actual published frame.
|
||||
}
|
||||
if (
|
||||
recordedBlueprintUrl &&
|
||||
@@ -1479,7 +1511,11 @@ export function RerunViewport({
|
||||
setRecordingBufferProgress(recordedBuffer.bufferProgress);
|
||||
}
|
||||
const readyToRender = followLive
|
||||
? viewerStartResolved
|
||||
? isLiveRerunPresentationReady(
|
||||
viewerStartResolved,
|
||||
rangeNs,
|
||||
liveActivitySequenceRef.current,
|
||||
)
|
||||
: isRecordedPlaybackReady(viewerStartResolved, artifactVerified, recordedBuffer);
|
||||
const presentationReady = presentationGateRef.current === "ready";
|
||||
if (!followLive && (!readyToRender || !presentationReady) && playing) {
|
||||
@@ -1531,6 +1567,25 @@ export function RerunViewport({
|
||||
});
|
||||
if (readyToRender && !readyPublished) {
|
||||
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) setRecordingBufferProgress(1);
|
||||
recordedSceneAdmitted = true;
|
||||
@@ -1563,11 +1618,6 @@ export function RerunViewport({
|
||||
// Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its
|
||||
// recording_open event to the JavaScript wrapper. The active store
|
||||
// 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({
|
||||
application_id: "nodedc_mission_core_spatial",
|
||||
recording_id: recordingId,
|
||||
@@ -1716,10 +1766,8 @@ export function RerunViewport({
|
||||
autoplayWhenReady,
|
||||
expectedTimelineEndSeconds,
|
||||
expectedTimelineStartSeconds,
|
||||
followLive,
|
||||
liveReceiverBindingIdentity,
|
||||
initialPlaybackStartSeconds,
|
||||
liveStreamId,
|
||||
liveRecoveryAuthorityIdentity,
|
||||
onPlaybackChange,
|
||||
onPlaybackControllerChange,
|
||||
onSelectionChange,
|
||||
@@ -1729,7 +1777,6 @@ export function RerunViewport({
|
||||
recordedArtifact?.sourceUrl,
|
||||
recordedArtifact?.viewerSourceUrl,
|
||||
retryNonce,
|
||||
sourceUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -156,7 +156,7 @@ function SpatialWorkspace({
|
||||
const [showDetections2d, setShowDetections2d] = useState(false);
|
||||
const [showSegmentation, setShowSegmentation] = 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
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
|
||||
@@ -8,6 +8,8 @@ let server;
|
||||
let claimExclusiveLiveViewer;
|
||||
let createRecordedOpenWatchdog;
|
||||
let createReentrantViewerDisposer;
|
||||
let isLiveRerunPresentationReady;
|
||||
let liveRerunReceiverBindingIdentity;
|
||||
let recordedOpenWatchdogTimeoutMs;
|
||||
let rerunViewerInitialSource;
|
||||
let rerunViewerOpenOptions;
|
||||
@@ -23,6 +25,8 @@ before(async () => {
|
||||
claimExclusiveLiveViewer,
|
||||
createRecordedOpenWatchdog,
|
||||
createReentrantViewerDisposer,
|
||||
isLiveRerunPresentationReady,
|
||||
liveRerunReceiverBindingIdentity,
|
||||
recordedOpenWatchdogTimeoutMs,
|
||||
rerunViewerInitialSource,
|
||||
rerunViewerOpenOptions,
|
||||
@@ -70,6 +74,31 @@ test("only the live receiver opens on the native following edge", () => {
|
||||
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", () => {
|
||||
const smallDelay = recordedOpenWatchdogTimeoutMs(4);
|
||||
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(
|
||||
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(
|
||||
source,
|
||||
@@ -221,6 +262,25 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
|
||||
source,
|
||||
/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 () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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.
|
||||
- `K1-P0-RERUN-ADMISSION`: backend decoded and published PCL, but the browser
|
||||
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
|
||||
clicked. Not fixed in the checkpoint increment.
|
||||
- `K1-P1-PENDING-FEEDBACK`: reconnect/search/select/provision transitions replace
|
||||
|
||||
Reference in New Issue
Block a user