diff --git a/changelog.d/97-stale-proposals.md b/changelog.d/97-stale-proposals.md new file mode 100644 index 0000000..d68b337 --- /dev/null +++ b/changelog.d/97-stale-proposals.md @@ -0,0 +1,3 @@ +### Added + +- An open proposal whose basis hosts have been observed again since is now reported as stale: `decisions list`, `decisions show` and `cloudfall why` add `stale` and `staleHosts` to each `proposed` decision, `--stale` on `decisions list` and `why` keeps only those, and `decisions approve` names each stale host and its newer observation time before asking for the id. Worked out on read from the observation files; the record and its schema do not change (#26) diff --git a/docs/specs/001-stale-proposals.md b/docs/specs/001-stale-proposals.md index 8a24eb3..776dc8e 100644 --- a/docs/specs/001-stale-proposals.md +++ b/docs/specs/001-stale-proposals.md @@ -1,6 +1,6 @@ # S-001: Stale proposals -status: approved +status: built ## Problem @@ -66,4 +66,16 @@ them. ## Issues +- cloudfall-dev/cloudfall#97: S-001-1, S-001-2, S-001-3, S-001-4, S-001-5, S-001-6, S-001-7, S-001-8, S-001-9 + ## Verification + +- S-001-1: proposed `deploy` on a tmp project with `hz1` observed at 09:00, rewrote `hz1.json` to 09:30, ran `cloudfall decisions list`; and `test_s001_1_a_newer_observation_makes_the_proposal_stale`, the proposal read `"stale": true, "staleHosts": ["hz1"]` +- S-001-2: `cloudfall decisions list` on the same project before the rewrite, plus `test_s001_2_the_same_missing_or_no_observation_is_not_stale`, `test_s001_2_a_basis_that_names_no_host_is_never_stale` and `test_s001_2_times_compare_as_moments_not_as_text`, each read `"stale": false` with no `staleHosts` +- S-001-3: `decisions list`, `decisions show` and `cloudfall why` on the tmp project's `ran` facts record, plus `test_s001_3_a_decision_that_is_not_proposed_carries_no_staleness` over verified, ran and superseded records, carried neither key +- S-001-4: `cloudfall decisions list --stale` and `--stale --status proposed` on the tmp project, plus `test_s001_4_the_stale_filter_keeps_only_the_stale_and_combines_with_status`, returned only the stale proposal, the same set both ways +- S-001-5: `cloudfall why --stale` on the tmp project, plus `test_s001_5_why_stale_keeps_only_the_stale_and_reports_them`, returned only the stale proposal with `stale` and `staleHosts` identical to `decisions list` +- S-001-6: `cloudfall decisions show deploy-20261020094012` on the tmp project, plus `test_s001_6_show_reports_a_stale_proposal`, reported `"stale": true, "staleHosts": ["hz1"]` +- S-001-7: `git hash-object` of both tmp records before and after `decisions list`, `decisions show` and `why`, plus `test_s001_7_working_out_staleness_writes_nothing` (byte and mtime compare), the bytes were unchanged +- S-001-8: `test_s001_8_approve_names_the_stale_hosts_and_still_runs` runs `decisions approve` at a simulated terminal against a real `ansible-playbook`; the prompt named `web-1 was observed again at 2026-09-21T16:02:10Z` and the decision ended `verified` (no terminal was available to run it by hand) +- S-001-9: wrote invalid JSON to the tmp project's `hz1.json` and ran `cloudfall decisions list`, plus `test_s001_9_an_observation_file_that_is_not_json_is_record_invalid`, it exited `RECORD_INVALID` (86) naming `tmp/s001/tmp/observed/hz1.json` diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 7d213bf..9bc70cf 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -23,7 +23,7 @@ # treaty reads a handler's annotations at registration, so a streaming # handler's Iterator and a provided MCP tool's Mapping must exist at runtime. from collections.abc import Iterator, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from functools import partial from http import HTTPStatus from importlib.metadata import version @@ -105,11 +105,14 @@ ApprovalChannel, ApprovalRequest, CheckRunner, + Decision, DecisionError, DecisionStatus, DecisionStore, + ObservationClock, ProposalRequest, Requirement, + Staleness, Targets, approve, propose, @@ -745,6 +748,25 @@ def store(self) -> DecisionStore: directory=self.root / self.decisions, catalog=SchemaCatalog(self.schemas) ) + def clock(self) -> ObservationClock: + """Read the observations the records' bases name, to tell what is stale.""" + return ObservationClock(repository=self.root) + + +_STALE_DESCRIPTION = ( + "Only proposals a host in their basis has been observed again since" +) + + +def _with_staleness( + decision: Decision, staleness: Staleness | None +) -> dict[str, object]: + """Return the record document, with whether an open proposal is stale.""" + document = decision.as_document() + if staleness is not None: + document.update(staleness.as_dict()) + return document + class CatalogPayload(Payload): """``operations list``: every declared operation and the risk counts.""" @@ -778,6 +800,7 @@ class ListDecisionsArgs(DecisionsArgs): default=(), description="Only decisions with this status (repeatable)", ) + stale: bool = Flag(default=False, description=_STALE_DESCRIPTION) @dataclass(frozen=True, slots=True, kw_only=True) @@ -830,17 +853,25 @@ def operations_show(args: ShowOperationArgs, _ctx: Ctx) -> OperationPayload: "List what still waits for an approval", "cloudfall decisions list --status proposed", ), + ( + "List the proposals made on evidence observed again since", + "cloudfall decisions list --stale", + ), ], ) def decisions_list(args: ListDecisionsArgs, _ctx: Ctx) -> DecisionsPayload: """Answer every decision record: the record outlives the catalog.""" store = args.store() + clock = args.clock() try: - documents = [ - decision.as_document() - for decision in store.list() - if not args.status or decision.status in args.status - ] + documents: list[dict[str, object]] = [] + for decision in store.list(): + if args.status and decision.status not in args.status: + continue + staleness = clock.staleness(decision) + if args.stale and (staleness is None or not staleness.stale): + continue + documents.append(_with_staleness(decision, staleness)) except DecisionError as error: raise _record_invalid(error) from error return DecisionsPayload( @@ -859,11 +890,14 @@ def decisions_show(args: ShowDecisionArgs, _ctx: Ctx) -> DecisionPayload: """Answer one decision record, or say there is none.""" try: decision = args.store().load(args.decision) + staleness = args.clock().staleness(decision) except DecisionError as error: if error.code == ERROR_DECISION_MISSING: raise Exit.NOT_FOUND(error.detail, context={"code": error.code}) from error raise _record_invalid(error) from error - return DecisionPayload({"status": "ok", "decision": decision.as_document()}) + return DecisionPayload( + {"status": "ok", "decision": _with_staleness(decision, staleness)} + ) # operator list, show @@ -2013,6 +2047,25 @@ def _decision_failed(error: DecisionError) -> Exception: return Exit.PRECONDITION(error.detail, context={"code": error.code}) +def _stale_warning(decision: Decision, staleness: Staleness | None) -> str: + """Name each host observed again since the proposal, for the person approving. + + A stale proposal is still approved when the person says so: with + observations on a timer every proposal goes stale within minutes. + """ + if staleness is None or not staleness.stale: + return "" + hosts = "; ".join( + f"{host} was observed again at {observed} " + f"(the proposal saw {decision.basis.observed_at[host]})" + for host, observed in sorted(staleness.newer.items()) + ) + return ( + f"This proposal is stale: {hosts}. Check mode ran against the older " + "evidence. " + ) + + @operations.command( "propose", description=( @@ -2140,10 +2193,15 @@ def decisions_approve(args: ApproveDecisionArgs, ctx: Ctx) -> Decided: refuse_unapprovable(decision) except DecisionError as error: raise _decision_failed(error) from error + try: + staleness = args.clock().staleness(decision) + except DecisionError as error: + raise _record_invalid(error) from error # Off a terminal this ends with PERSON_REQUIRED, and a mistyped id with # ATTESTATION_MISMATCH, before anything runs: --yes never approves. attestation = ctx.attest( - f"The diff is at {decision.check.diff.path}. Type the decision id to " + f"{_stale_warning(decision, staleness)}The diff is at " + f"{decision.check.diff.path}. Type the decision id to " f"approve {decision.decision_id.value} and run it", expected=decision.decision_id.value, ) @@ -3172,6 +3230,7 @@ class WhyArgs(DecisionsArgs): default=(), description="Only decisions with this status (repeatable)", ) + stale: bool = Flag(default=False, description=_STALE_DESCRIPTION) investigations: Path = Flag( default=Path(INVESTIGATION_DIRECTORY), description=( @@ -3191,13 +3250,14 @@ def __post_init__(self) -> None: def query(self) -> WhyQuery: """Return the question, as the record is filtered by it.""" - return WhyQuery.from_boundary( + query = WhyQuery.from_boundary( host=self.host, operation=self.operation.value if self.operation is not None else None, since=self.since, until=self.until, status=tuple(entry.value for entry in self.status), ) + return replace(query, stale=self.stale) class WhyPayload(Payload): @@ -3230,7 +3290,7 @@ def why(args: WhyArgs, _ctx: Ctx) -> WhyPayload: store = DecisionStore(directory=args.root / args.decisions, catalog=catalog) try: investigations = Investigations.load(args.root / args.investigations, catalog) - result = answer(store, args.query(), investigations) + result = answer(store, args.query(), investigations, args.clock()) except (DecisionError, WhyError) as error: raise Exit.RECORD_INVALID(error.detail, context={"code": error.code}) from error return WhyPayload(result.as_dict()) diff --git a/sdk/src/cloudfall/decision.py b/sdk/src/cloudfall/decision.py index e4f9567..707b299 100644 --- a/sdk/src/cloudfall/decision.py +++ b/sdk/src/cloudfall/decision.py @@ -82,6 +82,7 @@ _ERROR_APPROVER_UNKNOWN = "decision_approver_unknown" _ERROR_CHANNEL_UNKNOWN = "decision_approval_channel_unknown" ERROR_RECORD_INVALID = "decision_record_invalid" +ERROR_OBSERVATION_INVALID = "decision_observation_invalid" class DecisionError(RuntimeError): @@ -242,6 +243,113 @@ def as_dict(self) -> dict[str, object]: } +@dataclass(frozen=True, slots=True) +class Staleness: + """Which hosts a proposal's basis names have been observed again since. + + Worked out when the record is read and never stored: the record stays + what it was when the proposal was made, and the observations say what + has happened since. + """ + + newer: Mapping[str, str] + """Each stale host and the later ``observedAt`` its observation file holds.""" + + @property + def stale(self) -> bool: + """Return whether any host the basis names was observed again since.""" + return bool(self.newer) + + def as_dict(self) -> dict[str, object]: + """Serialize the staleness beside a decision at system boundaries.""" + result: dict[str, object] = {"stale": self.stale} + if self.stale: + result["staleHosts"] = sorted(self.newer) + return result + + +@dataclass(frozen=True, slots=True) +class ObservationClock: + """When each host was last observed, read from the folders bases name. + + A relative basis folder is the repository's, as ``propose`` records it. + Each folder is read once, however many decisions cite it. + """ + + repository: Path + _folders: dict[Path, dict[str, tuple[str, Path]]] = field( + default_factory=dict, init=False, repr=False, compare=False + ) + + def staleness(self, decision: Decision) -> Staleness | None: + """Return whether one decision is stale; None when it is not ``proposed``. + + Only an open proposal can be stale: every other status is final. + A host the basis names whose observation file is gone does not count. + """ + if decision.status is not DecisionStatus.PROPOSED: + return None + if not decision.basis.observed_at: + return Staleness(newer={}) + latest = self._latest(decision.basis.observations) + newer: dict[str, str] = {} + for host, recorded in sorted(decision.basis.observed_at.items()): + current = latest.get(host) + if current is None: + continue + observed, path = current + then = _observation_moment(recorded, f"decision {decision.decision_id}") + if _observation_moment(observed, str(path)) > then: + newer[host] = observed + return Staleness(newer=newer) + + def _latest(self, observations: Path) -> dict[str, tuple[str, Path]]: + folder = ( + observations + if observations.is_absolute() + else self.repository / observations + ) + cached = self._folders.get(folder) + if cached is not None: + return cached + latest: dict[str, tuple[str, Path]] = {} + if folder.exists() and not folder.is_dir(): + message = f"basis observations are not a folder: {folder}" + raise DecisionError(ERROR_OBSERVATION_INVALID, message) + if folder.is_dir(): + for path in sorted(folder.glob("*.json")): + observed = _observed_at(path) + if observed is not None: + latest[path.stem] = (observed, path) + self._folders[folder] = latest + return latest + + +def _observed_at(path: Path) -> str | None: + """Return the ``spec.observedAt`` one observation file holds, as written.""" + try: + document = cast("object", json.loads(path.read_text(encoding="utf-8"))) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + message = f"observation file is not valid JSON: {path}: {error}" + raise DecisionError(ERROR_OBSERVATION_INVALID, message) from error + if not isinstance(document, dict): + return None + spec = document.get("spec") + if isinstance(spec, dict) and isinstance(spec.get("observedAt"), str): + return str(spec["observedAt"]) + return None + + +def _observation_moment(value: str, source: str) -> datetime: + """Parse an observation time; a naive one is UTC, as the record writes it.""" + try: + parsed = datetime.fromisoformat(value) + except ValueError as error: + message = f"observation time is not ISO 8601: {source}: {value!r}" + raise DecisionError(ERROR_OBSERVATION_INVALID, message) from error + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC) + + class ApprovalChannel(StrEnum): """How an approval reached the record. diff --git a/sdk/src/cloudfall/why.py b/sdk/src/cloudfall/why.py index e345ce2..ee17d37 100644 --- a/sdk/src/cloudfall/why.py +++ b/sdk/src/cloudfall/why.py @@ -35,7 +35,14 @@ from collections.abc import Iterable, Mapping, Sequence from pathlib import Path - from cloudfall.decision import Approval, Decision, DecisionStore, RunRecord + from cloudfall.decision import ( + Approval, + Decision, + DecisionStore, + ObservationClock, + RunRecord, + Staleness, + ) from cloudfall.validation import SchemaCatalog _ERROR_INSTANT_INVALID = "why_instant_invalid" @@ -103,6 +110,8 @@ class WhyQuery: since: Instant | None = None until: Instant | None = None statuses: frozenset[DecisionStatus] = frozenset() + stale: bool = False + """Only the proposals whose basis hosts have been observed again since.""" def __post_init__(self) -> None: """Refuse a window that ends before it starts.""" @@ -150,12 +159,18 @@ def as_dict(self) -> dict[str, object]: result["until"] = self.until.as_string() if self.statuses: result["status"] = sorted(status.value for status in self.statuses) + if self.stale: + result["stale"] = True return result - def matches(self, decision: Decision) -> bool: + def matches( + self, decision: Decision, staleness: Staleness | None = None + ) -> bool: """Return whether one decision is part of the answer.""" if self.operation is not None and decision.operation_id != self.operation: return False + if self.stale and (staleness is None or not staleness.stale): + return False if self.statuses and decision.status not in self.statuses: return False if self.host is not None and self.host.value not in hosts_of(decision): @@ -177,14 +192,19 @@ class Explanation: hosts: tuple[str, ...] story: tuple[str, ...] proposer: Proposer | None = None + staleness: Staleness | None = None + """Whether an open proposal was made on evidence observed again since.""" def as_dict(self) -> dict[str, object]: """Serialize the explanation with the record it is drawn from.""" + record = self.decision.as_document() + if self.staleness is not None: + record.update(self.staleness.as_dict()) document: dict[str, object] = { "id": self.decision.decision_id.value, "hosts": list(self.hosts), "story": list(self.story), - "decision": self.decision.as_document(), + "decision": record, } if self.proposer is not None: document["agent"] = self.proposer.as_dict() @@ -304,28 +324,42 @@ def answer( store: DecisionStore, query: WhyQuery, investigations: Investigations | None = None, + clock: ObservationClock | None = None, ) -> WhyAnswer: - """Answer the question from the record, newest decision first.""" - matched = [decision for decision in store.list() if query.matches(decision)] - matched.sort(key=lambda decision: decision.proposed_at, reverse=True) + """Answer the question from the record, newest decision first. + + With a ``clock``, each open proposal says whether it is stale; without + one, nothing is, so a ``stale`` question matches nothing. + """ + matched: list[tuple[Decision, Staleness | None]] = [] + for decision in store.list(): + staleness = clock.staleness(decision) if clock is not None else None + if query.matches(decision, staleness): + matched.append((decision, staleness)) + matched.sort(key=lambda entry: entry[0].proposed_at, reverse=True) proposers = investigations.proposers if investigations is not None else {} return WhyAnswer( query=query, directory=str(store.directory), explanations=tuple( - explain(decision, proposers.get(decision.decision_id.value)) - for decision in matched + explain(decision, proposers.get(decision.decision_id.value), staleness) + for decision, staleness in matched ), ) -def explain(decision: Decision, proposer: Proposer | None = None) -> Explanation: +def explain( + decision: Decision, + proposer: Proposer | None = None, + staleness: Staleness | None = None, +) -> Explanation: """Tell one decision from its record, and the agent turn that asked for it.""" return Explanation( decision=decision, hosts=tuple(sorted(hosts_of(decision))), story=tuple(_story(decision, proposer)), proposer=proposer, + staleness=staleness, ) diff --git a/sdk/tests/test_stale.py b/sdk/tests/test_stale.py new file mode 100644 index 0000000..17ade82 --- /dev/null +++ b/sdk/tests/test_stale.py @@ -0,0 +1,384 @@ +"""Stale proposals: open ones whose fleet evidence was observed again (S-001).""" + +from __future__ import annotations + +import io +import json +from dataclasses import replace +from typing import TYPE_CHECKING, Any + +from cloudfall.app import app +from cloudfall.cli import main +from cloudfall.decision import ( + DECISION_DIRECTORY, + ApprovalChannel, + ApprovalRequest, + DecisionStatus, + ObservationClock, + approve, + propose, +) +from cloudfall.domain import ResourceId +from test_catalog import _repository +from test_decision import ( + LATER, + MOMENT, + _proposal, + _proposed_locally, + _Runner, + _store, + _Terminal, +) + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + from cloudfall.decision import Decision + +T1 = "2026-09-21T09:55:24Z" +"""When ``_proposal``'s observation of web-1 says it was made.""" + +T2 = "2026-09-21T16:02:10Z" + + +def _observe(repository: Path, host: str, observed_at: str) -> Path: + """Write the observation file a later ``cloudfall observe`` would leave.""" + path = repository / "tmp" / "observed" / f"{host}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"spec": {"observedAt": observed_at}}), encoding="utf-8" + ) + return path + + +def _open_proposal(repository: Path) -> Decision: + """Propose deploy on web-1 against web-1 observed at T1.""" + return propose(_proposal(repository), _store(repository), lambda: MOMENT, _Runner()) + + +def _run( + argv: list[str], capsys: pytest.CaptureFixture[str] +) -> tuple[int, dict[str, Any]]: + exit_code = main(argv) + return exit_code, json.loads(capsys.readouterr().out) + + +def _listed( + repository: Path, capsys: pytest.CaptureFixture[str], *flags: str +) -> list[dict[str, Any]]: + exit_code, payload = _run( + ["decisions", "list", "--repository", str(repository), *flags], capsys + ) + assert exit_code == 0, payload + return list(payload["data"]["decisions"]) + + +T2_MOMENT = MOMENT.replace(hour=16) + + +def _settled(repository: Path) -> tuple[Decision, Decision, Decision, Decision]: + """Record a verified deploy, a read that ran, a superseded and an open proposal. + + Every one's basis names web-1 observed at T1. + """ + store = _store(repository) + verified = approve( + ApprovalRequest( + decision=_open_proposal(repository), + approver="roman", + repository=repository, + via=ApprovalChannel.TERMINAL, + ), + store, + lambda: MOMENT, + _Runner(), + ) + ran = propose( + _proposal(repository, "facts", target=None, inputs={}), + store, + lambda: LATER, + _Runner(), + ) + older = propose( + _proposal(repository, target="web-2"), store, lambda: LATER, _Runner() + ) + still_open = propose( + _proposal(repository, target="web-2"), store, lambda: T2_MOMENT, _Runner() + ) + superseded = store.load(older.decision_id) + assert verified.status is DecisionStatus.VERIFIED + assert ran.status is DecisionStatus.RAN + assert superseded.status is DecisionStatus.SUPERSEDED + assert still_open.status is DecisionStatus.PROPOSED + return verified, ran, superseded, still_open + + +def test_s001_1_a_newer_observation_makes_the_proposal_stale( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + _open_proposal(repository) + _observe(repository, "web-1", T2) + + (entry,) = _listed(repository, capsys) + + assert entry["spec"]["basis"]["observedAt"] == {"web-1": T1} + assert entry["stale"] is True + assert entry["staleHosts"] == ["web-1"] + + +def test_s001_2_the_same_missing_or_no_observation_is_not_stale( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + _open_proposal(repository) + + (same,) = _listed(repository, capsys) + (repository / "tmp" / "observed" / "web-1.json").unlink() + (missing,) = _listed(repository, capsys) + + for entry in (same, missing): + assert entry["stale"] is False + assert "staleHosts" not in entry + + +def test_s001_2_a_basis_that_names_no_host_is_never_stale( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + empty = repository / "tmp" / "empty" + empty.mkdir(parents=True) + request = replace(_proposal(repository), observations=empty) + propose(request, _store(repository), lambda: MOMENT, _Runner()) + _observe(repository, "web-1", T2) + + (entry,) = _listed(repository, capsys) + + assert entry["spec"]["basis"] == {"observations": "tmp/empty", "observedAt": {}} + assert entry["stale"] is False + assert "staleHosts" not in entry + + +def test_s001_2_times_compare_as_moments_not_as_text(tmp_path: Path) -> None: + """The same moment in another offset, or an earlier one that sorts later.""" + repository = _repository(tmp_path) + decision = _open_proposal(repository) + + _observe(repository, "web-1", "2026-09-21T11:55:24+02:00") + same_moment = ObservationClock(repository=repository).staleness(decision) + _observe(repository, "web-1", "2026-09-21T10:55:24+02:00") + earlier = ObservationClock(repository=repository).staleness(decision) + _observe(repository, "web-1", "2026-09-21T09:55:25+00:00") + one_second_later = ObservationClock(repository=repository).staleness(decision) + + assert same_moment is not None + assert not same_moment.stale + assert earlier is not None + assert not earlier.stale + assert one_second_later is not None + assert one_second_later.newer == {"web-1": "2026-09-21T09:55:25+00:00"} + + +def test_s001_3_a_decision_that_is_not_proposed_carries_no_staleness( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + *settled, _ = _settled(repository) + _observe(repository, "web-1", T2) + root = ("--repository", str(repository)) + settled_ids = {decision.decision_id.value for decision in settled} + + listed = _listed(repository, capsys) + shown = [ + _run(["decisions", "show", decision_id, *root], capsys)[1]["data"]["decision"] + for decision_id in sorted(settled_ids) + ] + exit_code, why = _run(["why", *root], capsys) + + assert exit_code == 0 + told = [entry["decision"] for entry in why["data"]["answers"]] + closed = [ + entry + for entry in (*listed, *shown, *told) + if entry["metadata"]["id"] in settled_ids + ] + assert len(closed) == 9 + for entry in closed: + assert entry["spec"]["status"] != "proposed" + assert "stale" not in entry + assert "staleHosts" not in entry + # The one open proposal beside them, on the same evidence, is stale. + assert [entry["stale"] for entry in listed if "stale" in entry] == [True] + + +def test_s001_4_the_stale_filter_keeps_only_the_stale_and_combines_with_status( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + *_, stale = _settled(repository) + empty = repository / "tmp" / "empty" + empty.mkdir(parents=True) + fresh = propose( + replace(_proposal(repository, target="web-3"), observations=empty), + _store(repository), + lambda: MOMENT.replace(hour=17), + _Runner(), + ) + _observe(repository, "web-1", T2) + + every = _listed(repository, capsys) + proposed = _listed(repository, capsys, "--status", "proposed") + only_stale = _listed(repository, capsys, "--stale") + stale_proposed = _listed(repository, capsys, "--stale", "--status", "proposed") + stale_verified = _listed(repository, capsys, "--stale", "--status", "verified") + + assert len(every) == 5 + assert {entry["metadata"]["id"] for entry in proposed} == { + stale.decision_id.value, + fresh.decision_id.value, + } + ids = [entry["metadata"]["id"] for entry in only_stale] + assert ids == [stale.decision_id.value] + assert [entry["metadata"]["id"] for entry in stale_proposed] == ids + assert stale_proposed == only_stale + assert stale_verified == [] + + +def test_s001_5_why_stale_keeps_only_the_stale_and_reports_them( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + *_, stale = _settled(repository) + _observe(repository, "web-1", T2) + + exit_code, payload = _run( + ["why", "--repository", str(repository), "--stale"], capsys + ) + (listed,) = _listed(repository, capsys, "--stale") + + assert exit_code == 0, payload + assert payload["data"]["query"] == {"stale": True} + assert [entry["id"] for entry in payload["data"]["answers"]] == [ + stale.decision_id.value + ] + told = payload["data"]["answers"][0]["decision"] + assert told["stale"] is True + assert told["staleHosts"] == ["web-1"] + assert told == listed + + +def test_s001_6_show_reports_a_stale_proposal( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + decision = _open_proposal(repository) + _observe(repository, "web-1", T2) + + exit_code, payload = _run( + [ + *("decisions", "show", decision.decision_id.value), + *("--repository", str(repository)), + ], + capsys, + ) + + assert exit_code == 0, payload + shown = payload["data"]["decision"] + assert shown["stale"] is True + assert shown["staleHosts"] == ["web-1"] + + +def test_s001_7_working_out_staleness_writes_nothing( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + decision = _open_proposal(repository) + _observe(repository, "web-1", T2) + record = repository / DECISION_DIRECTORY / f"{decision.decision_id.value}.json" + before = record.read_bytes() + stat = record.stat() + root = ("--repository", str(repository)) + + listed = _listed(repository, capsys) + shown = _run(["decisions", "show", decision.decision_id.value, *root], capsys) + told = _run(["why", *root], capsys) + + assert listed[0]["stale"] is True + assert shown[1]["data"]["decision"]["stale"] is True + assert told[1]["data"]["answers"][0]["decision"]["stale"] is True + assert record.read_bytes() == before + assert record.stat().st_mtime_ns == stat.st_mtime_ns + assert "stale" not in json.loads(before)["spec"] + + +def test_s001_8_approve_names_the_stale_hosts_and_still_runs( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + decision_id = _proposed_locally(repository, capsys) + _observe(repository, "web-1", T2) + stdout = _Terminal() + stderr = io.StringIO() + + exit_code = app.run( + [ + *("decisions", "approve", decision_id), + *("--repository", str(repository), "--approver", "roman"), + *("--format", "json"), + ], + stdin=_Terminal(f"{decision_id}\n"), + stdout=stdout, + stderr=stderr, + ) + + prompt = stderr.getvalue() + stdout.getvalue() + assert exit_code == 0, prompt + assert "stale" in prompt + assert f"web-1 was observed again at {T2}" in prompt + loaded = _store(repository).load(ResourceId.from_boundary(decision_id)) + assert loaded.status is DecisionStatus.VERIFIED + assert loaded.approval is not None + + +def test_s001_8_a_fresh_proposal_prompt_says_nothing_of_staleness( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + decision_id = _proposed_locally(repository, capsys) + stdout = _Terminal() + stderr = io.StringIO() + + exit_code = app.run( + [ + *("decisions", "approve", decision_id), + *("--repository", str(repository), "--approver", "roman"), + *("--format", "json"), + ], + stdin=_Terminal(f"{decision_id}\n"), + stdout=stdout, + stderr=stderr, + ) + + prompt = stderr.getvalue() + stdout.getvalue() + assert exit_code == 0, prompt + assert "The diff is at" in prompt + assert "stale" not in prompt + + +def test_s001_9_an_observation_file_that_is_not_json_is_record_invalid( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + repository = _repository(tmp_path) + _open_proposal(repository) + broken = repository / "tmp" / "observed" / "web-1.json" + broken.write_text("{not json", encoding="utf-8") + + exit_code, payload = _run( + ["decisions", "list", "--repository", str(repository)], capsys + ) + + assert exit_code != 0 + assert payload["error"]["code"] == "RECORD_INVALID" + assert str(broken) in payload["error"]["message"] + assert payload["error"]["context"]["code"] == "decision_observation_invalid"