fix(replay): recover failed archives and release deleted sessions

This commit is contained in:
DCCONSTRUCTIONS
2026-07-29 01:55:23 +03:00
parent 2a97cf28c0
commit 1eba7c82ba
8 changed files with 217 additions and 7 deletions
+24
View File
@@ -199,6 +199,7 @@ export default function App() {
const livePerceptionLayersRef = useRef<LivePerceptionLayers>(defaultLivePerceptionLayers);
const livePerceptionRevisionRef = useRef(0);
const replayActiveRef = useRef(false);
const selectedRecordedSessionIdRef = useRef<string | null>(null);
const sceneSettingsCommitterActiveRef = useRef(true);
const sceneSettingsCommitterRef = useRef<LatestAsyncCommitter<SceneSettings> | null>(null);
const polygonDatasetRouteOpenedRef = useRef(false);
@@ -536,6 +537,7 @@ export default function App() {
// produced a validated launch descriptor. Keep the old scene mounted
// before this point; now perform one controlled receiver teardown before
// accepting the already-ready archive.
selectedRecordedSessionIdRef.current = null;
setReplayTransitioning(true);
setRecordedReplay(null);
setRecordedReplayLabel(null);
@@ -553,6 +555,7 @@ export default function App() {
if (sourceSwitchBlockedRef.current) {
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
}
selectedRecordedSessionIdRef.current = session.id;
setRecordedReplay(launch);
setRecordedReplayLabel(session.label);
setSourceUrl(launch.sourceUrl);
@@ -564,11 +567,28 @@ export default function App() {
if (outcome !== "accepted") setReplayTransitioning(false);
}, []);
const releaseRecordedReplayForDelete = useCallback(async (sessionId: string) => {
if (selectedRecordedSessionIdRef.current !== sessionId) return;
// Deleting the selected archive is a source transition. Unmount Rerun and
// recorded media first so no admission request can outlive the evidence.
selectedRecordedSessionIdRef.current = null;
setReplayTransitioning(true);
setRecordedReplay(null);
setRecordedReplayLabel(null);
setSourceUrl("");
setSourceDraft("");
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.setTimeout(resolve, 0));
});
if (selectedRecordedSessionIdRef.current === null) setReplayTransitioning(false);
}, []);
const activateAutomaticSpatialSource = useCallback(() => {
// A plugin-owned live/file-replay start must release any host-selected
// archive or manual URL before the new acquisition becomes non-terminal.
// This transition is an internal start boundary, not an operator source
// switch, so it intentionally does not consult the acquisition guard.
selectedRecordedSessionIdRef.current = null;
setReplayTransitioning(false);
setRecordedReplay(null);
setRecordedReplayLabel(null);
@@ -786,6 +806,7 @@ export default function App() {
onReplayAccepted={acceptRecordedReplay}
onReplaySettled={(_session, outcome) =>
settleRecordedReplaySwitch(outcome)}
onDeleteBegin={releaseRecordedReplayForDelete}
/>
) : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
@@ -838,6 +859,7 @@ export default function App() {
onReplayAccepted: acceptRecordedReplay,
onReplaySettled: (_session, outcome) =>
settleRecordedReplaySwitch(outcome),
onDeleteBegin: releaseRecordedReplayForDelete,
}}
navigation={{
openView,
@@ -886,6 +908,7 @@ export default function App() {
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => {
if (sourceSwitchBlockedRef.current) return;
selectedRecordedSessionIdRef.current = null;
setSourceDraft("");
setSourceUrl("");
setRecordedReplay(null);
@@ -901,6 +924,7 @@ export default function App() {
title={sourceSwitchBlockedReason ?? undefined}
onClick={() => {
if (sourceSwitchBlockedRef.current) return;
selectedRecordedSessionIdRef.current = null;
setSourceUrl(sourceDraft.trim());
setRecordedReplay(null);
setRecordedReplayLabel(null);
@@ -105,6 +105,7 @@ export interface ObservationSessionReplayCallbacks {
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
}
export function ObservationSessionSelect({
@@ -114,6 +115,7 @@ export function ObservationSessionSelect({
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
}: ObservationSessionReplayCallbacks & {
limit?: number;
disabled?: boolean;
@@ -127,6 +129,7 @@ export function ObservationSessionSelect({
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
});
const triggerCopy = sessions.replayProgress
? progressCopy(sessions.replayProgress.phase)
@@ -313,6 +316,7 @@ export function ObservationSessionArchive({
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
}: ObservationSessionReplayCallbacks & {
limit?: number;
labsOnly?: boolean;
@@ -327,6 +331,7 @@ export function ObservationSessionArchive({
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
});
const items = sessions.items;
@@ -52,6 +52,20 @@ export interface ObservationReplayCoordinator {
cancel: () => void;
}
export async function deleteObservationSessionAfterTeardown(
sessionId: string,
{
onDeleteBegin,
deleteSession = deleteObservationSession,
}: {
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
deleteSession?: (sessionId: string) => Promise<void>;
} = {},
): Promise<void> {
await onDeleteBegin?.(sessionId);
await deleteSession(sessionId);
}
export interface ObservationPreparationPollingOptions {
signal: AbortSignal;
fetcher?: ObservationSessionFetch;
@@ -375,6 +389,7 @@ export function useObservationSessions({
onReplayBegin,
onReplayAccepted,
onReplaySettled,
onDeleteBegin,
}: {
limit?: number;
scope?: ObservationSessionScope;
@@ -392,6 +407,8 @@ export function useObservationSessions({
session: ObservationSessionSummary,
outcome: ObservationReplayOutcome,
) => void | Promise<void>;
/** Releases an active viewer before its evidence and derived cache are deleted. */
onDeleteBegin?: (sessionId: string) => void | Promise<void>;
} = {}): ObservationSessionsController {
const [items, setItems] = useState<ObservationSessionSummary[]>([]);
const [state, setState] = useState<ObservationSessionsLoadState>("idle");
@@ -606,7 +623,7 @@ export function useObservationSessions({
setDeletingSessionId(sessionId);
setError(null);
try {
await deleteObservationSession(sessionId);
await deleteObservationSessionAfterTeardown(sessionId, { onDeleteBegin });
if (!mounted.current) return false;
setItems((current) => current.filter((item) => item.id !== sessionId));
setFailedSessionId((current) => current === sessionId ? null : current);
@@ -617,7 +634,7 @@ export function useObservationSessions({
} finally {
if (mounted.current) setDeletingSessionId(null);
}
}, [deletingSessionId, replayingSessionId]);
}, [deletingSessionId, onDeleteBegin, replayingSessionId]);
return {
items,
@@ -583,6 +583,7 @@ export function LaboratoryArchiveWorkspace(props: LaboratoryWorkspaceProps) {
onReplayBegin: props.sessionArchive.onReplayBegin,
onReplayAccepted: props.sessionArchive.onReplayAccepted,
onReplaySettled: props.sessionArchive.onReplaySettled,
onDeleteBegin: props.sessionArchive.onDeleteBegin,
});
const publishedWorks = useMemo(
() => sessions.items.filter((session) => (
@@ -17,6 +17,7 @@ let ObservationSessionApiError;
let ObservationSessionContractError;
let decodeObservationRecordedMediaManifest;
let createObservationReplayCoordinator;
let deleteObservationSessionAfterTeardown;
let resolveObservationSessionReplay;
let waitForObservationReplayPreparation;
let storeObservationReplayPreparation;
@@ -45,6 +46,7 @@ before(async () => {
} = await server.ssrLoadModule("/src/core/observation/sessionArchive.ts"));
({
createObservationReplayCoordinator,
deleteObservationSessionAfterTeardown,
resolveObservationSessionReplay,
waitForObservationReplayPreparation,
storeObservationReplayPreparation,
@@ -437,6 +439,56 @@ test("delete API uses one opaque same-origin target and requires an empty 204",
);
});
test("session deletion releases the selected presentation before the DELETE request", async () => {
const events = [];
await deleteObservationSessionAfterTeardown("session-20260716T205632Z", {
onDeleteBegin: async (sessionId) => {
events.push(`release:${sessionId}`);
},
deleteSession: async (sessionId) => {
events.push(`delete:${sessionId}`);
},
});
assert.deepEqual(events, [
"release:session-20260716T205632Z",
"delete:session-20260716T205632Z",
]);
let deleteCalled = false;
await assert.rejects(
deleteObservationSessionAfterTeardown("session-20260716T205632Z", {
onDeleteBegin: async () => {
throw new Error("viewer teardown failed");
},
deleteSession: async () => {
deleteCalled = true;
},
}),
/viewer teardown failed/,
);
assert.equal(deleteCalled, false);
});
test("App atomically clears the selected Rerun source before deleting its session", async () => {
const appSource = await readFile(new URL("../src/App.tsx", import.meta.url), "utf8");
assert.match(
appSource,
/selectedRecordedSessionIdRef\.current = session\.id[\s\S]*setRecordedReplay\(launch\)/,
);
assert.match(
appSource,
/releaseRecordedReplayForDelete[\s\S]*selectedRecordedSessionIdRef\.current !== sessionId[\s\S]*selectedRecordedSessionIdRef\.current = null[\s\S]*setRecordedReplay\(null\)[\s\S]*setSourceUrl\(""\)/,
);
assert.match(
appSource,
/onDeleteBegin=\{releaseRecordedReplayForDelete\}/,
);
assert.match(
appSource,
/onDeleteBegin:\s*releaseRecordedReplayForDelete/,
);
});
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
const calls = [];
const launch = await replayObservationSession("session-20260716T205632Z", {