Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand Down
44 changes: 43 additions & 1 deletion config/schemas/v1/operation-decision.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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
}
},
Expand Down
7 changes: 5 additions & 2 deletions examples/disk-full-incident/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
61 changes: 56 additions & 5 deletions sdk/src/cloudfall/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
CHECK_TIMEOUT_SECONDS,
DECISION_DIRECTORY,
ERROR_DECISION_MISSING,
ERROR_RECORD_INVALID,
ApprovalChannel,
ApprovalRequest,
CheckRunner,
Expand All @@ -111,6 +112,7 @@
Targets,
approve,
propose,
refuse_unapprovable,
)
from cloudfall.domain import (
ConnectionAddress,
Expand Down Expand Up @@ -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``."""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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."""
Expand All @@ -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),
)


Expand All @@ -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"),
],
)
Expand Down Expand Up @@ -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]:
Expand Down
Loading
Loading