222 lines
7.3 KiB
Python
222 lines
7.3 KiB
Python
"""Durable admission representation: persist before dispatch (T05)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from wf_api.run_lifecycle import (
|
|
load_stored_run,
|
|
materialize_admitted_view,
|
|
persist_admission,
|
|
recover_admission_view,
|
|
)
|
|
from wf_artifacts import (
|
|
PinnedRunEnvironment,
|
|
StoredRunStatus,
|
|
WorkflowArtifact,
|
|
WorkflowDeployment,
|
|
)
|
|
from wf_artifacts.runs.store import FileRunStore
|
|
|
|
|
|
def _env() -> PinnedRunEnvironment:
|
|
deployment = WorkflowDeployment(
|
|
id="dep-1",
|
|
artifact_id="wf-1",
|
|
artifact_version=1,
|
|
bindings=[{"logical_source": "demo", "concrete_source": "demo.personal"}],
|
|
)
|
|
artifact = WorkflowArtifact(
|
|
id="wf-1",
|
|
version=1,
|
|
title="Wf-1",
|
|
input_schema={"type": "object", "properties": {}},
|
|
output_schema={"type": "object", "properties": {}},
|
|
outcomes=("ok",),
|
|
plan={"name": "wf-1", "nodes": [], "edges": []},
|
|
)
|
|
return PinnedRunEnvironment(
|
|
deployment=deployment, root_artifact=artifact, child_artifacts=[]
|
|
)
|
|
|
|
|
|
def test_admission_persists_authority_before_view(tmp_path: Path) -> None:
|
|
store = FileRunStore(tmp_path)
|
|
run_id = store.allocate_run_id()
|
|
at = datetime(2026, 9, 8, 13, 0, tzinfo=UTC)
|
|
admission = persist_admission(
|
|
store=store,
|
|
run_id=run_id,
|
|
environment=_env(),
|
|
resolved_input={"team": "eng"},
|
|
max_steps=100,
|
|
scheduled_at=at,
|
|
schedule_id="sched-1",
|
|
schedule_revision=2,
|
|
)
|
|
assert admission.id == run_id
|
|
assert admission.scheduled_at == at
|
|
# Admission record exists before any run view or checkpoint.
|
|
assert store.get_admission(run_id).id == run_id
|
|
with pytest.raises(KeyError):
|
|
store.get_run(run_id)
|
|
record = materialize_admitted_view(store=store, admission=admission)
|
|
assert record.status is StoredRunStatus.ADMITTED
|
|
assert record.latest_checkpoint_id is None
|
|
# Inspection distinguishes admitted (no checkpoint) from stopped.
|
|
with pytest.raises(KeyError, match="no checkpoints"):
|
|
store.get_latest_checkpoint(run_id)
|
|
|
|
|
|
def test_admission_view_round_trips_real_serialization(tmp_path: Path) -> None:
|
|
store = FileRunStore(tmp_path)
|
|
run_id = store.allocate_run_id()
|
|
admission = persist_admission(
|
|
store=store,
|
|
run_id=run_id,
|
|
environment=_env(),
|
|
resolved_input={"a": [1, None, "x"]},
|
|
max_steps=None,
|
|
scheduled_at=None,
|
|
schedule_id=None,
|
|
schedule_revision=None,
|
|
)
|
|
record = materialize_admitted_view(store=store, admission=admission)
|
|
reloaded = store.get_run(run_id)
|
|
assert reloaded.id == record.id
|
|
assert reloaded.status is StoredRunStatus.ADMITTED
|
|
reloaded_admission = store.get_admission(run_id)
|
|
assert reloaded_admission.resolved_input == {"a": [1, None, "x"]}
|
|
assert reloaded_admission.environment.deployment.id == "dep-1"
|
|
|
|
|
|
def test_run_ids_are_store_backed_across_restart(tmp_path: Path) -> None:
|
|
first = FileRunStore(tmp_path).allocate_run_id()
|
|
second = FileRunStore(tmp_path).allocate_run_id()
|
|
assert first != second
|
|
# Reopening the store never reuses identities.
|
|
third = FileRunStore(tmp_path).allocate_run_id()
|
|
assert third not in {first, second}
|
|
|
|
|
|
def test_fault_before_admission_persists_nothing(tmp_path: Path, monkeypatch) -> None:
|
|
store = FileRunStore(tmp_path)
|
|
run_id = store.allocate_run_id()
|
|
|
|
def _boom(path: Path, payload: object) -> None:
|
|
raise OSError("injected write failure")
|
|
|
|
monkeypatch.setattr(store, "_write_json", _boom)
|
|
with pytest.raises(OSError, match="injected"):
|
|
persist_admission(
|
|
store=store,
|
|
run_id=run_id,
|
|
environment=_env(),
|
|
resolved_input={},
|
|
max_steps=None,
|
|
scheduled_at=None,
|
|
schedule_id=None,
|
|
schedule_revision=None,
|
|
)
|
|
assert FileRunStore(tmp_path).list_admissions() == []
|
|
with pytest.raises(KeyError):
|
|
store.get_admission(run_id)
|
|
|
|
|
|
def test_fault_between_admission_and_view_recovers_without_dispatch(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store = FileRunStore(tmp_path)
|
|
run_id = store.allocate_run_id()
|
|
admission = persist_admission(
|
|
store=store,
|
|
run_id=run_id,
|
|
environment=_env(),
|
|
resolved_input={"team": "eng"},
|
|
max_steps=None,
|
|
scheduled_at=None,
|
|
schedule_id=None,
|
|
schedule_revision=None,
|
|
)
|
|
assert admission.id == run_id
|
|
# Crash before the view is materialized: admission exists, view missing.
|
|
with pytest.raises(KeyError):
|
|
store.get_run(run_id)
|
|
# Recovery materializes the missing view but never executes work.
|
|
record = recover_admission_view(store=store, run_id=run_id)
|
|
assert record.status is StoredRunStatus.ADMITTED
|
|
assert store.get_run(run_id).id == run_id
|
|
# Idempotent: second recovery returns the same view.
|
|
again = recover_admission_view(store=store, run_id=run_id)
|
|
assert again.id == run_id
|
|
|
|
|
|
def test_admitted_run_has_no_fabricated_checkpoint_or_output(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
store = FileRunStore(tmp_path)
|
|
run_id = store.allocate_run_id()
|
|
admission = persist_admission(
|
|
store=store,
|
|
run_id=run_id,
|
|
environment=_env(),
|
|
resolved_input={},
|
|
max_steps=None,
|
|
scheduled_at=None,
|
|
schedule_id=None,
|
|
schedule_revision=None,
|
|
)
|
|
record = materialize_admitted_view(store=store, admission=admission)
|
|
assert record.latest_checkpoint_id is None
|
|
assert store.list_checkpoints(run_id) == []
|
|
# Loading a stopped run from an admitted view must fail closed, not
|
|
# fabricate trace/output/step counts.
|
|
with pytest.raises(ValueError, match="admitted"):
|
|
load_stored_run(store, run_id)
|
|
|
|
|
|
def test_corrupt_run_id_sequence_fails_closed(tmp_path: Path) -> None:
|
|
FileRunStore(tmp_path)
|
|
seq_path = tmp_path / "runs" / "_run_id_seq.json"
|
|
seq_path.write_text('{"seq": "not-an-int"}', encoding="utf-8")
|
|
with pytest.raises(ValueError, match="corrupt run-id sequence"):
|
|
FileRunStore(tmp_path).allocate_run_id()
|
|
|
|
|
|
def test_view_without_admission_fails_closed(tmp_path: Path) -> None:
|
|
store = FileRunStore(tmp_path)
|
|
run_id = store.allocate_run_id()
|
|
admission = persist_admission(
|
|
store=store,
|
|
run_id=run_id,
|
|
environment=_env(),
|
|
resolved_input={},
|
|
max_steps=None,
|
|
scheduled_at=None,
|
|
schedule_id=None,
|
|
schedule_revision=None,
|
|
)
|
|
materialize_admitted_view(store=store, admission=admission)
|
|
(tmp_path / "runs" / run_id / "admission.json").unlink()
|
|
with pytest.raises(KeyError, match="unknown run admission"):
|
|
recover_admission_view(store=store, run_id=run_id)
|
|
|
|
|
|
def test_non_json_resolved_input_rejected_before_dispatch(tmp_path: Path) -> None:
|
|
store = FileRunStore(tmp_path)
|
|
run_id = store.allocate_run_id()
|
|
with pytest.raises(ValueError, match="must be finite"):
|
|
persist_admission(
|
|
store=store,
|
|
run_id=run_id,
|
|
environment=_env(),
|
|
resolved_input={"x": float("inf")}, # type: ignore[dict-item]
|
|
max_steps=None,
|
|
scheduled_at=None,
|
|
schedule_id=None,
|
|
schedule_revision=None,
|
|
)
|