feat(archive): complete recorded session lifecycle
This commit is contained in:
parent
ffffee1879
commit
71c85e9894
|
|
@ -40,7 +40,10 @@ import {
|
||||||
} from "./core/runtime/latestAsyncCommitter";
|
} from "./core/runtime/latestAsyncCommitter";
|
||||||
import { useObservationLayout } from "./core/observation/useObservationLayout";
|
import { useObservationLayout } from "./core/observation/useObservationLayout";
|
||||||
import { recordedObservationSources } from "./core/observation/recordedObservationSources";
|
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 { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||||
import {
|
import {
|
||||||
|
|
@ -136,6 +139,7 @@ export default function App() {
|
||||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
const [activeRoot, setActiveRoot] = useState<RootId | null>(null);
|
||||||
const [sourceUrl, setSourceUrl] = useState("");
|
const [sourceUrl, setSourceUrl] = useState("");
|
||||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||||
|
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||||
const [sourceDraft, setSourceDraft] = useState("");
|
const [sourceDraft, setSourceDraft] = useState("");
|
||||||
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
|
const [sourceWindowOpen, setSourceWindowOpen] = useState(false);
|
||||||
|
|
@ -447,6 +451,7 @@ export default function App() {
|
||||||
// accepting the already-ready archive.
|
// accepting the already-ready archive.
|
||||||
setReplayTransitioning(true);
|
setReplayTransitioning(true);
|
||||||
setRecordedReplay(null);
|
setRecordedReplay(null);
|
||||||
|
setRecordedReplayLabel(null);
|
||||||
setSourceUrl("");
|
setSourceUrl("");
|
||||||
setSourceDraft("");
|
setSourceDraft("");
|
||||||
await new Promise<void>((resolve) => {
|
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) {
|
if (sourceSwitchBlockedRef.current) {
|
||||||
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
|
throw new Error(SPATIAL_SOURCE_SWITCH_BLOCKED_REASON);
|
||||||
}
|
}
|
||||||
setRecordedReplay(launch);
|
setRecordedReplay(launch);
|
||||||
|
setRecordedReplayLabel(session.label);
|
||||||
setSourceUrl(launch.sourceUrl);
|
setSourceUrl(launch.sourceUrl);
|
||||||
setSourceDraft(launch.sourceUrl);
|
setSourceDraft(launch.sourceUrl);
|
||||||
setReplayTransitioning(false);
|
setReplayTransitioning(false);
|
||||||
|
|
@ -475,6 +484,7 @@ export default function App() {
|
||||||
// switch, so it intentionally does not consult the acquisition guard.
|
// switch, so it intentionally does not consult the acquisition guard.
|
||||||
setReplayTransitioning(false);
|
setReplayTransitioning(false);
|
||||||
setRecordedReplay(null);
|
setRecordedReplay(null);
|
||||||
|
setRecordedReplayLabel(null);
|
||||||
setSourceUrl("");
|
setSourceUrl("");
|
||||||
setSourceDraft("");
|
setSourceDraft("");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -634,7 +644,11 @@ export default function App() {
|
||||||
<ApplicationPanel
|
<ApplicationPanel
|
||||||
key={activeDefinition.id}
|
key={activeDefinition.id}
|
||||||
eyebrow={activeDefinition.eyebrow}
|
eyebrow={activeDefinition.eyebrow}
|
||||||
title={activeDefinition.title}
|
title={
|
||||||
|
activeDefinition.kind === "spatial" && replayActive && recordedReplayLabel
|
||||||
|
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
||||||
|
: activeDefinition.title
|
||||||
|
}
|
||||||
description={activeDefinition.description}
|
description={activeDefinition.description}
|
||||||
expanded={workspace.contentExpanded}
|
expanded={workspace.contentExpanded}
|
||||||
onExpandedChange={workspace.setContentExpanded}
|
onExpandedChange={workspace.setContentExpanded}
|
||||||
|
|
@ -646,11 +660,10 @@ export default function App() {
|
||||||
) : activeDefinition.kind === "spatial" ? (
|
) : activeDefinition.kind === "spatial" ? (
|
||||||
<div className="observation-header-tools">
|
<div className="observation-header-tools">
|
||||||
<ObservationSessionSelect
|
<ObservationSessionSelect
|
||||||
limit={3}
|
|
||||||
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
|
disabled={runtime.pendingAction !== null || sourceSwitchBlocked}
|
||||||
blockedReason={sourceSwitchBlockedReason}
|
blockedReason={sourceSwitchBlockedReason}
|
||||||
onReplayBegin={beginRecordedReplaySwitch}
|
onReplayBegin={beginRecordedReplaySwitch}
|
||||||
onReplayAccepted={(_session, launch) => acceptRecordedReplay(launch)}
|
onReplayAccepted={acceptRecordedReplay}
|
||||||
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
|
onReplaySettled={(_session, outcome) => settleRecordedReplaySwitch(outcome)}
|
||||||
/>
|
/>
|
||||||
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
{layoutSaveNotice || workspaceLayoutProfile.error ? (
|
||||||
|
|
@ -739,6 +752,7 @@ export default function App() {
|
||||||
setSourceDraft("");
|
setSourceDraft("");
|
||||||
setSourceUrl("");
|
setSourceUrl("");
|
||||||
setRecordedReplay(null);
|
setRecordedReplay(null);
|
||||||
|
setRecordedReplayLabel(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Сбросить адрес
|
Сбросить адрес
|
||||||
|
|
@ -752,6 +766,7 @@ export default function App() {
|
||||||
if (sourceSwitchBlockedRef.current) return;
|
if (sourceSwitchBlockedRef.current) return;
|
||||||
setSourceUrl(sourceDraft.trim());
|
setSourceUrl(sourceDraft.trim());
|
||||||
setRecordedReplay(null);
|
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 {
|
import {
|
||||||
type ObservationSessionReplayLaunch,
|
type ObservationSessionReplayLaunch,
|
||||||
|
|
@ -93,7 +94,7 @@ function sessionDescription(session: ObservationSessionSummary): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ObservationSessionSelect({
|
export function ObservationSessionSelect({
|
||||||
limit = 3,
|
limit = 100,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
blockedReason = null,
|
blockedReason = null,
|
||||||
onReplayBegin,
|
onReplayBegin,
|
||||||
|
|
@ -116,6 +117,7 @@ export function ObservationSessionSelect({
|
||||||
outcome: ObservationReplayOutcome,
|
outcome: ObservationReplayOutcome,
|
||||||
) => void | Promise<void>;
|
) => void | Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||||
const sessions = useObservationSessions({
|
const sessions = useObservationSessions({
|
||||||
limit,
|
limit,
|
||||||
replayEnabled: blockedReason === null,
|
replayEnabled: blockedReason === null,
|
||||||
|
|
@ -130,7 +132,7 @@ export function ObservationSessionSelect({
|
||||||
: "Сохранённые сессии";
|
: "Сохранённые сессии";
|
||||||
const presentedTriggerCopy = blockedReason ?? triggerCopy;
|
const presentedTriggerCopy = blockedReason ?? triggerCopy;
|
||||||
|
|
||||||
return (
|
return <>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
className="observation-session-select"
|
className="observation-session-select"
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
|
|
@ -184,33 +186,48 @@ export function ObservationSessionSelect({
|
||||||
<div className="observation-session-menu__list">
|
<div className="observation-session-menu__list">
|
||||||
{sessions.items.map((session) => {
|
{sessions.items.map((session) => {
|
||||||
const pending = sessions.replayingSessionId === session.id;
|
const pending = sessions.replayingSessionId === session.id;
|
||||||
|
const deleting = sessions.deletingSessionId === session.id;
|
||||||
const failed = sessions.failedSessionId === session.id;
|
const failed = sessions.failedSessionId === session.id;
|
||||||
const visualState = observationSessionVisualState(session, { pending, failed });
|
const visualState = observationSessionVisualState(session, { pending, failed });
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
type="button"
|
className="observation-session-option"
|
||||||
className="nodedc-dropdown-option observation-session-option"
|
data-deleting={deleting ? "true" : undefined}
|
||||||
disabled={pending || !session.replayable}
|
|
||||||
onClick={() => {
|
|
||||||
void sessions.replay(session.id).then((accepted) => {
|
|
||||||
if (accepted) close();
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="nodedc-dropdown-option__icon">
|
<button
|
||||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
type="button"
|
||||||
</span>
|
className="nodedc-dropdown-option observation-session-option__open"
|
||||||
<span className="nodedc-dropdown-option__body">
|
disabled={pending || deleting || !session.replayable}
|
||||||
<span className="nodedc-dropdown-option__label">{session.label}</span>
|
onClick={() => {
|
||||||
<span className="nodedc-dropdown-option__description">
|
void sessions.replay(session.id).then((accepted) => {
|
||||||
{sessionDescription(session)}
|
if (accepted) close();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="nodedc-dropdown-option__icon">
|
||||||
|
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
<span className="nodedc-dropdown-option__body">
|
||||||
<span className="observation-session-option__state">
|
<span className="nodedc-dropdown-option__label">{session.label}</span>
|
||||||
{observationSessionVisualLabel(visualState)}
|
<span className="nodedc-dropdown-option__description">
|
||||||
</span>
|
{sessionDescription(session)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
@ -242,5 +259,29 @@ export function ObservationSessionSelect({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Dropdown>
|
</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 (
|
return (
|
||||||
<div className="observation-media__empty">
|
<div className="observation-media__empty">
|
||||||
<Icon name={sourceIcon[source.modality]} size={20} />
|
<Icon name={sourceIcon[source.modality]} size={20} />
|
||||||
<strong>{observationSourceStatusLabel(source)}</strong>
|
<strong>
|
||||||
|
{source.modality === "video" && source.activation && !source.activation.selected
|
||||||
|
? "Источник отключён"
|
||||||
|
: observationSourceStatusLabel(source)}
|
||||||
|
</strong>
|
||||||
<span>
|
<span>
|
||||||
{source.modality === "video"
|
{source.modality === "video"
|
||||||
? source.activation?.controllable
|
? source.activation?.controllable
|
||||||
? source.activation.selected
|
? source.activation.selected
|
||||||
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
|
? "Повторите подключение — локальный адаптер перезапустит выбранную камеру"
|
||||||
: "Откройте канал — локальный адаптер подключит выбранную камеру"
|
: "Канал остановлен оператором; при необходимости его можно открыть снова"
|
||||||
: "Канал известен, browser-preview сейчас недоступен"
|
: "Канал известен, browser-preview сейчас недоступен"
|
||||||
: source.description}
|
: source.description}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -393,15 +393,27 @@ async function mountVerifiedRecordedEpoch(
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
): Promise<() => void> {
|
): Promise<() => void> {
|
||||||
const descriptor = epoch.descriptor;
|
const descriptor = epoch.descriptor;
|
||||||
if (
|
if (!descriptor.mediaType.startsWith("video/mp4;") || !video.canPlayType(descriptor.mediaType)) {
|
||||||
descriptor.mediaType === "video/mp4" ||
|
|
||||||
!globalThis.MediaSource ||
|
|
||||||
!MediaSource.isTypeSupported(descriptor.mediaType)
|
|
||||||
) {
|
|
||||||
throw new Error("Archived camera codec is not supported");
|
throw new Error("Archived camera codec is not supported");
|
||||||
}
|
}
|
||||||
const mediaSource = new MediaSource();
|
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
const objectUrl = URL.createObjectURL(mediaSource);
|
|
||||||
|
// 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 = () => {
|
const cleanup = () => {
|
||||||
video.pause();
|
video.pause();
|
||||||
video.removeAttribute("src");
|
video.removeAttribute("src");
|
||||||
|
|
@ -409,39 +421,8 @@ async function mountVerifiedRecordedEpoch(
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
};
|
};
|
||||||
video.src = objectUrl;
|
video.src = objectUrl;
|
||||||
|
video.load();
|
||||||
try {
|
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(
|
await waitForSeekableArchive(
|
||||||
video,
|
video,
|
||||||
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
descriptor.timelineEndSeconds - descriptor.timelineStartSeconds,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
// A device-agnostic frontend safety policy. Sixteen channels covers multi-rig
|
// A device-agnostic frontend safety policy. Sixteen channels covers multi-rig
|
||||||
// vehicles while the independent byte/concurrency limits keep admission
|
// vehicles while the independent byte/concurrency limits keep admission
|
||||||
// bounded. OPFS-backed sealed generations are the planned scaling path beyond
|
// bounded. One real accepted archive is ~163 MiB for a single camera, so the
|
||||||
// this in-memory laboratory policy.
|
// 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_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_RECORDED_SESSION_CAMERA_BYTES = 512 * 1024 * 1024;
|
||||||
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
|
export const MAX_CONCURRENT_RECORDED_CAMERA_PREPARATIONS = 1;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1110,6 +1110,46 @@ export async function fetchObservationSessionCatalog({
|
||||||
return decodeObservationSessionCatalog(body);
|
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(
|
export async function replayObservationSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,18 @@ export function observationPresentationSourceAfterLayoutApply(
|
||||||
return mode === "preserve" ? currentSourceId : null;
|
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 {
|
export interface ObservationLayoutController {
|
||||||
visibleSourceIds: ReadonlySet<string>;
|
visibleSourceIds: ReadonlySet<string>;
|
||||||
focusedSourceId: string | null;
|
focusedSourceId: string | null;
|
||||||
|
|
@ -230,7 +242,25 @@ export function useObservationLayout(
|
||||||
}
|
}
|
||||||
const desired = desiredSnapshotRef.current;
|
const desired = desiredSnapshotRef.current;
|
||||||
if (desired) {
|
if (desired) {
|
||||||
|
const previousIdentity = initializedCatalog.current;
|
||||||
initializedCatalog.current = identity;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (initializedCatalog.current === identity) return;
|
if (initializedCatalog.current === identity) return;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
decodeObservationSessionPreparation,
|
decodeObservationSessionPreparation,
|
||||||
|
deleteObservationSession,
|
||||||
fetchObservationSessionCatalog,
|
fetchObservationSessionCatalog,
|
||||||
fetchObservationSessionPreparation,
|
fetchObservationSessionPreparation,
|
||||||
replayObservationSession,
|
replayObservationSession,
|
||||||
|
|
@ -32,9 +33,11 @@ export interface ObservationSessionsController {
|
||||||
preparation: ObservationSessionPreparation | null;
|
preparation: ObservationSessionPreparation | null;
|
||||||
replayProgress: ObservationReplayProgress | null;
|
replayProgress: ObservationReplayProgress | null;
|
||||||
failedSessionId: string | null;
|
failedSessionId: string | null;
|
||||||
|
deletingSessionId: string | null;
|
||||||
refresh: () => Promise<boolean>;
|
refresh: () => Promise<boolean>;
|
||||||
replay: (sessionId: string) => Promise<boolean>;
|
replay: (sessionId: string) => Promise<boolean>;
|
||||||
retry: () => Promise<boolean>;
|
retry: () => Promise<boolean>;
|
||||||
|
remove: (sessionId: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ObservationReplayAttempt {
|
export interface ObservationReplayAttempt {
|
||||||
|
|
@ -360,7 +363,7 @@ export function clearObservationReplayPreparation(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useObservationSessions({
|
export function useObservationSessions({
|
||||||
limit = 3,
|
limit = 100,
|
||||||
replayEnabled = true,
|
replayEnabled = true,
|
||||||
onReplayBegin,
|
onReplayBegin,
|
||||||
onReplayAccepted,
|
onReplayAccepted,
|
||||||
|
|
@ -389,12 +392,12 @@ export function useObservationSessions({
|
||||||
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
|
const [preparation, setPreparation] = useState<ObservationSessionPreparation | null>(null);
|
||||||
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
|
const [replayProgress, setReplayProgress] = useState<ObservationReplayProgress | null>(null);
|
||||||
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
|
const [failedSessionId, setFailedSessionId] = useState<string | null>(null);
|
||||||
|
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
|
||||||
const mounted = useRef(true);
|
const mounted = useRef(true);
|
||||||
const catalogSequence = useRef(0);
|
const catalogSequence = useRef(0);
|
||||||
const reattachStarted = useRef(false);
|
const reattachStarted = useRef(false);
|
||||||
const replayEnabledRef = useRef(replayEnabled);
|
const replayEnabledRef = useRef(replayEnabled);
|
||||||
replayEnabledRef.current = replayEnabled;
|
replayEnabledRef.current = replayEnabled;
|
||||||
const preparationPollSequence = useRef(0);
|
|
||||||
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
|
const replayCoordinator = useRef<ObservationReplayCoordinator | null>(null);
|
||||||
if (replayCoordinator.current === null) {
|
if (replayCoordinator.current === null) {
|
||||||
replayCoordinator.current = createObservationReplayCoordinator();
|
replayCoordinator.current = createObservationReplayCoordinator();
|
||||||
|
|
@ -409,26 +412,30 @@ export function useObservationSessions({
|
||||||
setReplayingSessionId(null);
|
setReplayingSessionId(null);
|
||||||
setReplayProgress(null);
|
setReplayProgress(null);
|
||||||
}, [replayEnabled]);
|
}, [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;
|
const sequence = ++catalogSequence.current;
|
||||||
setState("loading");
|
if (foreground) setState("loading");
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
|
const catalog = await fetchObservationSessionCatalog({ limit: safeLimit });
|
||||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||||
setItems(catalog.items.slice(0, safeLimit));
|
setItems(catalog.items.slice(0, safeLimit));
|
||||||
setState("ready");
|
setState("ready");
|
||||||
|
setError(null);
|
||||||
return true;
|
return true;
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
if (!mounted.current || sequence !== catalogSequence.current) return false;
|
||||||
setState("error");
|
if (foreground) setState("error");
|
||||||
setError(errorMessage(loadError));
|
setError(errorMessage(loadError));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}, [safeLimit]);
|
}, [safeLimit]);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => loadCatalog(true), [loadCatalog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mounted.current = true;
|
mounted.current = true;
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|
@ -439,44 +446,21 @@ export function useObservationSessions({
|
||||||
};
|
};
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const catalogHasActivePreparation = items.some((item) => (
|
|
||||||
item.preparation !== null &&
|
|
||||||
["queued", "validating", "exporting", "finalizing"].includes(item.preparation.state)
|
|
||||||
));
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state !== "ready" || !catalogHasActivePreparation) return;
|
if (state !== "ready") return;
|
||||||
const sequence = ++preparationPollSequence.current;
|
let disposed = false;
|
||||||
const controller = new AbortController();
|
let timer = 0;
|
||||||
const timer = window.setTimeout(async () => {
|
const poll = async () => {
|
||||||
try {
|
if (disposed) return;
|
||||||
const catalog = await fetchObservationSessionCatalog({
|
await loadCatalog(false);
|
||||||
limit: safeLimit,
|
if (!disposed) timer = window.setTimeout(() => void poll(), 2_000);
|
||||||
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();
|
|
||||||
};
|
};
|
||||||
}, [catalogHasActivePreparation, items, safeLimit, state]);
|
timer = window.setTimeout(() => void poll(), 2_000);
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [loadCatalog, state]);
|
||||||
|
|
||||||
const executeReplay = useCallback(async (
|
const executeReplay = useCallback(async (
|
||||||
session: ObservationSessionSummary,
|
session: ObservationSessionSummary,
|
||||||
|
|
@ -606,6 +590,24 @@ export function useObservationSessions({
|
||||||
return replay(failedSessionId);
|
return replay(failedSessionId);
|
||||||
}, [failedSessionId, replay]);
|
}, [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 {
|
return {
|
||||||
items,
|
items,
|
||||||
state,
|
state,
|
||||||
|
|
@ -614,8 +616,10 @@ export function useObservationSessions({
|
||||||
preparation,
|
preparation,
|
||||||
replayProgress,
|
replayProgress,
|
||||||
failedSessionId,
|
failedSessionId,
|
||||||
|
deletingSessionId,
|
||||||
refresh,
|
refresh,
|
||||||
replay,
|
replay,
|
||||||
retry,
|
retry,
|
||||||
|
remove,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,9 +117,42 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.observation-session-option {
|
.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;
|
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 {
|
.observation-session-option i {
|
||||||
display: block;
|
display: block;
|
||||||
width: 0.45rem;
|
width: 0.45rem;
|
||||||
|
|
@ -217,6 +250,10 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.observation-session-delete-error {
|
||||||
|
color: rgb(var(--nodedc-danger-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.workspace-layout-feedback {
|
.workspace-layout-feedback {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
|
||||||
|
|
@ -323,11 +323,19 @@ function SpatialWorkspace({
|
||||||
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
|
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
|
||||||
const timeline = state?.observationTimeline;
|
const timeline = state?.observationTimeline;
|
||||||
const viewportRef = useRef<HTMLDivElement>(null);
|
const viewportRef = useRef<HTMLDivElement>(null);
|
||||||
const presentedViewerStatus = rerunPresentationStatus(
|
const intentionalSourceEnd = !recordedSource && [
|
||||||
viewerStatus,
|
"awaiting_external_stop",
|
||||||
recordedSessionGate,
|
"stopping",
|
||||||
recordedSource,
|
"finalizing",
|
||||||
);
|
"completed",
|
||||||
|
].includes(state?.acquisition?.state ?? "");
|
||||||
|
const presentedViewerStatus = intentionalSourceEnd
|
||||||
|
? "idle"
|
||||||
|
: rerunPresentationStatus(
|
||||||
|
viewerStatus,
|
||||||
|
recordedSessionGate,
|
||||||
|
recordedSource,
|
||||||
|
);
|
||||||
|
|
||||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||||
setViewerStatus(status);
|
setViewerStatus(status);
|
||||||
|
|
@ -389,7 +397,7 @@ function SpatialWorkspace({
|
||||||
}, [observationLayout.setViewportSize]);
|
}, [observationLayout.setViewportSize]);
|
||||||
|
|
||||||
const viewerStatusLabel = {
|
const viewerStatusLabel = {
|
||||||
idle: "Источник не назначен",
|
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
|
||||||
loading: "Подключение",
|
loading: "Подключение",
|
||||||
ready: "Визуализатор готов",
|
ready: "Визуализатор готов",
|
||||||
error: "Ошибка источника",
|
error: "Ошибка источника",
|
||||||
|
|
@ -500,7 +508,7 @@ function SpatialWorkspace({
|
||||||
>
|
>
|
||||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||||
{viewerMessage ? <small>{viewerMessage}</small> : null}
|
{!intentionalSourceEnd && viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -679,6 +687,7 @@ function CameraSourceCard({
|
||||||
: selectedPeer
|
: selectedPeer
|
||||||
? "Переключить"
|
? "Переключить"
|
||||||
: "Открыть канал";
|
: "Открыть канал";
|
||||||
|
const sourceDisconnected = Boolean(source.activation && !source.activation.selected && visible);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
|
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
|
||||||
|
|
@ -686,8 +695,11 @@ function CameraSourceCard({
|
||||||
<span>{source.label}</span>
|
<span>{source.label}</span>
|
||||||
<div className="camera-slot__head-actions">
|
<div className="camera-slot__head-actions">
|
||||||
<span className="camera-slot__status">
|
<span className="camera-slot__status">
|
||||||
<i data-availability={source.availability} aria-hidden="true" />
|
<i
|
||||||
{observationSourceStatusLabel(source)}
|
data-availability={sourceDisconnected ? "unverified" : source.availability}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{sourceDisconnected ? "Источник отключён" : observationSourceStatusLabel(source)}
|
||||||
</span>
|
</span>
|
||||||
{source.activation && !focused ? (
|
{source.activation && !focused ? (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ let decodeObservationSessionCatalog;
|
||||||
let decodeObservationSessionReplay;
|
let decodeObservationSessionReplay;
|
||||||
let decodeObservationSessionPreparation;
|
let decodeObservationSessionPreparation;
|
||||||
let fetchObservationSessionCatalog;
|
let fetchObservationSessionCatalog;
|
||||||
|
let deleteObservationSession;
|
||||||
let replayObservationSession;
|
let replayObservationSession;
|
||||||
let fetchObservationSessionPreparation;
|
let fetchObservationSessionPreparation;
|
||||||
let cancelObservationSessionPreparation;
|
let cancelObservationSessionPreparation;
|
||||||
|
|
@ -34,6 +35,7 @@ before(async () => {
|
||||||
decodeObservationSessionReplay,
|
decodeObservationSessionReplay,
|
||||||
decodeObservationSessionPreparation,
|
decodeObservationSessionPreparation,
|
||||||
fetchObservationSessionCatalog,
|
fetchObservationSessionCatalog,
|
||||||
|
deleteObservationSession,
|
||||||
replayObservationSession,
|
replayObservationSession,
|
||||||
fetchObservationSessionPreparation,
|
fetchObservationSessionPreparation,
|
||||||
cancelObservationSessionPreparation,
|
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);
|
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", () => {
|
test("session catalog exposes authoritative background preparation state", () => {
|
||||||
const catalog = decodeObservationSessionCatalog({
|
const catalog = decodeObservationSessionCatalog({
|
||||||
items: [session({
|
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 () => {
|
test("replay API accepts only a same-origin seekable recording descriptor", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const launch = await replayObservationSession("session-20260716T205632Z", {
|
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 {
|
const {
|
||||||
MAX_RECORDED_CAMERA_SOURCES,
|
MAX_RECORDED_CAMERA_SOURCES,
|
||||||
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||||
|
|
@ -86,10 +86,10 @@ test("camera admission enforces independent 16/128/512 MiB limits before fetchin
|
||||||
recordedCameraDescriptorPreflight,
|
recordedCameraDescriptorPreflight,
|
||||||
} = admission;
|
} = admission;
|
||||||
assert.equal(MAX_RECORDED_CAMERA_SOURCES, 16);
|
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(MAX_RECORDED_SESSION_CAMERA_BYTES, 512 * 1024 * 1024);
|
||||||
assert.equal(recordedCameraDescriptorPreflight(
|
assert.equal(recordedCameraDescriptorPreflight(
|
||||||
Array.from({ length: 4 }, (_, index) => ({
|
Array.from({ length: 2 }, (_, index) => ({
|
||||||
id: `camera.${index}`,
|
id: `camera.${index}`,
|
||||||
byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
byteLength: MAX_RECORDED_MEDIA_SOURCE_BYTES,
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ let saveObservationWorkspaceLayoutProfile;
|
||||||
let WorkspaceLayoutApiError;
|
let WorkspaceLayoutApiError;
|
||||||
let WorkspaceLayoutContractError;
|
let WorkspaceLayoutContractError;
|
||||||
let observationPresentationSourceAfterLayoutApply;
|
let observationPresentationSourceAfterLayoutApply;
|
||||||
|
let visibleSourceIdsAfterRecordedCatalogActivation;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({
|
server = await createServer({
|
||||||
|
|
@ -31,9 +32,10 @@ before(async () => {
|
||||||
WorkspaceLayoutApiError,
|
WorkspaceLayoutApiError,
|
||||||
WorkspaceLayoutContractError,
|
WorkspaceLayoutContractError,
|
||||||
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
|
} = 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 () => {
|
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 () => {
|
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import re
|
||||||
import secrets
|
import secrets
|
||||||
import stat
|
import stat
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterable, Iterator
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -175,6 +175,36 @@ class RecordedMediaInspector:
|
||||||
self._cache[key] = _CachedManifest(identity=identity, manifest=manifest)
|
self._cache[key] = _CachedManifest(identity=identity, manifest=manifest)
|
||||||
return 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(
|
def restore_prepared(
|
||||||
self,
|
self,
|
||||||
artifact: RecordedMediaArtifact,
|
artifact: RecordedMediaArtifact,
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,13 @@ class _WorkerGeneration:
|
||||||
worker: threading.Thread | None = None
|
worker: threading.Thread | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _LaunchReservation:
|
||||||
|
preparation_id: str
|
||||||
|
release: Callable[[], None]
|
||||||
|
timer: threading.Timer
|
||||||
|
|
||||||
|
|
||||||
class SessionRecordingPreparationManager:
|
class SessionRecordingPreparationManager:
|
||||||
"""One bounded, process-owned conversion worker for durable recordings.
|
"""One bounded, process-owned conversion worker for durable recordings.
|
||||||
|
|
||||||
|
|
@ -131,6 +138,7 @@ class SessionRecordingPreparationManager:
|
||||||
self._ready_restorer = ready_restorer
|
self._ready_restorer = ready_restorer
|
||||||
self._guard = threading.RLock()
|
self._guard = threading.RLock()
|
||||||
self._current_by_session: dict[str, _PreparationJob] = {}
|
self._current_by_session: dict[str, _PreparationJob] = {}
|
||||||
|
self._launch_reservations: dict[str, _LaunchReservation] = {}
|
||||||
self._closed = True
|
self._closed = True
|
||||||
self._generation_counter = 0
|
self._generation_counter = 0
|
||||||
self._active_generation: _WorkerGeneration | None = None
|
self._active_generation: _WorkerGeneration | None = None
|
||||||
|
|
@ -379,10 +387,7 @@ class SessionRecordingPreparationManager:
|
||||||
if pinned is None:
|
if pinned is None:
|
||||||
return None
|
return None
|
||||||
snapshot, release = pinned
|
snapshot, release = pinned
|
||||||
timer = threading.Timer(lease_seconds, release)
|
self._reserve_launch_lease(snapshot, release, lease_seconds)
|
||||||
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
|
||||||
timer.daemon = True
|
|
||||||
timer.start()
|
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
def pin_ready(
|
def pin_ready(
|
||||||
|
|
@ -393,6 +398,7 @@ class SessionRecordingPreparationManager:
|
||||||
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
|
) -> tuple[RecordingPreparationSnapshot, Callable[[], None]] | None:
|
||||||
"""Cheaply lease the exact already-validated ready generation."""
|
"""Cheaply lease the exact already-validated ready generation."""
|
||||||
|
|
||||||
|
launch_release: Callable[[], None] | None = None
|
||||||
with self._guard:
|
with self._guard:
|
||||||
job = self._current_by_session.get(session_id)
|
job = self._current_by_session.get(session_id)
|
||||||
if (
|
if (
|
||||||
|
|
@ -409,7 +415,17 @@ class SessionRecordingPreparationManager:
|
||||||
self._current_by_session.pop(session_id, None)
|
self._current_by_session.pop(session_id, None)
|
||||||
return None
|
return None
|
||||||
snapshot = self._snapshot_locked(job)
|
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(
|
def reserve_ready(
|
||||||
self,
|
self,
|
||||||
|
|
@ -426,12 +442,26 @@ class SessionRecordingPreparationManager:
|
||||||
if pinned is None:
|
if pinned is None:
|
||||||
return None
|
return None
|
||||||
snapshot, release = pinned
|
snapshot, release = pinned
|
||||||
timer = threading.Timer(lease_seconds, release)
|
self._reserve_launch_lease(snapshot, release, lease_seconds)
|
||||||
timer.name = f"missioncore-recording-launch-lease-{snapshot.preparation_id}"
|
|
||||||
timer.daemon = True
|
|
||||||
timer.start()
|
|
||||||
return snapshot
|
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:
|
def cancel(self, session_id: str, *, preparation_id: str | None = None) -> bool:
|
||||||
with self._guard:
|
with self._guard:
|
||||||
job = self._current_by_session.get(session_id)
|
job = self._current_by_session.get(session_id)
|
||||||
|
|
@ -447,11 +477,30 @@ class SessionRecordingPreparationManager:
|
||||||
self._transition_locked(job, "cancelled", job.progress)
|
self._transition_locked(job, "cancelled", job.progress)
|
||||||
return True
|
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:
|
def close(self, *, timeout: float = 5.0) -> None:
|
||||||
with self._guard:
|
with self._guard:
|
||||||
if self._closed:
|
if self._closed:
|
||||||
return
|
return
|
||||||
self._closed = True
|
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():
|
for job in self._current_by_session.values():
|
||||||
if job.state in ACTIVE_PREPARATION_STATES:
|
if job.state in ACTIVE_PREPARATION_STATES:
|
||||||
job.interrupted_by_restart = not job.cancelled_by_operator
|
job.interrupted_by_restart = not job.cancelled_by_operator
|
||||||
|
|
@ -466,9 +515,46 @@ class SessionRecordingPreparationManager:
|
||||||
pending.stop_event.set()
|
pending.stop_event.set()
|
||||||
self._pending_generation = None
|
self._pending_generation = None
|
||||||
worker = None if active is None else active.worker
|
worker = None if active is None else active.worker
|
||||||
|
for reservation in reservations:
|
||||||
|
reservation.release()
|
||||||
if worker is not None:
|
if worker is not None:
|
||||||
worker.join(timeout=max(0.0, timeout))
|
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:
|
def _run_generation(self, generation: _WorkerGeneration) -> None:
|
||||||
work_queue = generation.work_queue
|
work_queue = generation.work_queue
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,30 @@ class SessionRecordingMaterializer:
|
||||||
self._increment_pin_locked(recording.session_id)
|
self._increment_pin_locked(recording.session_id)
|
||||||
return self._release_callback(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:
|
def __call__(self, command: ReplayCommand) -> MaterializedRecording:
|
||||||
"""Alias for :meth:`materialize`, suitable for the web API protocol."""
|
"""Alias for :meth:`materialize`, suitable for the web API protocol."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,15 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import stat
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from k1link.artifacts import utc_now_iso
|
from k1link.artifacts import utc_now_iso
|
||||||
|
|
||||||
|
|
@ -142,11 +145,18 @@ class SessionStore:
|
||||||
candidates = source.discover(allowed_root)
|
candidates = source.discover(allowed_root)
|
||||||
imported: list[str] = []
|
imported: list[str] = []
|
||||||
for candidate in candidates:
|
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)
|
imported.append(candidate.session_id)
|
||||||
with self._lock, self._connect() as connection:
|
with self._lock, self._connect() as connection:
|
||||||
connection.execute("BEGIN IMMEDIATE")
|
connection.execute("BEGIN IMMEDIATE")
|
||||||
discovered = set(imported)
|
discovered = {candidate.session_id for candidate in candidates}
|
||||||
indexed = connection.execute(
|
indexed = connection.execute(
|
||||||
"SELECT session_id FROM observation_sessions "
|
"SELECT session_id FROM observation_sessions "
|
||||||
"WHERE plugin_id = ? AND archive_id = ? AND allowed_root = ?",
|
"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)
|
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(
|
def prepare_replay(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,79 @@ def build_session_router(
|
||||||
detail="Некорректный идентификатор сессии.",
|
detail="Некорректный идентификатор сессии.",
|
||||||
) from exc
|
) 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")
|
@router.post("/api/v1/observation-sessions/{session_id}/replay")
|
||||||
async def replay_observation_session(
|
async def replay_observation_session(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ from k1link.sessions import (
|
||||||
RecordedMediaInspector,
|
RecordedMediaInspector,
|
||||||
ReplayCommand,
|
ReplayCommand,
|
||||||
SessionIntegrityError,
|
SessionIntegrityError,
|
||||||
|
SessionNotFoundError,
|
||||||
SessionRecordingMaterializer,
|
SessionRecordingMaterializer,
|
||||||
SessionRecordingPreparationManager,
|
SessionRecordingPreparationManager,
|
||||||
SessionStore,
|
SessionStore,
|
||||||
|
|
@ -246,6 +247,116 @@ def test_session_router_lists_details_and_dispatches_opaque_replay(tmp_path: Pat
|
||||||
assert_no_local_paths(value, repository)
|
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(
|
def test_session_router_returns_seekable_recording_and_serves_byte_ranges(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -433,3 +433,38 @@ def test_launch_reservation_blocks_eviction_until_lease_expires(tmp_path: Path)
|
||||||
assert not first_recording.path.exists()
|
assert not first_recording.path.exists()
|
||||||
finally:
|
finally:
|
||||||
manager.close()
|
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
|
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(
|
def test_materializer_rejects_changed_digest_bound_secondary_artifact(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,39 @@ def test_catalog_reconciles_sessions_removed_from_one_evidence_root(tmp_path: Pa
|
||||||
assert store.list_recent().items == ()
|
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:
|
def test_catalog_uses_valid_project_name_as_session_display_name(tmp_path: Path) -> None:
|
||||||
sessions = tmp_path / "sessions"
|
sessions = tmp_path / "sessions"
|
||||||
session = make_legacy_session(sessions, "20260716T205632Z_viewer_live")
|
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
|
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(
|
def test_interrupted_capture_replays_only_committed_prefix_before_partial_raw_tail(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue