Commit Graph

3 Commits

Author SHA1 Message Date
Anso b689733447 refactor(gitops): one layered status across the Drift tab, Git source sheet, and application view (#2063)
* refactor(gitops): one layered status across the Drift tab, Git source sheet, and application view

The GitOps state used to be a stack of tinted cards on five surfaces: a
posture card, approval chips, a card per facet, a card per target, and a
caveats box, restating the same state several times. Each surface now
shows one status: a single answer (with its resolving button, such as
Review update or Retry), a quiet row naming each stage that applies, and
an Evidence control holding recorded authority, policy lines, targets,
and anything Sencho could not prove.

- Stages that say nothing about an application (unbound placement, an
  absent rollout) are left out of the row instead of printed as "n/a".
- Evidence is never silently lost: the answer carries a marker for
  partial evidence, unreached nodes, and unproven items, and every
  attention reason is kept, the first in the answer and the rest in the
  evidence.
- The Git source sheet's Status section drops from about 450px to about
  170px, and its separate source-state and last-applied rows go; the
  header now says "never applied" until something is.
- The application view no longer shows the posture twice.

Adds a domain-free StatusPath primitive, a pure status model, and a
GitOpsStatus composer; retires the facet-card and approval-chip
components and keeps the policy lines. Docs, the tutorial, and the two
affected screenshots are updated.

* test(gitops): open the status evidence in the digest drift spec

The per-service digests now sit under the Evidence control of the GitOps
status, so the spec opens it before looking for them.
2026-10-02 18:30:16 -04:00
Anso c92004dc6a fix(blueprints): retry a refused drift repair instead of stranding the row (#2028)
* fix(blueprints): retry a refused drift repair instead of stranding the row

A drift repair that could not finish left the deployment row reading
`correcting` forever, so nothing ever retried it. `drift_enforce_start`
writes `correcting` before the repair runs, and a repair that then answered
without ever reaching a deploy wrote no status of its own. That state is
in-flight as far as the decision is concerned, so `computeDecision` skipped it
and the preview projection rendered it as the informational
`in_flight_correct`. No later tick re-checked the node: the workload kept
running the moved image, one correction-failed alert fired, and then nothing,
for as long as the drift lasted. Four ticks on a refused repair recorded one
drift check, one repair attempt and one alert, and did nothing on the rest.

A refusal, or a repair that throws, now settles the row back to `drifted` with
the reason in `last_error`, so the next tick checks and retries it. A throw
counts because resolving a remote node's platform label is a network call made
before any deploy, so it can reject with nothing written and used to escape to
the per-blueprint catch with the row still `correcting`.

The row deliberately does not become `failed` or `pending`. Both are retry
buckets for an ordinary deploy, so the next tick would repair by tag and adopt
whatever the tag points at now, which is the one thing a digest-pinned repair
exists to prevent.

The failure alert fires when the row changed across the attempt, compared
against a snapshot read before the repair ran. It cannot be decided from the
row afterwards: a repair that reaches a deploy writes its own `last_error` and
returns the very same string, so asking the row afterwards finds the failure
already sitting there and mistakes a new incident for a repeat, dropping the
first failure of every kind silently. Losing the deploy lock is a deferral
rather than a failure, so the row still leaves `correcting` with a reason and
the operator is not paged about a race they cannot act on. A converged row now
forgets its last failure, as the deploy acknowledgement already did, so a
failure that returns after a repair counts as a new incident.

computeDecision also treats a `drifted` row at the current revision as a
drift-check target. The projection already reached it through its own status
fallback with the same action, severity and confirmable membership, so nothing
an operator sees changes; it is here so the decision a tick acts on is not two
files away in the preview projection.

Verified: 10333 backend tests pass with no failures, typecheck and lint clean,
and an e2e case drives drift, repair and reconvergence on a real Inline
Blueprint, asserting the row returns to Active with no drift summary and the
workload runs the approved digest again.

* fix(blueprints): correct the drift repair settle found in review

Four corrections to the refused-repair settle, all found by reviewing the
change rather than by a failing test.

The deferral note was the serious one. Losing the deploy lock is silent, and
it wrote a note about the race into `last_error`, which is the replay key. One
lost-lock tick between two identical failures therefore displaced the failure
being replayed, and the next tick reported that same known failure again as
though it were new. A deferral now leaves `last_error` alone: the row keeps
saying what it last actually failed with, which is worth more to an operator
than a note about a race that is already over.

An alert now quotes the row rather than the outcome where the outcome carries
only a shorthand. The name-conflict paths return the bare word `name_conflict`
and then park the row for good, so that one alert is the only signal the
operator will ever get, and it has to name the directory in the way. The row's
own text is only preferred when the repair actually wrote a status: when the
row is still `correcting`, nothing was written and the row is still carrying
the previous failure, which must not be reported as the current one.

A target that converges while its repair is in flight is no longer reported.
The drift the failure belonged to no longer exists, so paging the operator
about it is stale news.

A repair that throws is now logged with its stack as well as alerted. This
boundary cannot tell an operational failure from a bug in the repair and both
arrive identically; an alert alone would repeat a bug every tick and never
reach a developer, and re-throwing would strand the row again.

Separately, the drift observation restamped `last_drift_at` every time it
re-observed the same drift, so a drift outliving one tick reported itself as
just found, forever. It is stamped only when the row was not already drifted.
Both observation sites did this, the Inline one and the Git-managed one.

Coverage added for the Git-managed repair path, which is the third dispatch and
had no case, for the timestamp, and for the three alert rules above. 19 cases
now, 5 of which fail with the whole change reverted.
2026-09-28 19:54:31 -04:00
Anso 61b25f48d2 feat(gitops): show per-target image digests and document the drift model (#1984)
* feat(gitops): show per-target image digests and document the drift model

The observed per-service digests already reached the projection, but the
approved side did not, so a reader could see that two identities differed
without ever seeing which digest each side held. The frozen artifact set's
per-service evidence now rides along with the expected identity, and each
target's card compares them per service: the approved digest beside the
running one, the approved child for the platform that node actually runs,
and matched, drifted, unverified, or not compared as the honest answer.

The comparison follows the model's own rules, so the view cannot claim a
match the projection does not, a stale or unqualified identity stays
unverified, a built service is labelled not compared rather than matched, and
a value that is not a content digest is ignored instead of compared.

Adds the drift model reference the model was missing: the seven classes and
what each is measured against, how to read an item, where each surface
shows it, the states that are progress rather than drift, what Sencho cannot
prove, and the four policy domains. The Blueprint page gains the
digest-drift section it never had, trimmed to the Enforce mechanics.

The end-to-end spec pins an approved digest, replaces the running workload
with different content under the same project, and asserts the deployment
reports the drift, the projection carries both digests, and the Drift tab
renders the comparison without adopting what the tag now points at.

* test(gitops): pin the digest drift spec to a resolvable repository

The spec pinned a locally invented repository, e2e-digest-probe:<stamp>@sha256:...,
which is not a reference any registry knows. That works or fails depending on
what the local Docker version decides to do with a tag-and-digest reference
that has no matching repo digest, and the observation filters the running
image's repo digests by the authored repository, so an invented repository can
never produce an observed digest at all. On a runner whose Docker resolves it
differently, the deploy never lands and the spec waits out its poll.

Both sides are now real upstream tags of one repository, so the approved
reference resolves from the local content store without a registry call and
the moved side is a real digest difference. The digests are read from the
daemon per repository rather than trusting Docker's ordering, and the two
sets are asserted disjoint before anything runs, so two tags that resolve to
one manifest fail by name instead of by timeout.

The spec also stopped assuming things it never checked. The baseline is
proven from the daemon, because the container that was actually created
from the approved reference is the ground truth for what is running, and
reading it costs one call where waiting for the projection's observation
costs a reconciler tick. Every poll now asserts its status, so an API error
reports itself instead of surfacing as a timeout, the apply retry is limited
to the stale-preview case the UI asks the operator to confirm again, Docker
calls are bounded, teardown clears its handle only after a confirmed delete
and then checks the containers are actually gone, and a Docker that is
installed but broken fails instead of skipping.

Verified against local dev servers across four runs at 16 to 55 seconds each,
with no leftover images, containers, or override files, and type-clean under a
strict invocation.

* test(gitops): prove Enforce repairs to the approved digest, not the moved tag

The digest drift spec proves detection. It does not prove repair, because it
runs the Blueprint in observe mode, and the Inline path is not the one that
repairs. The Git-managed loop re-observes a drifted target and applies the
configured drift mode, so that is where the repair mechanic lives, and it now
has a test of its own.

The test starts from a genuinely drifted target rather than an assumed one. The
fixture seeds a newer candidate artifact set carrying a moved digest, points
the latest pointer at it while the approved and last-known-good pointers stay
on the original, and records a runtime observation carrying the moved digest.
That starting state is asserted before repair runs, so a fixture that quietly
stopped seeding it fails instead of passing on a correct-looking end state.

Repair then has to do the one thing that matters: hand the deploy the approved
platform child digest, take it from the accepted artifact set rather than the
most recent resolution or the running image, and leave every pointer that says
what was approved exactly where it was. The observation still reads as drifted
afterwards, because repair changes what runs, not what was agreed.

The test is mutation-checked. Pointing repair at the latest artifact set instead
of the expected one makes it fail with the moved digest, which is the bug
this exists to catch.

The taxonomy page also records the settings-only reconcile decision properly:
source drift compares commits, so a change of compose path, env path, or
context directory creates no expected-versus-observed pair and no drift item. It
surfaces as reconcile required, and for a Direct application that is where you
fetch again. The page says plainly that this is a choice about what the word
drift means, not proof that the materialized content was unchanged, because
changing where Compose is read from can change the content.

The portfolio page no longer keeps its own copy of the class list. It names the
classes by reference to the drift model page, so the two cannot disagree.

Verified: backend typecheck and lint clean, 9515 backend tests passing, frontend
typecheck clean with zero lint errors and 3457 tests across 347 files, docs
config parses.

* test(gitops): pin a settings-only reconcile as attention, not drift

The decision is recorded on the taxonomy page: a change to the compose
path, env path, or context directory, with the accepted commit still
matching the ref, creates no source drift item. Source drift compares
commits, and that shape has no expected-versus-observed pair of commits.

That decision needed a test to hold it, and the coverage that existed
asserted the facet status and the offered action without ever asserting that
no source item is emitted. The behavior was therefore unpinned in both
directions: nothing would have failed if the producer started reporting it,
and nothing would have failed if it stopped.

This adds the missing half. The fixture is the decided case exactly: same
commit, different materialization fingerprint. No source drift item, the
facet reads reconcile required, and fetch is offered. It is mutation-checked,
and dropping the commit comparison from the producer makes it fail, so it
holds the line rather than merely restating it.
2026-09-24 19:19:19 -04:00