feat(observatory): add bounded M5.1 session review

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 17:29:39 +03:00
parent 81fdf6904a
commit e6a9846167
14 changed files with 1099 additions and 2 deletions
+2
View File
@@ -806,6 +806,8 @@ export default function App() {
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control
) : activeDefinition.kind === "observatory" ? (
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
) : activeDefinition.root === "system" ? (
<SystemWorkspaceSelector
value={activeDefinition.id}
@@ -0,0 +1,145 @@
import {
fetchObservationSessionCatalog,
type ObservationLabInstance,
type ObservationSessionFetch,
type ObservationSessionSummary,
} from "../observation/sessionArchive";
export interface ObservatoryEvidence {
readonly sessionId: string;
readonly label: string;
readonly status: ObservationSessionSummary["status"];
readonly publishedAtUtc: string;
readonly lab: ObservationLabInstance;
}
export interface ObservatorySession {
readonly source: ObservationSessionSummary;
readonly evidence: readonly ObservatoryEvidence[];
}
export interface ObservatoryCatalog {
readonly items: readonly ObservatorySession[];
readonly unresolvedEvidence: readonly ObservatoryEvidence[];
readonly window: {
readonly limit: number;
readonly sourceCount: number;
readonly laboratoryCount: number;
readonly sourceLimitReached: boolean;
readonly laboratoryLimitReached: boolean;
};
}
export class ObservatoryCatalogContractError extends Error {
constructor(message: string) {
super(message);
this.name = "ObservatoryCatalogContractError";
}
}
function newestFirst(left: string, right: string): number {
return Date.parse(right) - Date.parse(left);
}
function boundedLimit(limit: number): number {
return Number.isFinite(limit)
? Math.min(100, Math.max(1, Math.floor(limit)))
: 100;
}
function evidenceFromSession(
session: ObservationSessionSummary,
): ObservatoryEvidence {
if (!session.lab) {
throw new ObservatoryCatalogContractError(
`LAB-каталог вернул сессию ${session.id} без типизированной связи с источником.`,
);
}
return {
sessionId: session.id,
label: session.label,
status: session.status,
publishedAtUtc: session.lab.publishedAtUtc,
lab: session.lab,
};
}
function sortEvidence(
evidence: readonly ObservatoryEvidence[],
): ObservatoryEvidence[] {
return [...evidence].sort((left, right) =>
newestFirst(left.publishedAtUtc, right.publishedAtUtc)
|| left.sessionId.localeCompare(right.sessionId));
}
export function buildObservatoryCatalog(
sourceSessions: readonly ObservationSessionSummary[],
laboratorySessions: readonly ObservationSessionSummary[],
limit = 100,
): ObservatoryCatalog {
const safeLimit = boundedLimit(limit);
const sources = new Map<string, ObservationSessionSummary>();
for (const source of sourceSessions) {
if (source.lab !== null) {
throw new ObservatoryCatalogContractError(
`Каталог источников вернул LAB-сессию ${source.id}.`,
);
}
sources.set(source.id, source);
}
const linked = new Map<string, ObservatoryEvidence[]>();
const unresolvedEvidence: ObservatoryEvidence[] = [];
for (const laboratorySession of laboratorySessions) {
const evidence = evidenceFromSession(laboratorySession);
const sourceId = evidence.lab.sourceSessionId;
if (!sources.has(sourceId)) {
// Both API projections are bounded newest-first windows without a
// cursor. Absence from the source window is not proof of a broken
// relationship and must not be presented as an integrity fault.
unresolvedEvidence.push(evidence);
continue;
}
const current = linked.get(sourceId) ?? [];
current.push(evidence);
linked.set(sourceId, current);
}
const items = [...sources.values()]
.sort((left, right) =>
newestFirst(left.startedAtUtc, right.startedAtUtc)
|| left.id.localeCompare(right.id))
.map((source) => ({
source,
evidence: sortEvidence(linked.get(source.id) ?? []),
}));
return {
items,
unresolvedEvidence: sortEvidence(unresolvedEvidence),
window: {
limit: safeLimit,
sourceCount: sourceSessions.length,
laboratoryCount: laboratorySessions.length,
sourceLimitReached: sourceSessions.length >= safeLimit,
laboratoryLimitReached: laboratorySessions.length >= safeLimit,
},
};
}
export async function fetchObservatoryCatalog({
signal,
limit = 100,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
limit?: number;
fetcher?: ObservationSessionFetch;
} = {}): Promise<ObservatoryCatalog> {
const safeLimit = boundedLimit(limit);
const [sourceCatalog, laboratoryCatalog] = await Promise.all([
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "source", fetcher }),
fetchObservationSessionCatalog({ signal, limit: safeLimit, scope: "laboratory", fetcher }),
]);
return buildObservatoryCatalog(sourceCatalog.items, laboratoryCatalog.items, safeLimit);
}
@@ -0,0 +1,63 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
fetchObservatoryCatalog,
type ObservatoryCatalog,
} from "./catalog";
export type ObservatoryCatalogState =
| "idle"
| "loading"
| "ready"
| "refreshing"
| "error";
export interface ObservatoryCatalogController {
readonly catalog: ObservatoryCatalog | null;
readonly state: ObservatoryCatalogState;
readonly error: string | null;
readonly refresh: () => void;
}
function errorMessage(error: unknown): string {
return error instanceof Error && error.message.trim()
? error.message
: "Каталог Обсерватории недоступен.";
}
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
export function useObservatoryCatalog(): ObservatoryCatalogController {
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
const [state, setState] = useState<ObservatoryCatalogState>("idle");
const [error, setError] = useState<string | null>(null);
const [generation, setGeneration] = useState(0);
const catalogRef = useRef<ObservatoryCatalog | null>(null);
useEffect(() => {
const controller = new AbortController();
setState(catalogRef.current ? "refreshing" : "loading");
setError(null);
void fetchObservatoryCatalog({ signal: controller.signal })
.then((next) => {
if (controller.signal.aborted) return;
catalogRef.current = next;
setCatalog(next);
setState("ready");
})
.catch((loadError: unknown) => {
if (controller.signal.aborted || isAbortError(loadError)) return;
setError(errorMessage(loadError));
setState("error");
});
return () => controller.abort();
}, [generation]);
const refresh = useCallback(() => {
setGeneration((current) => current + 1);
}, []);
return { catalog, state, error, refresh };
}
+13 -1
View File
@@ -23,7 +23,8 @@ export type WorkspaceKind =
| "datasets"
| "artifact-health"
| "lab-archive"
| "simulations";
| "simulations"
| "observatory";
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
@@ -166,6 +167,17 @@ export const workspaces: WorkspaceDefinition[] = [
kind: "simulations",
groups: [],
},
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Обсерватория восприятия",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
},
{
id: "contour-health",
root: "fleet",
+1
View File
@@ -21,6 +21,7 @@
@import "./styles/device.css";
@import "./styles/responsive.css";
@import "./styles/observation.css";
@import "./styles/observatory.css";
@import "./styles/environment-settings.css";
@import "./styles/system-telemetry.css";
@import "./styles/artifact-health.css";
@@ -0,0 +1,207 @@
.observatory-workspace {
display: grid;
gap: 1rem;
min-width: 0;
min-height: 100%;
padding: 1rem;
}
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice,
.observatory-session-summary > header,
.observatory-evidence > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice {
flex-wrap: wrap;
}
.observatory-lead > div,
.observatory-catalog-bar__copy,
.observatory-session-summary header > div,
.observatory-evidence header > div {
min-width: 0;
}
.observatory-lead h2,
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-evidence h3 {
margin: 0.3rem 0 0;
}
.observatory-lead p,
.observatory-catalog-bar p,
.observatory-state p,
.observatory-evidence-empty p {
max-width: 52rem;
margin: 0.35rem 0 0;
color: var(--nodedc-text-muted);
line-height: 1.5;
}
.observatory-catalog-bar__copy {
flex: 1 1 auto;
}
.observatory-catalog-bar__controls {
flex: 1 1 32rem;
justify-content: flex-end;
}
.observatory-catalog-bar__controls .nodedc-select-anchor {
flex: 1 1 22rem;
min-width: min(28rem, 100%);
}
.observatory-state {
display: grid;
min-height: 19rem;
place-items: center;
align-content: center;
gap: 0.7rem;
text-align: center;
}
.observatory-state h3 {
max-width: 42rem;
margin: 0;
}
.observatory-session-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(24rem, 100%), 1fr));
gap: 1rem;
align-items: start;
}
.observatory-session-summary,
.observatory-evidence {
display: grid;
gap: 1rem;
}
.observatory-session-summary dl,
.observatory-evidence-list dl {
display: grid;
gap: 0.75rem;
margin: 0;
}
.observatory-session-summary dl > div,
.observatory-evidence-list dl > div {
display: grid;
grid-template-columns: minmax(7.5rem, 0.42fr) minmax(0, 1fr);
gap: 0.85rem;
align-items: baseline;
}
.observatory-session-summary dt,
.observatory-evidence-list dt {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-session-summary dd,
.observatory-evidence-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
}
.observatory-session-summary code,
.observatory-evidence-list code {
font-size: var(--nodedc-font-size-xs);
}
.observatory-modalities {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
color: var(--nodedc-text-muted);
}
.observatory-evidence-list {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.observatory-evidence-card {
display: grid;
gap: 0.85rem;
}
.observatory-evidence-list strong,
.observatory-evidence-list span {
display: block;
}
.observatory-evidence-list span {
margin-top: 0.25rem;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
}
.observatory-evidence-empty {
display: grid;
min-height: 13rem;
place-items: center;
align-content: center;
gap: 0.55rem;
text-align: center;
}
.observatory-evidence__bounded-note {
margin: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
line-height: 1.45;
}
.observatory-notice {
justify-content: flex-start;
color: var(--nodedc-text-secondary);
}
.observatory-notice__copy {
min-width: 0;
flex: 1 1 auto;
}
@media (max-width: 920px) {
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice {
align-items: stretch;
flex-direction: column;
}
.observatory-catalog-bar__controls {
flex-basis: auto;
}
.observatory-catalog-bar__controls .nodedc-select-anchor {
flex: 0 0 auto;
width: 100%;
min-width: 0;
}
.observatory-session-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -41,6 +41,7 @@ import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
import { NetworkWorkspace } from "./system/NetworkWorkspace";
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
import { ObservatoryWorkspace } from "./observatory/ObservatoryWorkspace";
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
if (status === "active") return "success";
if (status === "ready") return "accent";
@@ -1191,6 +1192,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
);
case "simulations":
return <SimulationWorkspace />;
case "observatory":
return <ObservatoryWorkspace definition={props.definition} />;
case "device":
return null;
}
@@ -0,0 +1,291 @@
import { useEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Button,
GlassSurface,
Icon,
Select,
StatusBadge,
} from "@nodedc/ui-react";
import type { ObservationSessionStatus } from "../../core/observation/sessionArchive";
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
import type { WorkspaceDefinition } from "../../productModel";
const MAX_PRESENTED_EVIDENCE = 6;
const statusLabel: Record<ObservationSessionStatus, string> = {
recording: "Запись идёт",
ready: "Готова",
degraded: "С деградацией",
interrupted: "Прервана",
failed: "Ошибка",
};
const modalityLabel: Record<string, string> = {
"point-cloud": "Облако точек",
pose: "Поза",
trajectory: "Траектория",
video: "Видео",
image: "Изображения",
depth: "Глубина",
telemetry: "Телеметрия",
};
function statusTone(
status: ObservationSessionStatus,
): "success" | "accent" | "warning" | "danger" | "neutral" {
if (status === "ready") return "success";
if (status === "recording") return "accent";
if (status === "degraded" || status === "interrupted") return "warning";
return "danger";
}
function formatTimestamp(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
function formatDuration(seconds: number): string {
const totalSeconds = Math.max(0, Math.round(seconds));
const hours = Math.floor(totalSeconds / 3_600);
const minutes = Math.floor((totalSeconds % 3_600) / 60);
const remainingSeconds = totalSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`
: `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
}
function catalogStateLabel(
state: ReturnType<typeof useObservatoryCatalog>["state"],
): string {
if (state === "ready") return "Срез актуален";
if (state === "refreshing") return "Обновляем каталог";
if (state === "error") return "Каталог недоступен";
return "Читаем каталог";
}
export function ObservatoryWorkspace({
definition,
}: {
definition: WorkspaceDefinition;
}) {
const controller = useObservatoryCatalog();
const [selectedSessionId, setSelectedSessionId] = useState("");
const items = controller.catalog?.items ?? [];
useEffect(() => {
if (items.some((item) => item.source.id === selectedSessionId)) return;
setSelectedSessionId(items[0]?.source.id ?? "");
}, [items, selectedSessionId]);
const selectedSession = items.find(
(item) => item.source.id === selectedSessionId,
) ?? null;
const presentedEvidence = selectedSession?.evidence.slice(
0,
MAX_PRESENTED_EVIDENCE,
) ?? [];
const options = useMemo(() => items.map(({ source, evidence }) => ({
value: source.id,
label: source.label,
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
})), [items]);
const initialLoading = !controller.catalog
&& ["idle", "loading"].includes(controller.state);
const unavailable = !controller.catalog && controller.state === "error";
return (
<div
className="observatory-workspace"
data-observatory-authority="observation-only"
data-observatory-viewer="detached"
>
<section className="observatory-lead">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
<p>
Записанные источники и строго связанные результаты без запуска тяжёлого
визуализатора и без доступа к управлению аппаратом.
</p>
</div>
<StatusBadge
tone={controller.state === "error" ? "danger" : controller.state === "ready" ? "success" : "neutral"}
>
{catalogStateLabel(controller.state)}
</StatusBadge>
</section>
<GlassSurface className="observatory-catalog-bar" padding="lg">
<div className="observatory-catalog-bar__copy">
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
<h3>Сохранённая сессия</h3>
<p>Выбор меняет только читаемую карточку и не готовит Rerun-запись в фоне.</p>
</div>
<div className="observatory-catalog-bar__controls">
<Select
label="Выбрать сохранённую сессию"
value={selectedSessionId}
options={options}
disabled={items.length === 0}
searchable
searchPlaceholder="Поиск по сессиям"
emptyLabel="Сессия не найдена"
minMenuWidth={360}
menuWidth={460}
onChange={setSelectedSessionId}
/>
<Button
size="compact"
variant="secondary"
disabled={controller.state === "loading" || controller.state === "refreshing"}
icon={<Icon name="refresh" />}
onClick={controller.refresh}
>
Обновить
</Button>
</div>
</GlassSurface>
{controller.error && controller.catalog ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<StatusBadge tone="warning">Показан последний срез</StatusBadge>
<span className="observatory-notice__copy">{controller.error}</span>
<Button size="compact" variant="ghost" onClick={controller.refresh}>Повторить</Button>
</GlassSurface>
) : null}
{controller.catalog
&& (controller.catalog.window.sourceLimitReached
|| controller.catalog.window.laboratoryLimitReached) ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
<StatusBadge tone="warning">Срез ограничен</StatusBadge>
<span className="observatory-notice__copy">
Загружены последние {controller.catalog.window.sourceCount} исходных сессий и{
" "
}{controller.catalog.window.laboratoryCount} LAB-результатов. Полнота исторических
связей не подтверждена.
</span>
</GlassSurface>
) : null}
{initialLoading ? (
<GlassSurface className="observatory-state" padding="lg">
<ActivityIndicator label="Читаем каталог сессий" />
<h3>Читаем источники и связи результатов</h3>
<p>Визуализатор и лабораторные сцены при этом не запускаются.</p>
</GlassSurface>
) : unavailable ? (
<GlassSurface className="observatory-state" padding="lg" role="alert">
<Icon name="alert" size={20} />
<StatusBadge tone="danger">Каталог недоступен</StatusBadge>
<h3>{controller.error}</h3>
<Button size="compact" variant="secondary" onClick={controller.refresh}>Повторить</Button>
</GlassSurface>
) : items.length === 0 ? (
<GlassSurface className="observatory-state" padding="lg">
<Icon name="database" size={20} />
<h3>Сохранённых сессий пока нет</h3>
<p>После завершения записи источник появится здесь без создания демонстрационных данных.</p>
</GlassSurface>
) : selectedSession ? (
<section className="observatory-session-grid" aria-label="Выбранная сессия и связанные результаты">
<GlassSurface className="observatory-session-summary" padding="lg">
<header>
<div>
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
<h3>{selectedSession.source.label}</h3>
</div>
<StatusBadge tone={statusTone(selectedSession.source.status)}>
{statusLabel[selectedSession.source.status]}
</StatusBadge>
</header>
<dl>
<div>
<dt>Идентификатор</dt>
<dd><code>{selectedSession.source.id}</code></dd>
</div>
<div><dt>Начало</dt><dd>{formatTimestamp(selectedSession.source.startedAtUtc)}</dd></div>
<div><dt>Длительность</dt><dd>{formatDuration(selectedSession.source.durationSeconds)}</dd></div>
<div>
<dt>Чтение</dt>
<dd>{selectedSession.source.replayable ? "Доступна" : "Не подготовлена"}</dd>
</div>
</dl>
<div className="observatory-modalities" aria-label="Каналы сессии">
{selectedSession.source.modalities.length > 0
? selectedSession.source.modalities.map((modality) => (
<StatusBadge key={modality} tone="neutral">
{modalityLabel[modality] ?? modality}
</StatusBadge>
))
: <span>Каналы не зафиксированы</span>}
</div>
</GlassSurface>
<GlassSurface className="observatory-evidence" padding="lg">
<header>
<div>
<span className="section-eyebrow">СВЯЗАННЫЕ РЕЗУЛЬТАТЫ</span>
<h3>Лабораторные доказательства</h3>
</div>
<StatusBadge tone={selectedSession.evidence.length > 0 ? "accent" : "neutral"}>
{selectedSession.evidence.length}
</StatusBadge>
</header>
{selectedSession.evidence.length > 0 ? (
<ol className="observatory-evidence-list">
{presentedEvidence.map((evidence) => (
<li key={evidence.sessionId}>
<GlassSurface className="observatory-evidence-card" padding="md" tone="soft">
<div>
<strong>{evidence.lab.labId}</strong>
<span>{evidence.label}</span>
</div>
<dl>
<div><dt>Тип результата</dt><dd>{evidence.lab.resultKind}</dd></div>
<div>
<dt>Result ID</dt>
<dd><code>{evidence.lab.resultId}</code></dd>
</div>
<div><dt>Опубликован</dt><dd>{formatTimestamp(evidence.publishedAtUtc)}</dd></div>
</dl>
</GlassSurface>
</li>
))}
</ol>
) : (
<div className="observatory-evidence-empty">
<Icon name="clipboard" size={18} />
<strong>Связанных результатов нет</strong>
<p>
Наличие исходной записи само по себе не является выводом о качестве
компьютерного зрения или безопасности прохождения.
</p>
</div>
)}
{selectedSession.evidence.length > presentedEvidence.length ? (
<p className="observatory-evidence__bounded-note">
Показаны {presentedEvidence.length} последних из {selectedSession.evidence.length}
{" "}связанных результатов. Полный архив остаётся в legacy LAB.
</p>
) : null}
</GlassSurface>
</section>
) : null}
{(controller.catalog?.unresolvedEvidence.length ?? 0) > 0 ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="status">
<StatusBadge tone="warning">Вне среза</StatusBadge>
<span className="observatory-notice__copy">
{controller.catalog?.unresolvedEvidence.length} результатов ссылаются на источники
вне текущего загруженного среза и не приписаны к квалификационным доказательствам.
</span>
</GlassSurface>
) : null}
</div>
);
}
@@ -104,3 +104,27 @@ test("laboratory UI is a bounded feature slice, not a central workspace branch",
assert.doesNotMatch(laboratoryCss, /\.e30-human-review/);
assert.match(e30HumanReviewCss, /\.e30-human-review/);
});
test("Observatory is a bounded read-only slice outside legacy LAB and viewer lifecycles", async () => {
const workspaceHub = await read("workspaces/Workspaces.tsx");
const observatory = await read("workspaces/observatory/ObservatoryWorkspace.tsx");
const observatoryCore = await read("core/observatory/catalog.ts");
const workspaceCss = await read("styles/workspaces.css");
const observatoryCss = await read("styles/observatory.css");
assert.match(
workspaceHub,
/case "observatory":[\s\S]*<ObservatoryWorkspace definition=\{props\.definition\} \/>/,
);
assert.match(observatory, /export function ObservatoryWorkspace/);
assert.match(observatory, /useObservatoryCatalog/);
assert.match(observatory, /data-observatory-authority="observation-only"/);
assert.match(observatory, /data-observatory-viewer="detached"/);
assert.doesNotMatch(
`${observatory}\n${observatoryCore}`,
/(?:core|components|workspaces)\/laboratory|\/api\/v1\/laboratory|RerunViewport|ObservationSessionSelect/,
);
assert.doesNotMatch(observatory, /replayObservationSession|deleteObservationSession/);
assert.doesNotMatch(workspaceCss, /\.observatory-/);
assert.match(observatoryCss, /\.observatory-workspace/);
});
@@ -0,0 +1,159 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let buildObservatoryCatalog;
let fetchObservatoryCatalog;
let ObservatoryCatalogContractError;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
buildObservatoryCatalog,
fetchObservatoryCatalog,
ObservatoryCatalogContractError,
} = await server.ssrLoadModule("/src/core/observatory/catalog.ts"));
});
after(async () => {
await server?.close();
});
function source(id, startedAtUtc) {
return {
id,
label: `Источник ${id}`,
startedAtUtc,
completedAtUtc: startedAtUtc,
status: "ready",
modalities: ["point-cloud", "video"],
durationSeconds: 42,
replayable: true,
preparation: null,
lab: null,
};
}
function evidence(id, sourceSessionId, publishedAtUtc) {
return {
id,
label: `Результат ${id}`,
startedAtUtc: publishedAtUtc,
completedAtUtc: publishedAtUtc,
status: "ready",
modalities: ["video"],
durationSeconds: 12,
replayable: true,
preparation: null,
lab: {
labId: `LAB-${id}`,
sourceSessionId,
resultKind: "recorded-evidence",
resultId: `result-${id}`,
sourceResultId: null,
configSha256: "a".repeat(64),
runCreatedAtUtc: publishedAtUtc,
publishedAtUtc,
provenance: { verdict: "must-not-be-inferred" },
},
};
}
test("Observatory joins evidence only by sourceSessionId and keeps deterministic order", () => {
const catalog = buildObservatoryCatalog(
[
source("older", "2026-08-28T10:00:00Z"),
source("newer", "2026-08-29T10:00:00Z"),
],
[
evidence("old-result", "newer", "2026-08-29T11:00:00Z"),
evidence("new-result", "newer", "2026-08-29T12:00:00Z"),
evidence("orphan", "missing", "2026-08-29T13:00:00Z"),
],
);
assert.deepEqual(catalog.items.map(({ source: item }) => item.id), ["newer", "older"]);
assert.deepEqual(
catalog.items[0].evidence.map((item) => item.sessionId),
["new-result", "old-result"],
);
assert.deepEqual(catalog.unresolvedEvidence.map((item) => item.sessionId), ["orphan"]);
assert.deepEqual(catalog.window, {
limit: 100,
sourceCount: 2,
laboratoryCount: 3,
sourceLimitReached: false,
laboratoryLimitReached: false,
});
assert.equal("verdict" in catalog.items[0].evidence[0], false);
assert.equal(catalog.items[1].evidence.length, 0);
});
test("Observatory fails visibly when the laboratory projection loses its typed link", () => {
assert.throws(
() => buildObservatoryCatalog(
[source("source", "2026-08-29T10:00:00Z")],
[source("not-lab", "2026-08-29T11:00:00Z")],
),
ObservatoryCatalogContractError,
);
});
test("Observatory fetches disjoint read-only source and laboratory projections", async () => {
const calls = [];
const fetcher = async (input, init) => {
calls.push({ input: String(input), method: init?.method });
return new Response(JSON.stringify({ items: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const catalog = await fetchObservatoryCatalog({ fetcher, limit: 50 });
assert.deepEqual(catalog, {
items: [],
unresolvedEvidence: [],
window: {
limit: 50,
sourceCount: 0,
laboratoryCount: 0,
sourceLimitReached: false,
laboratoryLimitReached: false,
},
});
assert.deepEqual(
calls.map(({ input }) => input).sort(),
[
"/api/v1/observation-sessions?limit=50&scope=laboratory",
"/api/v1/observation-sessions?limit=50&scope=source",
],
);
assert.deepEqual(new Set(calls.map(({ method }) => method)), new Set(["GET"]));
});
test("Observatory exposes bounded-window uncertainty without inventing a broken link", () => {
const catalog = buildObservatoryCatalog(
[
source("newer", "2026-08-29T10:00:00Z"),
source("older", "2026-08-28T10:00:00Z"),
],
[
evidence("linked", "newer", "2026-08-29T11:00:00Z"),
evidence("outside-window", "older-than-window", "2026-08-29T12:00:00Z"),
],
2,
);
assert.equal(catalog.window.sourceLimitReached, true);
assert.equal(catalog.window.laboratoryLimitReached, true);
assert.deepEqual(
catalog.unresolvedEvidence.map((item) => item.sessionId),
["outside-window"],
);
});
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let productModel;
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
productModel = await server.ssrLoadModule("/src/productModel.ts");
});
after(async () => {
await server?.close();
});
async function read(relativePath) {
return readFile(new URL(`../src/${relativePath}`, import.meta.url), "utf8");
}
test("Observatory is the third independent Polygon workspace", () => {
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
);
assert.deepEqual(
productModel.workspaceById("observatory"),
{
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Обсерватория восприятия",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
icon: "eye",
kind: "observatory",
groups: [],
},
);
assert.equal(productModel.workspaceById("lab-archive").kind, "lab-archive");
});
test("Observatory reads catalog evidence without inheriting replay or LAB composition", async () => {
const [app, workspaceHub, workspace, hook, viewerProfiles, styles] = await Promise.all([
read("App.tsx"),
read("workspaces/Workspaces.tsx"),
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("core/observatory/useObservatoryCatalog.ts"),
read("core/observation/viewerProfile.ts"),
read("styles/observatory.css"),
]);
assert.match(app, /activeDefinition\.kind === "observatory"[\s\S]*Только наблюдение/);
assert.doesNotMatch(app, /\["recordings", "lab-archive", "observatory"\]/);
assert.match(workspaceHub, /case "observatory":[\s\S]*<ObservatoryWorkspace/);
assert.match(workspace, /useObservatoryCatalog/);
assert.match(workspace, /Связанных результатов нет/);
assert.match(workspace, /не является выводом о качестве/);
assert.match(workspace, /\.evidence\.slice\([\s\S]*MAX_PRESENTED_EVIDENCE/);
assert.match(workspace, /Полный архив остаётся в legacy LAB/);
assert.match(workspace, /Полнота исторических/);
assert.match(workspace, /вне текущего загруженного среза/);
assert.match(workspace, /observatory-notice__copy/);
assert.match(workspace, /observatory-evidence-card/);
assert.doesNotMatch(workspace, /Нарушена связь|compactIdentity/);
assert.doesNotMatch(
`${workspace}\n${hook}`,
/RerunViewport|ObservationSessionSelect|resolveObservationSessionReplay|deleteObservationSession|setInterval|setTimeout/,
);
assert.match(viewerProfiles, /kind: "live-acquisition"/);
assert.match(viewerProfiles, /kind: "recorded-session"/);
assert.match(viewerProfiles, /kind: "lab-recorded-evidence"/);
assert.match(
styles,
/@media \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar__controls \.nodedc-select-anchor \{[\s\S]*flex: 0 0 auto;/,
);
assert.doesNotMatch(styles, /nodedc-glass-surface|nodedc-status-badge/);
});
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations"],
["lab-archive", "simulations", "observatory"],
);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
@@ -27,6 +27,19 @@ test("top navigation has no Center and Park owns contour health first", () => {
);
assert.equal(productModel.workspacesForRoot("fleet")[0]?.id, "contour-health");
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory"],
);
assert.equal(productModel.workspaceById("lab-archive")?.kind, "lab-archive");
assert.deepEqual(
{
root: productModel.workspaceById("observatory")?.root,
kind: productModel.workspaceById("observatory")?.kind,
icon: productModel.workspaceById("observatory")?.icon,
},
{ root: "polygon", kind: "observatory", icon: "eye" },
);
});
test("every laboratory result uses the shared evidence template", async () => {
+93
View File
@@ -0,0 +1,93 @@
# Observatory — product-surface brief
Status: accepted by the product owner for the first M5.1 vertical slice on 2026-08-30.
## Operator and job story
The operator is an engineer qualifying the perception stack before any control authority is put on
an unmanned platform. After a source session has been recorded, the engineer needs one lightweight
place to identify that immutable source and see which laboratory results are actually linked to it.
The first slice is used repeatedly while recordings and results are being produced; it is not a
mission-planning surface and it never sends navigation, braking or actuation commands.
## Placement decision
The accepted placement is `Тестировочный контур → Обсерватория`, with the operator-facing
description `Сессии и квалификация`. It is a third workspace alongside the existing
`Лабораторные контуры` and `Симуляции` entries.
Alternatives considered:
1. Replace or refactor `Лабораторные контуры` in place. Rejected for M5.1 because the current LAB
archive is a working, documented legacy projector and rollback reference.
2. Add an Observatory mode to `Данные → Сессии и записи`. Rejected because that surface owns
storage and replay, while Observatory owns the recurring qualification-review job.
3. Add a dedicated workspace under `Тестировочный контур`. Selected because it creates a clean
composition boundary without changing K1, Simulation or the legacy LAB lifecycle.
## Entity, evidence and authority
The source entity is an immutable observation Session. Its visible facts are limited to the
validated Session catalog contract: identity, timestamps, status, duration, modalities and
readability. Laboratory evidence is linked only by the typed `lab.sourceSessionId` relationship.
Source and LAB projections are independent bounded newest-first windows. A LAB result whose source
is absent from the loaded source window remains unresolved and visible as `вне среза`; it is never
attached heuristically and its absence is not presented as a broken relationship. Reaching the
100-item boundary marks historical completeness as unknown until cursor pagination exists.
The presence of a Session or linked LAB result is not a computer-vision pass and is not evidence of
safe navigation. M5.1 does not infer a verdict from provenance, labels or result kind. The workspace
is observation-only: command, navigation and safety authority are all absent.
## Viewer-profile boundary
The existing source-specific viewer contracts remain separate and unchanged:
| Source job | Existing profile | Clock / load ownership |
| --- | --- | --- |
| Live equipment | `live-acquisition` | `stream_time`; live receiver and recovery authority |
| Historical Sessions | `recorded-session` | `session_time`; explicit preparation and progressive admission |
| Legacy LAB evidence | `lab-recorded-evidence` | `source-sequence`; explicit comparison-only loading |
Canonical LAB compositions may configure the recorded Rerun engine inside the bounded LAB slice,
but that does not make Observatory a LAB or live consumer. The first Observatory slice mounts no
viewer at all. A later viewer slice may activate only the historical `recorded-session` profile
after an explicit Session selection and a separate acceptance proof. It must not add a fourth
profile or silently collapse the three existing lifecycles.
## Information hierarchy and states
1. Catalog authority and refresh state.
2. Explicit source Session selection.
3. Selected Session identity, timing, modalities and readability.
4. Strictly linked immutable LAB evidence.
5. Fail-visible bounded-window and unresolved-evidence status.
The admitted states are initial loading, ready with items, ready empty, refreshing with the last
valid snapshot, and unavailable/error with retry. If a selected Session disappears after refresh,
selection moves to the first valid source or to the empty state. No demo rows, fabricated progress,
placeholder actions or hidden polling are allowed.
## Design Guideline composition
The existing `ApplicationShell`, `AdminNavigationPanel` and `ApplicationPanel` composition remains
the owner of navigation and workspace framing. The feature reuses canonical `Select`, `Button`,
`Icon`, `StatusBadge`, `GlassSurface` and `ActivityIndicator` primitives. The semantic Session and
evidence projection belongs to `workspaces/observatory`; feature styles own layout only and do not
introduce a parallel control or surface grammar. The registered `eye` icon identifies the new
workspace.
## First-slice acceptance
- Polygon navigation order is `Лабораторные контуры → Симуляции → Обсерватория`.
- Opening Observatory loads only the typed source and laboratory Session catalogs.
- Opening Observatory mounts no Rerun, canvas, WebGL, RRD, WebSocket or LAB result route.
- Source and LAB records are joined only by `sourceSessionId`; evidence outside the independently
bounded source window remains visible as unresolved and never becomes a false integrity verdict.
- Reaching either 100-item catalog boundary explicitly marks historical completeness as unknown.
- The selected source presents at most six newest linked results while retaining the exact total;
the historical tail remains in legacy LAB and is never mounted into a long Observatory DOM.
- Loading, empty, refreshing, error/retry and ready states contain no synthetic data.
- Linked evidence is never presented as a CV or safety pass.
- Existing LAB, Simulation, K1 and Data/Sessions code paths remain unchanged.
- Live, historical Session and legacy LAB viewer-profile contracts remain distinct.