feat(observatory): seal blocked run preparations

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 23:14:05 +03:00
parent 8d5aeb0533
commit 2a5763d3fb
12 changed files with 1935 additions and 25 deletions
+1 -1
View File
@@ -171,7 +171,7 @@ export const workspaces: WorkspaceDefinition[] = [
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Обсерватория восприятия",
title: "Проверка компьютерного зрения",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
icon: "eye",
@@ -1,5 +1,7 @@
.observatory-workspace {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 1rem;
min-width: 0;
min-height: 100%;
@@ -37,6 +39,14 @@
margin: 0.3rem 0 0;
}
.observatory-catalog-bar h3,
.observatory-session-summary h3,
.observatory-evidence h3 {
font-size: var(--nodedc-font-size-lg);
letter-spacing: -0.02em;
line-height: 1.15;
}
.observatory-lead p,
.observatory-catalog-bar p,
.observatory-state p,
@@ -44,7 +54,14 @@
max-width: 52rem;
margin: 0.35rem 0 0;
color: var(--nodedc-text-muted);
line-height: 1.5;
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-catalog-bar,
.observatory-session-summary {
align-self: start;
min-height: 0;
}
.observatory-catalog-bar__copy {
@@ -77,6 +94,8 @@
.observatory-session-stack {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 0.9rem;
container-name: observatory-session;
container-type: inline-size;
@@ -130,6 +149,8 @@
margin: 0;
overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-session-summary__end {
@@ -168,7 +189,8 @@
max-width: 68rem;
margin: 0.3rem 0 0;
color: var(--nodedc-text-muted);
line-height: 1.45;
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-setup-detail__badges,
@@ -212,6 +234,8 @@
margin: 0;
overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-setup-results {
@@ -250,6 +274,8 @@
.observatory-setup-results small {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-sm);
line-height: 1.35;
}
.observatory-setup-detail__footer {
@@ -145,6 +145,7 @@ export function ObservatoryWorkspace({
const [mutationReconciliation, setMutationReconciliation] = useState<
ObservatoryMutationReconciliation | null
>(null);
const overviewRef = useRef<HTMLElement | null>(null);
const replayCoordinatorRef = useRef(createObservationReplayCoordinator());
const items = controller.catalog?.items ?? EMPTY_OBSERVATORY_ITEMS;
@@ -153,6 +154,11 @@ export function ObservatoryWorkspace({
setReplay((current) => current.kind === "closed" ? current : { kind: "closed" });
}, []);
const returnToOverview = useCallback(() => {
closeReplay();
overviewRef.current?.scrollIntoView({ block: "start" });
}, [closeReplay]);
useEffect(() => {
if (items.some((item) => item.source.id === selectedSessionId)) return;
closeReplay();
@@ -352,7 +358,7 @@ export function ObservatoryWorkspace({
data-observatory-authority="observation-only"
data-observatory-viewer={replay.kind === "ready" ? "attached" : "detached"}
>
<section className="observatory-lead">
<section ref={overviewRef} className="observatory-lead">
<div>
<span className="section-eyebrow">{definition.eyebrow}</span>
<h2>{definition.title}</h2>
@@ -368,7 +374,7 @@ export function ObservatoryWorkspace({
</StatusBadge>
</section>
<GlassSurface className="observatory-catalog-bar" padding="lg">
<GlassSurface className="observatory-catalog-bar" padding="md">
<div className="observatory-catalog-bar__copy">
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
<h3>Сохранённая сессия</h3>
@@ -606,7 +612,7 @@ export function ObservatoryWorkspace({
<h3>Проверяем точную связь результата с исходной сессией</h3>
<p>Визуализатор и данные маршрута ещё не запущены.</p>
</div>
<Button size="compact" variant="ghost" onClick={closeReplay}>Отменить</Button>
<Button size="compact" variant="ghost" onClick={returnToOverview}>Отменить</Button>
</GlassSurface>
) : replay.kind === "error" ? (
<GlassSurface className="observatory-replay-state" padding="lg" role="alert">
@@ -623,7 +629,7 @@ export function ObservatoryWorkspace({
>
Повторить
</Button>
<Button size="compact" variant="ghost" onClick={closeReplay}>Закрыть</Button>
<Button size="compact" variant="ghost" onClick={returnToOverview}>Закрыть</Button>
</div>
</GlassSurface>
) : replay.kind === "ready" ? (
@@ -638,7 +644,7 @@ export function ObservatoryWorkspace({
size="compact"
variant="secondary"
icon={<Icon name="close" size={14} />}
onClick={closeReplay}
onClick={returnToOverview}
>
Закрыть разбор
</Button>
@@ -35,7 +35,7 @@ test("Observatory is the third independent Polygon workspace", () => {
id: "observatory",
root: "polygon",
label: "Обсерватория",
title: "Обсерватория восприятия",
title: "Проверка компьютерного зрения",
eyebrow: "ТЕСТОВЫЙ КОНТУР / ОБСЕРВАТОРИЯ",
description: "Сессии и квалификация компьютерного зрения без доступа к управлению.",
icon: "eye",
@@ -95,6 +95,10 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
assert.match(workspace, /role="alert"/);
assert.match(workspace, /Повторить/);
assert.match(workspace, /Закрыть разбор/);
assert.match(
workspace,
/const returnToOverview[\s\S]*closeReplay\(\);[\s\S]*scrollIntoView\(\{ block: "start" \}\)/,
);
assert.match(workspace, /<h3>\{replayEvidence\?\.label \?\? replay\.binding\.resultId\}<\/h3>/);
assert.doesNotMatch(workspace, /RAVNOVES004TREE · полный маршрут восприятия/);
assert.match(workspace, /const selectSession[\s\S]*closeReplay\(\);[\s\S]*setSelectedSessionId/);
@@ -130,6 +134,14 @@ test("Observatory mounts the one shared canonical replay only after explicit adm
styles,
/\.observatory-session-stack \{[\s\S]*container-name: observatory-session;[\s\S]*container-type: inline-size;/,
);
assert.match(
styles,
/\.observatory-workspace \{[\s\S]*grid-auto-rows: max-content;[\s\S]*align-content: start;/,
);
assert.match(
styles,
/\.observatory-session-summary__facts dd \{[\s\S]*font-size: var\(--nodedc-font-size-sm\);/,
);
assert.match(
styles,
/@container observatory-session \(max-width: 56rem\) \{[\s\S]*\.observatory-session-summary \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/,
+28
View File
@@ -275,6 +275,34 @@ 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
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.
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 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.
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.
## Planning discipline
A planned LAB number is not a placeholder page. Before execution it must name:
@@ -43,9 +43,78 @@ def sha256_file(path: Path) -> str:
def git_revision() -> str:
result = subprocess.run(
["git", "rev-parse", "HEAD"], cwd=REPOSITORY_ROOT, check=True, capture_output=True, text=True
["git", "rev-parse", "HEAD"],
cwd=REPOSITORY_ROOT,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
revision = result.stdout.strip()
if re.fullmatch(r"[a-f0-9]{40}", revision) is None:
raise ArtifactBuildError("Git revision is not a full SHA-1")
return revision
def materialize_revision(revision: str, destination: Path) -> None:
"""Materialize only the declared release inputs from one exact Git commit."""
if re.fullmatch(r"[a-f0-9]{40}", revision) is None:
raise ArtifactBuildError("artifact revision is invalid")
if destination.exists():
raise ArtifactBuildError("revision destination already exists")
verified = subprocess.run(
["git", "rev-parse", "--verify", f"{revision}^{{commit}}"],
cwd=REPOSITORY_ROOT,
check=False,
capture_output=True,
text=True,
)
if verified.returncode != 0 or verified.stdout.strip() != revision:
raise ArtifactBuildError("artifact revision does not identify an existing commit")
archive_path = destination.parent / "source.tar"
archived = subprocess.run(
[
"git",
"archive",
"--format=tar",
"--output",
str(archive_path),
revision,
"--",
*(path.as_posix() for path in SOURCES),
],
cwd=REPOSITORY_ROOT,
check=False,
capture_output=True,
text=True,
)
if archived.returncode != 0:
raise ArtifactBuildError("declared release inputs are not present in artifact revision")
destination.mkdir()
root = destination.resolve()
with tarfile.open(archive_path, "r:") as archive:
members = archive.getmembers()
for member in members:
target = (destination / member.name).resolve()
if target != root and root not in target.parents:
raise ArtifactBuildError("Git archive contains an unsafe path")
if not member.isdir() and not member.isreg():
raise ArtifactBuildError("Git archive contains a non-regular release input")
for member in members:
target = destination / member.name
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
continue
stream = archive.extractfile(member)
if stream is None:
raise ArtifactBuildError("Git archive release input cannot be read")
target.parent.mkdir(parents=True, exist_ok=True)
with stream, target.open("wb") as output:
while chunk := stream.read(1024 * 1024):
output.write(chunk)
def tar_info(path: Path, arcname: str) -> tarfile.TarInfo:
@@ -81,24 +150,34 @@ def write_archive(stage: Path, target: Path) -> None:
archive.addfile(info, io.BytesIO())
def build(patch_id: str, output_directory: Path, *, revision: str | None = None) -> dict[str, object]:
def build(
patch_id: str,
output_directory: Path,
*,
revision: str | None = None,
) -> dict[str, object]:
if PATCH_ID.fullmatch(patch_id) is None:
raise ArtifactBuildError("patch id is invalid")
sources = tuple(REPOSITORY_ROOT / source for source in SOURCES)
if any(path.is_symlink() or not path.is_file() for path in sources):
raise ArtifactBuildError("release input is not a regular file")
selected_revision = revision or git_revision()
if re.fullmatch(r"[a-f0-9]{40}", selected_revision) is None:
raise ArtifactBuildError("artifact revision is invalid")
with tempfile.TemporaryDirectory(prefix="mission-core-m49-tgs-full-") as directory:
stage = Path(directory)
snapshot = stage / "source"
materialize_revision(selected_revision, snapshot)
sources = tuple(snapshot / source for source in SOURCES)
if any(path.is_symlink() or not path.is_file() for path in sources):
raise ArtifactBuildError("release input is not a regular file")
payload = stage / "payload"
payload.mkdir()
files: dict[str, dict[str, object]] = {}
for source in sources:
destination = payload / source.name
destination.write_bytes(source.read_bytes())
files[destination.name] = {"bytes": destination.stat().st_size, "sha256": sha256_file(destination)}
files[destination.name] = {
"bytes": destination.stat().st_size,
"sha256": sha256_file(destination),
}
release = {
"schema_version": "missioncore.m49-tgs-full-shadow-worker-release/v1",
"patch_id": patch_id,
@@ -106,7 +185,9 @@ def build(patch_id: str, output_directory: Path, *, revision: str | None = None)
"worker_id": "worker-006",
"candidate_id": "travel-tgs-full-shadow",
"license": "GPL-3.0-or-later",
"source_pack_sha256": "0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944",
"source_pack_sha256": (
"0685d24219d8236caf8b7f1685e93f6d6b59e7fd015a768d88a92bbe8b154944"
),
"images": {
"travel": "sha256:7b412020f4d8392d1d1ed1b33beadc44140f0ea8f781e62dd69796042334300f",
"parity": "sha256:ceb13548617e4bd3f619766bfdff00af3fa5160946b367828da6d2233dcdcba0",
@@ -121,10 +202,14 @@ def build(patch_id: str, output_directory: Path, *, revision: str | None = None)
"files": files,
}
release_path = payload / "release.json"
release_path.write_text(json.dumps(release, indent=2, sort_keys=True) + "\n", encoding="utf-8")
release_path.write_text(
json.dumps(release, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
payload_names = sorted((*files, release_path.name))
(stage / "manifest.env").write_text(
f"id={patch_id}\ncomponent=mission-core-worker\ntype=qualification-release\n", encoding="utf-8"
f"id={patch_id}\ncomponent=mission-core-worker\ntype=qualification-release\n",
encoding="utf-8",
)
(stage / "files.txt").write_text("\n".join(payload_names) + "\n", encoding="utf-8")
target = output_directory.resolve() / f"nodedc-{patch_id}.tgz"
@@ -142,10 +227,15 @@ def build(patch_id: str, output_directory: Path, *, revision: str | None = None)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("patch_id")
parser.add_argument("--output-directory", type=Path, default=REPOSITORY_ROOT / ".runtime/worker-artifacts")
parser.add_argument(
"--output-directory",
type=Path,
default=REPOSITORY_ROOT / ".runtime/worker-artifacts",
)
parser.add_argument("--revision")
arguments = parser.parse_args()
try:
result = build(arguments.patch_id, arguments.output_directory)
result = build(arguments.patch_id, arguments.output_directory, revision=arguments.revision)
except (ArtifactBuildError, OSError, subprocess.SubprocessError) as exc:
parser.error(str(exc))
print(json.dumps(result, indent=2, sort_keys=True))
+34
View File
@@ -3,6 +3,24 @@
from k1link.observatory.canonical_result import (
is_admitted_observatory_recorded_result,
)
from k1link.observatory.run_preparations import (
MAX_RUN_PREPARATION_RECORDS,
MAX_RUN_PREPARATION_STORAGE_BYTES,
OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA,
OBSERVATORY_RUN_PREPARATION_SCHEMA,
RUN_PREPARATION_DATABASE_NAME,
RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS,
ObservatoryRunPreparation,
ObservatoryRunPreparationCapacityError,
ObservatoryRunPreparationConflictError,
ObservatoryRunPreparationError,
ObservatoryRunPreparationIntegrityError,
ObservatoryRunPreparationIntent,
ObservatoryRunPreparationLedger,
ObservatoryRunPreparationNotFoundError,
load_observatory_run_preparation_ledger,
observatory_run_preparation_request_sha256,
)
from k1link.observatory.setups import (
LABORATORY_SETUP_CATALOG_SCHEMA,
LABORATORY_SETUP_REGISTRY_SCHEMA,
@@ -13,7 +31,23 @@ from k1link.observatory.setups import (
__all__ = [
"LABORATORY_SETUP_CATALOG_SCHEMA",
"LABORATORY_SETUP_REGISTRY_SCHEMA",
"MAX_RUN_PREPARATION_RECORDS",
"MAX_RUN_PREPARATION_STORAGE_BYTES",
"OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA",
"OBSERVATORY_RUN_PREPARATION_SCHEMA",
"RUN_PREPARATION_DATABASE_NAME",
"RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS",
"LaboratorySetupRegistry",
"LaboratorySetupRegistryError",
"ObservatoryRunPreparation",
"ObservatoryRunPreparationCapacityError",
"ObservatoryRunPreparationConflictError",
"ObservatoryRunPreparationError",
"ObservatoryRunPreparationIntegrityError",
"ObservatoryRunPreparationIntent",
"ObservatoryRunPreparationLedger",
"ObservatoryRunPreparationNotFoundError",
"is_admitted_observatory_recorded_result",
"load_observatory_run_preparation_ledger",
"observatory_run_preparation_request_sha256",
]
+712
View File
@@ -0,0 +1,712 @@
"""Durable, observation-only preparation receipts for Observatory runs.
This ledger deliberately does not model a run lifecycle. Mission Core does
not currently have a durable dispatcher for the archived Observatory
RunDefinition, so a preparation can only record the exact admitted input and
the fail-closed blocker. A real run ledger must be introduced together with
an atomic durable dispatch receipt; until then ``run_id`` remains absent.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import sqlite3
import threading
from collections.abc import Callable, Iterator
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Final, Literal
from uuid import uuid4
from k1link.artifacts import utc_now_iso
OBSERVATORY_RUN_PREPARATION_SCHEMA: Final = (
"missioncore.observatory-run-preparation/v1"
)
OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA: Final = (
"missioncore.observatory-run-preparation-request/v1"
)
RUN_PREPARATION_DATABASE_NAME: Final = "observatory-run-preparations.sqlite3"
MAX_RUN_PREPARATION_RECORDS: Final = 10_000
MAX_RUN_PREPARATION_STORAGE_BYTES: Final = 64 * 1024 * 1024
RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS: Final = 0.1
_SQLITE_BUSY_TIMEOUT_MILLISECONDS: Final = 100
_PREPARATION_ID = re.compile(r"^observatory-prep-[a-f0-9]{32}$")
_IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$")
_SESSION_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
_IDENTIFIER = re.compile(r"^[a-z][a-z0-9-]{2,95}$")
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
_STATE: Literal["blocked"] = "blocked"
_AUTHORITY: Final = {
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
"production_accepted": False,
}
_TABLE_SQL = """
CREATE TABLE observatory_run_preparations (
preparation_id TEXT PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
request_sha256 TEXT NOT NULL,
receipt_sha256 TEXT NOT NULL,
source_session_id TEXT NOT NULL,
source_catalog_sha256 TEXT NOT NULL,
setup_id TEXT NOT NULL,
definition_id TEXT NOT NULL,
definition_version INTEGER NOT NULL CHECK (definition_version > 0),
definition_sha256 TEXT NOT NULL,
state TEXT NOT NULL CHECK (state = 'blocked'),
blocker_code TEXT NOT NULL,
blocker_message TEXT NOT NULL,
created_at_utc TEXT NOT NULL
);
"""
_SCHEMA_SQL = _TABLE_SQL.replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ", 1)
_EXPECTED_COLUMNS: Final = (
("preparation_id", "TEXT", 0, 1),
("idempotency_key", "TEXT", 1, 0),
("request_sha256", "TEXT", 1, 0),
("receipt_sha256", "TEXT", 1, 0),
("source_session_id", "TEXT", 1, 0),
("source_catalog_sha256", "TEXT", 1, 0),
("setup_id", "TEXT", 1, 0),
("definition_id", "TEXT", 1, 0),
("definition_version", "INTEGER", 1, 0),
("definition_sha256", "TEXT", 1, 0),
("state", "TEXT", 1, 0),
("blocker_code", "TEXT", 1, 0),
("blocker_message", "TEXT", 1, 0),
("created_at_utc", "TEXT", 1, 0),
)
class ObservatoryRunPreparationError(RuntimeError):
"""Base error for the isolated Observatory preparation ledger."""
class ObservatoryRunPreparationConflictError(ObservatoryRunPreparationError):
"""An idempotency key is already bound to another preparation request."""
class ObservatoryRunPreparationNotFoundError(ObservatoryRunPreparationError):
"""A preparation receipt is not present."""
class ObservatoryRunPreparationIntegrityError(ObservatoryRunPreparationError):
"""The durable ledger violates its fail-closed immutable contract."""
class ObservatoryRunPreparationCapacityError(ObservatoryRunPreparationError):
"""The bounded preparation ledger cannot admit another receipt."""
@dataclass(frozen=True, slots=True)
class ObservatoryRunPreparationIntent:
"""Validated, path-free identity captured at the preparation boundary."""
idempotency_key: str
source_session_id: str
source_catalog_sha256: str
setup_id: str
definition_id: str
definition_version: int
definition_sha256: str
blocker_code: str
blocker_message: str
def __post_init__(self) -> None:
_validate_idempotency_key(self.idempotency_key)
_validate_pattern(self.source_session_id, _SESSION_ID, "source session id")
_validate_digest(self.source_catalog_sha256, "source catalog sha256")
_validate_pattern(self.setup_id, _IDENTIFIER, "setup id")
_validate_pattern(self.definition_id, _IDENTIFIER, "definition id")
if (
not isinstance(self.definition_version, int)
or isinstance(self.definition_version, bool)
or self.definition_version < 1
):
raise ValueError("definition version must be positive")
_validate_digest(self.definition_sha256, "definition sha256")
_validate_pattern(self.blocker_code, _IDENTIFIER, "blocker code")
_validate_text(self.blocker_message, "blocker message", max_length=1_000)
@property
def request_sha256(self) -> str:
"""Fingerprint only the exact client payload used for idempotency."""
return observatory_run_preparation_request_sha256(
idempotency_key=self.idempotency_key,
source_session_id=self.source_session_id,
setup_id=self.setup_id,
definition_sha256=self.definition_sha256,
)
@dataclass(frozen=True, slots=True)
class ObservatoryRunPreparation:
preparation_id: str
idempotency_key: str
request_sha256: str
receipt_sha256: str
source_session_id: str
source_catalog_sha256: str
setup_id: str
definition_id: str
definition_version: int
definition_sha256: str
blocker_code: str
blocker_message: str
created_at_utc: str
state: Literal["blocked"] = _STATE
@classmethod
def from_intent(
cls,
intent: ObservatoryRunPreparationIntent,
*,
preparation_id: str,
created_at_utc: str,
) -> ObservatoryRunPreparation:
receipt_sha256 = _receipt_sha256(
preparation_id=preparation_id,
idempotency_key=intent.idempotency_key,
request_sha256=intent.request_sha256,
source_session_id=intent.source_session_id,
source_catalog_sha256=intent.source_catalog_sha256,
setup_id=intent.setup_id,
definition_id=intent.definition_id,
definition_version=intent.definition_version,
definition_sha256=intent.definition_sha256,
blocker_code=intent.blocker_code,
blocker_message=intent.blocker_message,
created_at_utc=created_at_utc,
)
return cls(
preparation_id=preparation_id,
idempotency_key=intent.idempotency_key,
request_sha256=intent.request_sha256,
receipt_sha256=receipt_sha256,
source_session_id=intent.source_session_id,
source_catalog_sha256=intent.source_catalog_sha256,
setup_id=intent.setup_id,
definition_id=intent.definition_id,
definition_version=intent.definition_version,
definition_sha256=intent.definition_sha256,
blocker_code=intent.blocker_code,
blocker_message=intent.blocker_message,
created_at_utc=created_at_utc,
)
def __post_init__(self) -> None:
_validate_pattern(self.preparation_id, _PREPARATION_ID, "preparation id")
intent = ObservatoryRunPreparationIntent(
idempotency_key=self.idempotency_key,
source_session_id=self.source_session_id,
source_catalog_sha256=self.source_catalog_sha256,
setup_id=self.setup_id,
definition_id=self.definition_id,
definition_version=self.definition_version,
definition_sha256=self.definition_sha256,
blocker_code=self.blocker_code,
blocker_message=self.blocker_message,
)
_validate_digest(self.request_sha256, "request sha256")
if self.request_sha256 != intent.request_sha256:
raise ObservatoryRunPreparationIntegrityError(
"stored preparation request fingerprint changed"
)
_validate_digest(self.receipt_sha256, "receipt sha256")
if self.state != _STATE:
raise ObservatoryRunPreparationIntegrityError(
"preparation cannot claim a run lifecycle state"
)
_validate_timestamp(self.created_at_utc)
if self.receipt_sha256 != _receipt_sha256(
preparation_id=self.preparation_id,
idempotency_key=self.idempotency_key,
request_sha256=self.request_sha256,
source_session_id=self.source_session_id,
source_catalog_sha256=self.source_catalog_sha256,
setup_id=self.setup_id,
definition_id=self.definition_id,
definition_version=self.definition_version,
definition_sha256=self.definition_sha256,
blocker_code=self.blocker_code,
blocker_message=self.blocker_message,
created_at_utc=self.created_at_utc,
):
raise ObservatoryRunPreparationIntegrityError(
"stored preparation receipt fingerprint changed"
)
def as_dict(self) -> dict[str, object]:
return {
"schema_version": OBSERVATORY_RUN_PREPARATION_SCHEMA,
"preparation_id": self.preparation_id,
"run_id": None,
"idempotency_key": self.idempotency_key,
"request_sha256": self.request_sha256,
"receipt_sha256": self.receipt_sha256,
"source": {
"session_id": self.source_session_id,
"catalog_sha256": self.source_catalog_sha256,
},
"setup": {
"setup_id": self.setup_id,
"definition": {
"definition_id": self.definition_id,
"version": self.definition_version,
"definition_sha256": self.definition_sha256,
},
},
"state": self.state,
"blocker": {
"reason_code": self.blocker_code,
"message": self.blocker_message,
},
"preconditions_passed": True,
"submission_allowed": False,
"dispatch_receipt": None,
"created_at_utc": self.created_at_utc,
"authority": dict(_AUTHORITY),
}
class ObservatoryRunPreparationLedger:
"""SQLite-backed immutable preparation receipts with durable idempotency."""
def __init__(
self,
data_dir: Path,
*,
clock: Callable[[], str] = utc_now_iso,
max_records: int = MAX_RUN_PREPARATION_RECORDS,
) -> None:
if (
not isinstance(max_records, int)
or isinstance(max_records, bool)
or not 1 <= max_records <= MAX_RUN_PREPARATION_RECORDS
):
raise ValueError("preparation ledger record quota is invalid")
self.data_dir = data_dir.expanduser().resolve()
self.database_path = self.data_dir / RUN_PREPARATION_DATABASE_NAME
self._clock = clock
self._max_records = max_records
self._lock = threading.RLock()
self._initialize()
def prepare(
self,
intent: ObservatoryRunPreparationIntent,
) -> tuple[ObservatoryRunPreparation, bool]:
"""Persist once, returning the original receipt for an exact retry."""
try:
with self._lock, self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
existing = connection.execute(
"SELECT * FROM observatory_run_preparations "
"WHERE idempotency_key = ?",
(intent.idempotency_key,),
).fetchone()
if existing is not None:
record = _record_from_row(existing)
if record.request_sha256 != intent.request_sha256:
raise ObservatoryRunPreparationConflictError(
"idempotency key is bound to another preparation request"
)
connection.commit()
return record, False
if self._bounded_record_count(connection) >= self._max_records:
raise ObservatoryRunPreparationCapacityError(
"preparation ledger record quota is exhausted"
)
record = ObservatoryRunPreparation.from_intent(
intent,
preparation_id=f"observatory-prep-{uuid4().hex}",
created_at_utc=self._clock(),
)
connection.execute(
"INSERT INTO observatory_run_preparations "
"(preparation_id, idempotency_key, request_sha256, receipt_sha256, "
"source_session_id, source_catalog_sha256, setup_id, "
"definition_id, definition_version, definition_sha256, "
"state, blocker_code, blocker_message, created_at_utc) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
record.preparation_id,
record.idempotency_key,
record.request_sha256,
record.receipt_sha256,
record.source_session_id,
record.source_catalog_sha256,
record.setup_id,
record.definition_id,
record.definition_version,
record.definition_sha256,
record.state,
record.blocker_code,
record.blocker_message,
record.created_at_utc,
),
)
connection.commit()
return record, True
except ObservatoryRunPreparationError:
raise
except (OSError, sqlite3.Error) as exc:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger write failed"
) from exc
def get(self, preparation_id: str) -> ObservatoryRunPreparation:
_validate_pattern(preparation_id, _PREPARATION_ID, "preparation id")
try:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT * FROM observatory_run_preparations "
"WHERE preparation_id = ?",
(preparation_id,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger read failed"
) from exc
if row is None:
raise ObservatoryRunPreparationNotFoundError(preparation_id)
return _record_from_row(row)
def get_by_idempotency_key(
self,
idempotency_key: str,
) -> ObservatoryRunPreparation | None:
"""Return a sealed receipt without consulting mutable source/catalog state."""
_validate_idempotency_key(idempotency_key)
try:
with self._lock, self._connect() as connection:
row = connection.execute(
"SELECT * FROM observatory_run_preparations "
"WHERE idempotency_key = ?",
(idempotency_key,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger read failed"
) from exc
return None if row is None else _record_from_row(row)
def _initialize(self) -> None:
try:
self.data_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
self._validate_storage_paths()
with suppress(OSError):
self.data_dir.chmod(0o700)
with self._connect() as connection:
connection.executescript(_SCHEMA_SQL)
self._validate_schema(connection)
if self._bounded_record_count(connection) > self._max_records:
raise ObservatoryRunPreparationCapacityError(
"preparation ledger record quota is exceeded"
)
connection.commit()
self._validate_storage_paths(require_database=True)
with suppress(OSError):
self.database_path.chmod(0o600)
_fsync_directory(self.data_dir)
except ObservatoryRunPreparationError:
raise
except (OSError, sqlite3.Error) as exc:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger initialization failed"
) from exc
def _validate_schema(self, connection: sqlite3.Connection) -> None:
columns = connection.execute(
"SELECT name, type, \"notnull\", pk FROM pragma_table_info(?) "
"ORDER BY cid LIMIT ?",
("observatory_run_preparations", len(_EXPECTED_COLUMNS) + 1),
).fetchall()
actual_columns = tuple(
(row["name"], row["type"], row["notnull"], row["pk"])
for row in columns
)
if actual_columns != _EXPECTED_COLUMNS:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger schema is incompatible"
)
indexes = connection.execute(
"SELECT name, \"unique\", partial FROM pragma_index_list(?) "
"ORDER BY seq LIMIT 9",
("observatory_run_preparations",),
).fetchall()
if len(indexes) > 8:
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger has an unsafe index set"
)
if not _has_full_binary_unique_index(
connection,
indexes,
"preparation_id",
):
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger lost receipt identity uniqueness"
)
if not _has_full_binary_unique_index(
connection,
indexes,
"idempotency_key",
):
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger lost idempotency uniqueness"
)
table = connection.execute(
"SELECT sql FROM sqlite_master "
"WHERE type = 'table' AND name = 'observatory_run_preparations' "
"LIMIT 1"
).fetchone()
if (
table is None
or not isinstance(table["sql"], str)
or _normalized_sql(table["sql"]) != _normalized_sql(_TABLE_SQL)
):
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger table contract is incompatible"
)
def _bounded_record_count(self, connection: sqlite3.Connection) -> int:
rows = connection.execute(
"SELECT 1 FROM observatory_run_preparations LIMIT ?",
(self._max_records + 1,),
).fetchall()
return len(rows)
def _validate_storage_paths(self, *, require_database: bool = False) -> None:
paths = (
self.database_path,
Path(f"{self.database_path}-wal"),
Path(f"{self.database_path}-shm"),
)
total_bytes = 0
for path in paths:
if path.is_symlink() or (path.exists() and not path.is_file()):
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger path is unsafe"
)
if path.exists():
total_bytes += path.stat().st_size
if require_database and not self.database_path.is_file():
raise ObservatoryRunPreparationIntegrityError(
"preparation ledger database is unavailable"
)
if total_bytes > MAX_RUN_PREPARATION_STORAGE_BYTES:
raise ObservatoryRunPreparationCapacityError(
"preparation ledger storage quota is exceeded"
)
@contextmanager
def _connect(self) -> Iterator[sqlite3.Connection]:
self._validate_storage_paths()
connection = sqlite3.connect(
self.database_path,
timeout=RUN_PREPARATION_SQLITE_LOCK_TIMEOUT_SECONDS,
)
connection.row_factory = sqlite3.Row
try:
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA synchronous = FULL")
connection.execute(
f"PRAGMA busy_timeout = {_SQLITE_BUSY_TIMEOUT_MILLISECONDS}"
)
connection.execute("PRAGMA journal_mode = WAL")
yield connection
finally:
connection.close()
def _record_from_row(row: sqlite3.Row) -> ObservatoryRunPreparation:
try:
return ObservatoryRunPreparation(
preparation_id=row["preparation_id"],
idempotency_key=row["idempotency_key"],
request_sha256=row["request_sha256"],
receipt_sha256=row["receipt_sha256"],
source_session_id=row["source_session_id"],
source_catalog_sha256=row["source_catalog_sha256"],
setup_id=row["setup_id"],
definition_id=row["definition_id"],
definition_version=row["definition_version"],
definition_sha256=row["definition_sha256"],
state=row["state"],
blocker_code=row["blocker_code"],
blocker_message=row["blocker_message"],
created_at_utc=row["created_at_utc"],
)
except (IndexError, KeyError, TypeError, ValueError) as exc:
raise ObservatoryRunPreparationIntegrityError(
"stored preparation receipt is invalid"
) from exc
def load_observatory_run_preparation_ledger(
data_dir: Path,
) -> tuple[ObservatoryRunPreparationLedger | None, str | None]:
"""Open the optional ledger within a short, fixed startup lock budget."""
try:
return ObservatoryRunPreparationLedger(data_dir), None
except (ObservatoryRunPreparationError, OSError) as exc:
return None, str(exc)
def observatory_run_preparation_request_sha256(
*,
idempotency_key: str,
source_session_id: str,
setup_id: str,
definition_sha256: str,
) -> str:
"""Hash the exact immutable client payload, without mutable resolution state."""
_validate_idempotency_key(idempotency_key)
_validate_pattern(source_session_id, _SESSION_ID, "source session id")
_validate_pattern(setup_id, _IDENTIFIER, "setup id")
_validate_digest(definition_sha256, "definition sha256")
document = {
"schema_version": OBSERVATORY_RUN_PREPARATION_REQUEST_SCHEMA,
"idempotency_key": idempotency_key,
"source_session_id": source_session_id,
"setup_id": setup_id,
"definition_sha256": definition_sha256,
}
return hashlib.sha256(_canonical_json(document)).hexdigest()
def _receipt_sha256(
*,
preparation_id: str,
idempotency_key: str,
request_sha256: str,
source_session_id: str,
source_catalog_sha256: str,
setup_id: str,
definition_id: str,
definition_version: int,
definition_sha256: str,
blocker_code: str,
blocker_message: str,
created_at_utc: str,
) -> str:
document = {
"schema_version": OBSERVATORY_RUN_PREPARATION_SCHEMA,
"preparation_id": preparation_id,
"idempotency_key": idempotency_key,
"request_sha256": request_sha256,
"source_session_id": source_session_id,
"source_catalog_sha256": source_catalog_sha256,
"setup_id": setup_id,
"definition_id": definition_id,
"definition_version": definition_version,
"definition_sha256": definition_sha256,
"state": _STATE,
"blocker_code": blocker_code,
"blocker_message": blocker_message,
"created_at_utc": created_at_utc,
}
return hashlib.sha256(_canonical_json(document)).hexdigest()
def _has_full_binary_unique_index(
connection: sqlite3.Connection,
indexes: list[sqlite3.Row],
column: str,
) -> bool:
for index in indexes:
if not bool(index["unique"]) or bool(index["partial"]):
continue
fields = connection.execute(
"SELECT name, coll, desc, key FROM pragma_index_xinfo(?) "
"ORDER BY seqno LIMIT 4",
(index["name"],),
).fetchall()
if len(fields) > 3:
continue
key_fields = [field for field in fields if bool(field["key"])]
if (
len(key_fields) == 1
and key_fields[0]["name"] == column
and key_fields[0]["coll"] == "BINARY"
and not bool(key_fields[0]["desc"])
):
return True
return False
def _normalized_sql(value: str) -> str:
return " ".join(value.split()).removesuffix(";")
def _validate_idempotency_key(value: object) -> None:
_validate_pattern(value, _IDEMPOTENCY_KEY, "idempotency key")
def _validate_digest(value: object, label: str) -> None:
_validate_pattern(value, _SHA256, label)
def _validate_pattern(value: object, pattern: re.Pattern[str], label: str) -> None:
if not isinstance(value, str) or pattern.fullmatch(value) is None:
raise ValueError(f"{label} is invalid")
def _validate_text(value: object, label: str, *, max_length: int) -> None:
if (
not isinstance(value, str)
or not value.strip()
or value != value.strip()
or len(value) > max_length
):
raise ValueError(f"{label} is invalid")
def _validate_timestamp(value: object) -> None:
_validate_text(value, "created timestamp", max_length=64)
assert isinstance(value, str)
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("created timestamp is invalid") from exc
if (
parsed.tzinfo is None
or parsed.utcoffset() != timedelta(0)
or not value.endswith("Z")
):
raise ValueError("created timestamp must use UTC")
def _canonical_json(value: object) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def _fsync_directory(path: Path) -> None:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0)
flags |= getattr(os, "O_DIRECTORY", 0)
descriptor = os.open(path, flags)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
+16 -1
View File
@@ -36,7 +36,12 @@ from k1link.laboratory.m48_raw_evidence import (
M48RawEvidenceError,
M48RawEvidenceReader,
)
from k1link.observatory import LaboratorySetupRegistry, LaboratorySetupRegistryError
from k1link.observatory import (
LaboratorySetupRegistry,
LaboratorySetupRegistryError,
ObservatoryRunPreparationLedger,
load_observatory_run_preparation_ledger,
)
from k1link.sessions import (
MaterializedRecording,
RecordedCameraFrameService,
@@ -191,6 +196,8 @@ LABORATORY_RUNNER = LaboratoryRunner(
LABORATORY_VALUE_REVIEW_REGISTRY = LaboratoryValueReviewRegistry.from_file(
REPOSITORY_ROOT / "config" / "laboratory-value-review.json"
)
OBSERVATORY_LABORATORY_SETUP_REGISTRY: LaboratorySetupRegistry | None
OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR: str | None
try:
OBSERVATORY_LABORATORY_SETUP_REGISTRY = LaboratorySetupRegistry.from_file(
REPOSITORY_ROOT / "config" / "observatory-laboratory-setups.json",
@@ -229,6 +236,12 @@ plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
session_store = SessionStore(REPOSITORY_ROOT)
OBSERVATORY_RUN_PREPARATION_LEDGER: ObservatoryRunPreparationLedger | None
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)
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)
@@ -710,6 +723,8 @@ app.include_router(
session_store,
setup_registry=OBSERVATORY_LABORATORY_SETUP_REGISTRY,
setup_registry_error=OBSERVATORY_LABORATORY_SETUP_REGISTRY_ERROR,
run_preparation_ledger=OBSERVATORY_RUN_PREPARATION_LEDGER,
run_preparation_ledger_error=OBSERVATORY_RUN_PREPARATION_LEDGER_ERROR,
)
)
app.include_router(
+267 -1
View File
@@ -3,13 +3,22 @@ from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Query, Response
from fastapi import Path as ApiPath
from pydantic import BaseModel, ConfigDict, Field
from k1link.observatory import (
LaboratorySetupRegistry,
ObservatoryRunPreparationCapacityError,
ObservatoryRunPreparationConflictError,
ObservatoryRunPreparationIntegrityError,
ObservatoryRunPreparationIntent,
ObservatoryRunPreparationLedger,
ObservatoryRunPreparationNotFoundError,
is_admitted_observatory_recorded_result,
observatory_run_preparation_request_sha256,
)
from k1link.sessions import SessionIntegrityError, SessionNotFoundError, SessionStore
from k1link.sessions.models import SessionSummary
OBSERVATORY_PROJECTION_SCHEMA: Literal[
"missioncore.observatory-lab-projection/v1"
@@ -57,17 +66,41 @@ class ObservatoryRunPreflightRequest(_StrictApiModel):
definition_sha256: str | None = Field(default=None, pattern=r"^[a-f0-9]{64}$")
class ObservatoryRunPreparationRequest(_StrictApiModel):
schema_version: Literal[
"missioncore.observatory-run-preparation-request/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}$",
)
definition_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
def build_observatory_router(
store: SessionStore,
*,
setup_registry: LaboratorySetupRegistry | None = None,
setup_registry_error: str | None = None,
run_preparation_ledger: ObservatoryRunPreparationLedger | None = None,
run_preparation_ledger_error: str | None = None,
) -> APIRouter:
"""Build bounded catalog-only mutations for typed Observatory projections."""
router = APIRouter(tags=["observatory"])
def source_summary(session_id: str):
def source_summary(session_id: str) -> SessionSummary:
try:
summary = store.get_session(session_id).summary
except SessionNotFoundError as exc:
@@ -89,6 +122,30 @@ def build_observatory_router(
)
return summary
def source_summary_with_catalog_snapshot(
session_id: str,
) -> tuple[SessionSummary, str]:
try:
detail, snapshot_sha256 = store.get_session_with_catalog_snapshot(session_id)
except SessionNotFoundError as exc:
raise HTTPException(status_code=404, detail="Исходная сессия не найдена.") from exc
except ValueError as exc:
raise HTTPException(
status_code=422,
detail="Некорректный идентификатор исходной сессии.",
) from exc
except SessionIntegrityError as exc:
raise HTTPException(
status_code=409,
detail="Каталог исходной сессии нарушил контракт целостности.",
) from exc
if detail.summary.lab is not None:
raise HTTPException(
status_code=409,
detail="Для подготовки нужна исходная, а не лабораторная сессия.",
)
return detail.summary, snapshot_sha256
def available_observatory_results(source_session_id: str) -> frozenset[str]:
if setup_registry is None:
return frozenset()
@@ -242,6 +299,215 @@ def build_observatory_router(
detail="Каталог сетапов Обсерватории не прошёл проверку целостности.",
)
if setup_registry is not None and run_preparation_ledger is not None:
@router.post("/api/v1/observatory/run-preparations")
def prepare_observatory_run(
request: ObservatoryRunPreparationRequest,
) -> dict[str, object]:
try:
existing = run_preparation_ledger.get_by_idempotency_key(
request.idempotency_key
)
request_sha256 = observatory_run_preparation_request_sha256(
idempotency_key=request.idempotency_key,
source_session_id=request.source_session_id,
setup_id=request.setup_id,
definition_sha256=request.definition_sha256,
)
except ObservatoryRunPreparationCapacityError as exc:
raise HTTPException(
status_code=503,
detail="Квота журнала подготовки расчётов исчерпана.",
) from exc
except (ObservatoryRunPreparationIntegrityError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Журнал подготовки расчётов недоступен.",
) from exc
if existing is not None:
if existing.request_sha256 != request_sha256:
raise HTTPException(
status_code=409,
detail="Ключ идемпотентности уже связан с другой подготовкой.",
)
return existing.as_dict()
source, source_catalog_sha256 = source_summary_with_catalog_snapshot(
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="Каталог сетапов Обсерватории нарушил контракт.",
)
definition = projected.get("run_definition")
if not isinstance(definition, dict):
raise HTTPException(
status_code=409,
detail="Для сетапа нет запускаемой RunDefinition.",
)
expected_digest = definition.get("definition_sha256")
if request.definition_sha256 != expected_digest:
raise HTTPException(
status_code=409,
detail="Идентичность RunDefinition изменилась; обновите каталог.",
)
compatibility = projected.get("compatibility")
preflight = projected.get("preflight")
executor = projected.get("executor")
if (
not isinstance(compatibility, dict)
or not isinstance(preflight, dict)
or not isinstance(executor, 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 (
preflight.get("submission_allowed") is not False
or executor.get("state") != "not-installed"
):
raise HTTPException(
status_code=503,
detail="Dispatch-контракт Обсерватории не установлен.",
)
definition_id = definition.get("definition_id")
definition_version = definition.get("version")
blocker_code = executor.get("reason_code")
blocker_message = executor.get("reason")
if (
not isinstance(definition_id, str)
or not isinstance(definition_version, int)
or isinstance(definition_version, bool)
or not isinstance(expected_digest, str)
or not isinstance(blocker_code, str)
or not isinstance(blocker_message, str)
):
raise HTTPException(
status_code=503,
detail="Каталог сетапов Обсерватории нарушил контракт.",
)
try:
intent = ObservatoryRunPreparationIntent(
idempotency_key=request.idempotency_key,
source_session_id=request.source_session_id,
source_catalog_sha256=source_catalog_sha256,
setup_id=request.setup_id,
definition_id=definition_id,
definition_version=definition_version,
definition_sha256=expected_digest,
blocker_code=blocker_code,
blocker_message=blocker_message,
)
preparation, _created = run_preparation_ledger.prepare(intent)
except ObservatoryRunPreparationConflictError as exc:
raise HTTPException(
status_code=409,
detail="Ключ идемпотентности уже связан с другой подготовкой.",
) from exc
except ObservatoryRunPreparationCapacityError as exc:
raise HTTPException(
status_code=503,
detail="Квота журнала подготовки расчётов исчерпана.",
) from exc
except (ObservatoryRunPreparationIntegrityError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Журнал подготовки расчётов недоступен.",
) from exc
return preparation.as_dict()
@router.get("/api/v1/observatory/run-preparations/{preparation_id}")
def get_observatory_run_preparation(
preparation_id: str = ApiPath(
min_length=49,
max_length=49,
pattern=r"^observatory-prep-[a-f0-9]{32}$",
),
) -> dict[str, object]:
try:
return run_preparation_ledger.get(preparation_id).as_dict()
except ObservatoryRunPreparationNotFoundError as exc:
raise HTTPException(
status_code=404,
detail="Подготовка расчёта не найдена.",
) from exc
except ObservatoryRunPreparationCapacityError as exc:
raise HTTPException(
status_code=503,
detail="Квота журнала подготовки расчётов исчерпана.",
) from exc
except (ObservatoryRunPreparationIntegrityError, ValueError) as exc:
raise HTTPException(
status_code=503,
detail="Журнал подготовки расчётов недоступен.",
) from exc
elif setup_registry_error is not None or run_preparation_ledger_error is not None:
@router.post("/api/v1/observatory/run-preparations")
def unavailable_observatory_run_preparation(
request: ObservatoryRunPreparationRequest,
) -> None:
del request
raise HTTPException(
status_code=503,
detail="Подготовка расчётов Обсерватории недоступна.",
)
@router.get("/api/v1/observatory/run-preparations/{preparation_id}")
def unavailable_observatory_run_preparation_receipt(
preparation_id: str = ApiPath(
min_length=49,
max_length=49,
pattern=r"^observatory-prep-[a-f0-9]{32}$",
),
) -> None:
del preparation_id
raise HTTPException(
status_code=503,
detail="Подготовка расчётов Обсерватории недоступна.",
)
@router.patch(
"/api/v1/observatory/lab-projections/{session_id}",
response_model=ObservatoryProjectionDocument,
+108 -2
View File
@@ -3,10 +3,13 @@ from __future__ import annotations
import hashlib
import importlib.util
import json
import subprocess
import sys
import tarfile
from pathlib import Path
import numpy as np
import pytest
from k1link.laboratory.m49_tgs_full_shadow import (
M49TgsFullShadowResult,
@@ -34,6 +37,49 @@ ARTIFACT = importlib.util.module_from_spec(ARTIFACT_SPEC)
ARTIFACT_SPEC.loader.exec_module(ARTIFACT)
def _create_artifact_git_repository(root: Path) -> tuple[str, dict[Path, bytes]]:
subprocess.run(["git", "init", "-q", str(root)], check=True, capture_output=True)
subprocess.run(
["git", "config", "user.email", "mission-core-test@example.invalid"],
cwd=root,
check=True,
capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Mission Core Test"],
cwd=root,
check=True,
capture_output=True,
)
committed: dict[Path, bytes] = {}
for index, relative in enumerate(ARTIFACT.SOURCES):
payload = f"committed source {index}: {relative.as_posix()}\n".encode()
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(payload)
committed[relative] = payload
subprocess.run(
["git", "add", "--", *ARTIFACT.SOURCES],
cwd=root,
check=True,
capture_output=True,
)
subprocess.run(
["git", "commit", "-q", "-m", "fixture"],
cwd=root,
check=True,
capture_output=True,
)
revision = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=root,
check=True,
capture_output=True,
text=True,
).stdout.strip()
return revision, committed
def test_full_shadow_exact_multiset_and_costmap_priority() -> None:
native = np.asarray(
[[2.0, 0.0, 0.0, 0.0], [2.0, 0.0, 0.0, 0.0], [3.0, 0.0, 1.0, 0.0], [4.0, 0.0, 2.0, 0.0]],
@@ -47,11 +93,10 @@ def test_full_shadow_exact_multiset_and_costmap_priority() -> None:
def test_full_shadow_worker_artifact_is_deterministic_and_cpu_only(tmp_path: Path) -> None:
revision = "a" * 40
revision = ARTIFACT.git_revision()
first = ARTIFACT.build("m49-tgs-full-test", tmp_path / "one", revision=revision)
second = ARTIFACT.build("m49-tgs-full-test", tmp_path / "two", revision=revision)
assert first["sha256"] == second["sha256"]
import tarfile
with tarfile.open(first["artifact"], "r:gz") as archive:
release = archive.extractfile("payload/release.json")
@@ -64,6 +109,67 @@ def test_full_shadow_worker_artifact_is_deterministic_and_cpu_only(tmp_path: Pat
assert "gpu_requested = $false" in runner_text
def test_full_shadow_explicit_revision_ignores_current_file_drift(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
repository = tmp_path / "repository"
revision, committed = _create_artifact_git_repository(repository)
drifted_source = ARTIFACT.SOURCES[0]
(repository / drifted_source).write_bytes(b"dirty current-worktree replacement\n")
monkeypatch.setattr(ARTIFACT, "REPOSITORY_ROOT", repository)
explicit = ARTIFACT.build(
"m49-tgs-full-clean-revision",
tmp_path / "explicit",
revision=revision,
)
default_head = ARTIFACT.build("m49-tgs-full-clean-revision", tmp_path / "default")
assert explicit["code_revision"] == revision
assert default_head["code_revision"] == revision
assert explicit["sha256"] == default_head["sha256"]
with tarfile.open(explicit["artifact"], "r:gz") as archive:
source = archive.extractfile(f"payload/{drifted_source.name}")
release_stream = archive.extractfile("payload/release.json")
assert source is not None and release_stream is not None
assert source.read() == committed[drifted_source]
release = json.loads(release_stream.read())
assert release["code_revision"] == revision
assert release["files"][drifted_source.name]["sha256"] == hashlib.sha256(
committed[drifted_source]
).hexdigest()
@pytest.mark.parametrize("revision", ["HEAD", "../HEAD", "--all", "A" * 40])
def test_full_shadow_artifact_rejects_unsafe_or_symbolic_revision(
revision: str,
tmp_path: Path,
) -> None:
with pytest.raises(ARTIFACT.ArtifactBuildError, match="artifact revision is invalid"):
ARTIFACT.build("m49-tgs-full-invalid-revision", tmp_path, revision=revision)
def test_full_shadow_artifact_rejects_missing_revision(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
repository = tmp_path / "repository"
revision, _ = _create_artifact_git_repository(repository)
missing_revision = "f" * 40 if revision != "f" * 40 else "e" * 40
monkeypatch.setattr(ARTIFACT, "REPOSITORY_ROOT", repository)
with pytest.raises(
ARTIFACT.ArtifactBuildError,
match="artifact revision does not identify an existing commit",
):
ARTIFACT.build(
"m49-tgs-full-missing-revision",
tmp_path / "artifact",
revision=missing_revision,
)
def test_full_shadow_seal_binds_visual_and_semantic_timelines(tmp_path: Path) -> None:
source = tmp_path / "worker"
source.mkdir()
+615
View File
@@ -0,0 +1,615 @@
from __future__ import annotations
import sqlite3
from concurrent.futures import ThreadPoolExecutor
from dataclasses import replace
from pathlib import Path
from threading import Barrier
from time import monotonic
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from k1link.observatory import (
MAX_RUN_PREPARATION_RECORDS,
RUN_PREPARATION_DATABASE_NAME,
LaboratorySetupRegistry,
ObservatoryRunPreparationCapacityError,
ObservatoryRunPreparationConflictError,
ObservatoryRunPreparationIntegrityError,
ObservatoryRunPreparationIntent,
ObservatoryRunPreparationLedger,
load_observatory_run_preparation_ledger,
)
from k1link.observatory import run_preparations as run_preparations_module
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"
SOURCE_SESSION_ID = "20260720T065719Z_viewer_live"
SOURCE_LABEL = "RAVNOVES00"
CURRENT_SOURCE_SESSION_ID = "20260828T130511Z_viewer_live"
SNAPSHOT_A = "a" * 64
SNAPSHOT_B = "b" * 64
def _registry() -> LaboratorySetupRegistry:
return LaboratorySetupRegistry.from_file(
REGISTRY_PATH,
repository_root=REPOSITORY_ROOT,
)
def _source(
session_id: str = SOURCE_SESSION_ID,
label: str = SOURCE_LABEL,
) -> SessionSummary:
return SessionSummary(
session_id=session_id,
display_name=label,
status="ready",
started_at_utc="2026-07-20T06:57:19Z",
completed_at_utc="2026-07-20T07:10:47Z",
duration_seconds=808.0,
modalities=("point-cloud", "trajectory", "video"),
source_count=3,
total_bytes=1,
replayable=True,
origin="recorded",
)
def _definition() -> dict[str, object]:
setup = _registry().catalog(_source())["setups"][0]
assert isinstance(setup, dict)
definition = setup["run_definition"]
assert isinstance(definition, dict)
return definition
def _intent(
*,
idempotency_key: str = "operator-request-001",
source_catalog_sha256: str = SNAPSHOT_A,
) -> ObservatoryRunPreparationIntent:
definition = _definition()
return ObservatoryRunPreparationIntent(
idempotency_key=idempotency_key,
source_session_id=SOURCE_SESSION_ID,
source_catalog_sha256=source_catalog_sha256,
setup_id="m49-tgs-full-shadow-v1",
definition_id=str(definition["definition_id"]),
definition_version=int(definition["version"]),
definition_sha256=str(definition["definition_sha256"]),
blocker_code="laboratory-runner-adapter-not-installed",
blocker_message=(
"Повторный запуск этого сетапа ещё не подключён к общему контуру расчёта."
),
)
class _ReadOnlySourceStore:
def __init__(self, source: SessionSummary | None = None) -> None:
resolved = source or _source()
self.sessions = {resolved.session_id: resolved}
self.snapshot_sha256 = SNAPSHOT_A
self.snapshot_reads = 0
def get_session(self, session_id: str):
try:
summary = self.sessions[session_id]
except KeyError as exc:
raise SessionNotFoundError(session_id) from exc
return SimpleNamespace(summary=summary)
def get_session_with_catalog_snapshot(self, session_id: str):
self.snapshot_reads += 1
return self.get_session(session_id), self.snapshot_sha256
def _client(
tmp_path: Path,
*,
store: _ReadOnlySourceStore | None = None,
) -> tuple[TestClient, ObservatoryRunPreparationLedger, _ReadOnlySourceStore]:
source_store = store or _ReadOnlySourceStore()
ledger = ObservatoryRunPreparationLedger(
tmp_path / "mission-core",
clock=lambda: "2026-08-30T20:00:00.000Z",
)
app = FastAPI()
app.include_router(
build_observatory_router(
source_store, # type: ignore[arg-type]
setup_registry=_registry(),
run_preparation_ledger=ledger,
)
)
return TestClient(app), ledger, source_store
def _request(
*,
idempotency_key: str = "operator-request-001",
definition_sha256: str | None = None,
) -> dict[str, object]:
definition = _definition()
return {
"schema_version": "missioncore.observatory-run-preparation-request/v1",
"idempotency_key": idempotency_key,
"source_session_id": SOURCE_SESSION_ID,
"setup_id": "m49-tgs-full-shadow-v1",
"definition_sha256": definition_sha256 or definition["definition_sha256"],
}
def _create_custom_ledger_schema(
data_dir: Path,
*,
idempotency_declaration: str,
extra_sql: str = "",
) -> None:
data_dir.mkdir(exist_ok=True)
with sqlite3.connect(data_dir / RUN_PREPARATION_DATABASE_NAME) as connection:
connection.executescript(
f"""
CREATE TABLE observatory_run_preparations (
preparation_id TEXT PRIMARY KEY,
idempotency_key {idempotency_declaration},
request_sha256 TEXT NOT NULL,
receipt_sha256 TEXT NOT NULL,
source_session_id TEXT NOT NULL,
source_catalog_sha256 TEXT NOT NULL,
setup_id TEXT NOT NULL,
definition_id TEXT NOT NULL,
definition_version INTEGER NOT NULL CHECK (definition_version > 0),
definition_sha256 TEXT NOT NULL,
state TEXT NOT NULL CHECK (state = 'blocked'),
blocker_code TEXT NOT NULL,
blocker_message TEXT NOT NULL,
created_at_utc TEXT NOT NULL
);
{extra_sql}
"""
)
def test_preparation_ledger_is_durable_and_exactly_idempotent(tmp_path: Path) -> None:
data_dir = tmp_path / "mission-core"
ledger = ObservatoryRunPreparationLedger(
data_dir,
clock=lambda: "2026-08-30T20:00:00.000Z",
)
first, first_created = ledger.prepare(_intent())
second, second_created = ledger.prepare(_intent())
restored = ObservatoryRunPreparationLedger(data_dir).get(first.preparation_id)
restored_by_key = ObservatoryRunPreparationLedger(data_dir).get_by_idempotency_key(
first.idempotency_key
)
assert first_created is True
assert second_created is False
assert second == first
assert restored == first
assert restored_by_key == first
assert first.state == "blocked"
assert first.as_dict()["run_id"] is None
assert first.as_dict()["dispatch_receipt"] is None
assert first.as_dict()["submission_allowed"] is False
assert first.as_dict()["receipt_sha256"] == first.receipt_sha256
with sqlite3.connect(data_dir / RUN_PREPARATION_DATABASE_NAME) as connection:
count = connection.execute(
"SELECT COUNT(*) FROM observatory_run_preparations"
).fetchone()[0]
assert count == 1
assert (data_dir / RUN_PREPARATION_DATABASE_NAME).stat().st_mode & 0o777 == 0o600
def test_preparation_idempotency_key_replays_original_resolved_source_identity(
tmp_path: Path,
) -> None:
ledger = ObservatoryRunPreparationLedger(tmp_path)
first, _created = ledger.prepare(_intent())
replayed, replay_created = ledger.prepare(
_intent(source_catalog_sha256=SNAPSHOT_B)
)
assert replay_created is False
assert replayed == first
assert replayed.source_catalog_sha256 == SNAPSHOT_A
with pytest.raises(ObservatoryRunPreparationConflictError, match="idempotency"):
ledger.prepare(replace(_intent(), definition_sha256="c" * 64))
def test_preparation_ledger_detects_stored_identity_drift(tmp_path: Path) -> None:
ledger = ObservatoryRunPreparationLedger(tmp_path)
record, _created = ledger.prepare(_intent())
with sqlite3.connect(ledger.database_path) as connection:
connection.execute(
"UPDATE observatory_run_preparations SET source_catalog_sha256 = ?",
(SNAPSHOT_B,),
)
connection.commit()
with pytest.raises(
ObservatoryRunPreparationIntegrityError,
match="receipt fingerprint",
):
ledger.get(record.preparation_id)
def test_preparation_ledger_rejects_an_incompatible_existing_schema(
tmp_path: Path,
) -> None:
tmp_path.mkdir(exist_ok=True)
with sqlite3.connect(tmp_path / RUN_PREPARATION_DATABASE_NAME) as connection:
connection.execute(
"CREATE TABLE observatory_run_preparations (preparation_id TEXT)"
)
with pytest.raises(
ObservatoryRunPreparationIntegrityError,
match="initialization|schema",
):
ObservatoryRunPreparationLedger(tmp_path)
def test_preparation_ledger_rejects_partial_idempotency_uniqueness(
tmp_path: Path,
) -> None:
_create_custom_ledger_schema(
tmp_path,
idempotency_declaration="TEXT NOT NULL",
extra_sql=(
"CREATE UNIQUE INDEX partial_idempotency "
"ON observatory_run_preparations(idempotency_key) "
"WHERE state = 'blocked';"
),
)
with pytest.raises(ObservatoryRunPreparationIntegrityError, match="uniqueness"):
ObservatoryRunPreparationLedger(tmp_path)
def test_preparation_ledger_rejects_non_binary_idempotency_collation(
tmp_path: Path,
) -> None:
_create_custom_ledger_schema(
tmp_path,
idempotency_declaration="TEXT COLLATE NOCASE NOT NULL UNIQUE",
)
with pytest.raises(ObservatoryRunPreparationIntegrityError, match="uniqueness"):
ObservatoryRunPreparationLedger(tmp_path)
def test_preparation_ledger_enforces_record_quota_but_keeps_retries_available(
tmp_path: Path,
) -> None:
ledger = ObservatoryRunPreparationLedger(tmp_path, max_records=1)
first, _created = ledger.prepare(_intent())
replayed, replay_created = ledger.prepare(_intent())
assert replay_created is False
assert replayed == first
with pytest.raises(ObservatoryRunPreparationCapacityError, match="quota"):
ledger.prepare(_intent(idempotency_key="operator-request-002"))
def test_preparation_ledger_concurrent_retry_commits_one_receipt(
tmp_path: Path,
) -> None:
first_ledger = ObservatoryRunPreparationLedger(tmp_path)
second_ledger = ObservatoryRunPreparationLedger(tmp_path)
barrier = Barrier(2)
def prepare(
ledger: ObservatoryRunPreparationLedger,
snapshot_sha256: str,
) -> tuple[str, str, bool]:
barrier.wait()
record, created = ledger.prepare(
_intent(source_catalog_sha256=snapshot_sha256)
)
return record.preparation_id, record.source_catalog_sha256, created
with ThreadPoolExecutor(max_workers=2) as executor:
first_future = executor.submit(prepare, first_ledger, SNAPSHOT_A)
second_future = executor.submit(prepare, second_ledger, SNAPSHOT_B)
first = first_future.result(timeout=10)
second = second_future.result(timeout=10)
assert first[0] == second[0]
assert first[1] == second[1]
assert sorted((first[2], second[2])) == [False, True]
with sqlite3.connect(first_ledger.database_path) as connection:
assert connection.execute(
"SELECT COUNT(*) FROM observatory_run_preparations"
).fetchone()[0] == 1
def test_preparation_ledger_startup_uses_only_bounded_admission_queries(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
statements: list[str] = []
connect = sqlite3.connect
def traced_connect(*args: object, **kwargs: object) -> sqlite3.Connection:
connection = connect(*args, **kwargs)
connection.set_trace_callback(statements.append)
return connection
monkeypatch.setattr(run_preparations_module.sqlite3, "connect", traced_connect)
ObservatoryRunPreparationLedger(tmp_path)
normalized = [statement.lower() for statement in statements]
assert not any("quick_check" in statement for statement in normalized)
quota_queries = [
statement
for statement in normalized
if "select 1 from observatory_run_preparations limit" in statement
]
assert len(quota_queries) == 1
assert str(MAX_RUN_PREPARATION_RECORDS + 1) in quota_queries[0]
def test_optional_ledger_loader_isolates_startup_from_ledger_corruption(
tmp_path: Path,
) -> None:
_create_custom_ledger_schema(
tmp_path,
idempotency_declaration="TEXT NOT NULL",
)
ledger, error = load_observatory_run_preparation_ledger(tmp_path)
assert ledger is None
assert error is not None
def test_optional_ledger_loader_has_a_short_database_lock_budget(
tmp_path: Path,
) -> None:
ledger = ObservatoryRunPreparationLedger(tmp_path)
locked = sqlite3.connect(ledger.database_path, timeout=0)
locked.execute("PRAGMA journal_mode = DELETE")
locked.execute("BEGIN EXCLUSIVE")
try:
started = monotonic()
loaded, error = load_observatory_run_preparation_ledger(tmp_path)
elapsed = monotonic() - started
finally:
locked.rollback()
locked.close()
assert loaded is None
assert error is not None
assert elapsed < 0.5
def test_preparation_endpoint_records_only_a_blocked_non_run_receipt(
tmp_path: Path,
) -> None:
client, ledger, source_store = _client(tmp_path)
response = client.post(
"/api/v1/observatory/run-preparations",
json=_request(),
)
assert response.status_code == 200
document = response.json()
assert document == ledger.get(document["preparation_id"]).as_dict()
assert document["schema_version"] == "missioncore.observatory-run-preparation/v1"
assert document["run_id"] is None
assert document["state"] == "blocked"
assert document["preconditions_passed"] is True
assert document["submission_allowed"] is False
assert document["dispatch_receipt"] is None
assert document["source"] == {
"session_id": SOURCE_SESSION_ID,
"catalog_sha256": SNAPSHOT_A,
}
assert document["setup"]["definition"]["definition_sha256"] == (
_definition()["definition_sha256"]
)
assert document["blocker"]["reason_code"] == (
"laboratory-runner-adapter-not-installed"
)
assert document["authority"] == {
"commands_enabled": False,
"actuation_allowed": False,
"navigation_or_safety_accepted": False,
"production_accepted": False,
}
assert source_store.snapshot_reads == 1
assert client.post("/api/v1/observatory/runs", json={}).status_code == 404
def test_preparation_endpoint_duplicate_returns_the_same_durable_receipt(
tmp_path: Path,
) -> None:
client, _ledger, _source_store = _client(tmp_path)
first = client.post("/api/v1/observatory/run-preparations", json=_request())
second = client.post("/api/v1/observatory/run-preparations", json=_request())
restored = client.get(
f"/api/v1/observatory/run-preparations/{first.json()['preparation_id']}"
)
assert first.status_code == second.status_code == restored.status_code == 200
assert second.json() == first.json()
assert restored.json() == first.json()
def test_preparation_endpoint_exact_retry_precedes_mutable_source_and_catalog_checks(
tmp_path: Path,
) -> None:
client, _ledger, source_store = _client(tmp_path)
first = client.post(
"/api/v1/observatory/run-preparations",
json=_request(),
)
assert first.status_code == 200
source_store.snapshot_sha256 = SNAPSHOT_B
source_store.sessions.clear()
replayed = client.post(
"/api/v1/observatory/run-preparations",
json=_request(),
)
assert replayed.status_code == 200
assert replayed.json() == first.json()
assert replayed.json()["source"]["catalog_sha256"] == SNAPSHOT_A
assert source_store.snapshot_reads == 1
def test_preparation_endpoint_new_key_captures_a_new_source_snapshot(
tmp_path: Path,
) -> None:
client, _ledger, source_store = _client(tmp_path)
first = client.post(
"/api/v1/observatory/run-preparations",
json=_request(),
)
assert first.status_code == 200
source_store.snapshot_sha256 = SNAPSHOT_B
second = client.post(
"/api/v1/observatory/run-preparations",
json=_request(idempotency_key="operator-request-002"),
)
assert second.status_code == 200
assert second.json()["preparation_id"] != first.json()["preparation_id"]
assert second.json()["source"]["catalog_sha256"] == SNAPSHOT_B
def test_preparation_endpoint_same_key_rejects_a_changed_client_payload_early(
tmp_path: Path,
) -> None:
client, _ledger, source_store = _client(tmp_path)
assert client.post(
"/api/v1/observatory/run-preparations",
json=_request(),
).status_code == 200
source_store.sessions.clear()
conflict = client.post(
"/api/v1/observatory/run-preparations",
json=_request(definition_sha256="f" * 64),
)
assert conflict.status_code == 409
assert source_store.snapshot_reads == 1
def test_preparation_endpoint_maps_early_ledger_capacity_failure_to_503(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, _ledger, source_store = _client(tmp_path)
monkeypatch.setattr(
run_preparations_module,
"MAX_RUN_PREPARATION_STORAGE_BYTES",
0,
)
response = client.post(
"/api/v1/observatory/run-preparations",
json=_request(),
)
assert response.status_code == 503
assert source_store.snapshot_reads == 0
def test_preparation_endpoint_rejects_stale_incompatible_and_non_executable_inputs(
tmp_path: Path,
) -> None:
client, _ledger, source_store = _client(tmp_path)
stale = client.post(
"/api/v1/observatory/run-preparations",
json=_request(definition_sha256="f" * 64),
)
assert stale.status_code == 409
source_store.sessions[SOURCE_SESSION_ID] = replace(
_source(),
replayable=False,
)
incompatible = client.post(
"/api/v1/observatory/run-preparations",
json=_request(idempotency_key="operator-request-002"),
)
assert incompatible.status_code == 409
current_store = _ReadOnlySourceStore(
_source(CURRENT_SOURCE_SESSION_ID, "RAVNOVES004TREE")
)
current_client, _current_ledger, _ = _client(tmp_path / "current", store=current_store)
no_definition = current_client.post(
"/api/v1/observatory/run-preparations",
json={
"schema_version": "missioncore.observatory-run-preparation-request/v1",
"idempotency_key": "operator-request-003",
"source_session_id": CURRENT_SOURCE_SESSION_ID,
"setup_id": "lab-v1-ravnoves004tree-final",
"definition_sha256": "f" * 64,
},
)
assert no_definition.status_code == 409
@pytest.mark.parametrize(
"request_patch",
[
{"idempotency_key": "../escape"},
{"source_session_id": "../source"},
{"setup_id": "../setup"},
{"definition_sha256": "not-a-digest"},
],
)
def test_preparation_endpoint_rejects_unsafe_request_fields(
tmp_path: Path,
request_patch: dict[str, object],
) -> None:
client, _ledger, _source_store = _client(tmp_path)
document = {**_request(), **request_patch}
assert client.post(
"/api/v1/observatory/run-preparations",
json=document,
).status_code == 422
def test_preparation_endpoint_fails_closed_when_ledger_is_unavailable() -> None:
app = FastAPI()
app.include_router(
build_observatory_router(
_ReadOnlySourceStore(), # type: ignore[arg-type]
setup_registry=_registry(),
run_preparation_ledger_error="database corrupt",
)
)
client = TestClient(app)
assert client.post(
"/api/v1/observatory/run-preparations",
json=_request(),
).status_code == 503
assert client.get(
"/api/v1/observatory/run-preparations/"
"observatory-prep-00000000000000000000000000000000"
).status_code == 503