feat(observatory): add durable recorded compute queue

This commit is contained in:
DCCONSTRUCTIONS
2026-08-31 01:04:25 +03:00
parent e72609120f
commit d67e86176e
23 changed files with 6300 additions and 369 deletions
@@ -61,8 +61,8 @@ export interface ObservatoryLaboratoryRunPreflight {
readonly sourceSessionId: string;
readonly setupId: string;
readonly definitionSha256: string | null;
readonly outcome: "existing" | "blocked";
readonly submissionAllowed: false;
readonly outcome: "existing" | "queueable" | "blocked";
readonly submissionAllowed: boolean;
readonly checks: readonly {
readonly checkId: string;
readonly outcome: "pass" | "fail" | "not-applicable";
@@ -287,8 +287,15 @@ function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight {
sourceSessionId: text(row.source_session_id, "preflight source_session_id"),
setupId: text(row.setup_id, "preflight setup_id"),
definitionSha256: digest,
outcome: oneOf(row.outcome, ["existing", "blocked"] as const, "preflight outcome"),
submissionAllowed: exact(row.submission_allowed, false, "preflight submission_allowed"),
outcome: oneOf(
row.outcome,
["existing", "queueable", "blocked"] as const,
"preflight outcome",
),
submissionAllowed: boolean(
row.submission_allowed,
"preflight submission_allowed",
),
checks: array(row.checks, "preflight checks").map((item) => {
const check = record(item, "preflight check");
exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check");
@@ -0,0 +1,241 @@
const SUBMIT_SCHEMA = "missioncore.observatory-recorded-run-submit/v1";
const JOB_SCHEMA = "missioncore.observatory-recorded-job/v1";
const JOB_LIST_SCHEMA = "missioncore.observatory-recorded-job-list/v1";
const SHA256 = /^[a-f0-9]{64}$/;
export type ObservatoryRecordedJobState =
| "accepted"
| "queued"
| "claimed"
| "running"
| "paused"
| "preemption-pending"
| "succeeded"
| "failed"
| "reconciliation-required";
export interface ObservatoryRecordedJob {
readonly jobId: string;
readonly idempotencyKey: string;
readonly sourceSessionId: string;
readonly setupId: string;
readonly definitionSha256: string;
readonly state: ObservatoryRecordedJobState;
readonly restartFromZero: boolean;
readonly resultId: string | null;
readonly terminalMessage: string | null;
readonly createdAtUtc: string;
readonly updatedAtUtc: string;
}
export type ObservatoryRecordedJobFetch = (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
export class ObservatoryRecordedJobContractError extends Error {
readonly status: number | null;
constructor(message: string, status: number | null = null) {
super(message);
this.name = "ObservatoryRecordedJobContractError";
this.status = status;
}
}
export async function fetchObservatoryRecordedJobs(
sourceSessionId: string,
setupId: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<readonly ObservatoryRecordedJob[]> {
const query = new URLSearchParams({
source_session_id: sourceSessionId,
setup_id: setupId,
limit: "20",
});
const response = await request(fetcher, `/api/v1/observatory/runs?${query}`, {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const row = record(body, "список расчётов");
exactKeys(row, ["authority", "items", "schema_version"], "список расчётов");
exact(row.schema_version, JOB_LIST_SCHEMA, "schema_version списка расчётов");
observationAuthority(row.authority);
return array(row.items, "items").map(decodeJob).filter((job) => (
job.sourceSessionId === sourceSessionId && job.setupId === setupId
));
}
export async function submitObservatoryRecordedJob(
sourceSessionId: string,
setupId: string,
idempotencyKey: string,
{
signal,
fetcher = globalThis.fetch,
}: {
signal?: AbortSignal;
fetcher?: ObservatoryRecordedJobFetch;
} = {},
): Promise<ObservatoryRecordedJob> {
const response = await request(fetcher, "/api/v1/observatory/runs", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: SUBMIT_SCHEMA,
idempotency_key: idempotencyKey,
source_session_id: sourceSessionId,
setup_id: setupId,
}),
signal,
});
const body = await responseBody(response);
if (!response.ok) throw apiError(body, response.status);
const job = decodeJob(body);
if (job.sourceSessionId !== sourceSessionId || job.setupId !== setupId) {
throw new ObservatoryRecordedJobContractError(
"Расчёт относится к другой сессии или сетапу.",
);
}
return job;
}
function decodeJob(value: unknown): ObservatoryRecordedJob {
const row = record(value, "расчёт");
exactKeys(row, [
"authority", "checkpoint_policy", "claim_generation", "created_at_utc", "executor",
"idempotency_key", "identity_sha256", "job_id", "preemption_receipt_sha256",
"preemption_requested", "priority", "request_sha256", "restart_from_zero", "result",
"schema_version", "setup", "source", "state", "submission_receipt_sha256", "terminal",
"updated_at_utc",
], "расчёт");
exact(row.schema_version, JOB_SCHEMA, "schema_version расчёта");
observationAuthority(row.authority);
digest(row.request_sha256, "request_sha256");
digest(row.identity_sha256, "identity_sha256");
digest(row.submission_receipt_sha256, "submission_receipt_sha256");
const source = record(row.source, "source");
exactKeys(source, [
"adapter", "bundle_sha256", "capability_manifest_sha256", "catalog_sha256", "session_id",
], "source");
digest(source.catalog_sha256, "source.catalog_sha256");
digest(source.bundle_sha256, "source.bundle_sha256");
digest(source.capability_manifest_sha256, "source.capability_manifest_sha256");
const setup = record(row.setup, "setup");
exactKeys(setup, ["definition_id", "definition_sha256", "definition_version", "setup_id"], "setup");
digest(setup.definition_sha256, "setup.definition_sha256");
const state = oneOf(row.state, [
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
"succeeded", "failed", "reconciliation-required",
] as const, "state");
const result = row.result === null ? null : record(row.result, "result");
if (result !== null) exactKeys(result, ["result_id", "sha256"], "result");
const terminal = row.terminal === null ? null : record(row.terminal, "terminal");
if (terminal !== null) exactKeys(terminal, ["code", "message"], "terminal");
return {
jobId: text(row.job_id, "job_id"),
idempotencyKey: text(row.idempotency_key, "idempotency_key"),
sourceSessionId: text(source.session_id, "source.session_id"),
setupId: text(setup.setup_id, "setup.setup_id"),
definitionSha256: String(setup.definition_sha256),
state,
restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"),
resultId: result === null ? null : text(result.result_id, "result.result_id"),
terminalMessage: terminal === null || terminal.message === null
? null
: text(terminal.message, "terminal.message"),
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
updatedAtUtc: text(row.updated_at_utc, "updated_at_utc"),
};
}
function observationAuthority(value: unknown): void {
const row = record(value, "authority");
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
exactKeys(row, keys, "authority");
keys.forEach((key) => exact(row[key], false, `authority.${key}`));
}
async function request(fetcher: ObservatoryRecordedJobFetch, input: string, init: RequestInit): Promise<Response> {
try {
return await fetcher(input, init);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") throw error;
throw new ObservatoryRecordedJobContractError("Очередь расчётов недоступна.");
}
}
async function responseBody(response: Response): Promise<unknown> {
const body = await response.text();
if (!body) return undefined;
try { return JSON.parse(body) as unknown; } catch { return body; }
}
function apiError(body: unknown, status: number): ObservatoryRecordedJobContractError {
const detail = body && typeof body === "object" && !Array.isArray(body)
? (body as Record<string, unknown>).detail
: null;
return new ObservatoryRecordedJobContractError(
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
status,
);
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new ObservatoryRecordedJobContractError(`${label}: ожидался объект.`);
}
return value as Record<string, unknown>;
}
function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) throw new ObservatoryRecordedJobContractError(`${label}: ожидался массив.`);
return value;
}
function text(value: unknown, label: string): string {
if (typeof value !== "string" || !value.trim()) {
throw new ObservatoryRecordedJobContractError(`${label}: ожидался текст.`);
}
return value;
}
function digest(value: unknown, label: string): string {
const valueText = text(value, label);
if (!SHA256.test(valueText)) throw new ObservatoryRecordedJobContractError(`${label}: некорректный digest.`);
return valueText;
}
function boolean(value: unknown, label: string): boolean {
if (typeof value !== "boolean") throw new ObservatoryRecordedJobContractError(`${label}: ожидался boolean.`);
return value;
}
function exact<T>(value: unknown, expected: T, label: string): T {
if (value !== expected) throw new ObservatoryRecordedJobContractError(`${label}: значение изменилось.`);
return expected;
}
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
if (typeof value !== "string" || !allowed.includes(value)) {
throw new ObservatoryRecordedJobContractError(`${label}: значение не поддерживается.`);
}
return value as T[number];
}
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
const actual = Object.keys(value).sort();
const sortedExpected = [...expected].sort();
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
throw new ObservatoryRecordedJobContractError(`${label}: обнаружены неизвестные поля.`);
}
}
@@ -111,6 +111,11 @@ export function useObservatoryLaboratorySetups(sourceSessionId: string) {
}
}, [preflight.kind, selectedSetup, sourceSessionId]);
useEffect(() => {
if (state !== "ready" || !selectedSetup || preflight.kind !== "idle") return;
void check();
}, [check, preflight.kind, selectedSetup, state]);
return {
catalog: activeCatalog,
state,
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
fetchObservatoryRecordedJobs,
submitObservatoryRecordedJob,
type ObservatoryRecordedJob,
} from "./recordedJobs";
type RecordedJobsState = "idle" | "loading" | "ready" | "refreshing" | "submitting" | "error";
const OPEN_STATES = new Set([
"accepted", "queued", "claimed", "running", "paused", "preemption-pending",
"reconciliation-required",
]);
export function useObservatoryRecordedJobs(sourceSessionId: string, setupId: string) {
const [jobs, setJobs] = useState<readonly ObservatoryRecordedJob[]>([]);
const [state, setState] = useState<RecordedJobsState>("idle");
const [error, setError] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
const requestRef = useRef<AbortController | null>(null);
const requestSequence = useRef(0);
const idempotencyKeys = useRef(new Map<string, string>());
const selectionKey = `${sourceSessionId}\u0000${setupId}`;
useEffect(() => {
requestRef.current?.abort();
requestRef.current = null;
if (!sourceSessionId || !setupId) {
setJobs([]);
setState("idle");
setError(null);
return;
}
const sequence = ++requestSequence.current;
const request = new AbortController();
requestRef.current = request;
setState((current) => jobs.length > 0 && current !== "idle" ? "refreshing" : "loading");
setError(null);
void fetchObservatoryRecordedJobs(sourceSessionId, setupId, { signal: request.signal })
.then((next) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setJobs(next);
setState("ready");
})
.catch((caught: unknown) => {
if (request.signal.aborted || requestSequence.current !== sequence) return;
setState("error");
setError(caught instanceof Error && caught.message.trim()
? caught.message
: "Очередь расчётов недоступна.");
});
return () => request.abort();
}, [revision, selectionKey]);
useEffect(() => () => requestRef.current?.abort(), []);
const latestJob = jobs[0] ?? null;
const activeJob = useMemo(
() => jobs.find((job) => OPEN_STATES.has(job.state)) ?? null,
[jobs],
);
useEffect(() => {
if (!latestJob || activeJob) return;
idempotencyKeys.current.delete(selectionKey);
}, [activeJob, latestJob, selectionKey]);
const refresh = useCallback(() => setRevision((value) => value + 1), []);
const submit = useCallback(async (): Promise<ObservatoryRecordedJob | null> => {
if (!sourceSessionId || !setupId || state === "submitting") return null;
if (activeJob) return activeJob;
requestRef.current?.abort();
const request = new AbortController();
requestRef.current = request;
const key = idempotencyKeys.current.get(selectionKey)
?? createIdempotencyKey();
idempotencyKeys.current.set(selectionKey, key);
setState("submitting");
setError(null);
try {
const job = await submitObservatoryRecordedJob(sourceSessionId, setupId, key, {
signal: request.signal,
});
if (request.signal.aborted || requestRef.current !== request) return null;
setJobs((current) => [job, ...current.filter((candidate) => candidate.jobId !== job.jobId)]);
setState("ready");
return job;
} catch (caught) {
if (request.signal.aborted || requestRef.current !== request) return null;
setState("error");
setError(caught instanceof Error && caught.message.trim()
? caught.message
: "Не удалось поставить расчёт в очередь.");
return null;
} finally {
if (requestRef.current === request) requestRef.current = null;
}
}, [activeJob, selectionKey, setupId, sourceSessionId, state]);
return { jobs, latestJob, activeJob, state, error, refresh, submit };
}
function createIdempotencyKey(): string {
const entropy = typeof globalThis.crypto?.randomUUID === "function"
? globalThis.crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
return `observatory-ui:${entropy}`;
}
export type ObservatoryRecordedJobsController = ReturnType<typeof useObservatoryRecordedJobs>;
+32 -165
View File
@@ -20,8 +20,6 @@
}
.observatory-lead,
.observatory-catalog-bar,
.observatory-catalog-bar__controls,
.observatory-notice {
flex-wrap: wrap;
}
@@ -35,14 +33,12 @@
.observatory-lead h2,
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-setup-detail h3,
.observatory-evidence h3 {
margin: 0.3rem 0 0;
}
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-setup-detail h3,
.observatory-evidence h3 {
font-size: var(--nodedc-font-size-lg);
letter-spacing: -0.02em;
@@ -50,7 +46,6 @@
}
.observatory-lead p,
.observatory-catalog-bar p,
.observatory-state p,
.observatory-evidence-empty p {
max-width: 52rem;
@@ -67,17 +62,37 @@
}
.observatory-catalog-bar__copy {
flex: 1 1 auto;
display: grid;
flex: 0 0 auto;
align-content: center;
gap: 0.1rem;
}
.observatory-catalog-bar h3 {
margin-top: 0.1rem;
}
.observatory-catalog-bar__controls {
flex: 2 1 48rem;
min-width: 0;
flex: 1 1 auto;
flex-wrap: nowrap;
justify-content: flex-end;
}
.observatory-catalog-bar__controls .nodedc-select-anchor {
flex: 1 1 18rem;
min-width: min(22rem, 100%);
min-width: 0;
}
.observatory-catalog-bar__controls > .nodedc-button {
flex: 0 0 auto;
}
.observatory-catalog-bar__run {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 0.55rem;
}
.observatory-state {
@@ -105,9 +120,9 @@
.observatory-session-summary {
display: grid;
grid-template-columns: minmax(14rem, 1.1fr) minmax(22rem, 1.6fr) minmax(14rem, auto);
grid-template-columns: minmax(11rem, 1.05fr) minmax(20rem, 1.6fr) minmax(11rem, auto);
align-items: center;
gap: 1rem;
gap: 0.75rem;
}
.observatory-session-summary__identity {
@@ -131,7 +146,7 @@
.observatory-session-summary__facts {
display: grid;
grid-template-columns: repeat(3, minmax(6.5rem, 1fr));
gap: 1rem;
gap: 0.75rem;
margin: 0;
}
@@ -159,7 +174,7 @@
display: grid;
min-width: 0;
justify-items: end;
gap: 0.6rem;
gap: 0.4rem;
}
.observatory-modalities {
@@ -171,135 +186,6 @@
color: var(--nodedc-text-muted);
}
.observatory-setup-detail {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.85rem 1rem;
container-name: observatory-setup;
container-type: inline-size;
}
.observatory-setup-detail__identity {
min-width: 0;
}
.observatory-setup-detail__identity h3 {
margin: 0.15rem 0 0;
}
.observatory-setup-detail__identity p {
max-width: 68rem;
margin: 0.3rem 0 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-setup-detail__badges,
.observatory-setup-detail__actions,
.observatory-setup-detail__reason {
display: flex;
align-items: center;
gap: 0.55rem;
}
.observatory-setup-detail__badges {
align-self: start;
justify-content: flex-end;
flex-wrap: wrap;
}
.observatory-setup-detail__facts {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.observatory-setup-detail__facts > div {
display: grid;
min-width: 0;
gap: 0.2rem;
padding: 0.65rem 0.75rem;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-glass-control-bg);
}
.observatory-setup-detail__facts dt {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.observatory-setup-detail__facts dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-setup-results {
display: grid;
grid-column: 1 / -1;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
}
.observatory-setup-results li {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
min-height: 3.15rem;
padding: 0.5rem 0.7rem;
border-radius: var(--nodedc-radius-control-compact);
background: var(--nodedc-glass-control-bg);
color: var(--nodedc-text-secondary);
}
.observatory-setup-results span {
display: grid;
min-width: 0;
gap: 0.1rem;
}
.observatory-setup-results strong,
.observatory-setup-results small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.observatory-setup-results small {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-setup-detail__footer {
display: flex;
grid-column: 1 / -1;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
padding-top: 0.1rem;
}
.observatory-setup-detail__reason {
min-width: 0;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
line-height: 1.4;
}
.observatory-setup-detail__actions {
flex: 0 0 auto;
}
.observatory-evidence {
display: grid;
gap: 0.65rem;
@@ -454,7 +340,7 @@
font-size: var(--nodedc-font-size-sm);
}
@container observatory-session (max-width: 56rem) {
@container observatory-session (max-width: 46rem) {
.observatory-session-summary {
grid-template-columns: minmax(0, 1fr);
align-items: start;
@@ -467,29 +353,6 @@
}
}
@container observatory-setup (max-width: 48rem) {
.observatory-setup-detail {
grid-template-columns: minmax(0, 1fr);
}
.observatory-setup-detail__badges {
justify-content: flex-start;
}
.observatory-setup-detail__facts {
grid-template-columns: minmax(0, 1fr);
}
.observatory-setup-detail__footer {
align-items: stretch;
flex-direction: column;
}
.observatory-setup-detail__actions {
flex-wrap: wrap;
}
}
@container observatory-session (max-width: 48rem) {
.observatory-evidence-card {
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -535,4 +398,8 @@
min-width: 0;
}
.observatory-catalog-bar__run {
justify-content: flex-end;
}
}
@@ -1,139 +0,0 @@
import {
ActivityIndicator,
Button,
GlassSurface,
Icon,
StatusBadge,
} from "@nodedc/ui-react";
import type { ObservatoryEvidence } from "../../core/observatory/catalog";
import type { ObservatoryLaboratorySetup } from "../../core/observatory/laboratorySetups";
import type { ObservatoryRecordedRunBinding } from "../../core/observatory/recordedRun";
import type { SetupPreflightState } from "../../core/observatory/useObservatoryLaboratorySetups";
const relationLabel: Readonly<Record<string, string>> = {
"primary-visual": "Основной визуальный разбор",
"compute-successor": "Связанное вычислительное доказательство",
"canonical-projection": "Готовый записанный разбор",
};
function workerLabel(value: string): string {
const match = /^worker-(\d+)$/.exec(value);
return match ? `Worker ${match[1]}` : value;
}
export function ObservatorySetupDetail({
setup,
evidence,
preflight,
onCheck,
onOpenExisting,
}: {
setup: ObservatoryLaboratorySetup;
evidence: readonly ObservatoryEvidence[];
preflight: SetupPreflightState;
onCheck: () => void;
onOpenExisting: (binding: ObservatoryRecordedRunBinding) => void;
}) {
const exactExisting = evidence.find(
(candidate) => candidate.recordedRun
&& setup.preflight.existingResultIds.includes(candidate.recordedRun.resultId),
)?.recordedRun ?? null;
const displayedReason = preflight.kind === "ready"
? preflight.value.checks.find((check) => check.checkId === "executor")?.message
?? setup.executor.reason
: preflight.kind === "error"
? preflight.message
: setup.preflight.reason;
return (
<GlassSurface className="observatory-setup-detail" padding="md">
<div className="observatory-setup-detail__identity">
<span className="section-eyebrow">СЕТАП ЛАБОРАТОРИИ</span>
<h3>{setup.displayName}</h3>
<p>{setup.description}</p>
</div>
<div className="observatory-setup-detail__badges">
<StatusBadge tone={setup.compatibility.compatible ? "success" : "warning"}>
{setup.compatibility.compatible ? "Совместим" : "Другая сессия"}
</StatusBadge>
<StatusBadge tone={setup.origin === "existing-result" ? "accent" : "neutral"}>
{setup.origin === "existing-result" ? "Готовый результат" : "Архивный сетап"}
</StatusBadge>
</div>
<dl className="observatory-setup-detail__facts">
<div>
<dt>Идентичность</dt>
<dd>
{setup.runDefinition
? `Версия ${setup.runDefinition.version} · конфигурация зафиксирована`
: "Готовый записанный результат без повторно запускаемой конфигурации"}
</dd>
</div>
<div>
<dt>Исполнитель</dt>
<dd>
{setup.preflight.outcome === "existing"
? "Не требуется для просмотра"
: `${workerLabel(setup.executor.contourId)} · повторный запуск не подключён`}
</dd>
</div>
<div>
<dt>Сохранено</dt>
<dd>{setup.preservedResults.length} неизменяемых результатов</dd>
</div>
</dl>
<ol className="observatory-setup-results" aria-label="Сохранённые результаты сетапа">
{setup.preservedResults.map((result) => (
<li key={result.resultId}>
<Icon name={result.access === "evidence-only" ? "database" : "clipboard"} size={15} />
<span>
<strong>{relationLabel[result.relation] ?? "Сохранённый результат"}</strong>
<small>
{result.access === "observatory"
? "Доступен в Обсерватории"
: result.access === "legacy-lab"
? "Сохранён в legacy LAB"
: "Сохранено как связанное доказательство"}
</small>
</span>
<StatusBadge tone={result.observatoryProjectionAvailable ? "success" : "neutral"}>
{result.access === "observatory"
? result.observatoryProjectionAvailable ? "Готов" : "Сохранён"
: result.access === "legacy-lab" ? "Legacy LAB" : "Evidence"}
</StatusBadge>
</li>
))}
</ol>
<div className="observatory-setup-detail__footer">
<div className="observatory-setup-detail__reason" role={preflight.kind === "error" ? "alert" : "status"}>
<Icon name={preflight.kind === "error" ? "alert" : "activity"} size={16} />
<span>{displayedReason}</span>
</div>
<div className="observatory-setup-detail__actions">
{exactExisting ? (
<Button
size="compact"
variant="secondary"
icon={<Icon name="eye" size={15} />}
onClick={() => onOpenExisting(exactExisting)}
>
Открыть готовый результат
</Button>
) : (
<Button
size="compact"
variant="secondary"
disabled={preflight.kind === "checking"}
onClick={onCheck}
>
{preflight.kind === "checking"
? <ActivityIndicator size="compact" label="Проверяем совместимость" />
: "Проверить совместимость"}
</Button>
)}
</div>
</div>
</GlassSurface>
);
}
@@ -32,8 +32,9 @@ import {
} from "../../core/observatory/catalogMutations";
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
import type { ObservatoryRecordedJobState } from "../../core/observatory/recordedJobs";
import type { WorkspaceDefinition } from "../../productModel";
import { ObservatorySetupDetail } from "./ObservatorySetupDetail";
const MAX_PRESENTED_EVIDENCE = 6;
const EMPTY_OBSERVATORY_ITEMS = [] as const;
@@ -87,6 +88,27 @@ const modalityLabel: Record<string, string> = {
telemetry: "Телеметрия",
};
const recordedJobStatus: Record<
ObservatoryRecordedJobState,
{
readonly label: string;
readonly tone: "success" | "accent" | "warning" | "danger" | "neutral";
}
> = {
accepted: { label: "Принят", tone: "neutral" },
queued: { label: "Ждёт Worker", tone: "neutral" },
claimed: { label: "Назначен Worker", tone: "accent" },
running: { label: "Выполняется", tone: "accent" },
paused: { label: "Пауза: live-поток", tone: "warning" },
"preemption-pending": {
label: "Ждём подтверждения остановки",
tone: "warning",
},
succeeded: { label: "Готово", tone: "success" },
failed: { label: "Ошибка расчёта", tone: "danger" },
"reconciliation-required": { label: "Нужна сверка", tone: "warning" },
};
function statusTone(
status: ObservationSessionStatus,
): "success" | "accent" | "warning" | "danger" | "neutral" {
@@ -136,6 +158,10 @@ export function ObservatoryWorkspace({
const controller = useObservatoryCatalog();
const [selectedSessionId, setSelectedSessionId] = useState("");
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
const recordedJobsController = useObservatoryRecordedJobs(
selectedSessionId,
setupController.selectedSetupId,
);
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
const [renameValue, setRenameValue] = useState("");
@@ -188,6 +214,29 @@ export function ObservatoryWorkspace({
: "Несовместим с выбранной сессией",
})) ?? []
), [setupController.catalog]);
const runPreflight = setupController.preflight.kind === "ready"
? setupController.preflight.value
: null;
const queueSubmissionAllowed = Boolean(
setupController.selectedSetup?.runDefinition
&& setupController.selectedSetup.compatibility.compatible
&& runPreflight?.outcome === "queueable"
&& runPreflight.submissionAllowed,
);
const presentedJob = recordedJobsController.activeJob
?? recordedJobsController.latestJob;
const presentedJobStatus = presentedJob
? recordedJobStatus[presentedJob.state]
: null;
const queueStatusError = setupController.preflight.kind === "error"
? setupController.preflight.message
: recordedJobsController.error;
const queueStateBusy = recordedJobsController.state === "loading"
|| recordedJobsController.state === "refreshing"
|| recordedJobsController.state === "submitting";
const canSubmitRecordedJob = queueSubmissionAllowed
&& recordedJobsController.state === "ready"
&& recordedJobsController.activeJob === null;
const initialLoading = !controller.catalog
&& ["idle", "loading"].includes(controller.state);
const unavailable = !controller.catalog && controller.state === "error";
@@ -374,11 +423,10 @@ export function ObservatoryWorkspace({
</StatusBadge>
</section>
<GlassSurface className="observatory-catalog-bar" padding="md">
<GlassSurface className="observatory-catalog-bar" padding="sm">
<div className="observatory-catalog-bar__copy">
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
<h3>Сохранённая сессия</h3>
<p>Выбор меняет только читаемую карточку и не готовит визуальный разбор в фоне.</p>
</div>
<div className="observatory-catalog-bar__controls">
<Select
@@ -418,10 +466,40 @@ export function ObservatoryWorkspace({
onClick={() => {
void controller.refresh();
setupController.refresh();
recordedJobsController.refresh();
}}
>
Обновить
</Button>
<div className="observatory-catalog-bar__run" aria-live="polite">
{queueStatusError ? (
<StatusBadge tone="danger" title={queueStatusError}>
Очередь недоступна
</StatusBadge>
) : recordedJobsController.state === "submitting" ? (
<StatusBadge tone="neutral">Ставим в очередь</StatusBadge>
) : presentedJobStatus ? (
<StatusBadge
tone={presentedJobStatus.tone}
title={presentedJob?.terminalMessage ?? undefined}
>
{presentedJobStatus.label}
</StatusBadge>
) : setupController.preflight.kind === "checking" ? (
<StatusBadge tone="neutral">Проверяем сетап</StatusBadge>
) : queueStateBusy ? (
<StatusBadge tone="neutral">Читаем очередь</StatusBadge>
) : null}
{canSubmitRecordedJob ? (
<Button
size="compact"
variant="primary"
onClick={() => { void recordedJobsController.submit(); }}
>
Рассчитать
</Button>
) : null}
</div>
</div>
</GlassSurface>
@@ -476,7 +554,7 @@ export function ObservatoryWorkspace({
</GlassSurface>
) : selectedSession ? (
<section className="observatory-session-stack" aria-label="Выбранная сессия и связанные результаты">
<GlassSurface className="observatory-session-summary" padding="md">
<GlassSurface className="observatory-session-summary" padding="sm">
<div className="observatory-session-summary__identity">
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
<h3>{selectedSession.source.label}</h3>
@@ -506,22 +584,6 @@ export function ObservatoryWorkspace({
</div>
</GlassSurface>
{setupController.selectedSetup ? (
<ObservatorySetupDetail
setup={setupController.selectedSetup}
evidence={selectedSession.evidence}
preflight={setupController.preflight}
onCheck={() => { void setupController.check(); }}
onOpenExisting={openReplay}
/>
) : setupController.error ? (
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
<StatusBadge tone="warning">Сетапы недоступны</StatusBadge>
<span className="observatory-notice__copy">{setupController.error}</span>
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
</GlassSurface>
) : null}
<section className="observatory-evidence">
<header>
<div>
@@ -156,6 +156,40 @@ test("Observatory preflight sends the exact selected definition and never submit
assert.equal(preflight.submissionAllowed, false);
});
test("Observatory dynamic preflight admits only an explicit queueable response", async () => {
const selected = (await fetchObservatoryLaboratorySetups("source-a", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-laboratory-setup-catalog/v1",
source_session_id: "source-a",
setups: [setup()],
authority,
}), { status: 200 }),
})).setups[0];
const preflight = await preflightObservatoryLaboratorySetup("source-a", selected, {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-run-preflight/v1",
source_session_id: "source-a",
setup_id: selected.setupId,
definition_sha256: "b".repeat(64),
outcome: "queueable",
submission_allowed: true,
checks: [{
check_id: "durable-queue",
outcome: "pass",
reason_code: "exact-binding-ready",
message: "Точный источник и очередь готовы.",
}],
existing_result_ids: [],
executor: setup().executor,
authority,
}), { status: 200 }),
});
assert.equal(preflight.outcome, "queueable");
assert.equal(preflight.submissionAllowed, true);
});
test("Observatory setup contract rejects authority escalation and response drift", async () => {
const escalated = setup();
escalated.authority = { ...authority, commands_enabled: true };
@@ -0,0 +1,179 @@
import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
let fetchObservatoryRecordedJobs;
let submitObservatoryRecordedJob;
let ObservatoryRecordedJobContractError;
const authority = {
commands_enabled: false,
actuation_allowed: false,
navigation_or_safety_accepted: false,
production_accepted: false,
};
function job(state = "queued") {
return {
schema_version: "missioncore.observatory-recorded-job/v1",
job_id: "observatory-run-0123456789abcdef0123456789abcdef",
idempotency_key: "observatory-ui:source-a:m49-tgs:request-a",
request_sha256: "1".repeat(64),
identity_sha256: "2".repeat(64),
submission_receipt_sha256: "3".repeat(64),
source: {
session_id: "source-a",
catalog_sha256: "4".repeat(64),
bundle_sha256: "5".repeat(64),
capability_manifest_sha256: "6".repeat(64),
adapter: {
adapter_id: "ravnoves00-m49-source-pack",
version: 1,
adapter_sha256: "7".repeat(64),
},
},
setup: {
setup_id: "m49-tgs",
definition_id: "m49-tgs",
definition_version: 1,
definition_sha256: "8".repeat(64),
},
executor: {
release_id: "m49-tgs-release",
release_sha256: "9".repeat(64),
image_sha256: "a".repeat(64),
model_release_ids: [],
learned_models: [],
model_manifest_sha256: "b".repeat(64),
resource_profile_id: "m49-tgs-cpu",
resource_profile_sha256: "c".repeat(64),
},
checkpoint_policy: {
mode: "non-checkpointable",
allowed_checkpoints: [],
last_checkpoint_id: null,
},
priority: { class: "recorded", rank: 100, server_owned: true },
state,
preemption_requested: state === "preemption-pending",
restart_from_zero: state === "paused",
preemption_receipt_sha256: null,
claim_generation: 0,
result: state === "succeeded"
? { result_id: "m49-result", sha256: "d".repeat(64) }
: null,
terminal: state === "failed"
? { code: "worker-failed", message: "Worker завершил расчёт с ошибкой." }
: null,
created_at_utc: "2026-08-31T10:00:00Z",
updated_at_utc: "2026-08-31T10:00:01Z",
authority,
};
}
before(async () => {
server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
});
({
fetchObservatoryRecordedJobs,
submitObservatoryRecordedJob,
ObservatoryRecordedJobContractError,
} = await server.ssrLoadModule("/src/core/observatory/recordedJobs.ts"));
});
after(async () => {
await server?.close();
});
test("Observatory reads the exact session/setup queue without polling", async () => {
const calls = [];
const jobs = await fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
fetcher: async (input, init) => {
calls.push({ input: String(input), init });
return new Response(JSON.stringify({
schema_version: "missioncore.observatory-recorded-job-list/v1",
items: [job("running")],
authority,
}), { status: 200 });
},
});
assert.equal(calls.length, 1);
assert.equal(
calls[0].input,
"/api/v1/observatory/runs?source_session_id=source-a&setup_id=m49-tgs&limit=20",
);
assert.equal(calls[0].init.method, "GET");
assert.equal(jobs[0].state, "running");
assert.equal(jobs[0].definitionSha256, "8".repeat(64));
});
test("Observatory submits only public identities and accepts every durable state", async () => {
const states = [
"accepted",
"queued",
"claimed",
"running",
"paused",
"preemption-pending",
"succeeded",
"failed",
"reconciliation-required",
];
for (const state of states) {
let request;
const result = await submitObservatoryRecordedJob(
"source-a",
"m49-tgs",
"observatory-ui:source-a:m49-tgs:request-a",
{
fetcher: async (input, init) => {
request = { input: String(input), init };
return new Response(JSON.stringify(job(state)), { status: 200 });
},
},
);
assert.equal(result.state, state);
assert.equal(request.input, "/api/v1/observatory/runs");
assert.equal(request.init.method, "POST");
assert.deepEqual(JSON.parse(request.init.body), {
schema_version: "missioncore.observatory-recorded-run-submit/v1",
idempotency_key: "observatory-ui:source-a:m49-tgs:request-a",
source_session_id: "source-a",
setup_id: "m49-tgs",
});
}
});
test("Observatory queue contract rejects authority escalation and response drift", async () => {
const escalated = job();
escalated.authority = { ...authority, commands_enabled: true };
await assert.rejects(
fetchObservatoryRecordedJobs("source-a", "m49-tgs", {
fetcher: async () => new Response(JSON.stringify({
schema_version: "missioncore.observatory-recorded-job-list/v1",
items: [escalated],
authority,
}), { status: 200 }),
}),
ObservatoryRecordedJobContractError,
);
const drifted = job();
drifted.worker_command = "forbidden";
await assert.rejects(
submitObservatoryRecordedJob(
"source-a",
"m49-tgs",
"observatory-ui:source-a:m49-tgs:request-a",
{ fetcher: async () => new Response(JSON.stringify(drifted), { status: 200 }) },
),
ObservatoryRecordedJobContractError,
);
});
@@ -144,11 +144,11 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
);
assert.match(
styles,
/\.observatory-catalog-bar h3,[\s\S]*\.observatory-session-summary h3,[\s\S]*\.observatory-setup-detail h3,[\s\S]*\.observatory-evidence h3 \{[\s\S]*font-size: var\(--nodedc-font-size-lg\);/,
/\.observatory-catalog-bar h3,[\s\S]*\.observatory-session-summary h3,[\s\S]*\.observatory-evidence h3 \{[\s\S]*font-size: var\(--nodedc-font-size-lg\);/,
);
assert.match(
styles,
/@container observatory-session \(max-width: 56rem\) \{[\s\S]*\.observatory-session-summary \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
/@container observatory-session \(max-width: 46rem\) \{[\s\S]*\.observatory-session-summary \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
);
assert.match(
styles,
@@ -206,23 +206,67 @@ test("Observatory rename and delete use admitted projection mutations and canoni
assert.match(hook, /applyObservatoryCatalogMutationOverlay\(/);
});
test("Observatory configurator keeps selection and preflight read-only", async () => {
const [workspace, setupDetail, setupHook] = await Promise.all([
test("Observatory keeps one compact selector axis without the obsolete setup detail", async () => {
const [workspace, styles, setupHook, jobsHook] = await Promise.all([
read("workspaces/observatory/ObservatoryWorkspace.tsx"),
read("workspaces/observatory/ObservatorySetupDetail.tsx"),
read("styles/observatory.css"),
read("core/observatory/useObservatoryLaboratorySetups.ts"),
read("core/observatory/useObservatoryRecordedJobs.ts"),
]);
assert.match(workspace, /className="observatory-catalog-bar" padding="sm"/);
assert.match(workspace, /label="Выбрать сохранённую сессию"/);
assert.match(workspace, /label="Выбрать сетап лаборатории"/);
assert.match(workspace, /Показан последний каталог сетапов/);
assert.match(setupDetail, /Проверить совместимость/);
assert.doesNotMatch(
setupDetail,
/Рассчитать лабораторию|definitionSha256|<small>\{result\.resultId\}/,
assert.doesNotMatch(workspace, /Выбор меняет только читаемую карточку/);
assert.doesNotMatch(workspace, /ObservatorySetupDetail|observatory-setup-detail/);
assert.doesNotMatch(styles, /observatory-setup-detail|observatory-setup-results/);
assert.match(workspace, /useObservatoryRecordedJobs/);
assert.match(
workspace,
/runPreflight\?\.outcome === "queueable"[\s\S]*runPreflight\.submissionAllowed/,
);
assert.match(
workspace,
/canSubmitRecordedJob \? \([\s\S]*>\s*Рассчитать\s*<\/Button>/,
);
assert.match(workspace, /accepted: \{ label: "Принят"/);
assert.match(workspace, /queued: \{ label: "Ждёт Worker"/);
assert.match(workspace, /claimed: \{ label: "Назначен Worker"/);
assert.match(workspace, /running: \{ label: "Выполняется"/);
assert.match(workspace, /paused: \{ label: "Пауза: live-поток"/);
assert.match(
workspace,
/"preemption-pending": \{[\s\S]*label: "Ждём подтверждения остановки"/,
);
assert.match(workspace, /succeeded: \{ label: "Готово"/);
assert.match(workspace, /failed: \{ label: "Ошибка расчёта"/);
assert.match(workspace, /"reconciliation-required": \{ label: "Нужна сверка"/);
assert.match(
jobsHook,
/OPEN_STATES[\s\S]*"preemption-pending",[\s\S]*"reconciliation-required"/,
);
assert.match(jobsHook, /return `observatory-ui:\$\{entropy\}`;/);
assert.doesNotMatch(jobsHook, /observatory-ui:[^`]*sourceSessionId|\.slice\(0, 160\)/);
assert.match(workspace, /recordedJobsController\.refresh\(\)/);
assert.doesNotMatch(`${workspace}\n${jobsHook}`, /setInterval|setTimeout/);
assert.match(
styles,
/\.observatory-catalog-bar__controls \{[\s\S]*min-width: 0;[\s\S]*flex: 1 1 auto;[\s\S]*flex-wrap: nowrap;[\s\S]*justify-content: flex-end;/,
);
assert.match(
styles,
/\.observatory-catalog-bar__run \{[\s\S]*display: flex;[\s\S]*align-items: center;/,
);
assert.match(
styles,
/@media \(max-width: 920px\)[\s\S]*\.observatory-catalog-bar,[\s\S]*\.observatory-catalog-bar__controls,[\s\S]*flex-direction: column;/,
);
assert.match(setupHook, /catalog\?\.sourceSessionId === sourceSessionId/);
assert.match(setupHook, /preflightRequest\.current\?\.abort\(\)/);
assert.match(setupHook, /preflightRequest\.current !== request/);
assert.doesNotMatch(`${workspace}\n${setupDetail}\n${setupHook}`, /\/api\/v1\/observatory\/runs/);
assert.match(
setupHook,
/state !== "ready" \|\| !selectedSetup \|\| preflight\.kind !== "idle"[\s\S]*void check\(\)/,
);
});
+4 -4
View File
@@ -3,8 +3,8 @@
"setups": [
{
"setup_id": "m49-tgs-full-shadow-v1",
"display_name": "M4.9T5 · TRAVEL TGS · полный source-paced shadow",
"description": "Сохранённая CPU-конфигурация RAVNOVES00: 4 489 кадров, causal rolling 1 s, полный визуальный разбор и отдельное доказательство integrated graph.",
"display_name": "M4.9T5 · TRAVEL TGS · CPU-only, без ML",
"description": "Сохранённая конфигурация RAVNOVES00: TRAVEL TGS без ML-моделей, 4 489 кадров, causal rolling 1 s, полный визуальный разбор и отдельное доказательство integrated graph.",
"origin": "archived-definition",
"source": {
"session_id": "20260720T065719Z_viewer_live",
@@ -58,8 +58,8 @@
},
{
"setup_id": "lab-v1-ravnoves004tree-final",
"display_name": "LAB V1 · финальный записанный контур восприятия",
"description": "Текущий точный RAVNOVES004TREE result: 6 830 кадров EoMT + DDRNet и синхронный записанный replay без доступа к управлению.",
"display_name": "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39",
"description": "Точный RAVNOVES004TREE result: 6 830 кадров EoMT Cityscapes Large 1024 + DDRNet-39 и синхронный записанный replay; полный TGS и независимый YOLOX отсутствуют.",
"origin": "existing-result",
"source": {
"session_id": "20260828T130511Z_viewer_live",
@@ -0,0 +1,61 @@
{
"schema_version": "missioncore.observatory-m49-recorded-queue-binding/v1",
"binding_id": "m49-ravnoves00-recorded-queue-v1",
"source": {
"session_id": "20260720T065719Z_viewer_live",
"label": "RAVNOVES00",
"required_modalities": [
"point-cloud",
"trajectory",
"video"
],
"source_pack": {
"artifact_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b",
"sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944",
"byte_length": 72996000,
"media_type": "application/vnd.nodedc.lidar-source-pack+npz",
"expected_timeline_frames": 4489,
"expected_available_lidar_frames": 3928
}
},
"setup": {
"setup_id": "m49-tgs-full-shadow-v1",
"definition_id": "m49-tgs-full-shadow",
"definition_version": 1,
"definition_sha256": "836a66639e69f7ea00de2a9111c1c6c9c3c00f1abd726f1df596aeb4e3a88ae6"
},
"source_adapter": {
"adapter_id": "ravnoves00-m49-source-pack",
"version": 1
},
"executor": {
"release_id": "m49-tgs-full-shadow-worker-release",
"artifact_sha256": "5e0ea16c7a5cc760463836718b0cd8b0006ffc4b202e5f706a20a86ef2f912ab",
"code_revision": "40c850b167dda366d8aa45d828520168affaf9fd",
"service_installed": false,
"image_set": {
"schema_version": "missioncore.observatory-executor-image-set/v1",
"images": {
"travel": "7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f",
"parity": "ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0"
}
},
"learned_models": []
},
"resource_profile": {
"schema_version": "missioncore.observatory-recorded-resource-profile/v1",
"profile_id": "worker006-cpu-single-run-v1",
"contour_id": "worker-006",
"concurrency": 1,
"checkpoint_policy": "non-checkpointable",
"restart_from_zero": true,
"staging_discard_required": true,
"resource_release_receipt_required": true
},
"authority": {
"commands_enabled": false,
"actuation_allowed": false,
"navigation_or_safety_accepted": false,
"production_accepted": false
}
}
+53 -22
View File
@@ -275,33 +275,64 @@ execution canaries. Once they prove one automatic run-to-evidence path, the LAB
canonicalization slice closes and remaining historical work stays `legacy` unless
an integrity or product need justifies a targeted migration.
## Observatory preparation and Worker dispatch
## Observatory durable recorded queue and Worker dispatch
Selecting a source and a laboratory setup in Observatory is not itself a run.
Before a remote executor is installed, Mission Core may persist only an immutable
`missioncore.observatory-run-preparation/v1` receipt. That receipt binds the exact
source-catalog snapshot, setup id, RunDefinition version and configuration digest
to one idempotency key. Its only admitted terminal state is `blocked`; it has no
`run_id`, dispatch receipt or execution authority.
The public submission contains only `source_session_id`, `setup_id` and an
idempotency key. It cannot supply commands, executable text, filesystem paths,
container images, model identities, resource limits or priority. The server
resolves the allowlisted pair and seals all executable identity into one durable
record:
A real Worker run begins only after a separate durable dispatcher has atomically
published an immutable dispatch receipt. The executor must be an additive,
allowlisted service owned outside the K1 acquisition path. It may accept bounded
versioned data, but never an arbitrary command, filesystem path or PowerShell
fragment from the UI. Exact retries return the original receipt; a reused key with
different identity fails closed. Transport uncertainty produces an explicit
reconciliation state and never an automatic duplicate run.
- the current source-catalog snapshot captured at admission, plus immutable source
bundle and source-capability-manifest SHA-256 identities;
- source-adapter id, version and SHA-256;
- setup RunDefinition id, version and SHA-256;
- executor release and image SHA-256 identities;
- learned-model manifest and resource-profile identities;
- checkpoint policy and the server-owned priority class.
The first executable Observatory setup remains the exact recorded
RAVNOVES00/M4.9T5 definition. Its full and integrated successors are different
definitions and results. The current RAVNOVES004TREE LAB V1 projection remains
replay-only until a new, independently versioned RunDefinition is reconstructed;
Mission Core does not invent a configuration for an already published result.
An exact retry returns the existing job. Reusing an idempotency key for another
identity fails closed. The ordinary lifecycle advances through `accepted`,
`queued`, `claimed` and `running`, then terminates as `succeeded` or `failed`.
`paused`, `preemption-pending` and `reconciliation-required` are explicit safety
branches rather than hidden retries. The durable queue serializes one recorded
compute owner and does not equate a queued receipt with a Worker execution
receipt.
Worker telemetry is secondary observation evidence. It does not replace the
authoritative run ledger, dispatch receipt, result validation or common laboratory
receipt. K1 control, Simulation/Gaussian runtimes and legacy LAB projections are
outside this executor boundary and are not restarted or migrated by it.
The only currently queueable pair is the exact `RAVNOVES00` source and
`M4.9T5 · TRAVEL TGS · CPU-only, без ML` RunDefinition. It seals an explicitly
empty learned-model list because TGS is an algorithmic CPU pipeline, not an
unknown model dependency. The binding pins the exact source pack rather than a
volatile whole-catalog digest; the server captures and seals the current catalog
snapshot into each admitted job. `LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39`
remains a result-only replay: no independently versioned durable executor exists
for that published result, so Mission Core does not invent one. Every other
session/setup pair fails closed until its own source adapter, capability manifest,
RunDefinition and executor identities are admitted.
Recorded work has priority rank `100`. A future live K1 lease has rank `0` and
closes new recorded claims while it is pending or active. Cooperative executors
yield only at an allowlisted checkpoint. A non-checkpointable monolith must be
cancelled by the scheduler, discard all staging output and later restart from
zero. Cancellation uses a crash-safe two-phase protocol: Mission Core first
persists one stable cancellation intent and activates live work only after an
identity-bound receipt proves resource release and staging discard. A retry uses
the same cancellation identity. A missing callback or receipt remains durably
pending for retry; a conflicting or indeterminate receipt enters reconciliation.
Every unresolved form blocks live activation and concurrent ownership.
An explicit live terminal trigger requeues paused recorded work.
ADR 0046 records this decision. The durable queue and identity contracts are
implemented; Worker claim transport, installation of the exact M4.9T5 executor
and wiring from the real K1 lifecycle to live-lease triggers remain pending.
Therefore a submitted M4.9T5 job may honestly wait in `queued` without implying
that Worker 006 can execute it yet.
Worker telemetry remains secondary observation evidence. It does not replace the
authoritative queue ledger, result validation or common laboratory receipt. K1
acquisition and control, the legacy LAB archive, and Simulation/Gaussian runtimes
are outside this boundary and are not restarted, migrated or modified by it.
## Planning discipline
+2 -2
View File
@@ -113,11 +113,11 @@ The next bounded slice adds an explicit laboratory Setup selection beside the so
Setup is a versioned, immutable compatibility contract, not a mutable bag of UI parameters. The
catalog keeps two independently named historical facts:
- `M4.9T5 · TRAVEL TGS · полный source-paced shadow` is an archived RunDefinition bound exactly to
- `M4.9T5 · TRAVEL TGS · CPU-only, без ML` is an archived RunDefinition bound exactly to
`20260720T065719Z_viewer_live`. Its configuration references are content-addressed, its primary
visual result remains in legacy LAB, and the integrated-graph result is retained as a compute
successor rather than presented as a second viewer.
- `LAB V1 · финальный записанный контур восприятия` is the current exact RAV004 result bound to
- `LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39` is the current exact RAV004 result bound to
`20260828T130511Z_viewer_live`. It predates the product RunDefinition contract and therefore keeps
`run_definition = null`; the UI identifies it as an existing result and never fabricates a config
digest for it.
@@ -0,0 +1,138 @@
# ADR 0046: Durable Observatory recorded queue and live K1 priority
Date: 2026-08-31
Status: accepted; durable queue core implemented; Worker and live-trigger wiring pending
## Context
Observatory must let an operator apply an admitted laboratory setup to a saved
session without turning the browser into an execution client. The current compute
host is a shared, finite Worker rather than an elastic pool. Recorded experiments
may wait, but a future live K1 perception process must acquire that compute
immediately and exclusively when its real acquisition lifecycle begins.
Historical LAB results are not automatically executable definitions. In
particular, the existing LAB V1 result proves a recorded EoMT and DDRNet analysis,
but it does not retain one independently versioned, durable executor contract that
can safely be reconstructed from its display metadata. Conversely, M4.9T5 has an
exact accepted RAVNOVES00 source pack and CPU-only TRAVEL TGS release which can be
sealed without inventing learned-model dependencies.
The queue must also survive process restarts and ambiguity around preemption. A
database flag alone cannot prove that a non-checkpointable Worker process released
CPU, memory and staging storage. Starting live K1 work before physical cancellation
is confirmed would permit two owners of the same constrained resource.
## Decision
Mission Core owns a bounded SQLite-backed recorded-job queue. The browser submits
only:
- `source_session_id`;
- `setup_id`;
- one idempotency key.
The request contains no command, script, path, environment variable, image,
model, resource limit or priority. The server resolves an allowlisted source/setup
pair and seals the following identities into the accepted job:
1. the current source-catalog snapshot SHA-256 captured at admission;
2. immutable source-bundle SHA-256;
3. source-capability-manifest SHA-256;
4. source-adapter id, version and SHA-256;
5. setup RunDefinition id, version and SHA-256;
6. executor release and container-image SHA-256;
7. learned-model release list and model-manifest SHA-256;
8. resource-profile SHA-256 and checkpoint policy.
The queue owns idempotency. An exact retry returns the original job and receipt.
The same key with changed identity is a conflict. Jobs advance through `accepted`,
`queued`, `claimed` and `running`, then terminate as `succeeded` or `failed`.
`paused`, `preemption-pending` and `reconciliation-required` expose safety-relevant
branches. Mission Core admits only one active recorded compute owner.
The queue is an orchestration and evidence boundary, not a Worker transport. A
queued job proves durable admission, not remote execution or eventual success.
Worker claims, terminal result publication and telemetry must later use a separate
authenticated, versioned service boundary; no arbitrary PowerShell or filesystem
payload is added to this contract.
## Current setup admission
The current executable matrix contains one exact pair:
- source: `RAVNOVES00`;
- setup: `M4.9T5 · TRAVEL TGS · CPU-only, без ML`;
- learned-model releases: `[]`;
- checkpoint policy: `non-checkpointable`.
The empty model list is affirmative provenance: this TGS configuration is a
CPU-only algorithmic pipeline. It is not a placeholder for unidentified weights.
Admission still reads and seals the current source-catalog snapshot and verifies
the pinned source bundle and capability manifest before the job is accepted. The
binding deliberately does not pin a digest of the whole mutable catalog: the
exact compute input is the immutable source pack, while the catalog snapshot is
job-specific provenance.
`LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39` remains result-only. The
published evidence stays viewable, but the setup cannot be queued until a new
independently versioned source adapter, RunDefinition and executable release are
created and accepted. The same fail-closed rule applies to every other
session/setup combination. The product may grow toward any admitted setup over
any compatible recorded source, but compatibility is evidence, not an assumption.
## Priority and preemption protocol
Priority is server-owned:
- live K1 lease: rank `0`;
- recorded replay job: rank `100`.
A pending or active live lease blocks new recorded claims. A running cooperative
job yields at an allowlisted checkpoint and moves to `paused` before live
activation. A non-checkpointable job cannot pretend to pause. Its transition is:
1. persist a stable cancellation intent bound to the job claim, executor release,
image, resource profile and live trigger;
2. invoke the scheduler-owned physical cancellation boundary;
3. persist an identity-bound cancellation receipt proving resource release,
staging discard and restart-from-zero;
4. only then activate the live K1 lease.
The cancellation identity is reused after a crash or retry. A missing callback or
receipt leaves the durable intent pending for retry; a conflicting or
indeterminate receipt moves the record to reconciliation. Both forms block live
activation instead of automatically launching duplicate work. When an
explicit live terminal trigger completes, fails or cancels the lease, paused
recorded jobs return to the queue. A non-checkpointable job starts again from
zero; partial staging never becomes evidence.
## Acceptance boundary
This decision accepts the durable queue core and its identity, lifecycle,
idempotency and preemption contracts. It does not claim that the complete compute
loop is deployed. The following remain implementation gates:
- authenticated Worker claim and result transport;
- installation of the exact M4.9T5 executor on the Worker;
- binding real K1 acquisition start/terminal events to live-lease triggers;
- physical acceptance that cancellation releases the required Worker resources
and that recorded work resumes only after the live lease terminates.
Until those gates close, an M4.9T5 submission may remain honestly `queued`. LAB V1
cannot be submitted, and no UI state may present it as executable.
## Consequences
- The UI stays a bounded selector and submitter; executable authority remains on
the server.
- Every accepted job is reproducible from sealed source, setup, executor, model
and resource identities rather than mutable host paths.
- Live K1 can receive hard priority without silently losing or concurrently
running recorded work.
- Non-checkpointable replay pays the deliberate cost of discard and
restart-from-zero after preemption.
- K1 acquisition/control principles, the legacy LAB archive and
Simulation/Gaussian remain unchanged.
- Adding a setup or source requires an admitted adapter and RunDefinition; a
display name or historical result is insufficient.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40
View File
@@ -42,6 +42,15 @@ from k1link.observatory import (
ObservatoryRunPreparationLedger,
load_observatory_run_preparation_ledger,
)
from k1link.observatory.m49_queue_binding import (
M49QueueBindingConfig,
M49QueueBindingError,
M49RecordedQueueBindingService,
)
from k1link.observatory.recorded_jobs import (
ObservatoryRecordedJobQueue,
ObservatoryRecordedQueueError,
)
from k1link.sessions import (
MaterializedRecording,
RecordedCameraFrameService,
@@ -242,6 +251,34 @@ OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR: str | None
OBSERVATORY_RUN_PREPARATION_LEDGER,
OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR,
) = load_observatory_run_preparation_ledger(session_store.data_dir)
OBSERVATORY_RECORDED_BINDING_SERVICE: M49RecordedQueueBindingService | None
OBSERVATORY_RECORDED_JOB_QUEUE: ObservatoryRecordedJobQueue | None
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR: str | None
try:
if OBSERVATORY_LABORATORY_SETUP_REGISTRY is None:
raise M49QueueBindingError("observatory setup registry is unavailable")
OBSERVATORY_RECORDED_BINDING_SERVICE = M49RecordedQueueBindingService(
data_dir=session_store.data_dir,
session_store=session_store,
setup_registry=OBSERVATORY_LABORATORY_SETUP_REGISTRY,
config=M49QueueBindingConfig.from_file(
REPOSITORY_ROOT
/ "config"
/ "observatory-m49-recorded-queue-binding.json"
),
)
OBSERVATORY_RECORDED_JOB_QUEUE = ObservatoryRecordedJobQueue(
session_store.data_dir,
definitions=OBSERVATORY_RECORDED_BINDING_SERVICE.definitions,
)
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR = None
except (M49QueueBindingError, ObservatoryRecordedQueueError, OSError, ValueError) as exc:
# Recorded execution remains an optional observation-only slice. A drifted
# seal or queue must fail closed without preventing K1, Simulation or legacy
# LAB from starting.
OBSERVATORY_RECORDED_BINDING_SERVICE = None
OBSERVATORY_RECORDED_JOB_QUEUE = None
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR = str(exc)
simulation_project_store = SimulationProjectStore(session_store.data_dir)
simulation_project_service = SimulationProjectService(simulation_project_store)
session_artifact_gateway = configured_artifact_gateway(session_store.data_dir)
@@ -725,6 +762,9 @@ app.include_router(
setup_registry_error=OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR,
run_preparation_ledger=OBSERVATORY_RUN_PREPARATION_LEDGER,
run_preparation_ledger_error=OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR,
recorded_binding_service=OBSERVATORY_RECORDED_BINDING_SERVICE,
recorded_job_queue=OBSERVATORY_RECORDED_JOB_QUEUE,
recorded_job_queue_error=OBSERVATORY_RECORDED_JOB_QUEUE_ERROR,
)
)
app.include_router(
+348 -3
View File
@@ -17,6 +17,18 @@ from k1link.observatory import (
is_admitted_observatory_recorded_result,
observatory_run_preparation_request_sha256,
)
from k1link.observatory.m49_queue_binding import (
M49QueueBindingError,
M49QueueBindingIntegrityError,
M49RecordedQueueBindingService,
)
from k1link.observatory.recorded_jobs import (
ObservatoryRecordedJobQueue,
ObservatoryRecordedQueueCapacityError,
ObservatoryRecordedQueueConflictError,
ObservatoryRecordedQueueError,
ObservatoryRecordedQueueNotFoundError,
)
from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore
from k1link.sessions.models import SessionSummary
@@ -32,6 +44,19 @@ OBSERVATORY_RUN_PREFLIGHT_REQUEST_SCHEMA: Literal[
OBSERVATORY_RUN_PREFLIGHT_SCHEMA: Literal[
"missioncore.observatory-run-preflight/v1"
] = "missioncore.observatory-run-preflight/v1"
OBSERVATORY_RECORDED_RUN_SUBMIT_SCHEMA: Literal[
"missioncore.observatory-recorded-run-submit/v1"
] = "missioncore.observatory-recorded-run-submit/v1"
OBSERVATORY_RECORDED_JOB_LIST_SCHEMA: Literal[
"missioncore.observatory-recorded-job-list/v1"
] = "missioncore.observatory-recorded-job-list/v1"
_OBSERVATION_ONLY_AUTHORITY: dict[str, bool] = {
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
"production_accepted": False,
}
class _StrictApiModel(BaseModel):
@@ -88,6 +113,25 @@ class ObservatoryRunPreparationRequest(_StrictApiModel):
definition_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
class ObservatoryRecordedRunSubmitRequest(_StrictApiModel):
schema_version: Literal["missioncore.observatory-recorded-run-submit/v1"]
idempotency_key: str = Field(
min_length=1,
max_length=160,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$",
)
source_session_id: str = Field(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
)
setup_id: str = Field(
min_length=3,
max_length=96,
pattern=r"^[a-z][a-z0-9-]{2,95}$",
)
def build_observatory_router(
store: SessionStore,
*,
@@ -95,6 +139,9 @@ def build_observatory_router(
setup_registry_error: str | None = None,
run_preparation_ledger: ObservatoryRunPreparationLedger | None = None,
run_preparation_ledger_error: str | None = None,
recorded_binding_service: M49RecordedQueueBindingService | None = None,
recorded_job_queue: ObservatoryRecordedJobQueue | None = None,
recorded_job_queue_error: str | None = None,
) -> APIRouter:
"""Build bounded catalog-only mutations for typed Observatory projections."""
@@ -224,6 +271,34 @@ def build_observatory_router(
assert isinstance(executor, dict)
compatible = compatibility.get("compatible") is True
existing = preflight.get("outcome") == "existing"
queue_binding_ready = False
queue_binding_reason: str | None = None
binding_service = recorded_binding_service
exact_queue_setup = (
binding_service is not None
and request.setup_id
== binding_service.config.setup.setup_id
)
if (
compatible
and not existing
and binding_service is not None
and exact_queue_setup
and isinstance(expected_digest, str)
):
try:
binding_service.check(
source_session_id=request.source_session_id,
setup_id=request.setup_id,
definition_sha256=expected_digest,
)
queue_binding_ready = True
except M49QueueBindingError:
queue_binding_reason = (
"Точная привязка источника и исполняемого релиза не прошла "
"проверку целостности."
)
queueable = queue_binding_ready and recorded_job_queue is not None
checks: list[dict[str, Any]] = [
{
"check_id": "source-compatibility",
@@ -247,26 +322,66 @@ def build_observatory_router(
},
{
"check_id": "executor",
"outcome": "not-applicable" if existing else "fail",
"outcome": (
"not-applicable"
if existing
else "pass"
if queue_binding_ready
else "fail"
),
"reason_code": (
"existing-result-does-not-require-executor"
if existing
else "executor-release-sealed"
if queue_binding_ready
else str(executor.get("reason_code"))
),
"message": (
"Готовый результат открывается без повторного запуска Worker."
if existing
else (
"Исполняемый релиз и его ресурсы запечатаны; отдельный "
"Worker service заберёт расчёт из очереди после установки."
)
if queue_binding_ready
else str(executor.get("reason"))
),
},
{
"check_id": "durable-queue",
"outcome": (
"not-applicable"
if existing
else "pass"
if queueable
else "fail"
),
"reason_code": (
"existing-result-does-not-require-queue"
if existing
else "durable-queue-ready"
if queueable
else "durable-queue-unavailable"
),
"message": (
"Готовый результат не требует постановки в очередь."
if existing
else "Durable-очередь готова принять расчёт."
if queueable
else queue_binding_reason
or "Этот источник и сетап пока нельзя поставить в очередь."
),
},
]
return {
"schema_version": OBSERVATORY_RUN_PREFLIGHT_SCHEMA,
"source_session_id": request.source_session_id,
"setup_id": request.setup_id,
"definition_sha256": expected_digest,
"outcome": "existing" if existing else "blocked",
"submission_allowed": False,
"outcome": (
"existing" if existing else "queueable" if queueable else "blocked"
),
"submission_allowed": queueable,
"checks": checks,
"existing_result_ids": preflight.get("existing_result_ids", []),
"executor": executor,
@@ -508,6 +623,236 @@ def build_observatory_router(
detail="Подготовка расчётов Обсерватории недоступна.",
)
if (
setup_registry is not None
and recorded_binding_service is not None
and recorded_job_queue is not None
):
@router.post("/api/v1/observatory/runs", status_code=202)
def submit_observatory_recorded_run(
request: ObservatoryRecordedRunSubmitRequest,
) -> dict[str, object]:
try:
existing_job = recorded_job_queue.get_by_idempotency_key(
request.idempotency_key
)
except ObservatoryRecordedQueueNotFoundError:
existing_job = None
except (ObservatoryRecordedQueueError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
) from exc
if existing_job is not None:
if (
existing_job.source_session_id != request.source_session_id
or existing_job.setup_id != request.setup_id
):
raise HTTPException(
status_code=409,
detail="Ключ идемпотентности уже связан с другим расчётом.",
)
return existing_job.as_dict()
source = source_summary(request.source_session_id)
try:
setup_registry.setup(request.setup_id)
except KeyError as exc:
raise HTTPException(
status_code=404,
detail="Сетап лаборатории не найден.",
) from exc
catalog = setup_registry.catalog(
source,
available_observatory_result_ids=available_observatory_results(
request.source_session_id
),
)
setups = catalog.get("setups")
if not isinstance(setups, list):
raise HTTPException(
status_code=503,
detail="Каталог сетапов Обсерватории нарушил контракт.",
)
projected = next(
(
item
for item in setups
if isinstance(item, dict) and item.get("setup_id") == request.setup_id
),
None,
)
if projected is None:
raise HTTPException(
status_code=503,
detail="Каталог сетапов Обсерватории нарушил контракт.",
)
compatibility = projected.get("compatibility")
preflight = projected.get("preflight")
definition = projected.get("run_definition")
if not isinstance(compatibility, dict) or not isinstance(preflight, dict):
raise HTTPException(
status_code=503,
detail="Каталог сетапов Обсерватории нарушил контракт.",
)
if compatibility.get("compatible") is not True:
raise HTTPException(
status_code=409,
detail="Исходная сессия несовместима с выбранным сетапом.",
)
if preflight.get("outcome") == "existing":
raise HTTPException(
status_code=409,
detail="Точный результат уже существует; новый расчёт не создан.",
)
if not isinstance(definition, dict):
raise HTTPException(
status_code=409,
detail="Для сетапа нет воспроизводимой RunDefinition.",
)
definition_sha256 = definition.get("definition_sha256")
if not isinstance(definition_sha256, str):
raise HTTPException(
status_code=503,
detail="Каталог сетапов Обсерватории нарушил контракт.",
)
try:
admission = recorded_binding_service.admit(
source_session_id=request.source_session_id,
setup_id=request.setup_id,
definition_sha256=definition_sha256,
)
job, _created = recorded_job_queue.submit(
admission.intent(idempotency_key=request.idempotency_key),
enqueue=True,
)
return job.as_dict()
except M49QueueBindingIntegrityError as exc:
raise HTTPException(
status_code=409,
detail=(
"Точная привязка источника и исполняемого релиза не прошла "
"проверку целостности."
),
) from exc
except ObservatoryRecordedQueueConflictError as exc:
try:
existing_job = recorded_job_queue.get_by_idempotency_key(
request.idempotency_key
)
except ObservatoryRecordedQueueNotFoundError:
existing_job = None
except (ObservatoryRecordedQueueError, ValueError) as lookup_exc:
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
) from lookup_exc
if (
existing_job is not None
and existing_job.source_session_id == request.source_session_id
and existing_job.setup_id == request.setup_id
):
return existing_job.as_dict()
raise HTTPException(
status_code=409,
detail="Ключ идемпотентности уже связан с другим расчётом.",
) from exc
except ObservatoryRecordedQueueCapacityError as exc:
raise HTTPException(
status_code=503,
detail="Квота durable-очереди расчётов исчерпана.",
) from exc
except (M49QueueBindingError, ObservatoryRecordedQueueError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
) from exc
@router.get("/api/v1/observatory/runs")
def list_observatory_recorded_runs(
source_session_id: str = Query(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
),
setup_id: str = Query(
min_length=3,
max_length=96,
pattern=r"^[a-z][a-z0-9-]{2,95}$",
),
limit: int = Query(default=20, ge=1, le=100),
) -> dict[str, object]:
try:
jobs = recorded_job_queue.list_jobs(
source_session_id=source_session_id,
setup_id=setup_id,
limit=limit,
)
except (ObservatoryRecordedQueueError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
) from exc
return {
"schema_version": OBSERVATORY_RECORDED_JOB_LIST_SCHEMA,
"items": [job.as_dict() for job in jobs],
"authority": dict(_OBSERVATION_ONLY_AUTHORITY),
}
@router.get("/api/v1/observatory/runs/{job_id}")
def get_observatory_recorded_run(
job_id: str = ApiPath(
min_length=48,
max_length=48,
pattern=r"^observatory-run-[a-f0-9]{32}$",
),
) -> dict[str, object]:
try:
return recorded_job_queue.get(job_id).as_dict()
except ObservatoryRecordedQueueNotFoundError as exc:
raise HTTPException(
status_code=404,
detail="Расчёт Обсерватории не найден.",
) from exc
except (ObservatoryRecordedQueueError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
) from exc
elif recorded_job_queue_error is not None:
@router.post("/api/v1/observatory/runs")
def unavailable_observatory_recorded_run(
request: ObservatoryRecordedRunSubmitRequest,
) -> None:
del request
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
)
@router.get("/api/v1/observatory/runs")
def unavailable_observatory_recorded_runs(
source_session_id: str = Query(
min_length=1,
max_length=128,
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
),
setup_id: str = Query(
min_length=3,
max_length=96,
pattern=r"^[a-z][a-z0-9-]{2,95}$",
),
limit: int = Query(default=20, ge=1, le=100),
) -> None:
del source_session_id, setup_id, limit
raise HTTPException(
status_code=503,
detail="Durable-очередь расчётов недоступна.",
)
@router.patch(
"/api/v1/observatory/lab-projections/{session_id}",
response_model=ObservatoryProjectionDocument,
+555
View File
@@ -0,0 +1,555 @@
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import cast
import pytest
import k1link.observatory.m49_queue_binding as binding_module
from k1link.artifact_gateway import (
ArtifactMember,
CentralArtifactStore,
LocalArtifactCache,
)
from k1link.observatory.m49_queue_binding import (
OBSERVATORY_EXECUTOR_BINDING_SCHEMA,
OBSERVATORY_SOURCE_BUNDLE_SCHEMA,
OBSERVATORY_SOURCE_CAPABILITY_SCHEMA,
SOURCE_DOCUMENT_STORE_DIRECTORY,
M49QueueBindingConfig,
M49QueueBindingIntegrityError,
M49RecordedQueueBindingService,
M49SourcePackIdentity,
M49SourcePackVerifier,
_verify_source_pack_from_cache,
)
from k1link.observatory.setups import LaboratorySetupRegistry
from k1link.sessions.models import (
SessionArtifact,
SessionDetail,
SessionSource,
SessionSummary,
)
from k1link.sessions.store import SessionStore
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
BINDING_PATH = (
REPOSITORY_ROOT / "config" / "observatory-m49-recorded-queue-binding.json"
)
SETUP_REGISTRY_PATH = (
REPOSITORY_ROOT / "config" / "observatory-laboratory-setups.json"
)
SOURCE_SESSION_ID = "20260720T065719Z_viewer_live"
SOURCE_LABEL = "RAVNOVES00"
SETUP_ID = "m49-tgs-full-shadow-v1"
DEFINITION_SHA256 = (
"836a66639e69f7ea00de2a9111c1c6c9c3c00f1abd726f1df596aeb4e3a88ae6"
)
SOURCE_PACK_SHA256 = (
"0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944"
)
EXECUTOR_ARTIFACT_SHA256 = (
"5e0ea16c7a5cc760463836718b0cd8b0006ffc4b202e5f706a20a86ef2f912ab"
)
CODE_REVISION = "40c850b167dda366d8aa45d828520168affaf9fd"
TRAVEL_IMAGE_SHA256 = (
"7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f"
)
PARITY_IMAGE_SHA256 = (
"ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0"
)
SOURCE_CATALOG_SHA256 = (
"24207af81b67de515ba9f1b899577bd877a00d058c3472269508980541b1e185"
)
class _SessionStore:
def __init__(
self,
data_dir: Path,
detail: SessionDetail,
catalog_sha256: str,
) -> None:
self.data_dir = data_dir.resolve()
self.detail = detail
self.catalog_sha256 = catalog_sha256
def get_session_with_catalog_snapshot(
self, session_id: str
) -> tuple[SessionDetail, str]:
if session_id != self.detail.summary.session_id:
raise KeyError(session_id)
return self.detail, self.catalog_sha256
def _canonical_sha256(value: object) -> str:
payload = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode()
return hashlib.sha256(payload).hexdigest()
def test_first_immutable_contract_store_syncs_directory_chain_bottom_up(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = b'{"schema_version":"test/v1"}'
digest = hashlib.sha256(payload).hexdigest()
root = tmp_path / "observatory-source-contracts"
synced: list[Path] = []
monkeypatch.setattr(
binding_module,
"_fsync_directory",
lambda path: synced.append(path),
)
binding_module._write_immutable_document(root, digest, payload)
assert synced == [
root / "objects" / "sha256" / digest[:2],
root / "objects" / "sha256",
root / "objects",
root,
tmp_path,
]
def test_existing_immutable_contract_replays_directory_chain_sync(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
payload = b'{"schema_version":"test/v1"}'
digest = hashlib.sha256(payload).hexdigest()
root = tmp_path / "observatory-source-contracts"
binding_module._write_immutable_document(root, digest, payload)
synced: list[Path] = []
monkeypatch.setattr(
binding_module,
"_fsync_directory",
lambda path: synced.append(path),
)
binding_module._write_immutable_document(root, digest, payload)
assert synced == [
root / "objects" / "sha256" / digest[:2],
root / "objects" / "sha256",
root / "objects",
root,
tmp_path,
]
def test_immutable_contract_store_rejects_intermediate_symlink(
tmp_path: Path,
) -> None:
payload = b'{"schema_version":"test/v1"}'
digest = hashlib.sha256(payload).hexdigest()
root = tmp_path / "observatory-source-contracts"
outside = tmp_path / "outside"
root.mkdir()
outside.mkdir()
(root / "objects").symlink_to(outside, target_is_directory=True)
with pytest.raises(
M49QueueBindingIntegrityError,
match="store directory is invalid",
):
binding_module._write_immutable_document(root, digest, payload)
assert list(outside.iterdir()) == []
def _detail() -> SessionDetail:
summary = SessionSummary(
session_id=SOURCE_SESSION_ID,
display_name=SOURCE_LABEL,
status="ready",
started_at_utc="2026-07-20T06:57:20.888Z",
completed_at_utc="2026-07-20T07:06:16.599Z",
duration_seconds=535.717620042,
modalities=("point-cloud", "trajectory", "video"),
source_count=3,
total_bytes=100,
replayable=True,
origin="xgrids-k1.viewer-live.evidence",
)
sources = (
SessionSource(
source_id="sensor.camera.right",
semantic_channel_id="camera.video.recorded",
modality="video",
status="recorded",
seekable=True,
artifact_id="recorded-video",
),
SessionSource(
source_id="sensor.lidar.primary",
semantic_channel_id="spatial.point-cloud.recorded",
modality="point-cloud",
status="recorded",
seekable=True,
artifact_id="raw-primary",
),
SessionSource(
source_id="spatial.trajectory",
semantic_channel_id="spatial.pose.recorded",
modality="trajectory",
status="recorded",
seekable=True,
artifact_id="raw-primary",
),
)
artifacts = (
SessionArtifact(
artifact_id="raw-primary",
kind="raw-transport",
media_type="application/x-nodedc-k1mqtt",
byte_length=64,
sha256="d" * 64,
integrity_status="verified",
),
SessionArtifact(
artifact_id="recorded-video",
kind="recorded-video",
media_type="video/mp4",
byte_length=36,
sha256=None,
integrity_status="validated-structure",
),
)
return SessionDetail(
summary=summary,
sources=sources,
artifacts=artifacts,
plugin_id="xgrids-k1",
archive_id="viewer-live",
)
def _registry() -> LaboratorySetupRegistry:
return LaboratorySetupRegistry.from_file(
SETUP_REGISTRY_PATH,
repository_root=REPOSITORY_ROOT,
)
def _artifact_cache(
data_dir: Path,
tmp_path: Path,
payload: bytes,
identity: M49SourcePackIdentity,
) -> LocalArtifactCache:
source = tmp_path / "source-pack.npz"
source.write_bytes(payload)
central = CentralArtifactStore(tmp_path / "central", create=True)
published = central.publish_file(source)
assert published.sha256 == identity.sha256
cache = LocalArtifactCache(
data_dir / "artifact-cache",
max_bytes=1024 * 1024,
free_space_reserve_bytes=0,
)
member = ArtifactMember(
role="lidar-source-pack",
media_type=identity.media_type,
sha256=published.sha256,
byte_length=published.byte_length,
)
cache.fetch(central, member, pin_id=f"session:{SOURCE_SESSION_ID}")
return cache
def _accept_source_pack(
_cache: LocalArtifactCache,
identity: M49SourcePackIdentity,
) -> None:
assert identity.sha256 == SOURCE_PACK_SHA256
assert identity.byte_length == 72_996_000
def _service(
tmp_path: Path,
*,
catalog_sha256: str = SOURCE_CATALOG_SHA256,
setup_registry: LaboratorySetupRegistry | None = None,
source_pack_verifier: M49SourcePackVerifier = _accept_source_pack,
) -> tuple[M49RecordedQueueBindingService, LocalArtifactCache]:
data_dir = tmp_path / "data"
data_dir.mkdir()
config = M49QueueBindingConfig.from_file(BINDING_PATH)
cache = LocalArtifactCache(
data_dir / "artifact-cache",
max_bytes=1024 * 1024,
free_space_reserve_bytes=0,
)
store = _SessionStore(data_dir, _detail(), catalog_sha256)
service = M49RecordedQueueBindingService(
data_dir=data_dir,
session_store=cast(SessionStore, store),
setup_registry=setup_registry or _registry(),
config=config,
artifact_cache=cache,
source_pack_verifier=source_pack_verifier,
)
return service, cache
def test_bundled_binding_freezes_the_accepted_exact_m49_identity() -> None:
config = M49QueueBindingConfig.from_file(BINDING_PATH)
definition = config.recorded_definition()
image_set = config.image_set_document()
resource = config.resource_profile.document()
assert config.source.session_id == SOURCE_SESSION_ID
assert config.source.label == SOURCE_LABEL
assert config.source.source_pack.sha256 == SOURCE_PACK_SHA256
assert config.source.source_pack.byte_length == 72_996_000
assert config.setup.setup_id == SETUP_ID
assert config.setup.definition_sha256 == DEFINITION_SHA256
assert config.executor.artifact_sha256 == EXECUTOR_ARTIFACT_SHA256
assert config.executor.code_revision == CODE_REVISION
assert config.executor.service_installed is False
assert image_set["images"] == [
{"role": "parity", "sha256": PARITY_IMAGE_SHA256},
{"role": "travel", "sha256": TRAVEL_IMAGE_SHA256},
]
assert definition.executor_image_sha256 == _canonical_sha256(image_set)
assert definition.model_release_ids == ()
assert definition.learned_models == ()
assert definition.checkpoint_policy == "non-checkpointable"
assert definition.allowed_checkpoints == ()
assert resource["restart_from_zero"] is True
assert resource["staging_discard_required"] is True
assert resource["resource_release_receipt_required"] is True
def test_check_is_read_only_and_admit_seals_path_free_source_contracts(
tmp_path: Path,
) -> None:
service, cache = _service(tmp_path)
document_root = service.data_dir / SOURCE_DOCUMENT_STORE_DIRECTORY
cache_database_mtime_ns = cache.database_path.stat().st_mtime_ns
checked = service.check(
source_session_id=SOURCE_SESSION_ID,
setup_id=SETUP_ID,
definition_sha256=DEFINITION_SHA256,
)
assert not document_root.exists()
assert cache.database_path.stat().st_mtime_ns == cache_database_mtime_ns
assert checked.source_bundle.schema_version == OBSERVATORY_SOURCE_BUNDLE_SCHEMA
assert (
checked.capability_manifest.schema_version
== OBSERVATORY_SOURCE_CAPABILITY_SCHEMA
)
assert checked.executor_binding.schema_version == OBSERVATORY_EXECUTOR_BINDING_SCHEMA
assert checked.source_catalog_sha256 == SOURCE_CATALOG_SHA256
assert checked.definition.definition_sha256 == DEFINITION_SHA256
assert checked.definition.model_release_ids == ()
assert checked.definition.checkpoint_policy == "non-checkpointable"
assert checked.source_bundle.document()["members"] == [
{
"role": "lidar-source-pack",
"artifact_id": service.config.source.source_pack.artifact_id,
"media_type": service.config.source.source_pack.media_type,
"sha256": service.config.source.source_pack.sha256,
"byte_length": service.config.source.source_pack.byte_length,
}
]
assert "path" not in json.dumps(checked.as_dict()).lower()
admitted = service.admit(
source_session_id=SOURCE_SESSION_ID,
setup_id=SETUP_ID,
definition_sha256=DEFINITION_SHA256,
)
repeated = service.admit(
source_session_id=SOURCE_SESSION_ID,
setup_id=SETUP_ID,
definition_sha256=DEFINITION_SHA256,
)
sealed_files = tuple(document_root.rglob("*.json"))
assert admitted == checked
assert repeated == admitted
assert len(sealed_files) == 3
assert {path.stem for path in sealed_files} == {
admitted.source_bundle.sha256,
admitted.capability_manifest.sha256,
admitted.executor_binding.sha256,
}
assert all(path.read_bytes() in {
admitted.source_bundle.payload,
admitted.capability_manifest.payload,
admitted.executor_binding.payload,
} for path in sealed_files)
assert service.definitions.resolve(SETUP_ID, DEFINITION_SHA256) == (
admitted.definition
)
intent = admitted.intent(idempotency_key="m49-recorded-submit-001")
assert intent.source_session_id == SOURCE_SESSION_ID
assert intent.source_catalog_sha256 == SOURCE_CATALOG_SHA256
assert intent.source_bundle_sha256 == admitted.source_bundle.sha256
assert intent.source_capability_manifest_sha256 == (
admitted.capability_manifest.sha256
)
@pytest.mark.parametrize(
("source_session_id", "setup_id", "definition_sha256", "message"),
[
("other-session", SETUP_ID, DEFINITION_SHA256, "exact source session"),
(SOURCE_SESSION_ID, "other-setup", DEFINITION_SHA256, "exact setup"),
(SOURCE_SESSION_ID, SETUP_ID, "f" * 64, "definition digest"),
],
)
def test_binding_rejects_every_other_session_setup_or_definition(
tmp_path: Path,
source_session_id: str,
setup_id: str,
definition_sha256: str,
message: str,
) -> None:
service, _cache = _service(tmp_path)
with pytest.raises(M49QueueBindingIntegrityError, match=message):
service.check(
source_session_id=source_session_id,
setup_id=setup_id,
definition_sha256=definition_sha256,
)
def test_binding_captures_the_current_session_catalog_snapshot(
tmp_path: Path,
) -> None:
service, _cache = _service(tmp_path, catalog_sha256="e" * 64)
admission = service.check(
source_session_id=SOURCE_SESSION_ID,
setup_id=SETUP_ID,
definition_sha256=DEFINITION_SHA256,
)
assert admission.source_catalog_sha256 == "e" * 64
assert admission.source_bundle.document()["catalog_sha256"] == "e" * 64
def test_binding_fails_closed_when_the_cached_source_pack_changes(
tmp_path: Path,
) -> None:
payload = b"sealed source pack fixture\n"
identity = M49SourcePackIdentity(
artifact_id="fixture-source-pack",
sha256=hashlib.sha256(payload).hexdigest(),
byte_length=len(payload),
media_type="application/vnd.nodedc.lidar-source-pack+npz",
expected_timeline_frames=2,
expected_available_lidar_frames=1,
)
data_dir = tmp_path / "data"
data_dir.mkdir()
cache = _artifact_cache(data_dir, tmp_path, payload, identity)
_verify_source_pack_from_cache(cache, identity)
cache.object_path(identity.sha256).write_bytes(b"x" * len(payload))
with pytest.raises(M49QueueBindingIntegrityError, match="artifact-cache metadata"):
_verify_source_pack_from_cache(cache, identity)
def test_binding_fails_closed_when_the_setup_registry_definition_changes(
tmp_path: Path,
) -> None:
document = json.loads(SETUP_REGISTRY_PATH.read_text(encoding="utf-8"))
document["setups"][0]["run_definition"]["definition_id"] = (
"m49-tgs-full-shadow-drift"
)
path = tmp_path / "setup-registry.json"
path.write_text(json.dumps(document), encoding="utf-8")
drifted_registry = LaboratorySetupRegistry.from_file(
path,
repository_root=REPOSITORY_ROOT,
)
service, _cache = _service(tmp_path, setup_registry=drifted_registry)
with pytest.raises(M49QueueBindingIntegrityError, match="setup definition changed"):
service.check(
source_session_id=SOURCE_SESSION_ID,
setup_id=SETUP_ID,
definition_sha256=DEFINITION_SHA256,
)
@pytest.mark.parametrize(
"mutation",
["unknown-key", "installed", "learned-model", "source-session"],
)
def test_binding_loader_rejects_authority_or_contract_expansion(
tmp_path: Path,
mutation: str,
) -> None:
document = json.loads(BINDING_PATH.read_text(encoding="utf-8"))
if mutation == "unknown-key":
document["command"] = "run arbitrary payload"
elif mutation == "installed":
document["executor"]["service_installed"] = True
elif mutation == "learned-model":
document["executor"]["learned_models"] = ["invented-model"]
else:
document["source"]["session_id"] = "different-source"
path = tmp_path / "invalid-binding.json"
path.write_text(json.dumps(document), encoding="utf-8")
with pytest.raises(M49QueueBindingIntegrityError):
M49QueueBindingConfig.from_file(path)
def test_binding_requires_the_session_store_data_dir_artifact_cache(
tmp_path: Path,
) -> None:
service, cache = _service(tmp_path)
config = service.config
foreign = LocalArtifactCache(
tmp_path / "foreign-cache",
max_bytes=1024 * 1024,
free_space_reserve_bytes=0,
)
with pytest.raises(M49QueueBindingIntegrityError, match="data-dir artifact cache"):
M49RecordedQueueBindingService(
data_dir=service.data_dir,
session_store=service._session_store, # noqa: SLF001 - exact boundary test
setup_registry=_registry(),
config=config,
artifact_cache=foreign,
)
assert cache.root == service.data_dir / "artifact-cache"
def test_binding_wraps_unavailable_default_artifact_cache(
tmp_path: Path,
) -> None:
data_dir = tmp_path / "data"
data_dir.mkdir()
(data_dir / "artifact-cache").write_text("not a directory", encoding="utf-8")
store = _SessionStore(data_dir, _detail(), SOURCE_CATALOG_SHA256)
with pytest.raises(
M49QueueBindingIntegrityError,
match="artifact cache is unavailable",
):
M49RecordedQueueBindingService(
data_dir=data_dir,
session_store=cast(SessionStore, store),
setup_registry=_registry(),
config=M49QueueBindingConfig.from_file(BINDING_PATH),
)
+728
View File
@@ -0,0 +1,728 @@
from __future__ import annotations
import sqlite3
from dataclasses import replace
from pathlib import Path
import pytest
from k1link.observatory.recorded_jobs import (
LIVE_K1_PRIORITY_RANK,
RECORDED_JOB_DATABASE_NAME,
RECORDED_PRIORITY_RANK,
ObservatoryLiveLeaseIntent,
ObservatoryNonCheckpointableCancellationReceipt,
ObservatoryRecordedJobIntent,
ObservatoryRecordedJobQueue,
ObservatoryRecordedPreemptionError,
ObservatoryRecordedQueueBusyError,
ObservatoryRecordedQueueConflictError,
ObservatoryRecordedQueueIntegrityError,
ObservatoryRecordedQueueStaleClaimError,
RecordedRunDefinition,
RecordedRunDefinitionRegistry,
)
NOW = "2026-08-30T21:00:00.000Z"
DEFINITION_SHA = "a" * 64
NON_CHECKPOINTABLE_DEFINITION_SHA = "b" * 64
ADAPTER_SHA = "c" * 64
CATALOG_SHA = "d" * 64
RESULT_SHA = "e" * 64
LIVE_EPOCH_SHA = "f" * 64
SOURCE_BUNDLE_SHA = "1" * 64
SOURCE_CAPABILITIES_SHA = "2" * 64
EXECUTOR_RELEASE_SHA = "3" * 64
EXECUTOR_IMAGE_SHA = "4" * 64
MODEL_MANIFEST_SHA = "5" * 64
RESOURCE_PROFILE_SHA = "6" * 64
def _definitions() -> RecordedRunDefinitionRegistry:
return RecordedRunDefinitionRegistry(
(
RecordedRunDefinition(
setup_id="travel-tgs-eomt-v1",
definition_id="travel-tgs-eomt",
definition_version=1,
definition_sha256=DEFINITION_SHA,
source_adapter_id="sealed-session-bundle",
source_adapter_version=1,
source_adapter_sha256=ADAPTER_SHA,
executor_release_id="travel-tgs-executor-v1",
executor_release_sha256=EXECUTOR_RELEASE_SHA,
executor_image_sha256=EXECUTOR_IMAGE_SHA,
model_release_ids=("eomt-v1", "travel-tgs-v1"),
model_manifest_sha256=MODEL_MANIFEST_SHA,
resource_profile_id="worker006-single-gpu-v1",
resource_profile_sha256=RESOURCE_PROFILE_SHA,
checkpoint_policy="cooperative",
allowed_checkpoints=(
"source-bundle-ready",
"model-batch-finished",
"evidence-sealed",
),
),
RecordedRunDefinition(
setup_id="legacy-monolith-v1",
definition_id="legacy-monolith",
definition_version=1,
definition_sha256=NON_CHECKPOINTABLE_DEFINITION_SHA,
source_adapter_id="sealed-session-bundle",
source_adapter_version=1,
source_adapter_sha256=ADAPTER_SHA,
executor_release_id="legacy-monolith-executor-v1",
executor_release_sha256=EXECUTOR_RELEASE_SHA,
executor_image_sha256=EXECUTOR_IMAGE_SHA,
model_release_ids=("legacy-monolith-v1",),
model_manifest_sha256=MODEL_MANIFEST_SHA,
resource_profile_id="worker006-single-gpu-v1",
resource_profile_sha256=RESOURCE_PROFILE_SHA,
checkpoint_policy="non-checkpointable",
),
)
)
def _queue(
tmp_path: Path,
*,
with_non_checkpointable_preemptor: bool = False,
) -> ObservatoryRecordedJobQueue:
preemptor = None
if with_non_checkpointable_preemptor:
def preemptor(request):
return ObservatoryNonCheckpointableCancellationReceipt.sealed(
request,
cancellation_id=f"cancel-{request.cancellation_request_id}",
)
return ObservatoryRecordedJobQueue(
tmp_path,
definitions=_definitions(),
clock=lambda: NOW,
non_checkpointable_preemptor=preemptor,
)
def _intent(
*,
idempotency_key: str = "recorded-request-001",
source_session_id: str = "20260828T130511Z_viewer_live",
source_catalog_sha256: str = CATALOG_SHA,
setup_id: str = "travel-tgs-eomt-v1",
definition_sha256: str = DEFINITION_SHA,
) -> ObservatoryRecordedJobIntent:
return ObservatoryRecordedJobIntent(
idempotency_key=idempotency_key,
source_session_id=source_session_id,
source_catalog_sha256=source_catalog_sha256,
source_bundle_sha256=SOURCE_BUNDLE_SHA,
source_capability_manifest_sha256=SOURCE_CAPABILITIES_SHA,
setup_id=setup_id,
definition_sha256=definition_sha256,
)
def _live_intent(
*,
trigger_id: str = "k1-live-start-001",
live_session_id: str = "20260830T210000Z_k1_live",
acquisition_epoch_sha256: str = LIVE_EPOCH_SHA,
) -> ObservatoryLiveLeaseIntent:
return ObservatoryLiveLeaseIntent(
trigger_id=trigger_id,
live_session_id=live_session_id,
acquisition_epoch_sha256=acquisition_epoch_sha256,
)
def _running_job(
queue: ObservatoryRecordedJobQueue,
*,
intent: ObservatoryRecordedJobIntent | None = None,
claim_request_id: str = "worker-claim-001",
):
job, created = queue.submit(intent or _intent())
assert created is True
queue.enqueue(job.job_id)
claim = queue.claim_next(
claimant_id="recorded-worker", claim_request_id=claim_request_id
)
assert claim is not None
running = queue.start(job.job_id, claim_token=claim.claim_token)
assert running.state == "running"
return running, claim
def test_submission_is_durable_exactly_idempotent_and_path_free(tmp_path: Path) -> None:
queue = _queue(tmp_path)
first, first_created = queue.submit(_intent())
second, second_created = queue.submit(_intent())
restored = ObservatoryRecordedJobQueue(
tmp_path,
definitions=_definitions(),
clock=lambda: NOW,
).get(first.job_id)
assert first_created is True
assert second_created is False
assert second == first
assert restored == first
assert first.state == "accepted"
assert first.priority_class == "recorded"
assert first.priority_rank == RECORDED_PRIORITY_RANK
assert first.source_adapter_id == "sealed-session-bundle"
assert first.checkpoint_policy == "cooperative"
projection = first.as_dict()
assert projection["priority"] == {
"class": "recorded",
"rank": RECORDED_PRIORITY_RANK,
"server_owned": True,
}
assert "path" not in str(projection).lower()
assert not hasattr(_intent(), "command")
assert not hasattr(_intent(), "image")
assert projection["source"]["bundle_sha256"] == SOURCE_BUNDLE_SHA
assert projection["source"]["capability_manifest_sha256"] == (
SOURCE_CAPABILITIES_SHA
)
assert projection["executor"]["release_sha256"] == EXECUTOR_RELEASE_SHA
assert projection["executor"]["image_sha256"] == EXECUTOR_IMAGE_SHA
assert projection["executor"]["model_manifest_sha256"] == MODEL_MANIFEST_SHA
assert projection["executor"]["resource_profile_sha256"] == (
RESOURCE_PROFILE_SHA
)
assert queue.database_path.name == RECORDED_JOB_DATABASE_NAME
assert queue.database_path.stat().st_mode & 0o777 == 0o600
with pytest.raises(ObservatoryRecordedQueueConflictError, match="idempotency"):
queue.submit(replace(_intent(), source_session_id="another-session"))
def test_submission_and_enqueue_can_commit_as_one_idempotent_transaction(
tmp_path: Path,
) -> None:
queue = _queue(tmp_path)
first, created = queue.submit(_intent(), enqueue=True)
repeated, repeated_created = queue.submit(_intent(), enqueue=True)
assert created is True
assert repeated_created is False
assert first.state == "queued"
assert repeated == first
def test_session_setup_identity_changes_for_each_exact_combination(tmp_path: Path) -> None:
queue = _queue(tmp_path)
first, _ = queue.submit(_intent())
second, _ = queue.submit(
_intent(
idempotency_key="recorded-request-002",
source_session_id="another-session",
)
)
third, _ = queue.submit(
_intent(
idempotency_key="recorded-request-003",
setup_id="legacy-monolith-v1",
definition_sha256=NON_CHECKPOINTABLE_DEFINITION_SHA,
)
)
assert len({first.identity_sha256, second.identity_sha256, third.identity_sha256}) == 3
assert second.source_adapter_sha256 == first.source_adapter_sha256
assert third.checkpoint_policy == "non-checkpointable"
with pytest.raises(ObservatoryRecordedQueueConflictError, match="allowlisted"):
queue.submit(
_intent(
idempotency_key="recorded-request-004",
definition_sha256="0" * 64,
)
)
def test_normal_recorded_job_lifecycle_and_terminal_idempotency(tmp_path: Path) -> None:
queue = _queue(tmp_path)
running, claim = _running_job(queue)
checkpointed = queue.checkpoint(
running.job_id,
claim_token=claim.claim_token,
checkpoint_id="model-batch-finished",
)
assert checkpointed.state == "running"
assert checkpointed.last_checkpoint_id == "model-batch-finished"
succeeded = queue.succeed(
running.job_id,
claim_token=claim.claim_token,
result_id="recorded-result-001",
result_sha256=RESULT_SHA,
)
replayed = queue.succeed(
running.job_id,
claim_token=claim.claim_token,
result_id="recorded-result-001",
result_sha256=RESULT_SHA,
)
assert succeeded.state == "succeeded"
assert replayed == succeeded
assert succeeded.result_sha256 == RESULT_SHA
assert succeeded.active_claim_token is None
with pytest.raises(ObservatoryRecordedQueueConflictError, match="terminal"):
queue.fail(
running.job_id,
claim_token=claim.claim_token,
error_code="worker-failed",
message="Synthetic failure.",
)
def test_success_requires_running_and_cannot_publish_during_preemption(
tmp_path: Path,
) -> None:
queue = _queue(tmp_path)
job, _ = queue.submit(_intent())
queue.enqueue(job.job_id)
claim = queue.claim_next(
claimant_id="recorded-worker",
claim_request_id="worker-claim-001",
)
assert claim is not None
with pytest.raises(ObservatoryRecordedQueueConflictError, match="claimed"):
queue.succeed(
job.job_id,
claim_token=claim.claim_token,
result_id="recorded-result-001",
result_sha256=RESULT_SHA,
)
queue.start(job.job_id, claim_token=claim.claim_token)
queue.request_live(_live_intent())
assert queue.get(job.job_id).preemption_requested is True
with pytest.raises(
ObservatoryRecordedQueueConflictError,
match="preemption",
):
queue.succeed(
job.job_id,
claim_token=claim.claim_token,
result_id="recorded-result-001",
result_sha256=RESULT_SHA,
)
def test_claim_is_exactly_idempotent_including_empty_result(tmp_path: Path) -> None:
queue = _queue(tmp_path)
empty = queue.claim_next(
claimant_id="recorded-worker", claim_request_id="empty-poll-001"
)
assert empty is None
job, _ = queue.submit(_intent())
queue.enqueue(job.job_id)
assert (
queue.claim_next(
claimant_id="recorded-worker", claim_request_id="empty-poll-001"
)
is None
)
claim = queue.claim_next(
claimant_id="recorded-worker", claim_request_id="non-empty-poll-001"
)
retry = queue.claim_next(
claimant_id="recorded-worker", claim_request_id="non-empty-poll-001"
)
assert claim is not None
assert retry is not None
assert retry.claim_token == claim.claim_token
assert retry.job.job_id == job.job_id
with pytest.raises(ObservatoryRecordedQueueConflictError, match="claim"):
queue.claim_next(
claimant_id="another-worker", claim_request_id="non-empty-poll-001"
)
def test_single_worker_resource_has_only_one_recorded_owner(tmp_path: Path) -> None:
queue = _queue(tmp_path)
first, _ = queue.submit(_intent())
second, _ = queue.submit(
_intent(
idempotency_key="recorded-request-002",
source_session_id="another-session",
)
)
queue.enqueue(first.job_id)
queue.enqueue(second.job_id)
first_claim = queue.claim_next(
claimant_id="recorded-worker", claim_request_id="owner-poll-001"
)
assert first_claim is not None
assert (
queue.claim_next(
claimant_id="recorded-worker", claim_request_id="owner-poll-002"
)
is None
)
completed_job_id = first_claim.job.job_id
queue.fail(
completed_job_id,
claim_token=first_claim.claim_token,
error_code="synthetic-failure",
message="Release the synthetic single-Worker ownership.",
)
second_claim = queue.claim_next(
claimant_id="recorded-worker", claim_request_id="owner-poll-003"
)
assert second_claim is not None
assert second_claim.job.job_id in {first.job_id, second.job_id} - {completed_job_id}
def test_live_lease_cooperatively_pauses_and_resumes_recorded_job(tmp_path: Path) -> None:
queue = _queue(tmp_path)
running, claim = _running_job(queue)
lease, created = queue.request_live(_live_intent())
exact_retry, retry_created = queue.request_live(_live_intent())
requested_job = queue.get(running.job_id)
assert created is True
assert retry_created is False
assert exact_retry == lease
assert lease.state == "pending"
assert lease.priority_rank == LIVE_K1_PRIORITY_RANK
assert requested_job.state == "running"
assert requested_job.preemption_requested is True
assert queue.admission_gate().blocked is True
with pytest.raises(ObservatoryRecordedQueueBusyError, match="safe preemption"):
queue.activate_live(lease.lease_id)
paused = queue.checkpoint(
running.job_id,
claim_token=claim.claim_token,
checkpoint_id="model-batch-finished",
)
assert paused.state == "paused"
active = queue.activate_live(lease.lease_id)
assert active.state == "active"
another, _ = queue.submit(
_intent(
idempotency_key="recorded-request-002",
source_session_id="another-session",
)
)
queue.enqueue(another.job_id)
assert (
queue.claim_next(
claimant_id="recorded-worker", claim_request_id="blocked-poll-001"
)
is None
)
completed = queue.finish_live(
lease.lease_id,
terminal_trigger_id="k1-live-finish-001",
outcome="completed",
)
completed_retry = queue.finish_live(
lease.lease_id,
terminal_trigger_id="k1-live-finish-001",
outcome="completed",
)
assert completed.state == "completed"
assert completed_retry == completed
assert queue.admission_gate().blocked is False
resumed = queue.get(running.job_id)
assert resumed.state == "queued"
assert resumed.preemption_requested is False
assert resumed.active_claim_token is None
with pytest.raises(ObservatoryRecordedQueueConflictError, match="terminal"):
queue.finish_live(
lease.lease_id,
terminal_trigger_id="different-terminal-trigger",
outcome="failed",
)
def test_cancelled_pending_live_lease_releases_cooperative_preemption_request(
tmp_path: Path,
) -> None:
queue = _queue(tmp_path)
running, claim = _running_job(queue)
lease, _ = queue.request_live(_live_intent())
assert queue.get(running.job_id).preemption_requested is True
cancelled = queue.finish_live(
lease.lease_id,
terminal_trigger_id="k1-live-cancel-001",
outcome="cancelled",
)
released = queue.get(running.job_id)
assert cancelled.state == "cancelled"
assert released.state == "running"
assert released.preemption_requested is False
assert released.active_claim_token == claim.claim_token
def test_live_request_pauses_claimed_job_before_execution(tmp_path: Path) -> None:
queue = _queue(tmp_path)
job, _ = queue.submit(_intent())
queue.enqueue(job.job_id)
claim = queue.claim_next(
claimant_id="recorded-worker", claim_request_id="worker-claim-001"
)
assert claim is not None
lease, _ = queue.request_live(_live_intent())
paused = queue.get(job.job_id)
assert paused.state == "paused"
assert queue.start(job.job_id, claim_token=claim.claim_token).state == "paused"
assert queue.activate_live(lease.lease_id).state == "active"
queue.finish_live(
lease.lease_id,
terminal_trigger_id="k1-live-finish-001",
outcome="completed",
)
with pytest.raises(ObservatoryRecordedQueueStaleClaimError, match="stale"):
queue.start(job.job_id, claim_token=claim.claim_token)
def test_non_checkpointable_job_is_cancelled_and_restarts_from_zero_for_live(
tmp_path: Path,
) -> None:
queue = _queue(tmp_path, with_non_checkpointable_preemptor=True)
running, claim = _running_job(
queue,
intent=_intent(
setup_id="legacy-monolith-v1",
definition_sha256=NON_CHECKPOINTABLE_DEFINITION_SHA,
),
)
lease, _ = queue.request_live(_live_intent())
paused = queue.get(running.job_id)
assert paused.state == "paused"
assert paused.preemption_requested is True
assert paused.restart_from_zero is True
assert paused.preemption_receipt_sha256 is not None
assert paused.active_claim_token is None
with pytest.raises(ObservatoryRecordedQueueStaleClaimError, match="stale"):
queue.checkpoint(
running.job_id,
claim_token=claim.claim_token,
checkpoint_id="model-batch-finished",
)
assert queue.activate_live(lease.lease_id).state == "active"
assert (
queue.finish_live(
lease.lease_id,
terminal_trigger_id="k1-live-finish-001",
outcome="failed",
).state
== "failed"
)
resumed = queue.get(running.job_id)
assert resumed.state == "queued"
assert resumed.restart_from_zero is True
new_claim = queue.claim_next(
claimant_id="recorded-worker", claim_request_id="worker-claim-002"
)
assert new_claim is not None
assert new_claim.job.job_id == running.job_id
assert new_claim.job.restart_from_zero is True
def test_live_request_fails_closed_without_non_checkpointable_canceler(
tmp_path: Path,
) -> None:
queue = _queue(tmp_path)
running, _claim = _running_job(
queue,
intent=_intent(
setup_id="legacy-monolith-v1",
definition_sha256=NON_CHECKPOINTABLE_DEFINITION_SHA,
),
)
with pytest.raises(ObservatoryRecordedPreemptionError, match="scheduler-owned"):
queue.request_live(_live_intent())
assert queue.admission_gate().blocked is True
assert queue.get(running.job_id).state == "preemption-pending"
with sqlite3.connect(queue.database_path) as connection:
intent_state = connection.execute(
"SELECT state FROM observatory_recorded_preemptions"
).fetchone()[0]
assert intent_state == "pending"
recovered = _queue(tmp_path, with_non_checkpointable_preemptor=True)
lease, created = recovered.request_live(_live_intent())
assert created is False
assert recovered.get(running.job_id).state == "paused"
assert recovered.get(running.job_id).restart_from_zero is True
assert recovered.activate_live(lease.lease_id).state == "active"
def test_durable_cancel_intent_survives_callback_crash_and_reuses_identity(
tmp_path: Path,
) -> None:
cancellation_request_ids: list[str] = []
def crash_after_external_side_effect(request):
cancellation_request_ids.append(request.cancellation_request_id)
raise OSError("synthetic scheduler process loss after cancellation")
queue = ObservatoryRecordedJobQueue(
tmp_path,
definitions=_definitions(),
clock=lambda: NOW,
non_checkpointable_preemptor=crash_after_external_side_effect,
)
running, _claim = _running_job(
queue,
intent=_intent(
setup_id="legacy-monolith-v1",
definition_sha256=NON_CHECKPOINTABLE_DEFINITION_SHA,
),
)
with pytest.raises(ObservatoryRecordedPreemptionError, match="did not release"):
queue.request_live(_live_intent())
assert queue.get(running.job_id).state == "preemption-pending"
def replay_exact_cancel(request):
cancellation_request_ids.append(request.cancellation_request_id)
return ObservatoryNonCheckpointableCancellationReceipt.sealed(
request,
cancellation_id=f"cancel-{request.cancellation_request_id}",
)
recovered = ObservatoryRecordedJobQueue(
tmp_path,
definitions=_definitions(),
clock=lambda: NOW,
non_checkpointable_preemptor=replay_exact_cancel,
)
lease, created = recovered.request_live(_live_intent())
assert created is False
assert len(cancellation_request_ids) == 2
assert cancellation_request_ids[0] == cancellation_request_ids[1]
assert recovered.get(running.job_id).state == "paused"
assert recovered.activate_live(lease.lease_id).state == "active"
def test_learned_model_release_list_may_be_empty_for_algorithm_only_tgs() -> None:
definition = RecordedRunDefinition(
setup_id="travel-tgs-algorithm-v1",
definition_id="travel-tgs-algorithm",
definition_version=1,
definition_sha256="7" * 64,
source_adapter_id="sealed-session-bundle",
source_adapter_version=1,
source_adapter_sha256=ADAPTER_SHA,
executor_release_id="travel-tgs-executor-v1",
executor_release_sha256=EXECUTOR_RELEASE_SHA,
executor_image_sha256=EXECUTOR_IMAGE_SHA,
model_release_ids=(),
model_manifest_sha256=MODEL_MANIFEST_SHA,
resource_profile_id="worker006-single-gpu-v1",
resource_profile_sha256=RESOURCE_PROFILE_SHA,
checkpoint_policy="cooperative",
allowed_checkpoints=("model-batch-finished",),
)
assert definition.model_release_ids == ()
assert definition.learned_models == ()
assert definition.model_manifest_sha256 == MODEL_MANIFEST_SHA
def test_reconciliation_required_is_durable_terminal_state(tmp_path: Path) -> None:
queue = _queue(tmp_path)
running, claim = _running_job(queue)
uncertain = queue.require_reconciliation(
running.job_id,
claim_token=claim.claim_token,
reason_code="worker-outcome-unknown",
message="Dispatch was accepted but its terminal receipt is unavailable.",
)
restored = ObservatoryRecordedJobQueue(
tmp_path,
definitions=_definitions(),
clock=lambda: NOW,
).get(running.job_id)
assert uncertain.state == "reconciliation-required"
assert restored == uncertain
def test_reconciliation_required_quarantines_recorded_and_live_ownership(
tmp_path: Path,
) -> None:
queue = _queue(tmp_path)
running, claim = _running_job(queue)
queue.require_reconciliation(
running.job_id,
claim_token=claim.claim_token,
reason_code="worker-outcome-unknown",
message="Worker ownership cannot be proven released.",
)
waiting, _ = queue.submit(
_intent(idempotency_key="recorded-request-002")
)
queue.enqueue(waiting.job_id)
assert queue.claim_next(
claimant_id="recorded-worker",
claim_request_id="worker-claim-after-reconciliation",
) is None
lease, _ = queue.request_live(_live_intent())
with pytest.raises(ObservatoryRecordedQueueBusyError, match="recorded work"):
queue.activate_live(lease.lease_id)
def test_live_lease_requires_explicit_valid_terminal_transition(tmp_path: Path) -> None:
queue = _queue(tmp_path)
lease, _ = queue.request_live(_live_intent())
with pytest.raises(ObservatoryRecordedQueueConflictError, match="complete"):
queue.finish_live(
lease.lease_id,
terminal_trigger_id="k1-live-finish-001",
outcome="completed",
)
cancelled = queue.finish_live(
lease.lease_id,
terminal_trigger_id="k1-live-cancel-001",
outcome="cancelled",
)
assert cancelled.state == "cancelled"
assert queue.admission_gate().blocked is False
def test_queue_detects_mutated_immutable_identity(tmp_path: Path) -> None:
queue = _queue(tmp_path)
job, _ = queue.submit(_intent())
with sqlite3.connect(queue.database_path) as connection:
connection.execute(
"UPDATE observatory_recorded_jobs SET source_catalog_sha256 = ? "
"WHERE job_id = ?",
("0" * 64, job.job_id),
)
connection.commit()
with pytest.raises(ObservatoryRecordedQueueIntegrityError, match="identity"):
queue.get(job.job_id)
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from fastapi import FastAPI
from fastapi.testclient import TestClient
from k1link.observatory.m49_queue_binding import M49QueueBindingIntegrityError
from k1link.observatory.recorded_jobs import (
ObservatoryRecordedJobIntent,
ObservatoryRecordedJobQueue,
RecordedRunDefinition,
RecordedRunDefinitionRegistry,
)
from k1link.observatory.setups import LaboratorySetupRegistry
from k1link.sessions import SessionNotFoundError
from k1link.sessions.models import SessionSummary
from k1link.web.observatory_api import build_observatory_router
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
REGISTRY_PATH = REPOSITORY_ROOT / "config" / "observatory-laboratory-setups.json"
RAV00_SESSION_ID = "20260720T065719Z_viewer_live"
SETUP_ID = "m49-tgs-full-shadow-v1"
CATALOG_SHA256 = "1" * 64
SOURCE_BUNDLE_SHA256 = "2" * 64
CAPABILITY_SHA256 = "3" * 64
def _source() -> SessionSummary:
return SessionSummary(
session_id=RAV00_SESSION_ID,
display_name="RAVNOVES00",
status="ready",
started_at_utc="2026-07-20T06:57:19Z",
completed_at_utc="2026-07-20T07:06:15Z",
duration_seconds=536.0,
modalities=("point-cloud", "trajectory", "video"),
source_count=3,
total_bytes=1,
replayable=True,
origin="recorded",
)
class _Store:
def get_session(self, session_id: str) -> SimpleNamespace:
if session_id != RAV00_SESSION_ID:
raise SessionNotFoundError(session_id)
return SimpleNamespace(summary=_source())
class _Admission:
def __init__(
self,
definition: RecordedRunDefinition,
catalog_sha256: str,
) -> None:
self.definition = definition
self.catalog_sha256 = catalog_sha256
def intent(self, *, idempotency_key: str) -> ObservatoryRecordedJobIntent:
return ObservatoryRecordedJobIntent(
idempotency_key=idempotency_key,
source_session_id=RAV00_SESSION_ID,
source_catalog_sha256=self.catalog_sha256,
source_bundle_sha256=SOURCE_BUNDLE_SHA256,
source_capability_manifest_sha256=CAPABILITY_SHA256,
setup_id=self.definition.setup_id,
definition_sha256=self.definition.definition_sha256,
)
class _BindingService:
def __init__(self, definition: RecordedRunDefinition) -> None:
self.config = SimpleNamespace(setup=SimpleNamespace(setup_id=SETUP_ID))
self.definition = definition
self.catalog_sha256 = CATALOG_SHA256
self.check_count = 0
self.admit_count = 0
def check(
self,
*,
source_session_id: str,
setup_id: str,
definition_sha256: str,
) -> _Admission:
self.check_count += 1
return self._resolve(source_session_id, setup_id, definition_sha256)
def admit(
self,
*,
source_session_id: str,
setup_id: str,
definition_sha256: str,
) -> _Admission:
self.admit_count += 1
return self._resolve(source_session_id, setup_id, definition_sha256)
def _resolve(
self,
source_session_id: str,
setup_id: str,
definition_sha256: str,
) -> _Admission:
if (
source_session_id != RAV00_SESSION_ID
or setup_id != self.definition.setup_id
or definition_sha256 != self.definition.definition_sha256
):
raise M49QueueBindingIntegrityError("not the exact M4.9 binding")
return _Admission(self.definition, self.catalog_sha256)
def _services(
tmp_path: Path,
) -> tuple[
LaboratorySetupRegistry,
_BindingService,
ObservatoryRecordedJobQueue,
RecordedRunDefinition,
]:
registry = LaboratorySetupRegistry.from_file(
REGISTRY_PATH,
repository_root=REPOSITORY_ROOT,
)
catalog = registry.catalog(_source())
setups = cast(list[dict[str, Any]], catalog["setups"])
definition_document = cast(dict[str, object], setups[0]["run_definition"])
definition = RecordedRunDefinition(
setup_id=SETUP_ID,
definition_id=str(definition_document["definition_id"]),
definition_version=int(definition_document["version"]),
definition_sha256=str(definition_document["definition_sha256"]),
source_adapter_id="ravnoves00-m49-source-pack",
source_adapter_version=1,
source_adapter_sha256="4" * 64,
executor_release_id="m49-tgs-full-shadow-worker-release",
executor_release_sha256="5" * 64,
executor_image_sha256="6" * 64,
model_release_ids=(),
model_manifest_sha256="7" * 64,
resource_profile_id="worker006-cpu-single-run-v1",
resource_profile_sha256="8" * 64,
checkpoint_policy="non-checkpointable",
)
binding = _BindingService(definition)
queue = ObservatoryRecordedJobQueue(
tmp_path,
definitions=RecordedRunDefinitionRegistry((definition,)),
)
return registry, binding, queue, definition
def test_exact_m49_preflight_is_queueable_and_submit_is_idempotently_queued(
tmp_path: Path,
) -> None:
registry, binding, queue, definition = _services(tmp_path)
app = FastAPI()
app.include_router(
build_observatory_router(
_Store(), # type: ignore[arg-type]
setup_registry=registry,
recorded_binding_service=binding, # type: ignore[arg-type]
recorded_job_queue=queue,
)
)
client = TestClient(app)
preflight = client.post(
"/api/v1/observatory/run-preflights",
json={
"schema_version": "missioncore.observatory-run-preflight-request/v1",
"source_session_id": RAV00_SESSION_ID,
"setup_id": SETUP_ID,
"definition_sha256": definition.definition_sha256,
},
)
assert preflight.status_code == 200
assert preflight.json()["outcome"] == "queueable"
assert preflight.json()["submission_allowed"] is True
assert next(
check
for check in preflight.json()["checks"]
if check["check_id"] == "durable-queue"
)["outcome"] == "pass"
assert binding.check_count == 1
assert binding.admit_count == 0
request = {
"schema_version": "missioncore.observatory-recorded-run-submit/v1",
"idempotency_key": "observatory-ui:m49:stable-request",
"source_session_id": RAV00_SESSION_ID,
"setup_id": SETUP_ID,
}
submitted = client.post("/api/v1/observatory/runs", json=request)
binding.catalog_sha256 = "9" * 64
repeated = client.post("/api/v1/observatory/runs", json=request)
assert submitted.status_code == 202
assert repeated.status_code == 202
assert submitted.json()["job_id"] == repeated.json()["job_id"]
assert submitted.json()["state"] == "queued"
assert submitted.json()["source"]["session_id"] == RAV00_SESSION_ID
assert submitted.json()["setup"]["setup_id"] == SETUP_ID
assert submitted.json()["executor"]["learned_models"] == []
assert submitted.json()["priority"] == {
"class": "recorded",
"rank": 100,
"server_owned": True,
}
assert binding.admit_count == 1
listed = client.get(
"/api/v1/observatory/runs",
params={"source_session_id": RAV00_SESSION_ID, "setup_id": SETUP_ID},
)
assert listed.status_code == 200
assert listed.json()["schema_version"] == (
"missioncore.observatory-recorded-job-list/v1"
)
assert [item["job_id"] for item in listed.json()["items"]] == [
submitted.json()["job_id"]
]
assert queue.admission_gate().blocked is False
def test_recorded_run_routes_fail_closed_when_queue_initialization_failed() -> None:
registry = LaboratorySetupRegistry.from_file(
REGISTRY_PATH,
repository_root=REPOSITORY_ROOT,
)
app = FastAPI()
app.include_router(
build_observatory_router(
_Store(), # type: ignore[arg-type]
setup_registry=registry,
recorded_job_queue_error="queue database is incompatible",
)
)
client = TestClient(app)
response = client.get(
"/api/v1/observatory/runs",
params={"source_session_id": RAV00_SESSION_ID, "setup_id": SETUP_ID},
)
assert response.status_code == 503
assert response.json()["detail"] == "Durable-очередь расчётов недоступна."
+3
View File
@@ -127,6 +127,7 @@ def test_repository_setup_registry_keeps_real_definition_and_pre_definition_resu
]
rav00 = registry.catalog(_source(RAV00_SESSION_ID, "RAVNOVES00"))
m49, current = rav00["setups"]
assert m49["display_name"] == "M4.9T5 · TRAVEL TGS · CPU-only, без ML"
assert m49["origin"] == "archived-definition"
assert m49["compatibility"]["compatible"] is True
assert m49["preflight"] == {
@@ -153,6 +154,8 @@ def test_repository_setup_registry_keeps_real_definition_and_pre_definition_resu
available_observatory_result_ids=frozenset({RAV004_RESULT_ID}),
)
existing = rav004["setups"][1]
assert existing["display_name"] == "LAB V1 · EoMT Cityscapes Large 1024 + DDRNet-39"
assert "полный TGS и независимый YOLOX отсутствуют" in existing["description"]
assert existing["origin"] == "existing-result"
assert existing["run_definition"] is None
assert existing["preflight"]["outcome"] == "existing"