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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ versioning for public release policy decisions.
## [Unreleased]

<!-- core-ops-release:start -->
### Changed

- Stateless `plan` and `apply` no longer flag a healthy host as "recovery from failed initial apply"; introduce `ApplyRunDisplayState::Stateless` so the rendered header carries only the `(stateless)` path-based provenance prefix when the host has converged objects but no `/var/lib/core-ops/` baseline. Empty-host invocations continue to render `(first run)` as before.
<!-- core-ops-release:end -->

## [2.2.2] - 2026-05-07
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "core-ops"
version = "2.2.2"
version = "2.2.3"
edition = "2021"
license = "AGPL-3.0-or-later"

Expand Down
7 changes: 7 additions & 0 deletions changes/fix-stateless-plan-annotation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
change_id: fix-stateless-plan-annotation
release_intent: patch
summary: Stateless `plan` and `apply` no longer flag a healthy host as "recovery from failed initial apply"; introduce `ApplyRunDisplayState::Stateless` so the rendered header carries only the `(stateless)` path-based provenance prefix when the host has converged objects but no `/var/lib/core-ops/` baseline. Empty-host invocations continue to render `(first run)` as before.
scope: cli
release_preparation: false
---
8 changes: 7 additions & 1 deletion src/cli/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -906,10 +906,16 @@ pub fn apply_with_report_stateless(
{
deterministic.scope_id = scope_id.clone();
}
// Stateless mode: empty host → FirstRun (informative for an actual
// initial apply); non-empty host → Stateless (suppress the
// misleading "recovery from failed initial apply" suffix — the
// controller cannot distinguish a successful prior apply from a
// failed one in stateless mode). The `(stateless)` path-based
// provenance prefix carries the operationally relevant signal.
let run_display_state = if observed_snapshot.objects.is_empty() {
ApplyRunDisplayState::FirstRun
} else {
ApplyRunDisplayState::Recovery
ApplyRunDisplayState::Stateless
};
let human_report = format_apply_output_report(
&deterministic,
Expand Down
15 changes: 11 additions & 4 deletions src/cli/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,20 @@ pub fn plan_stateless(
verify_state(&result.desired, &observed),
);
// Stateless mode has no last_applied baseline by design (init'd
// state is never read, FR-013 / SC-009). Treat as FirstRun for
// header-rendering purposes — the source path is always the
// primary identifier in the rendered header.
// state is never read, FR-013 / SC-009). When the host is empty,
// "(first run)" is still useful and accurate; the user is about
// to create everything from scratch. When the host already has
// observed objects we cannot safely tag the run as "recovery
// from failed initial apply" — there is no record distinguishing
// "host has units because a prior apply succeeded" from "host
// has units because a prior apply failed mid-flight." Fall back
// to the dedicated `Stateless` variant in that case so the
// path-based provenance prefix (`(stateless)`) carries the
// operationally relevant signal alone.
let run_display_state = if observed_snapshot.objects.is_empty() {
ApplyRunDisplayState::FirstRun
} else {
ApplyRunDisplayState::Recovery
ApplyRunDisplayState::Stateless
};
let mut deterministic = reconcile_deterministic_plan_with_runtime(
&desired_snapshot,
Expand Down
13 changes: 13 additions & 0 deletions src/cli/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ pub enum ApplyRunDisplayState {
Managed,
FirstRun,
Recovery,
/// Stateless mode (`--source-repo`): no `/var/lib/core-ops/` baseline is
/// read or written, so the controller cannot distinguish "host has units
/// because a prior apply succeeded" from "host has units because a prior
/// apply failed mid-flight." The first-run / recovery suffix does not
/// apply; the path-based provenance prefix (e.g. `(stateless)`) carries
/// the operationally relevant signal.
Stateless,
}

#[derive(Clone, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -1997,6 +2004,9 @@ fn apply_header_revision_context(
crate::cli::status::render_revision_with_requested_ref(target, requested_ref)
)
}
ApplyRunDisplayState::Stateless => {
crate::cli::status::render_revision_with_requested_ref(target, requested_ref)
}
ApplyRunDisplayState::Managed => match previous {
Some(previous) if previous != target => {
format!(
Expand Down Expand Up @@ -2659,6 +2669,9 @@ fn plan_header_revision_context_with_state(
"{} (recovery from failed initial apply)",
crate::cli::status::render_revision_with_requested_ref(target, requested_ref)
),
(_, ApplyRunDisplayState::Stateless) => {
crate::cli::status::render_revision_with_requested_ref(target, requested_ref)
}
(None, ApplyRunDisplayState::Managed) => {
crate::cli::status::render_revision_with_requested_ref(target, requested_ref)
}
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/provenance_state/valid-success.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"controller": {
"version": "2.2.2",
"version": "2.2.3",
"revision": "8f3c2ab",
"build_time": "2026-03-23T10:00:00Z",
"tree_state": "clean"
Expand Down
52 changes: 52 additions & 0 deletions tests/integration/test_apply_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,58 @@ fn apply_report_surfaces_verification_failures_for_unchanged_objects() {
assert!(rendered.contains("Outcome: non-converging"));
}

#[test]
fn stateless_run_display_state_omits_first_run_and_recovery_suffixes() {
// Stateless mode: regardless of whether the host has observed
// objects, the rendered header MUST NOT carry "(first run)" or
// "(recovery from failed initial apply)" suffixes — neither is
// distinguishable in stateless mode (no /var/lib/core-ops/
// baseline is read or written). The path-based provenance prefix
// (e.g. `(stateless)` in the source-path identifier rendered by
// the caller) is the operationally relevant signal.
let plan_first = DeterministicReconciliationPlan {
desired_revision_id: Some("rev-2".to_string()),
baseline_revision_id: None,
requested_repository: None,
requested_ref: None,
last_applied_requested_repository: None,
last_applied_requested_ref: None,
scope_id: "host:alpha".to_string(),
actions: vec![DeterministicPlannedAction {
object_id: "frontend.container".to_string(),
classification: DeterministicActionClass::Update,
reason: "actual state diverged from desired snapshot".to_string(),
dependency_context: Vec::new(),
semantic_diff: Default::default(),
}],
drift_records: Vec::new(),
graph: SemanticDependencyGraph {
nodes: vec![SemanticDependencyNode {
object_id: "frontend.container".to_string(),
object_kind: ManagedObjectKind::QuadletResource,
ordering_key: "frontend.container".to_string(),
}],
edges: Vec::new(),
},
};

let stateless_apply = format_apply_output_report(
&plan_first,
&[],
None,
ApplyHumanMode::Default,
ApplyRunDisplayState::Stateless,
);
assert!(
!stateless_apply.contains("(first run)"),
"stateless apply header must not carry (first run): {stateless_apply}"
);
assert!(
!stateless_apply.contains("(recovery from failed initial apply)"),
"stateless apply header must not carry (recovery from failed initial apply): {stateless_apply}"
);
}

#[test]
fn apply_streaming_report_emits_progress_then_terminal_lines() {
let _lock = path_lock().lock().unwrap_or_else(|err| err.into_inner());
Expand Down
Loading