feat(observatory): seal blocked run preparations
This commit is contained in:
@@ -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\);/,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user