feat(archive): complete recorded session lifecycle
This commit is contained in:
parent
ffffee1879
commit
71c85e9894
|
|
@ -40,7 +40,10 @@ import {
|
|||
} from "./core/runtime/latestAsyncCommitter";
|
||||
import { useObservationLayout } from "./core/observation/useObservationLayout";
|
||||
import { recordedObservationSources } from "./core/observation/recordedObservationSources";
|
||||
import type { ObservationSessionReplayLaunch } from "./core/observation/sessionArchive";
|
||||
import type {
|
||||
ObservationSessionReplayLaunch,
|
||||
ObservationSessionSummary,
|
||||
} from "./core/observation/sessionArchive";
|
||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||
import {
|
||||
|
|
@ -136,6 +139,7 @@ export default function App() {
|
|||
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||
const [sourceDraft, setSourceDraft] = useState("");
|
||||
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
|
||||
|
|
@ -447,6 +451,7 @@ export default function App() {
|
|||
// accepting the already-ready archive.
|
||||
setReplayTransitioning(true);
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
await new Promise<void>((resolve) => {
|
||||
|
|
@ -454,11 +459,15 @@ export default function App() {
|
|||
});
|
||||
}, []);
|
||||
|
||||
const acceptRecordedReplay = useCallback((launch: ObservationSessionReplayLaunch) => {
|
||||
const acceptRecordedReplay = useCallback((
|
||||
session: ObservationSessionSummary,
|
||||
launch: ObservationSessionReplayLaunch,
|
||||
) => {
|
||||
if (sourceSwitchBlockedRef.current) {
|
||||
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
|
||||
}
|
||||
setRecordedReplay(launch);
|
||||
setRecordedReplayLabel(session.label);
|
||||
setSourceUrl(launch.sourceUrl);
|
||||
setSourceDraft(launch.sourceUrl);
|
||||
setReplayTransitioning(false);
|
||||
|
|
@ -475,6 +484,7 @@ export default function App() {
|
|||
// switch, so it intentionally does not consult the acquisition guard.
|
||||
setReplayTransitioning(false);
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
setSourceUrl("");
|
||||
setSourceDraft("");
|
||||
}, []);
|
||||
|
|
@ -634,7 +644,11 @@ export default function App() {
|
|||
<ApplicationPanel
|
||||
key={activeDefinition.id}
|
||||
eyebrow={activeDefinition.eyebrow}
|
||||
title={activeDefinition.title}
|
||||
title={
|
||||
activeDefinition.kind === "spatial" && replayActive && recordedReplayLabel
|
||||
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
||||
: activeDefinition.title
|
||||
}
|
||||
description={activeDefinition.description}
|
||||
expanded={workspace.contentExpanded}
|
||||
onExpandedChange={workspace.setContentExpanded}
|
||||
|
|
@ -646,11 +660,10 @@ export default function App() {
|
|||
) : activeDefinition.kind === "spatial" ? (
|
||||
<div className="observation-header-tools">
|
||||
<ObservationSessionSelect
|
||||
limit={3}
|
||||
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
|
||||
blockedReason={sourceSwitchBlockedReason}
|
||||
onReplayBegin={beginRecordedReplaySwitch}
|
||||
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
|
||||
onReplayAccepted={acceptRecordedReplay}
|
||||
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
|
||||
/>
|
||||
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
||||
|
|
@ -739,6 +752,7 @@ export default function App() {
|
|||
setSourceDraft("");
|
||||
setSourceUrl("");
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
}}
|
||||
>
|
||||
Сбросить адрес
|
||||
|
|
@ -752,6 +766,7 @@ export default function App() {
|
|||
if (sourceSwitchBlockedRef.current) return;
|
||||
setSourceUrl(sourceDraft.trim());
|
||||
setRecordedReplay(null);
|
||||
setRecordedReplayLabel(null);
|
||||
}}
|
||||
>
|
||||
Применить адрес
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Dropdown, Icon } from "@nodedc/ui-react";
|
||||
import { useState } from "react";
|
||||
import { ConfirmationModal, Dropdown, Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
type ObservationSessionReplayLaunch,
|
||||
|
|
@ -93,7 +94,7 @@ function sessionDescription(session: ObservationSessionSummary): string {
|
|||
}
|
||||
|
||||
export function ObservationSessionSelect({
|
||||
limit = 3,
|
||||
limit = 100,
|
||||
disabled = false,
|
||||
blockedReason = null,
|
||||
onReplayBegin,
|
||||
|
|
@ -116,6 +117,7 @@ export function ObservationSessionSelect({
|
|||
outcome: ObservationReplayOutcome,
|
||||
) => void | Promise<void>;
|
||||
}) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
replayEnabled: blockedReason === null,
|
||||
|
|
@ -130,7 +132,7 @@ export function ObservationSessionSelect({
|
|||
: "Сохранённые сессии";
|
||||
const presentedTriggerCopy = blockedReason ?? triggerCopy;
|
||||
|
||||
return (
|
||||
return <>
|
||||
<Dropdown
|
||||
className="observation-session-select"
|
||||
placement="bottom-end"
|
||||
|
|
@ -184,33 +186,48 @@ export function ObservationSessionSelect({
|
|||
<div className="observation-session-menu__list">
|
||||
{sessions.items.map((session) => {
|
||||
const pending = sessions.replayingSessionId === session.id;
|
||||
const deleting = sessions.deletingSessionId === session.id;
|
||||
const failed = sessions.failedSessionId === session.id;
|
||||
const visualState = observationSessionVisualState(session, { pending, failed });
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={session.id}
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-session-option"
|
||||
disabled={pending || !session.replayable}
|
||||
onClick={() => {
|
||||
void sessions.replay(session.id).then((accepted) => {
|
||||
if (accepted) close();
|
||||
});
|
||||
}}
|
||||
className="observation-session-option"
|
||||
data-deleting={deleting ? "true" : undefined}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{session.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
{sessionDescription(session)}
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-dropdown-option observation-session-option__open"
|
||||
disabled={pending || deleting || !session.replayable}
|
||||
onClick={() => {
|
||||
void sessions.replay(session.id).then((accepted) => {
|
||||
if (accepted) close();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="nodedc-dropdown-option__icon">
|
||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="observation-session-option__state">
|
||||
{observationSessionVisualLabel(visualState)}
|
||||
</span>
|
||||
</button>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{session.label}</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
{sessionDescription(session)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="observation-session-option__state">
|
||||
{deleting ? "Удаление…" : observationSessionVisualLabel(visualState)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="observation-session-option__delete"
|
||||
aria-label={`Удалить сохранённую сессию ${session.label}`}
|
||||
disabled={pending || deleting}
|
||||
onClick={() => setDeleteTarget(session)}
|
||||
>
|
||||
<Icon name="trash" size={15} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -242,5 +259,29 @@ export function ObservationSessionSelect({
|
|||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
<ConfirmationModal
|
||||
open={deleteTarget !== null}
|
||||
title="Удалить сохранённую сессию?"
|
||||
description={deleteTarget ? <>
|
||||
<strong>{deleteTarget.label}</strong>
|
||||
<p>
|
||||
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
|
||||
видеоматериалы будут удалены с этого сервера без возможности восстановления.
|
||||
</p>
|
||||
{sessions.error && sessions.deletingSessionId === null ? (
|
||||
<p className="observation-session-delete-error" role="alert">{sessions.error}</p>
|
||||
) : null}
|
||||
</> : null}
|
||||
confirmLabel="Удалить сессию"
|
||||
pendingLabel="Удаление…"
|
||||
danger
|
||||
onClose={() => {
|
||||
if (sessions.deletingSessionId === null) setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
if (await sessions.remove(deleteTarget.id)) setDeleteTarget(null);
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,13 +135,17 @@ export function ObservationMedia({
|
|||
return (
|
||||
<div className="observation-media__empty">
|
||||
<Icon name={sourceIcon[source.modality]} size={20} />
|
||||
<strong>{observationSourceStatusLabel(source)}</strong>
|
||||
<strong>
|
||||
{source.modality === "video" && source.activation && !source.activation.selected
|
||||
? "Источник отключён"
|
||||
: observationSourceStatusLabel(source)}
|
||||
</strong>
|
||||
<span>
|
||||
{source.modality === "video"
|
||||
? source.activation?.controllable
|
||||
? source.activation.selected
|
||||
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
|
||||
: "Откройте канал — локальный адаптер подключит выбранную камеру"
|
||||
: "Канал остановлен оператором; при необходимости его можно открыть снова"
|
||||
: "Канал известен, browser-preview сейчас недоступен"
|
||||
: source.description}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -393,15 +393,27 @@ async function mountVerifiedRecordedEpoch(
|
|||
signal: AbortSignal,
|
||||
): Promise<() => void> {
|
||||
const descriptor = epoch.descriptor;
|
||||
if (
|
||||
descriptor.mediaType === "video/mp4" ||
|
||||
!globalThis.MediaSource ||
|
||||
!MediaSource.isTypeSupported(descriptor.mediaType)
|
||||
) {
|
||||
if (!descriptor.mediaType.startsWith("video/mp4;") || !video.canPlayType(descriptor.mediaType)) {
|
||||
throw new Error("Archived camera codec is not supported");
|
||||
}
|
||||
const mediaSource = new MediaSource();
|
||||
const objectUrl = URL.createObjectURL(mediaSource);
|
||||
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
// A sealed fragmented-MP4 generation is already complete and immutable.
|
||||
// Present it as one Blob so Chromium can index and seek the whole archive
|
||||
// without retaining the same 166+ MiB epoch in a quota-limited MSE
|
||||
// SourceBuffer. Every constituent byte was fetched and SHA-256 verified
|
||||
// before this boundary, and Blob preserves their canonical order.
|
||||
const payload = new Blob([epoch.init, ...epoch.segments], {
|
||||
type: descriptor.mediaType,
|
||||
});
|
||||
const expectedByteLength = epoch.init.byteLength + epoch.segments.reduce(
|
||||
(total, segment) => total + segment.byteLength,
|
||||
0,
|
||||
);
|
||||
if (payload.size !== expectedByteLength) {
|
||||
throw new Error("Archived camera Blob is incomplete");
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(payload);
|
||||
const cleanup = () => {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
|
|
@ -409,39 +421,8 @@ async function mountVerifiedRecordedEpoch(
|
|||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
video.src = objectUrl;
|
||||
video.load();
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
cleanupListeners();
|
||||
resolve();
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanupListeners();
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
const cleanupListeners = () => {
|
||||
mediaSource.removeEventListener("sourceopen", onOpen);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
mediaSource.addEventListener("sourceopen", onOpen, { once: true });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
if (signal.aborted || mediaSource.readyState !== "open") {
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
}
|
||||
const sourceBuffer = mediaSource.addSourceBuffer(descriptor.mediaType);
|
||||
await appendRecordedMediaBuffer(sourceBuffer, epoch.init, signal);
|
||||
for (const segment of epoch.segments) {
|
||||
await appendRecordedMediaBuffer(sourceBuffer, segment, signal);
|
||||
}
|
||||
if (signal.aborted || mediaSource.readyState !== "open" || sourceBuffer.updating) {
|
||||
throw new Error("Archived camera MediaSource closed before full append");
|
||||
}
|
||||
mediaSource.endOfStream();
|
||||
await waitForSeekableArchive(
|
||||
video,
|
||||
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
// A device-agnostic frontend safety policy. Sixteen channels covers multi-rig
|
||||
// vehicles while the independent byte/concurrency limits keep admission
|
||||
// bounded. OPFS-backed sealed generations are the planned scaling path beyond
|
||||
// this in-memory laboratory policy.
|
||||
// bounded. One real accepted archive is ~163 MiB for a single camera, so the
|
||||
// old 128 MiB laboratory ceiling rejected a valid sealed generation before the
|
||||
// first manifest request. OPFS-backed sealed generations remain the scaling
|
||||
// path beyond this in-memory policy.
|
||||
export const MAX_RECORDED_CAMERA_SOURCES = 16;
|
||||
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 128 * 1024 * 1024;
|
||||
export const MAX_RECORDED_MEDIA_SOURCE_BYTES = 256 * 1024 * 1024;
|
||||
export const MAX_RECORDED_SESSION_CAMERA_BYTES = 512 * 1024 * 1024;
|
||||
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
|
||||
|
||||
|
|
|
|||
|
|
@ -1110,6 +1110,46 @@ export async function fetchObservationSessionCatalog({
|
|||
return decodeObservationSessionCatalog(body);
|
||||
}
|
||||
|
||||
export async function deleteObservationSession(
|
||||
sessionId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: { signal?: AbortSignal; fetcher?: ObservationSessionFetch } = {},
|
||||
): Promise<void> {
|
||||
if (!SAFE_ID.test(sessionId)) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Идентификатор удаляемой сессии имеет недопустимый формат.",
|
||||
);
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(
|
||||
`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservationSessionApiError("Не удалось удалить сохранённую сессию.");
|
||||
}
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok || response.status !== 204) {
|
||||
throw new ObservationSessionApiError(
|
||||
apiErrorMessage(body, `Удаление сессии вернуло HTTP ${response.status}.`),
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (body !== undefined) {
|
||||
throw new ObservationSessionContractError(
|
||||
"Сервер вернул данные после подтверждённого удаления сессии.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function replayObservationSession(
|
||||
sessionId: string,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,6 +27,18 @@ export function observationPresentationSourceAfterLayoutApply(
|
|||
return mode === "preserve" ? currentSourceId : null;
|
||||
}
|
||||
|
||||
export function visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
currentIds: readonly string[],
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
): string[] {
|
||||
let visibleIds = [...currentIds];
|
||||
for (const source of sources) {
|
||||
if (source.transport !== "recording" || !canOpenByDefault(source)) continue;
|
||||
visibleIds = openObservationSource(visibleIds, source.id, sources).visibleIds;
|
||||
}
|
||||
return visibleIds;
|
||||
}
|
||||
|
||||
export interface ObservationLayoutController {
|
||||
visibleSourceIds: ReadonlySet<string>;
|
||||
focusedSourceId: string | null;
|
||||
|
|
@ -230,7 +242,25 @@ export function useObservationLayout(
|
|||
}
|
||||
const desired = desiredSnapshotRef.current;
|
||||
if (desired) {
|
||||
const previousIdentity = initializedCatalog.current;
|
||||
initializedCatalog.current = identity;
|
||||
if (
|
||||
previousIdentity !== identity &&
|
||||
sources.some((source) => source.transport === "recording")
|
||||
) {
|
||||
const nextVisible = visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
visibleIdsRef.current,
|
||||
sources,
|
||||
);
|
||||
commitVisibleIds(nextVisible);
|
||||
const firstRecordedOverlay = sources.find((source) => (
|
||||
source.transport === "recording" &&
|
||||
source.capabilities.overlay &&
|
||||
nextVisible.includes(source.id)
|
||||
));
|
||||
commitActiveFloatingSourceId(firstRecordedOverlay?.id ?? null);
|
||||
persistLiveLayout();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (initializedCatalog.current === identity) return;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||
|
||||
import {
|
||||
decodeObservationSessionPreparation,
|
||||
deleteObservationSession,
|
||||
fetchObservationSessionCatalog,
|
||||
fetchObservationSessionPreparation,
|
||||
replayObservationSession,
|
||||
|
|
@ -32,9 +33,11 @@ export interface ObservationSessionsController {
|
|||
preparation: ObservationSessionPreparation | null;
|
||||
replayProgress: ObservationReplayProgress | null;
|
||||
failedSessionId: string | null;
|
||||
deletingSessionId: string | null;
|
||||
refresh: () => Promise<boolean>;
|
||||
replay: (sessionId: string) => Promise<boolean>;
|
||||
retry: () => Promise<boolean>;
|
||||
remove: (sessionId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ObservationReplayAttempt {
|
||||
|
|
@ -360,7 +363,7 @@ export function clearObservationReplayPreparation(
|
|||
}
|
||||
|
||||
export function useObservationSessions({
|
||||
limit = 3,
|
||||
limit = 100,
|
||||
replayEnabled = true,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
|
|
@ -389,12 +392,12 @@ export function useObservationSessions({
|
|||
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
|
||||
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
|
||||
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
|
||||
const mounted = useRef(true);
|
||||
const catalogSequence = useRef(0);
|
||||
const reattachStarted = useRef(false);
|
||||
const replayEnabledRef = useRef(replayEnabled);
|
||||
replayEnabledRef.current = replayEnabled;
|
||||
const preparationPollSequence = useRef(0);
|
||||
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
|
||||
if (replayCoordinator.current === null) {
|
||||
replayCoordinator.current = createObservationReplayCoordinator();
|
||||
|
|
@ -409,26 +412,30 @@ export function useObservationSessions({
|
|||
setReplayingSessionId(null);
|
||||
setReplayProgress(null);
|
||||
}, [replayEnabled]);
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 3;
|
||||
const safeLimit = Number.isFinite(limit)
|
||||
? Math.min(100, Math.max(1, Math.floor(limit)))
|
||||
: 100;
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const loadCatalog = useCallback(async (foreground: boolean) => {
|
||||
const sequence = ++catalogSequence.current;
|
||||
setState("loading");
|
||||
setError(null);
|
||||
if (foreground) setState("loading");
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
setState("ready");
|
||||
setError(null);
|
||||
return true;
|
||||
} catch (loadError) {
|
||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||
setState("error");
|
||||
if (foreground) setState("error");
|
||||
setError(errorMessage(loadError));
|
||||
return false;
|
||||
}
|
||||
}, [safeLimit]);
|
||||
|
||||
const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void refresh();
|
||||
|
|
@ -439,44 +446,21 @@ export function useObservationSessions({
|
|||
};
|
||||
}, [refresh]);
|
||||
|
||||
const catalogHasActivePreparation = items.some((item) => (
|
||||
item.preparation !== null &&
|
||||
["queued", "validating", "exporting", "finalizing"].includes(item.preparation.state)
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || !catalogHasActivePreparation) return;
|
||||
const sequence = ++preparationPollSequence.current;
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const catalog = await fetchObservationSessionCatalog({
|
||||
limit: safeLimit,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (
|
||||
mounted.current &&
|
||||
!controller.signal.aborted &&
|
||||
sequence === preparationPollSequence.current
|
||||
) {
|
||||
setItems(catalog.items.slice(0, safeLimit));
|
||||
}
|
||||
} catch (pollError) {
|
||||
if (
|
||||
mounted.current &&
|
||||
!controller.signal.aborted &&
|
||||
sequence === preparationPollSequence.current
|
||||
) {
|
||||
setError(errorMessage(pollError));
|
||||
}
|
||||
}
|
||||
}, 1_500);
|
||||
return () => {
|
||||
preparationPollSequence.current += 1;
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
if (state !== "ready") return;
|
||||
let disposed = false;
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
if (disposed) return;
|
||||
await loadCatalog(false);
|
||||
if (!disposed) timer = window.setTimeout(() => void poll(), 2_000);
|
||||
};
|
||||
}, [catalogHasActivePreparation, items, safeLimit, state]);
|
||||
timer = window.setTimeout(() => void poll(), 2_000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [loadCatalog, state]);
|
||||
|
||||
const executeReplay = useCallback(async (
|
||||
session: ObservationSessionSummary,
|
||||
|
|
@ -606,6 +590,24 @@ export function useObservationSessions({
|
|||
return replay(failedSessionId);
|
||||
}, [failedSessionId, replay]);
|
||||
|
||||
const remove = useCallback(async (sessionId: string) => {
|
||||
if (deletingSessionId !== null || replayingSessionId === sessionId) return false;
|
||||
setDeletingSessionId(sessionId);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteObservationSession(sessionId);
|
||||
if (!mounted.current) return false;
|
||||
setItems((current) => current.filter((item) => item.id !== sessionId));
|
||||
setFailedSessionId((current) => current === sessionId ? null : current);
|
||||
return true;
|
||||
} catch (deleteError) {
|
||||
if (mounted.current) setError(errorMessage(deleteError));
|
||||
return false;
|
||||
} finally {
|
||||
if (mounted.current) setDeletingSessionId(null);
|
||||
}
|
||||
}, [deletingSessionId, replayingSessionId]);
|
||||
|
||||
return {
|
||||
items,
|
||||
state,
|
||||
|
|
@ -614,8 +616,10 @@ export function useObservationSessions({
|
|||
preparation,
|
||||
replayProgress,
|
||||
failedSessionId,
|
||||
deletingSessionId,
|
||||
refresh,
|
||||
replay,
|
||||
retry,
|
||||
remove,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,9 +117,42 @@
|
|||
}
|
||||
|
||||
.observation-session-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 2.35rem;
|
||||
align-items: stretch;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.observation-session-option__open {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.observation-session-option__delete {
|
||||
display: grid;
|
||||
min-width: 2.35rem;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--nodedc-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-option__delete:hover:not(:disabled),
|
||||
.observation-session-option__delete:focus-visible {
|
||||
background: transparent;
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.observation-session-option__delete:disabled,
|
||||
.observation-session-option[data-deleting="true"] {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.observation-session-option i {
|
||||
display: block;
|
||||
width: 0.45rem;
|
||||
|
|
@ -217,6 +250,10 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.observation-session-delete-error {
|
||||
color: rgb(var(--nodedc-danger-rgb));
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.workspace-layout-feedback {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -323,11 +323,19 @@ function SpatialWorkspace({
|
|||
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const presentedViewerStatus = rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
const intentionalSourceEnd = !recordedSource && [
|
||||
"awaiting_external_stop",
|
||||
"stopping",
|
||||
"finalizing",
|
||||
"completed",
|
||||
].includes(state?.acquisition?.state ?? "");
|
||||
const presentedViewerStatus = intentionalSourceEnd
|
||||
? "idle"
|
||||
: rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
|
|
@ -389,7 +397,7 @@ function SpatialWorkspace({
|
|||
}, [observationLayout.setViewportSize]);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: "Источник не назначен",
|
||||
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
|
|
@ -500,7 +508,7 @@ function SpatialWorkspace({
|
|||
>
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||
{viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
{!intentionalSourceEnd && viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -679,6 +687,7 @@ function CameraSourceCard({
|
|||
: selectedPeer
|
||||
? "Переключить"
|
||||
: "Открыть канал";
|
||||
const sourceDisconnected = Boolean(source.activation && !source.activation.selected && visible);
|
||||
|
||||
return (
|
||||
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
|
||||
|
|
@ -686,8 +695,11 @@ function CameraSourceCard({
|
|||
<span>{source.label}</span>
|
||||
<div className="camera-slot__head-actions">
|
||||
<span className="camera-slot__status">
|
||||
<i data-availability={source.availability} aria-hidden="true" />
|
||||
{observationSourceStatusLabel(source)}
|
||||
<i
|
||||
data-availability={sourceDisconnected ? "unverified" : source.availability}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{sourceDisconnected ? "Источник отключён" : observationSourceStatusLabel(source)}
|
||||
</span>
|
||||
{source.activation && !focused ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ let decodeObservationSessionCatalog;
|
|||
let decodeObservationSessionReplay;
|
||||
let decodeObservationSessionPreparation;
|
||||
let fetchObservationSessionCatalog;
|
||||
let deleteObservationSession;
|
||||
let replayObservationSession;
|
||||
let fetchObservationSessionPreparation;
|
||||
let cancelObservationSessionPreparation;
|
||||
|
|
@ -34,6 +35,7 @@ before(async () => {
|
|||
decodeObservationSessionReplay,
|
||||
decodeObservationSessionPreparation,
|
||||
fetchObservationSessionCatalog,
|
||||
deleteObservationSession,
|
||||
replayObservationSession,
|
||||
fetchObservationSessionPreparation,
|
||||
cancelObservationSessionPreparation,
|
||||
|
|
@ -136,6 +138,39 @@ test("session catalog decodes canonical snake_case into a path-free camelCase mo
|
|||
assert.equal("path" in catalog.items[0], false);
|
||||
});
|
||||
|
||||
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(
|
||||
new URL("../src/styles/observation-sessions.css", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const spatialControls = await readFile(
|
||||
new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
const acquisitionPipeline = await readFile(
|
||||
new URL(
|
||||
"../../../plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(appSource, /setRecordedReplayLabel\(session\.label\)/);
|
||||
assert.match(appSource, /`\$\{activeDefinition\.title\}: \$\{recordedReplayLabel\}`/);
|
||||
const deleteStyle = styles.slice(
|
||||
styles.indexOf(".observation-session-option__delete"),
|
||||
styles.indexOf(".observation-session-option__delete:disabled"),
|
||||
);
|
||||
assert.match(deleteStyle, /background:\s*transparent/);
|
||||
assert.doesNotMatch(deleteStyle, /danger-rgb\) \/ 0\.12/);
|
||||
assert.doesNotMatch(spatialControls, /Индикатор постоянно зелёный/);
|
||||
assert.doesNotMatch(acquisitionPipeline, /Индикатор постоянно зелёный/);
|
||||
});
|
||||
|
||||
test("session catalog exposes authoritative background preparation state", () => {
|
||||
const catalog = decodeObservationSessionCatalog({
|
||||
items: [session({
|
||||
|
|
@ -302,6 +337,37 @@ test("catalog API preserves HTTP detail without accepting a malformed success bo
|
|||
);
|
||||
});
|
||||
|
||||
test("delete API uses one opaque same-origin target and requires an empty 204", async () => {
|
||||
const calls = [];
|
||||
await deleteObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(
|
||||
calls[0].input,
|
||||
"/api/v1/observation-sessions/session-20260716T205632Z",
|
||||
);
|
||||
assert.equal(calls[0].init.method, "DELETE");
|
||||
await assert.rejects(
|
||||
deleteObservationSession("../private", { fetcher: async () => new Response(null) }),
|
||||
ObservationSessionContractError,
|
||||
);
|
||||
await assert.rejects(
|
||||
deleteObservationSession("session-20260716T205632Z", {
|
||||
fetcher: async () => new Response(JSON.stringify({ detail: "recording is open" }), {
|
||||
status: 409,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
}),
|
||||
(error) => error instanceof ObservationSessionApiError &&
|
||||
error.status === 409 && error.message === "recording is open",
|
||||
);
|
||||
});
|
||||
|
||||
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
|
||||
const calls = [];
|
||||
const launch = await replayObservationSession("session-20260716T205632Z", {
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ test("any RRD or camera failure closes the complete recorded session", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("camera admission enforces independent 16/128/512 MiB limits before fetching", () => {
|
||||
test("camera admission enforces independent 16/256/512 MiB limits before fetching", () => {
|
||||
const {
|
||||
MAX_RECORDED_CAMERA_SOURCES,
|
||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
|
|
@ -86,10 +86,10 @@ test("camera admission enforces independent 16/128/512 MiB limits before fetchin
|
|||
recordedCameraDescriptorPreflight,
|
||||
} = admission;
|
||||
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
|
||||
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 128 * 1024 * 1024);
|
||||
assert.equal(MAX_RECORDED_MEDIA_SOURCE_BYTES, 256 * 1024 * 1024);
|
||||
assert.equal(MAX_RECORDED_SESSION_CAMERA_BYTES, 512 * 1024 * 1024);
|
||||
assert.equal(recordedCameraDescriptorPreflight(
|
||||
Array.from({ length: 4 }, (_, index) => ({
|
||||
Array.from({ length: 2 }, (_, index) => ({
|
||||
id: `camera.${index}`,
|
||||
byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ let saveObservationWorkspaceLayoutProfile;
|
|||
let WorkspaceLayoutApiError;
|
||||
let WorkspaceLayoutContractError;
|
||||
let observationPresentationSourceAfterLayoutApply;
|
||||
let visibleSourceIdsAfterRecordedCatalogActivation;
|
||||
|
||||
before(async () => {
|
||||
server = await createServer({
|
||||
|
|
@ -31,9 +32,10 @@ before(async () => {
|
|||
WorkspaceLayoutApiError,
|
||||
WorkspaceLayoutContractError,
|
||||
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
|
||||
({ observationPresentationSourceAfterLayoutApply } = await server.ssrLoadModule(
|
||||
"/src/core/observation/useObservationLayout.ts",
|
||||
));
|
||||
({
|
||||
observationPresentationSourceAfterLayoutApply,
|
||||
visibleSourceIdsAfterRecordedCatalogActivation,
|
||||
} = await server.ssrLoadModule("/src/core/observation/useObservationLayout.ts"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
|
|
@ -181,6 +183,36 @@ test("fullscreen presentation survives the viewport resize it causes", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("opening a recorded catalog reveals its sealed cameras beside the point cloud", () => {
|
||||
const source = (id, modality, transport = "recording") => ({
|
||||
id,
|
||||
modality,
|
||||
transport,
|
||||
availability: "available",
|
||||
previewUrl: null,
|
||||
delivery: modality === "video" ? { kind: "recorded-fmp4-manifest" } : null,
|
||||
activation: null,
|
||||
capabilities: {
|
||||
defaultVisible: true,
|
||||
overlay: modality === "video",
|
||||
},
|
||||
});
|
||||
const sources = [
|
||||
source("recorded.spatial.primary", "point-cloud"),
|
||||
source("recorded.camera.left", "video"),
|
||||
source("recorded.camera.right", "video"),
|
||||
source("live.camera", "video", "websocket"),
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
visibleSourceIdsAfterRecordedCatalogActivation(
|
||||
["recorded.spatial.primary"],
|
||||
sources,
|
||||
),
|
||||
["recorded.spatial.primary", "recorded.camera.left", "recorded.camera.right"],
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
|
||||
const calls = [];
|
||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import re
|
|||
import secrets
|
||||
import stat
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import Iterable, Iterator
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
|
@ -175,6 +175,36 @@ class RecordedMediaInspector:
|
|||
self._cache[key] = _CachedManifest(identity=identity, manifest=manifest)
|
||||
return manifest
|
||||
|
||||
def delete_prepared(self, session_id: str, artifact_ids: Iterable[str]) -> None:
|
||||
"""Forget and remove path-free preparation sidecars for one session."""
|
||||
|
||||
unique_artifact_ids = tuple(dict.fromkeys(artifact_ids))
|
||||
with self._lock:
|
||||
for artifact_id in unique_artifact_ids:
|
||||
self._cache.pop((session_id, artifact_id), None)
|
||||
if self._cache_root is None:
|
||||
return
|
||||
for artifact_id in unique_artifact_ids:
|
||||
sidecar = self._cache_root / _sidecar_name(session_id, artifact_id)
|
||||
try:
|
||||
metadata = sidecar.lstat()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError(
|
||||
"recorded media preparation could not be inspected"
|
||||
) from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
raise SessionIntegrityError(
|
||||
"recorded media preparation is not a regular file"
|
||||
)
|
||||
try:
|
||||
sidecar.unlink()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError(
|
||||
"recorded media preparation could not be deleted"
|
||||
) from exc
|
||||
|
||||
def restore_prepared(
|
||||
self,
|
||||
artifact: RecordedMediaArtifact,
|
||||
|
|
|
|||
|
|
@ -87,6 +87,13 @@ class _WorkerGeneration:
|
|||
worker: threading.Thread | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _LaunchReservation:
|
||||
preparation_id: str
|
||||
release: Callable[[], None]
|
||||
timer: threading.Timer
|
||||
|
||||
|
||||
class SessionRecordingPreparationManager:
|
||||
"""One bounded, process-owned conversion worker for durable recordings.
|
||||
|
||||
|
|
@ -131,6 +138,7 @@ class SessionRecordingPreparationManager:
|
|||
self._ready_restorer = ready_restorer
|
||||
self._guard = threading.RLock()
|
||||
self._current_by_session: dict[str, _PreparationJob] = {}
|
||||
self._launch_reservations: dict[str, _LaunchReservation] = {}
|
||||
self._closed = True
|
||||
self._generation_counter = 0
|
||||
self._active_generation: _WorkerGeneration | None = None
|
||||
|
|
@ -379,10 +387,7 @@ class SessionRecordingPreparationManager:
|
|||
if pinned is None:
|
||||
return None
|
||||
snapshot, release = pinned
|
||||
timer = threading.Timer(lease_seconds, release)
|
||||
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
self._reserve_launch_lease(snapshot, release, lease_seconds)
|
||||
return snapshot
|
||||
|
||||
def pin_ready(
|
||||
|
|
@ -393,6 +398,7 @@ class SessionRecordingPreparationManager:
|
|||
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
|
||||
"""Cheaply lease the exact already-validated ready generation."""
|
||||
|
||||
launch_release: Callable[[], None] | None = None
|
||||
with self._guard:
|
||||
job = self._current_by_session.get(session_id)
|
||||
if (
|
||||
|
|
@ -409,7 +415,17 @@ class SessionRecordingPreparationManager:
|
|||
self._current_by_session.pop(session_id, None)
|
||||
return None
|
||||
snapshot = self._snapshot_locked(job)
|
||||
return snapshot, pinned
|
||||
reservation = self._launch_reservations.get(session_id)
|
||||
if (
|
||||
reservation is not None
|
||||
and reservation.preparation_id == snapshot.preparation_id
|
||||
):
|
||||
self._launch_reservations.pop(session_id, None)
|
||||
reservation.timer.cancel()
|
||||
launch_release = reservation.release
|
||||
if launch_release is not None:
|
||||
launch_release()
|
||||
return snapshot, pinned
|
||||
|
||||
def reserve_ready(
|
||||
self,
|
||||
|
|
@ -426,12 +442,26 @@ class SessionRecordingPreparationManager:
|
|||
if pinned is None:
|
||||
return None
|
||||
snapshot, release = pinned
|
||||
timer = threading.Timer(lease_seconds, release)
|
||||
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
self._reserve_launch_lease(snapshot, release, lease_seconds)
|
||||
return snapshot
|
||||
|
||||
def release_launch_reservation(self, session_id: str) -> bool:
|
||||
"""Release an unused launch-to-GET lease for one session.
|
||||
|
||||
The first matching recording GET consumes this reservation after it
|
||||
acquires its own response-lifetime pin. Deletion may release a launch
|
||||
reservation that was never consumed; an active response pin remains
|
||||
independently protected by the materializer.
|
||||
"""
|
||||
|
||||
with self._guard:
|
||||
reservation = self._launch_reservations.pop(session_id, None)
|
||||
if reservation is None:
|
||||
return False
|
||||
reservation.timer.cancel()
|
||||
reservation.release()
|
||||
return True
|
||||
|
||||
def cancel(self, session_id: str, *, preparation_id: str | None = None) -> bool:
|
||||
with self._guard:
|
||||
job = self._current_by_session.get(session_id)
|
||||
|
|
@ -447,11 +477,30 @@ class SessionRecordingPreparationManager:
|
|||
self._transition_locked(job, "cancelled", job.progress)
|
||||
return True
|
||||
|
||||
def discard(self, session_id: str) -> bool:
|
||||
"""Forget one non-active job before its source session is deleted."""
|
||||
|
||||
with self._guard:
|
||||
job = self._current_by_session.get(session_id)
|
||||
if job is not None and job.state in ACTIVE_PREPARATION_STATES:
|
||||
return False
|
||||
self._current_by_session.pop(session_id, None)
|
||||
reservation = self._launch_reservations.pop(session_id, None)
|
||||
if reservation is not None:
|
||||
reservation.timer.cancel()
|
||||
if reservation is not None:
|
||||
reservation.release()
|
||||
return True
|
||||
|
||||
def close(self, *, timeout: float = 5.0) -> None:
|
||||
with self._guard:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
reservations = tuple(self._launch_reservations.values())
|
||||
self._launch_reservations.clear()
|
||||
for reservation in reservations:
|
||||
reservation.timer.cancel()
|
||||
for job in self._current_by_session.values():
|
||||
if job.state in ACTIVE_PREPARATION_STATES:
|
||||
job.interrupted_by_restart = not job.cancelled_by_operator
|
||||
|
|
@ -466,9 +515,46 @@ class SessionRecordingPreparationManager:
|
|||
pending.stop_event.set()
|
||||
self._pending_generation = None
|
||||
worker = None if active is None else active.worker
|
||||
for reservation in reservations:
|
||||
reservation.release()
|
||||
if worker is not None:
|
||||
worker.join(timeout=max(0.0, timeout))
|
||||
|
||||
def _reserve_launch_lease(
|
||||
self,
|
||||
snapshot: RecordingPreparationSnapshot,
|
||||
release: Callable[[], None],
|
||||
lease_seconds: float,
|
||||
) -> None:
|
||||
timer = threading.Timer(
|
||||
lease_seconds,
|
||||
self._expire_launch_reservation,
|
||||
args=(snapshot.session_id, snapshot.preparation_id),
|
||||
)
|
||||
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
||||
timer.daemon = True
|
||||
reservation = _LaunchReservation(
|
||||
preparation_id=snapshot.preparation_id,
|
||||
release=release,
|
||||
timer=timer,
|
||||
)
|
||||
with self._guard:
|
||||
previous = self._launch_reservations.pop(snapshot.session_id, None)
|
||||
if previous is not None:
|
||||
previous.timer.cancel()
|
||||
self._launch_reservations[snapshot.session_id] = reservation
|
||||
if previous is not None:
|
||||
previous.release()
|
||||
timer.start()
|
||||
|
||||
def _expire_launch_reservation(self, session_id: str, preparation_id: str) -> None:
|
||||
with self._guard:
|
||||
reservation = self._launch_reservations.get(session_id)
|
||||
if reservation is None or reservation.preparation_id != preparation_id:
|
||||
return
|
||||
self._launch_reservations.pop(session_id, None)
|
||||
reservation.release()
|
||||
|
||||
def _run_generation(self, generation: _WorkerGeneration) -> None:
|
||||
work_queue = generation.work_queue
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -218,6 +218,30 @@ class SessionRecordingMaterializer:
|
|||
self._increment_pin_locked(recording.session_id)
|
||||
return self._release_callback(recording.session_id)
|
||||
|
||||
def delete_cached(self, session_id: str) -> bool:
|
||||
"""Delete one exact derived RRD cache unless a response still leases it."""
|
||||
|
||||
if SESSION_ID_PATTERN.fullmatch(session_id) is None:
|
||||
raise ValueError("recording cache session id is invalid")
|
||||
with self._lock_for(session_id), self._cache_guard:
|
||||
if self._pinned_sessions.get(session_id, 0) > 0:
|
||||
return False
|
||||
session_root = self.recordings_root / session_id
|
||||
if session_root.exists() or session_root.is_symlink():
|
||||
metadata = session_root.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise RecordingMaterializationError(
|
||||
"session recording cache is not a real directory"
|
||||
)
|
||||
if session_root.parent.resolve(strict=True) != self.recordings_root:
|
||||
raise RecordingMaterializationError(
|
||||
"session recording cache escapes the private data root"
|
||||
)
|
||||
shutil.rmtree(session_root)
|
||||
with self._memory_guard:
|
||||
self._validated_memory.pop(session_id, None)
|
||||
return True
|
||||
|
||||
def __call__(self, command: ReplayCommand) -> MaterializedRecording:
|
||||
"""Alias for :meth:`materialize`, suitable for the web API protocol."""
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,15 @@ from __future__ import annotations
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import stat
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
|
|
@ -142,11 +145,18 @@ class SessionStore:
|
|||
candidates = source.discover(allowed_root)
|
||||
imported: list[str] = []
|
||||
for candidate in candidates:
|
||||
self._upsert_candidate(source, candidate)
|
||||
try:
|
||||
self._upsert_candidate(source, candidate)
|
||||
except SessionIntegrityError:
|
||||
# One incomplete/corrupt evidence directory must not starve a
|
||||
# later valid session in the same plugin archive. Keep any
|
||||
# previously indexed row because the candidate is still
|
||||
# physically present, but never admit its current artifacts.
|
||||
continue
|
||||
imported.append(candidate.session_id)
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
discovered = set(imported)
|
||||
discovered = {candidate.session_id for candidate in candidates}
|
||||
indexed = connection.execute(
|
||||
"SELECT session_id FROM observation_sessions "
|
||||
"WHERE plugin_id = ? AND archive_id = ? AND allowed_root = ?",
|
||||
|
|
@ -232,6 +242,60 @@ class SessionStore:
|
|||
)
|
||||
return SessionDetail(summary=_summary_from_row(row), sources=sources, artifacts=artifacts)
|
||||
|
||||
def delete_session(self, session_id: str) -> None:
|
||||
"""Permanently delete one exact catalogued evidence directory and row."""
|
||||
|
||||
_validate_identifier(session_id, "session id")
|
||||
with self._lock:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT allowed_root, session_root FROM observation_sessions "
|
||||
"WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise SessionNotFoundError("observation session was not found")
|
||||
|
||||
allowed_root = Path(row["allowed_root"]).expanduser().resolve()
|
||||
session_root = Path(row["session_root"]).expanduser()
|
||||
try:
|
||||
unresolved_parent = session_root.parent.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("session deletion root is unavailable") from exc
|
||||
if (
|
||||
unresolved_parent != allowed_root
|
||||
or session_root.name != session_id
|
||||
or session_root == allowed_root
|
||||
):
|
||||
raise SessionIntegrityError("session deletion target escapes its allowed root")
|
||||
|
||||
if session_root.exists() or session_root.is_symlink():
|
||||
try:
|
||||
metadata = session_root.lstat()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("session deletion target is unavailable") from exc
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise SessionIntegrityError("session deletion target is not a real directory")
|
||||
tombstone = allowed_root / f".{session_id}.{uuid4().hex}.deleting"
|
||||
try:
|
||||
os.replace(session_root, tombstone)
|
||||
shutil.rmtree(tombstone)
|
||||
except OSError as exc:
|
||||
if tombstone.exists() and not session_root.exists():
|
||||
with _ignore_os_error():
|
||||
os.replace(tombstone, session_root)
|
||||
raise SessionIntegrityError("session evidence could not be deleted") from exc
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
deleted = connection.execute(
|
||||
"DELETE FROM observation_sessions WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).rowcount
|
||||
connection.commit()
|
||||
if deleted != 1:
|
||||
raise SessionNotFoundError("observation session was not found")
|
||||
|
||||
def prepare_replay(
|
||||
self,
|
||||
session_id: str,
|
||||
|
|
|
|||
|
|
@ -303,6 +303,79 @@ def build_session_router(
|
|||
detail="Некорректный идентификатор сессии.",
|
||||
) from exc
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/observation-sessions/{session_id}",
|
||||
status_code=204,
|
||||
)
|
||||
async def delete_observation_session(session_id: str) -> Response:
|
||||
try:
|
||||
store.get_session(session_id)
|
||||
recorded_artifacts = store.list_recorded_media(session_id)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Некорректный идентификатор сессии.",
|
||||
) from exc
|
||||
|
||||
if recording_preparation_manager is not None:
|
||||
snapshot = recording_preparation_manager.status(session_id)
|
||||
if snapshot is not None and snapshot.state in {
|
||||
"queued",
|
||||
"validating",
|
||||
"exporting",
|
||||
"finalizing",
|
||||
}:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Дождитесь завершения подготовки записи перед удалением.",
|
||||
)
|
||||
await run_in_threadpool(
|
||||
recording_preparation_manager.release_launch_reservation,
|
||||
session_id,
|
||||
)
|
||||
|
||||
cache_deleter = getattr(recording_materializer, "delete_cached", None)
|
||||
if callable(cache_deleter):
|
||||
try:
|
||||
cache_deleted = await run_in_threadpool(cache_deleter, session_id)
|
||||
except (OSError, RecordingMaterializationError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Подготовленную запись пока нельзя удалить.",
|
||||
) from exc
|
||||
if cache_deleted is not True:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Запись сейчас открыта в интерфейсе. Закройте её и повторите удаление.",
|
||||
)
|
||||
|
||||
if (
|
||||
recording_preparation_manager is not None
|
||||
and not recording_preparation_manager.discard(session_id)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Подготовка записи ещё выполняется.",
|
||||
)
|
||||
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
recorded_media_inspector.delete_prepared,
|
||||
session_id,
|
||||
(artifact.artifact_id for artifact in recorded_artifacts),
|
||||
)
|
||||
await run_in_threadpool(store.delete_session, session_id)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (OSError, SessionIntegrityError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Сервер не смог безопасно удалить файлы этой сессии.",
|
||||
) from exc
|
||||
return Response(status_code=204)
|
||||
|
||||
@router.post("/api/v1/observation-sessions/{session_id}/replay")
|
||||
async def replay_observation_session(
|
||||
session_id: str,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from k1link.sessions import (
|
|||
RecordedMediaInspector,
|
||||
ReplayCommand,
|
||||
SessionIntegrityError,
|
||||
SessionNotFoundError,
|
||||
SessionRecordingMaterializer,
|
||||
SessionRecordingPreparationManager,
|
||||
SessionStore,
|
||||
|
|
@ -246,6 +247,116 @@ def test_session_router_lists_details_and_dispatches_opaque_replay(tmp_path: Pat
|
|||
assert_no_local_paths(value, repository)
|
||||
|
||||
|
||||
def test_delete_session_removes_evidence_and_cache_but_refuses_an_open_recording(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
|
||||
def export_recording(source: Path, destination: Path) -> dict[str, object]:
|
||||
payload = b"deletable-recording"
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1,
|
||||
}
|
||||
|
||||
materializer = SessionRecordingMaterializer(
|
||||
store.data_dir,
|
||||
exporter=export_recording,
|
||||
)
|
||||
recording, release = materializer.materialize_pinned(store.prepare_replay(session.name))
|
||||
router = build_session_router(store, recording_materializer=materializer)
|
||||
delete_route = endpoint(router, "/api/v1/observation-sessions/{session_id}", "DELETE")
|
||||
|
||||
with pytest.raises(HTTPException) as conflict:
|
||||
asyncio.run(delete_route(session_id=session.name))
|
||||
assert conflict.value.status_code == 409
|
||||
assert session.is_dir()
|
||||
assert recording.path.is_file()
|
||||
|
||||
release()
|
||||
response = asyncio.run(delete_route(session_id=session.name))
|
||||
|
||||
assert response.status_code == 204
|
||||
assert not session.exists()
|
||||
assert not recording.path.parent.exists()
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
store.get_session(session.name)
|
||||
|
||||
|
||||
def test_completed_recording_get_does_not_hold_delete_for_launch_lease(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
payload = b"recording-opened-and-closed"
|
||||
|
||||
def export_recording(source: Path, destination: Path) -> dict[str, object]:
|
||||
destination.write_bytes(payload)
|
||||
return {
|
||||
"source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"rrd_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"rrd_bytes": len(payload),
|
||||
"timeline": "session_time",
|
||||
"timeline_start_ns": 0,
|
||||
"timeline_end_ns": 1_000_000_000,
|
||||
}
|
||||
|
||||
materializer = SessionRecordingMaterializer(store.data_dir, exporter=export_recording)
|
||||
command = store.prepare_replay(session.name)
|
||||
materializer.materialize(command)
|
||||
manager = SessionRecordingPreparationManager(materializer)
|
||||
resolved = manager.resolve_cached(command)
|
||||
assert resolved is not None and resolved.recording is not None
|
||||
router = build_session_router(
|
||||
store,
|
||||
recording_materializer=materializer,
|
||||
recording_preparation_manager=manager,
|
||||
)
|
||||
replay_route = endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}/replay",
|
||||
"POST",
|
||||
)
|
||||
recording_route = endpoint(
|
||||
router,
|
||||
"/api/v1/observation-sessions/{session_id}/recording.rrd",
|
||||
"GET",
|
||||
)
|
||||
delete_route = endpoint(router, "/api/v1/observation-sessions/{session_id}", "DELETE")
|
||||
try:
|
||||
replay = asyncio.run(replay_route(session_id=session.name, request=None))
|
||||
generation = replay["launch"]["sha256"]
|
||||
file_response = asyncio.run(
|
||||
recording_route(
|
||||
session_id=session.name,
|
||||
generation=generation,
|
||||
if_match=None,
|
||||
if_none_match=None,
|
||||
)
|
||||
)
|
||||
_, body = asyncio.run(render_file_response(file_response, range_header=None))
|
||||
assert body == payload
|
||||
|
||||
deleted = asyncio.run(delete_route(session_id=session.name))
|
||||
|
||||
assert deleted.status_code == 204
|
||||
assert not session.exists()
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_session_router_returns_seekable_recording_and_serves_byte_ranges(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -433,3 +433,38 @@ def test_launch_reservation_blocks_eviction_until_lease_expires(tmp_path: Path)
|
|||
assert not first_recording.path.exists()
|
||||
finally:
|
||||
manager.close()
|
||||
|
||||
|
||||
def test_first_recording_get_pin_consumes_launch_reservation(tmp_path: Path) -> None:
|
||||
command = _command(tmp_path / "session")
|
||||
|
||||
def exporter(source: Path, destination: Path) -> dict[str, object]:
|
||||
return _summary(source, destination, b"recording")
|
||||
|
||||
materializer = SessionRecordingMaterializer(
|
||||
tmp_path / "private",
|
||||
exporter=exporter,
|
||||
free_space_reserve_bytes=0,
|
||||
)
|
||||
manager = SessionRecordingPreparationManager(materializer)
|
||||
try:
|
||||
materializer.materialize(command)
|
||||
resolved = manager.resolve_cached(command)
|
||||
assert resolved is not None and resolved.state == "ready"
|
||||
reserved = manager.reserve_cached(command, lease_seconds=60.0)
|
||||
assert reserved is not None
|
||||
|
||||
pinned = manager.pin_ready(
|
||||
command.session_id,
|
||||
preparation_id=reserved.preparation_id,
|
||||
)
|
||||
|
||||
assert pinned is not None
|
||||
_, release_response = pinned
|
||||
assert manager.release_launch_reservation(command.session_id) is False
|
||||
assert materializer.delete_cached(command.session_id) is False
|
||||
|
||||
release_response()
|
||||
assert materializer.delete_cached(command.session_id) is True
|
||||
finally:
|
||||
manager.close()
|
||||
|
|
|
|||
|
|
@ -129,6 +129,25 @@ def test_materializer_reuses_only_a_digest_validated_private_cache(tmp_path: Pat
|
|||
assert exporter.calls == 1
|
||||
|
||||
|
||||
def test_delete_cached_refuses_a_live_lease_then_removes_the_exact_cache(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
command = _command(tmp_path / "source")
|
||||
materializer = SessionRecordingMaterializer(
|
||||
tmp_path / "private",
|
||||
exporter=FakeExporter(),
|
||||
)
|
||||
recording, release = materializer.materialize_pinned(command)
|
||||
|
||||
assert materializer.delete_cached(command.session_id) is False
|
||||
assert recording.path.is_file()
|
||||
|
||||
release()
|
||||
assert materializer.delete_cached(command.session_id) is True
|
||||
assert not recording.path.parent.exists()
|
||||
assert command.primary_artifact.path.is_file()
|
||||
|
||||
|
||||
def test_materializer_rejects_changed_digest_bound_secondary_artifact(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -178,6 +178,39 @@ def test_catalog_reconciles_sessions_removed_from_one_evidence_root(tmp_path: Pa
|
|||
assert store.list_recent().items == ()
|
||||
|
||||
|
||||
def test_corrupt_candidate_does_not_starve_later_valid_session(tmp_path: Path) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
corrupt = sessions / "20260716T125502Z_viewer_live"
|
||||
corrupt.mkdir(parents=True)
|
||||
(corrupt / "manifest.redacted.json").write_text("{}", encoding="utf-8")
|
||||
valid = make_legacy_session(sessions, "20260718T201659Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
|
||||
assert store.reconcile_archive(xgrids_k1_archive_source(sessions)) == (valid.name,)
|
||||
assert [item.session_id for item in store.list_recent().items] == [valid.name]
|
||||
|
||||
|
||||
def test_present_corrupt_candidate_does_not_delete_its_last_valid_catalog_row(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
retained = make_legacy_session(sessions, "20260716T125502Z_viewer_live")
|
||||
later = make_legacy_session(sessions, "20260718T201659Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
source = xgrids_k1_archive_source(sessions)
|
||||
assert store.reconcile_archive(source) == (retained.name, later.name)
|
||||
|
||||
shutil.rmtree(retained / "captures")
|
||||
|
||||
assert store.reconcile_archive(source) == (later.name,)
|
||||
assert {item.session_id for item in store.list_recent().items} == {
|
||||
retained.name,
|
||||
later.name,
|
||||
}
|
||||
|
||||
|
||||
def test_catalog_uses_valid_project_name_as_session_display_name(tmp_path: Path) -> None:
|
||||
sessions = tmp_path / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
|
|
@ -671,6 +704,45 @@ def test_current_session_marker_keeps_active_capture_out_of_replay(tmp_path: Pat
|
|||
assert store.get_session(session.name).summary.replayable is True
|
||||
|
||||
|
||||
def test_delete_session_removes_only_the_exact_evidence_tree_and_catalog_row(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
deleted = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
retained = make_legacy_session(sessions, "20260716T205633Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
|
||||
store.delete_session(deleted.name)
|
||||
|
||||
assert not deleted.exists()
|
||||
assert retained.is_dir()
|
||||
with pytest.raises(SessionNotFoundError):
|
||||
store.get_session(deleted.name)
|
||||
assert store.get_session(retained.name).summary.session_id == retained.name
|
||||
|
||||
|
||||
def test_delete_session_rejects_a_catalog_target_outside_its_allowed_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = tmp_path / "repo"
|
||||
sessions = repository / "sessions"
|
||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
||||
store = SessionStore(repository, data_dir=tmp_path / "data")
|
||||
store.reconcile_archive(xgrids_k1_archive_source(sessions))
|
||||
with sqlite3.connect(store.database_path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE observation_sessions SET session_root = ? WHERE session_id = ?",
|
||||
(str(repository), session.name),
|
||||
)
|
||||
|
||||
with pytest.raises(SessionIntegrityError, match="escapes"):
|
||||
store.delete_session(session.name)
|
||||
|
||||
assert session.is_dir()
|
||||
|
||||
|
||||
def test_interrupted_capture_replays_only_committed_prefix_before_partial_raw_tail(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Reference in New Issue