diff --git a/CHANGELOG.md b/CHANGELOG.md index 038bcf5..139ad49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ Notable changes to Cloudfall. The format follows ### Added +- A decision record has two new statuses, added to + `operation-decision.schema.json` beside the old ones. `ran`: a `risk: read` + operation has run by the time it is recorded, so it is recorded with its + run's exit code (`ranExitCode`) instead of waiting as `proposed` forever. + `superseded`: when `operations propose` records an operation, an older + `proposed` record of the same operation, targets and inputs is closed and + names the new decision (`supersededBy`); an approved or finished record is + never touched. Since it reads the record first, `operations propose` now + stops with `RECORD_INVALID` on a record that does not load, before check + mode runs. `decisions approve` refuses a `ran` or `superseded` record with + `PRECONDITION` before asking for the decision id. `decisions list --status S` and + `cloudfall why --status S` (repeatable) keep only those statuses, and an + unknown status is refused. Records already on disk keep their status. One + disk-full incident left 47 records for `cloudfall why --host hz1`, most of + them reads and repeats (#26) - Every turn of `agent investigate` keeps its raw trace, in the stream's `turn` event (`trace`) and in the investigation record (`spec.trace`): the model's reasoning when it returns one (Nemotron's `reasoning_content`), diff --git a/config/schemas/v1/operation-decision.schema.json b/config/schemas/v1/operation-decision.schema.json index a8de463..e7f5dd4 100644 --- a/config/schemas/v1/operation-decision.schema.json +++ b/config/schemas/v1/operation-decision.schema.json @@ -192,9 +192,21 @@ "rejected", "executed", "verified", - "failed" + "failed", + "ran", + "superseded" ] }, + "ranExitCode": { + "description": "The exit code of a read operation's run, which happens when it is proposed. Set when the status is ran.", + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + "supersededBy": { + "description": "The decision that proposed the same operation, targets and inputs again. Set when the status is superseded.", + "$ref": "common.schema.json#/$defs/resourceId" + }, "approval": { "type": "object", "properties": { @@ -363,6 +375,36 @@ "check", "status" ], + "allOf": [ + { + "if": { + "properties": { + "status": { + "const": "ran" + } + } + }, + "then": { + "required": [ + "ranExitCode" + ] + } + }, + { + "if": { + "properties": { + "status": { + "const": "superseded" + } + } + }, + "then": { + "required": [ + "supersededBy" + ] + } + } + ], "additionalProperties": false } }, diff --git a/examples/disk-full-incident/README.md b/examples/disk-full-incident/README.md index f1fcf0f..8fa2a61 100644 --- a/examples/disk-full-incident/README.md +++ b/examples/disk-full-incident/README.md @@ -32,8 +32,11 @@ The catalog: | `converge` | mutating | runs `site.yml` | | `postgresql-reinit` | destructive | drops the cluster with all its data and creates an empty one | -Read operations run when called and return what the hosts reported. The -others run in check mode only and leave a decision a person approves. +Read operations run when called, return what the hosts reported, and are +recorded as `ran`. The others run in check mode only and leave a decision a +person approves; proposing the same one again marks the older decision +`superseded`. `cloudfall decisions list --status proposed` shows what still +waits. ## Run it diff --git a/sdk/src/cloudfall/app.py b/sdk/src/cloudfall/app.py index 9ae77a7..da2d3dc 100644 --- a/sdk/src/cloudfall/app.py +++ b/sdk/src/cloudfall/app.py @@ -100,6 +100,7 @@ CHECK_TIMEOUT_SECONDS, DECISION_DIRECTORY, ERROR_DECISION_MISSING, + ERROR_RECORD_INVALID, ApprovalChannel, ApprovalRequest, CheckRunner, @@ -111,6 +112,7 @@ Targets, approve, propose, + refuse_unapprovable, ) from cloudfall.domain import ( ConnectionAddress, @@ -759,6 +761,16 @@ class DecisionPayload(Payload): command = "decisions show" +@dataclass(frozen=True, slots=True, kw_only=True) +class ListDecisionsArgs(DecisionsArgs): + """Arguments of ``decisions list``.""" + + status: tuple[DecisionStatus, ...] = Flag( + default=(), + description="Only decisions with this status (repeatable)", + ) + + @dataclass(frozen=True, slots=True, kw_only=True) class ShowDecisionArgs(DecisionsArgs): """Arguments of ``decisions show``.""" @@ -803,13 +815,23 @@ def operations_show(args: ShowOperationArgs, _ctx: Ctx) -> OperationPayload: description="List the decision records this repository holds, oldest first", danger_level="safe", exit_codes=["RECORD_INVALID"], - examples=[("List the decision records", "cloudfall decisions list")], + examples=[ + ("List the decision records", "cloudfall decisions list"), + ( + "List what still waits for an approval", + "cloudfall decisions list --status proposed", + ), + ], ) -def decisions_list(args: DecisionsArgs, _ctx: Ctx) -> DecisionsPayload: +def decisions_list(args: ListDecisionsArgs, _ctx: Ctx) -> DecisionsPayload: """Answer every decision record: the record outlives the catalog.""" store = args.store() try: - documents = [decision.as_document() for decision in store.list()] + documents = [ + decision.as_document() + for decision in store.list() + if not args.status or decision.status in args.status + ] except DecisionError as error: raise _record_invalid(error) from error return DecisionsPayload( @@ -1989,7 +2011,13 @@ def _decision_failed(error: DecisionError) -> Exception: "on the fleet changes" ), danger_level="mutating", - exit_codes=["CONFIG_INVALID", "NOT_FOUND", "PRECONDITION", "CHECK_FAILED"], + exit_codes=[ + "CONFIG_INVALID", + "NOT_FOUND", + "PRECONDITION", + "CHECK_FAILED", + "RECORD_INVALID", + ], timeout=None, subprocess=Subprocess("ansible-playbook"), required_tools={"ansible-playbook": "2.21.0"}, @@ -2030,6 +2058,10 @@ def _propose(args: ProposeArgs, run: CheckRunner) -> Decided: try: decision = propose(request, store, run=run) except DecisionError as error: + # A new proposal reads the open ones it may supersede, so a broken + # record stops it the way it stops every other reader of the record. + if error.code == ERROR_RECORD_INVALID: + raise _record_invalid(error) from error raise _decision_failed(error) from error output = ( (args.root / decision.check.diff.path).read_text(encoding="utf-8") @@ -2087,6 +2119,10 @@ def decisions_approve(args: ApproveDecisionArgs, ctx: Ctx) -> Decided: if error.code == ERROR_DECISION_MISSING: raise Exit.NOT_FOUND(error.detail, context={"code": error.code}) from error raise _record_invalid(error) from error + try: + refuse_unapprovable(decision) + except DecisionError as error: + raise _decision_failed(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( @@ -3049,6 +3085,10 @@ class WhyArgs(DecisionsArgs): "Only decisions with a moment at or before this ISO 8601 time or date" ), ) + status: tuple[DecisionStatus, ...] = Flag( + default=(), + description="Only decisions with this status (repeatable)", + ) def __post_init__(self) -> None: """Refuse a time the question cannot be asked with.""" @@ -3065,6 +3105,7 @@ def query(self) -> WhyQuery: 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), ) @@ -3085,6 +3126,10 @@ class WhyPayload(Payload): renderers={"html": FormatRenderer(render_why_document, media_type="text/html")}, examples=[ ("Ask about one host", "cloudfall why --host h1"), + ( + "Only the decisions that verified or failed", + "cloudfall why --status verified --status failed", + ), ("One page for a person", "cloudfall why --since 2026-10-01 --format html"), ], ) @@ -3839,7 +3884,13 @@ def _provided_tools(args: McpServeArgs, ctx: Ctx) -> list[McpTool]: return _engine_tools(args, root, ctx) -_PROPOSE_EXIT_CODES = ("CONFIG_INVALID", "NOT_FOUND", "PRECONDITION", "CHECK_FAILED") +_PROPOSE_EXIT_CODES = ( + "CONFIG_INVALID", + "NOT_FOUND", + "PRECONDITION", + "CHECK_FAILED", + "RECORD_INVALID", +) def _operation_tool_schema(operation: Operation) -> dict[str, object]: diff --git a/sdk/src/cloudfall/decision.py b/sdk/src/cloudfall/decision.py index 827ffdb..7cf28d4 100644 --- a/sdk/src/cloudfall/decision.py +++ b/sdk/src/cloudfall/decision.py @@ -71,7 +71,7 @@ _ERROR_NOT_PROPOSED = "decision_not_proposed" _ERROR_APPROVER_UNKNOWN = "decision_approver_unknown" _ERROR_CHANNEL_UNKNOWN = "decision_approval_channel_unknown" -_ERROR_RECORD_INVALID = "decision_record_invalid" +ERROR_RECORD_INVALID = "decision_record_invalid" class DecisionError(RuntimeError): @@ -113,6 +113,11 @@ class DecisionStatus(StrEnum): EXECUTED = "executed" VERIFIED = "verified" FAILED = "failed" + RAN = "ran" + """A read operation: it ran when it was proposed; nothing waits on it.""" + + SUPERSEDED = "superseded" + """A newer proposal of the same operation, targets and inputs replaced it.""" def gate(risk: RiskLevel) -> tuple[Requirement, str]: @@ -287,6 +292,16 @@ class Decision: execution: RunRecord | None = None verification: RunRecord | None = None verdict: str | None = None + ran_exit_code: int | None = None + superseded_by: ResourceId | None = None + + def same_call(self, other: Decision) -> bool: + """Return whether both name the same operation, targets and inputs.""" + return ( + self.operation_id == other.operation_id + and self.targets == other.targets + and _canonical(self.inputs) == _canonical(other.inputs) + ) def as_document(self) -> dict[str, object]: """Serialize the decision as a schema-valid record document.""" @@ -315,6 +330,10 @@ def as_document(self) -> dict[str, object]: spec["verify"] = self.verification.as_dict() if self.verdict is not None: spec["verdict"] = self.verdict + if self.ran_exit_code is not None: + spec["ranExitCode"] = self.ran_exit_code + if self.superseded_by is not None: + spec["supersededBy"] = self.superseded_by.value return { "apiVersion": API_VERSION, "kind": DECISION_KIND, @@ -362,7 +381,7 @@ def load(self, decision_id: ResourceId) -> Decision: ) except (UnicodeDecodeError, json.JSONDecodeError) as error: message = f"decision record is not valid JSON: {path}: {error}" - raise DecisionError(_ERROR_RECORD_INVALID, message) from error + raise DecisionError(ERROR_RECORD_INVALID, message) from error try: self.catalog.validate_named(DECISION_SCHEMA, document) except ValidationError as error: @@ -371,7 +390,7 @@ def load(self, decision_id: ResourceId) -> Decision: f"decision record does not match its schema: {path}: " f"{field}: {error.message}" ) - raise DecisionError(_ERROR_RECORD_INVALID, message) from error + raise DecisionError(ERROR_RECORD_INVALID, message) from error return _decision_from_document(document) def list(self) -> tuple[Decision, ...]: @@ -418,10 +437,19 @@ def propose( Nothing on the fleet changes: check mode is the whole point of the step. The record it leaves is what an approval is given against. + + A read operation has run by the time it is recorded, so it is recorded + as ``ran`` with its exit code rather than left waiting. An older open + proposal of the same operation, targets and inputs is marked + ``superseded`` by the new one: approving the older one would run what + the newer check already replaced. """ operation = request.operation declared = _validated_inputs(operation, request.inputs) pattern = _validated_targets(operation, request.targets) + # Read before anything runs: a record that does not load stops the + # proposal here rather than after check mode ran. + recorded = store.list() moment = (now or _utc_now)() decision_id = _decision_identifier(operation.operation_id, moment) diff_path = store.directory / f"{decision_id.value}{_DIFF_SUFFIX}" @@ -439,6 +467,7 @@ def propose( diff_path.parent.mkdir(parents=True, exist_ok=True) exit_code = (run or _run_check)(argv, environment, diff_path) requirement, reason = gate(operation.risk) + ran = requirement is Requirement.RUNS_FREELY decision = Decision( decision_id=decision_id, proposed_at=_timestamp(moment), @@ -454,9 +483,28 @@ def propose( reason=reason, basis=read_basis(request.observations, request.repository), check=_preview(exit_code, tree, diff_path, request.repository), - status=DecisionStatus.PROPOSED, + status=DecisionStatus.RAN if ran else DecisionStatus.PROPOSED, + verdict=( + f"a read operation runs when it is proposed; it exited {exit_code}" + if ran + else None + ), + ran_exit_code=exit_code if ran else None, ) store.save(decision) + for older in recorded: + if older.status is DecisionStatus.PROPOSED and older.same_call(decision): + store.update( + replace( + older, + status=DecisionStatus.SUPERSEDED, + superseded_by=decision_id, + verdict=( + f"{decision_id.value} proposed the same operation, " + "targets and inputs again" + ), + ) + ) return decision @@ -504,12 +552,7 @@ def approve( human read and the run that follows it. """ decision = request.decision - if decision.status is not DecisionStatus.PROPOSED: - message = ( - f"decision {decision.decision_id} is {decision.status.value}, so " - "there is nothing left to approve" - ) - raise DecisionError(_ERROR_NOT_PROPOSED, message) + refuse_unapprovable(decision) if not request.approver.strip(): message = "an approval records who gave it; pass --approver or set USER" raise DecisionError(_ERROR_APPROVER_UNKNOWN, message) @@ -545,6 +588,40 @@ def approve( return approved +def refuse_unapprovable(decision: Decision) -> None: + """Refuse a decision that is not an open proposal, before anything runs. + + Callers that ask a person for the decision id check this first, so + nobody types an id only to hear there was nothing to approve. + """ + if decision.status is DecisionStatus.PROPOSED: + return + if decision.status is DecisionStatus.SUPERSEDED: + # A read is recorded as ran, so the decision that replaced an old + # read record has nothing left to approve either. + instead = ( + "it ran when it was proposed, so there is nothing to approve" + if decision.requirement is Requirement.RUNS_FREELY + else "approve that one instead" + ) + message = ( + f"decision {decision.decision_id} is superseded by " + f"{decision.superseded_by}, a newer proposal of the same operation, " + f"targets and inputs; {instead}" + ) + elif decision.status is DecisionStatus.RAN: + message = ( + f"decision {decision.decision_id} is a read operation that ran when " + "it was proposed, so there is nothing to approve" + ) + else: + message = ( + f"decision {decision.decision_id} is {decision.status.value}, so " + "there is nothing left to approve" + ) + raise DecisionError(_ERROR_NOT_PROPOSED, message) + + def _outcome( execution: RunRecord, verification: RunRecord | None, @@ -797,6 +874,11 @@ def _coerced( raise DecisionError(_ERROR_INPUT_TYPE, message) +def _canonical(inputs: Mapping[str, object]) -> str: + """Return the inputs as one comparable text: ``1`` and ``true`` differ.""" + return json.dumps(dict(inputs), sort_keys=True) + + def _decision_identifier(operation_id: ResourceId, moment: datetime) -> ResourceId: stamp = moment.strftime("%Y%m%d%H%M%S") return ResourceId.from_boundary(f"{operation_id.value}-{stamp}") @@ -887,6 +969,14 @@ def _decision_from_document(document: Mapping[str, object]) -> Decision: ), status=DecisionStatus(str(spec["status"])), verdict=str(spec["verdict"]) if "verdict" in spec else None, + ran_exit_code=( + int(cast("int", spec["ranExitCode"])) if "ranExitCode" in spec else None + ), + superseded_by=( + ResourceId.from_boundary(spec["supersededBy"]) + if "supersededBy" in spec + else None + ), execution=_run_from_document(spec.get("execution")), verification=_run_from_document(spec.get("verify")), approval=_approval_from_document(approval), diff --git a/sdk/src/cloudfall/why.py b/sdk/src/cloudfall/why.py index d3d7ce0..d6fc7f2 100644 --- a/sdk/src/cloudfall/why.py +++ b/sdk/src/cloudfall/why.py @@ -30,6 +30,7 @@ _ERROR_INSTANT_INVALID = "why_instant_invalid" _ERROR_WINDOW_INVERTED = "why_window_inverted" +_ERROR_STATUS_UNKNOWN = "why_status_unknown" class WhyError(RuntimeError): @@ -90,6 +91,7 @@ class WhyQuery: operation: ResourceId | None = None since: Instant | None = None until: Instant | None = None + statuses: frozenset[DecisionStatus] = frozenset() def __post_init__(self) -> None: """Refuse a window that ends before it starts.""" @@ -111,6 +113,7 @@ def from_boundary( operation: object = None, since: object = None, until: object = None, + status: Iterable[object] = (), ) -> WhyQuery: """Build a query from the primitives a CLI or tool call carries.""" return cls( @@ -120,6 +123,7 @@ def from_boundary( ), since=Instant.from_boundary(since) if _present(since) else None, until=Instant.from_boundary(until) if _present(until) else None, + statuses=frozenset(_status(value) for value in status), ) def as_dict(self) -> dict[str, object]: @@ -133,12 +137,16 @@ def as_dict(self) -> dict[str, object]: result["since"] = self.since.as_string() if self.until is not None: result["until"] = self.until.as_string() + if self.statuses: + result["status"] = sorted(status.value for status in self.statuses) return result def matches(self, decision: Decision) -> 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.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): return False if self.since is None and self.until is None: @@ -251,7 +259,7 @@ def render_why_document(document: Mapping[str, object]) -> str: '
The record holds no decision this question is about.
' ) filters = ", ".join( - f"{escape(key)} = {escape(str(value))}" + f"{escape(key)} = {escape(_filter_value(value))}" for key, value in query.items() ) asked = escape(filters) if filters else "Every decision the record holds" @@ -312,6 +320,7 @@ def _render_card(entry: Mapping[str, object]) -> str: spec = cast("Mapping[str, object]", decision["spec"]) operation = cast("Mapping[str, object]", spec["operation"]) status = DecisionStatus(str(spec["status"])) + ran_exit_code = spec.get("ranExitCode") story = cast("Sequence[str]", entry["story"]) steps = "".join(f"{escape(str(entry['id']))}