feat(archive): complete recorded session lifecycle

This commit is contained in:
DCCONSTRUCTIONS 2026-07-19 01:07:21 +03:00
parent ffffee1879
commit 71c85e9894
22 changed files with 922 additions and 144 deletions

View File

@ -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);
}}
>
Применить адрес

View File

@ -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,14 +186,19 @@ 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}
className="observation-session-option"
data-deleting={deleting ? "true" : undefined}
>
<button
type="button"
className="nodedc-dropdown-option observation-session-option"
disabled={pending || !session.replayable}
className="nodedc-dropdown-option observation-session-option__open"
disabled={pending || deleting || !session.replayable}
onClick={() => {
void sessions.replay(session.id).then((accepted) => {
if (accepted) close();
@ -208,9 +215,19 @@ export function ObservationSessionSelect({
</span>
</span>
<span className="observation-session-option__state">
{observationSessionVisualLabel(visualState)}
{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);
}}
/>
</>;
}

View File

@ -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>

View File

@ -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,

View File

@ -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;

View File

@ -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,
{

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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;

View File

@ -323,7 +323,15 @@ function SpatialWorkspace({
const floatingSourceMaximized = Boolean(observationLayout.maximizedFloatingSourceId);
const timeline = state?.observationTimeline;
const viewportRef = useRef<HTMLDivElement>(null);
const presentedViewerStatus = rerunPresentationStatus(
const intentionalSourceEnd = !recordedSource && [
"awaiting_external_stop",
"stopping",
"finalizing",
"completed",
].includes(state?.acquisition?.state ?? "");
const presentedViewerStatus = intentionalSourceEnd
? "idle"
: rerunPresentationStatus(
viewerStatus,
recordedSessionGate,
recordedSource,
@ -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

View File

@ -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", {

View File

@ -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,
})),

View File

@ -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());

View File

@ -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,

View File

@ -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,6 +415,16 @@ class SessionRecordingPreparationManager:
self._current_by_session.pop(session_id, None)
return None
snapshot = self._snapshot_locked(job)
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(
@ -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:

View File

@ -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."""

View File

@ -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:
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,

View File

@ -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,

View File

@ -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:

View File

@ -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()

View File

@ -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:

View File

@ -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: