Commit Graph

2180 Commits

Author SHA1 Message Date
Anso 2e0bf078c4 docs: retire the standalone known-limitations file (#2022)
Limitations are disclosed on the feature page they qualify and fixed
forward. Removes KNOWN_LIMITATIONS.md, the pre-1.0 disclaimer notes that
linked to it, and names the Compose v2 plugin and Linux production host
in the quickstart prerequisites.
2026-09-27 13:40:40 -04:00
Anso e0a54258f8 fix(gitops): bound concurrency for the startup preflight backfill (#2004)
* fix(gitops): bound concurrency for the startup preflight backfill

backfillMissingPreflightEvaluations awaited ensureRolloutAuthorization
serially for every live-authorized Blueprint application that lacked
stored preflight evidence. Each evaluation can spend the full preflight
timeout on registry probes, so a hub with N such applications delayed
HTTP readiness by up to N times that timeout: startup awaits the backfill
before binding the server.

Spread the evaluations over a pool capped at 3, reusing the existing
mapWithConcurrency helper. Every failure is contained to the one
application that caused it, so a throwing evaluation can neither abandon
the batch nor leave the evaluations still in flight writing authorizations
beside the HTTP bind. Previously one throw escaped to the startup catch
and skipped every remaining backfill.

The tally reads stored evidence rather than the returned verdict, since
evidence is what derive projects from, and every branch that produces no
evidence logs why.

Tests: bounded overlap with a peak-in-flight counter, the skip cases for
an already-evaluated application and for an authorization that no longer
resolves, failure isolation, evidence-based tallying, and one case driving
the backfill through the real authorization path over five database
fixtures to cover concurrent evaluation end to end.

* fix(gitops): contain a per-app binding failure in the startup backfill

The candidate filter called liveRolloutBinding unguarded, so a store read
that threw for one application rejected the whole batch and startup
reported it as a rollout reconstruction failure, naming the wrong step.

The binding read now happens inside the per-application callback, under the
same guard as the evaluation itself: a revoked authorization stays a silent
per-app skip, and a store that cannot resolve one costs that application
alone, never the evaluations still in flight. The backfill keeps its own
startup step so any failure it still raises is logged as the backfill.

Derive stays fail-closed: an app that ends without stored evidence is
unchanged, and the tally still reads stored evidence rather than the
returned verdict.
2026-09-27 05:11:31 +00:00
Anso b197c1bbaf test(git): correct the v3 pending-blob test name and assert the decoded version (#2003)
* test(git): correct the v3 pending-blob test name and assert the decoded version

The test in the pending blob decode branches was named "round-trips the v3
blob with candidate path and inventory". Nothing round-trips: the test
hand-builds a v3 payload, encrypts it, and decodes it. encodePendingCompose
is the only encoder for the pending candidate and hardcodes v: 4, so the
codec cannot produce a v3 blob at all. A v3 row can only come from a
database written by an older build, which is why the v3 branch of
decodePendingCompose exists: to refuse it rather than read it as a
reviewable plan.

Renames the test to "decodes a v3 blob with candidate path and inventory",
which matches the sibling "decodes a v2 blob without a candidate" beside it.

Also declares version on the test-local DecodeApi return type, which listed
only the four fields this block reads out of the nine the real signature
returns, and asserts the decoded version is 3. That field is the
discriminator apply branches on to refuse the blob as LEGACY_PENDING, so
pinning it makes the test's name verified rather than assumed.

* test(git): tighten the pending-blob decode branch tests

Two accuracy fixes in the pending blob decode branches, neither of which
changes what the block covers.

Drops encodePendingCompose from the test-local DecodeApi type. No test in
the block calls it, and the declaration was a trap rather than a convenience:
it took four parameters where the real method takes five, the fifth being
the plan identity. A test that reached for it to round-trip a payload would
have type-checked and then thrown on the missing argument. Removing the
member removes the trap, and adding a fifth parameter to keep an unused
surface alive would be the same speculative code in a different shape.

Renames "falls back to legacy plaintext for unknown shapes" to "falls back
to legacy plaintext when the blob has no version marker". The test feeds
opaque text, which reaches the plaintext tail because it carries no version
marker. It does not cover the paths a reader would assume "unknown shapes"
means, namely JSON carrying an unrecognised version or a v2 marker whose
files check fails. The name now states the trigger the test actually
exercises.
2026-09-27 04:23:52 +00:00
Anso ab9af21762 refactor(gitops): drop the orphaned compose-only digest pin guard (#2002)
The apply-local pin guard validates digestPins after the write, against the
rendered model, using digestPinsMatchServiceNames with the service names from
buildEffectiveServiceModel. That change left the previous compose-only guard
behind with no remaining production caller.

Remove composeServiceNames and digestPinsMatchComposeServices along with the
isRecord and parseYaml imports that only they used, and drop the now-inaccurate
"compose + override" clause from the surviving helper's doc comment. The
additionalServiceNames parameter was already redundant: the rendered model is
built by running docker compose config across every file in the deploy spec,
so override-declared services are present in it already.

Also remove the readNodePlatform re-export from artifactResolve, which nothing
imported. The function stays in effectiveArtifactContext, where it is still
used to read the node's Docker platform.

Delete the two tests that only exercised digestPinsMatchComposeServices. The
live contract is still covered: the surviving rendered multi-file test asserts
the positive case, the unknown-pin-key rejection and the empty-service-set
rejection, and the route-level 400 for a pin mismatch remains covered in
blueprints-route-validation.

Pin validation behaviour is unchanged.
2026-09-27 04:21:00 +00:00
Anso 66dac5d183 test(git): name the multi-file pull test for the v4 pending format (#2001)
The test drove upsert -> pull -> apply and asserted the apply succeeded,
so the pending blob it exercised was necessarily the current one. The v2
branches in decodePendingCompose exist only to refuse pre-upgrade rows as
LEGACY_PENDING, and encodePendingCompose is the only encoder and always
writes v: 4. The old title named a format this flow cannot produce.

Names the format instead of the test's era, matching the sibling tests that
already say "v2 pending blob" and "v4 pending blob". Assertions and
production code are unchanged.
2026-09-27 04:13:39 +00:00
Anso acfcb5567b fix(gitops): keep an application addressable when its evidence cannot be read (#2000)
* fix(gitops): keep an application addressable when its evidence cannot be read

A remote application whose evidence this hub cannot walk answered 404, the
same as one that does not exist, so an operator's only signal was an
application that had vanished. It now answers 503 with an
`evidence_unavailable` code when the row names the application but the
projection cannot be read, and the view says evidence is unavailable and
offers a retry. A row that never named the application still answers 404,
and an unreadable row stays 404 so the two cannot be told apart.

Validating that answer meant walking the projection properly. The detail
endpoint and the portfolio now check the same evidence: an artifact
qualification the facet and its latest evidence disagree on, a blank
generation or identity standing in for a real one, and a status from a
newer node all read as unknown evidence rather than as a convergence
claim. Convergence itself is now proven from the target evidence rather
than read off the rollout status, an Inline Blueprint no longer inherits a
Blueprint's claim, and a target the intent withdrew no longer keeps an
application in the attention queue.

Reading a remote row also authorizes it against the node that owns it
rather than the node the caller is viewing, and the phone portfolio says
partial when the merge cap dropped rows, not only when a node failed.

* fix(gitops): grade unfamiliar evidence as unknown instead of unavailable

Two shape validators rejected a vocabulary this build does not recognize, which
made a whole projection unusable rather than merely unfamiliar. An unusable
projection costs the operator its row: the portfolio shows an evidence
unavailable placeholder and the detail route answers 503. That claims the
evidence is unavailable, which is a stronger statement than an unfamiliar word
supports.

A last-known-good unavailable reason is now accepted on its structure and
graded as unknown evidence instead, on both sides of the wire. The browser
refusing the reason the server had already graded would only have replaced the
honest answer with a total read failure, and the two validators would have
disagreed about the same payload.

A drift identity kind is accepted the same way, so one unfamiliar kind costs
that row its label instead of the whole application. The label names the kind
it cannot read instead of rendering nothing. The kind list is a total record
over the identity union, so adding a kind without wording for it is a compile
error, and the lookup is own-property, so an inherited key reads as unfamiliar.

A blank reason is still rejected, and the mirror consistency rules still reject
an incoherent last-known-good record.

Verified: backend and frontend tsc clean; seven backend GitOps suites pass (446
tests); full frontend suite passes (3597 tests); frontend lint reports 0 errors.

* fix(gitops): restore the recovery-required rollback scope and drop a dead import

The conflict resolution carried this branch's three-member failed-runtime set
over the four-member set on the main branch, because this branch had reordered
the same three entries and the two edits merged without a conflict marker. A
target whose recovery is restoring or compensating reports recovery_required,
so it stopped counting as a failed target and the rollback dialog stopped
offering the failed-targets scope for exactly the stacks that need recovery.

The set now matches the main branch, with the type annotation kept. The
existing test covered recovery_failed only, so the scope is now asserted for
both recovery states.

Also removes a type import the same resolution orphaned. The type checker does
not report unused imports, so this surfaced only in the backend lint job.
2026-09-27 04:06:03 +00:00
Anso aadbd05e06 fix(app-store): give every catalogue port exactly one protocol (#2019)
* fix(app-store): give every catalogue port exactly one protocol

Installing certain App Store apps failed with "Sencho could not render this
stack's Compose model to check external networks." The catalogue port mapper
built each spec as `${external}:${internal}/${protocol}`. The bundled
LinuxServer.io catalogue sends `external` and `internal` as strings, never
sends a `protocol` key, and for 19 of its 201 apps carries the protocol inside
the container value, so swag produced `443:443/udp/tcp` and wireguard produced
`51820:51820/udp/tcp`. Docker Compose rejects both with `invalid proto`, the
render returns no model, and the external-network gate refuses the deploy.
Jellyfin, qbittorrent, transmission, syncthing, deluge, and unifi were affected
the same way; they cluster in Networking because that is where the catalogue
documents the protocol in the path.

Replace the mapper with a registry-agnostic normalizer applied at the single
ingest boundary, for both the bundled and the custom-registry path. A string
entry is already in Compose's own syntax and passes through verbatim, so the
custom-registry format is untouched by construction and the YAML emitter stays
the only owner of escaping. A structured entry is read through the host and
container field names registries actually use and gets exactly one protocol. An
entry that contradicts itself, or that names a transport Compose cannot accept,
is dropped with a counted warning rather than emitted as a spec that fails
later and more quietly.

The catalogue cache key is now derived from the registry URL. It was a fixed
key, so changing the registry served the previous registry's catalogue for the
full 24 hour TTL to any caller that did not also bust the cache, which only the
Settings form did.

Make the render failure name its own cause. The resolver already reduced a
failure to a specific diagnosis, but the deploy gate discarded it for a
sentence naming none of the possible faults, and the resolver's own fallback
guessed from a list that a malformed port does not match. The gate now
surfaces the diagnosis, and the resolver quotes Compose's own redacted stderr,
which also carries the timeout and output-cap reasons. A pre-flight refusal is
now recognized by type in the template deploy path: nothing has started, so it
always rolls back, and its message reaches the operator instead of a rule's
paraphrase. Matching that text against the generic rules would otherwise have
suppressed the rollback and left a broken compose file on disk.

Verified against all 331 live catalogue entries and by installing swag and
wireguard end to end: both deploy, their Compose models render, and each port
carries one protocol. Also closes two redaction gaps that surfacing stderr made
reachable: scheme-less registry credentials in an image reference, and keyword
secrets quoted on both sides of their separator.

* fix(app-store): correct the audit findings on catalogue ports and redaction

Three corrections to the previous commit, from an audit of the PR.

The scheme-less credential rule added to the shared log redactor matched
digest-pinned image references. It turned
lscr.io/linuxserver/swag:latest@sha256:abc123 into
lscr.io/linuxserver/[redacted]@sha256:abc123, dropping the repository and tag
from every pinned reference it touched, and reported a secret that was not
there. The redactor has 18 non-test callers, one of them the GitOps source
controller, where the pinned digest is the artifact identity being diagnosed.
Two constraints narrow the rule to actual credentials: the credential must
start a token, so a URL path containing "a:b@c/" is left alone, and the "@"
must be followed by a host and a path, which is what a digest never has after
it. The known cost of that narrowing is recorded in the code: a credential with
no path after the host is not caught, and neither is a password containing "@",
which an image reference cannot carry unencoded.

The catalogue format was chosen by hostname, so anything other than the bundled
API was read as a Portainer v2 catalogue. An LSIO mirror or a caching proxy
answered with a body the Portainer mapper did not recognise, it returned no
templates, and the App Store came up empty with nothing logged and nothing
reported. The format is now decided by the body, and a body matching neither
supported shape raises a message that names both shapes, says the older
top-level array format is not supported, and points at the Registry URL setting.
That message is surfaced to the operator rather than replaced by the generic
fetch failure, and a registry that legitimately has nothing in it is still
accepted.

The deployment sheet split each port spec on ":" and took the first piece as the
host port and the second as the container. For a spec pinning a bind address,
127.0.0.1:8080:80/tcp, it offered a bind address in the port field, showed the
host port as the container port, checked port conflicts against an address, and
on deploy wrote 127.0.0.1:8080, losing the container port and the protocol while
reporting success. One parser now reads the address, the host port and the
container out of every short form, and rewriting substitutes only the host-port
segment, so the rest survives by construction. An address is recognized by what
it is not, since a catalogue can publish a hostname there as readily as a dotted
quad. A port range is no longer placed in a field that accepts one number, which
had been blocking the install, and it stays visible rather than being dropped. A
cleared or non-numeric field leaves the registry's spec alone.

Verified against a custom registry publishing the shapes the bundled catalogue
never sends. The sheet offers 8080 beside 127.0.0.1:80/tcp, offers no field for
a range or a container-only port, and an edited host port deploys as
127.0.0.1:9090:80/tcp with the bind address, container port and protocol intact.
An unrecognized body now reports its reason in the App Store instead of showing
an empty grid. The bundled catalogue is unchanged, and an install that collides
with a port already bound on the host still rolls back cleanly.

* fix(app-store): leave an unbracketed IPv6 port spec exactly as published

A Compose short form can pin a bind address without brackets, and Compose
accepts it: "::1:8080:80" is read as address "::1", published port 8080,
container port 80. Splitting on colons cannot find the boundary, because
neither "::1" nor "::1:8080" can be ruled out as the address, and the previous
parse reported an empty address, so the deployment sheet offered 8080 and
displayed "8080 : :80" with the bind address hidden.

A spec whose leading region holds more than one colon is now left exactly as
the registry published it: no port field, no conflict check, and no rewrite.
The sheet shows the published text whole, without the separator that would
imply a host and container split it does not have. Every unambiguous form is
unaffected, because a valid unbracketed spec has at most three fields and a
bracketed one is consumed before this check.

Also record, next to the rule it applies to, that the credential matcher
over-matches text shaped like a time and a host, "10:30@host/a". That shape is
indistinguishable from a numeric user and password, and the direction is
deliberate: an over-redacted log line costs a word, an under-redacted one
leaks.
2026-09-26 18:31:05 -04:00
Anso 248154a2d3 fix(blueprints): gate digest-pinned remote apply on leaf capability (#2005)
Enforce repairs a frozen image-digest disagreement by redeploying the
stack at the approved digest with the pull suppressed, so a drifted
image cannot come back under the same tag. For a remote target the hub
carried those pins in the apply request, but it never checked that the
node receiving the request understood them. A node running a release
that predates digest pins drops the field and redeploys by tag anyway,
which is the one outcome the pin exists to prevent: the drifted image is
pulled again, and the row is marked active while the drift is still
there.

The existing mixed-version guard on that request only fires when the
node lacks the atomic apply endpoint entirely, so a node in between the
two behaviours was never caught.

The hub now probes the target for a new capability and refuses to send
a digest-pinned apply to a node that does not advertise it, or whose
metadata it could not read. The refusal is the same upgrade-required
error the missing-endpoint case already raises, and it lands before the
delivery contract is negotiated, so nothing about the apply leaves the
hub. Applies that carry no pins are untouched, so nodes that never take
part in a digest repair need no upgrade for them.

The capability is advertised through node metadata, so an upgraded node
starts serving pinned applies with no further configuration.
2026-09-26 17:28:44 -04:00
Anso 32fd95dd57 feat: health-gated Blueprint rollout policy (#2018)
* feat: health-gated Blueprint rollout policy

A rollout policy that reacts to each target's health outcome instead of
deploying the whole fleet blind. Five modes, applied one target at a time
under any non-observe policy:

  observe     record the outcome and keep deploying the rest
  pause       hold the rollout before the next target
  retry_once  deploy the same generation one more time
  stop        hold, and leave the failed target where it is
  rollback    put the failed target back on the generation it ran before

The policy is frozen into the rollout generation at authorization, so a
fleet is never halfway between two policies, and it is carried across
Blueprint edits. Observe is the default and is exactly the old behaviour.

How it works:

  - A health run is reserved, with the whole rollout binding, before the
    apply goes out, so a lost response still leaves a durable record.
  - The next target is the first required node not settled by health, not
    the first unacked one, so a fleet cannot run ahead of a check.
  - The verdict drives a pure decision function; the executor carries the
    result out afterwards, so the state is durable before anything is
    applied or restored.
  - `unknown` never retries, stops, or rolls back. Missing evidence is
    held for a human, not acted on.
  - Remote targets are refused for non-observe policies before the first
    apply, because this instance cannot produce a health verdict for a
    remote node's Docker.

Permissions: the policy write and the controls take `stack:deploy` at the
application, and every target the policy can act on is checked exactly.

* fix: hold before a policy restore and never lose its record

Five review findings on the health-gated rollout policy, all on the paths
the policy exists to make safe.

A completion that could not be recorded was swallowed. `holdRollout` ran
only after `restoreTargetToGeneration` returned, so a `rollbackCompleted`
that threw left the outer catch with nothing to do: no hold, no partial
record, and the target stuck in `restoring` while the fleet carried on as
if the policy had not stopped it. The restore and the record of it are now
one unit, and any failure records a partial failure on the target *and* the
application, since `rollbackInProgress` opened the application into
`restoring`.

The hold now precedes the restore rather than following it. A restore is
external and slow, and until it returns the rollout is still authorized, so
an operator dispatch or an auto-accept could deploy the next target, or
this one again, while it was in flight.

A decided rollback was indistinguishable from a pause, so a crash between
deciding and doing lost it: the fence read as a question, a resume answered
it, and the rollout moved on past a target still on the failed generation.
It now has its own reason, which no resume clears.

A resume re-paused on every restart. A kept fence is also what reconstruction
holds on, so a rollout the operator had already answered for was held again
each time. A resume now records the answer as `stop_acknowledged`, which
still keeps the target out of the queue but no longer holds the rollout.

A policy rollback passed null captured refs, which cleared the source
acceptance the restored generation was bound to and recorded it unprovable.
That is source-authority state, and a health verdict has none over it.

A blocked follow-up dispatch now holds the rollout with the reason it was
refused. There is no scheduler, so that dispatch is the only thing that
brings the next target.

Also: the policy route required the fleet-wide deploy grant on top of the
per-target checks, unlike the pause and resume routes beside it, so an
operator whose grants are scoped to specific stacks could pause and resume
but not configure the policy. The fleet-wide grant is now required only
when there is no target to check.

The rollback happy-path test asserted only that the restore had been
*called*, and passed while `rollbackCompleted` threw inside the executor
because the fixture's generation row was fake. It now seeds a real one and
asserts the completion, plus the completion-failure, restore-throws, and
source-acceptance cases.

* fix: a pending roll back stops the rollout, not just its own target

Excluding a `rollback_pending` target from the queue is not enough. The
queue steps over it and deploys the next one, so a fleet advances past a
target still on the generation that failed, with no restore done and
nothing telling the operator. Resuming did not help either: it left the
fence in place and unpaused the application, so the next dispatch moved on
and the following restart paused the rollout again, repeating for as long
as the operator alternated the two.

A gated dispatch now refuses outright while any target of the current
rollout generation is `rollback_pending`, and the refusal names the manual
restore route as the way out. The fence stays where it was, so the target
card still reports it.

The test now uses two nodes and dispatches after the resume, so the
step-over is visible; it is verified to fail without the change.

Also corrects the schema comment, which claimed a restart mid-rollback
"reconciles the rollback". It fences the unfinished restore and does not
replay it, which is what the comment says now.
2026-09-26 15:34:14 -04:00
Anso 91648b049d fix(gitops): keep Stack Drift, notifications, and metrics on the canonical posture (#2011)
* fix(gitops): keep Stack Drift, notifications, and metrics on the canonical posture

Three surfaces consumed GitOps application status without going through the
posture read model, so each could contradict the portfolio about the same
application. The portfolio, the application detail panel, and the Blueprint
surfaces were already on it; these three were not.

Stack Drift rendered only the node-local revision, which has no posture and no
evidence freshness. Every facet card could read satisfied with no drift listed
while the portfolio reported the application unsettled, because the evidence
was stale, a node had gone dark, or a target was unreachable. The tab now reads
the hub-owned portfolio row by application id and renders the posture, the
evidence notices, and per-target evidence freshness, and each drift item shows
the age of the observation behind it. The read is keyed on the application id
the projection already carries rather than on a stack name, because a Blueprint
application has no stack name of its own and two applications can share a
deploy stack.

A settled source attempt whose outcome is unproven notified as a pull failure
at error level, while the portfolio reported the same application as
in-progress. Unproven is now its own category at info level, saying the attempt
settled without proving a result. Genuine failures are unchanged.

A recorded health-check failure produced no notification at all, because the
finalize stage was not notifiable, so a known-bad workload left the portfolio
reporting a failure with nothing behind it in the bell. A recorded failure now
notifies, and only a recorded failure: a passing check, a check that could not
reach a verdict, a verdict that could not be tied to the running stack, and a
failure recorded against a generation a rollout has already superseded are all
silent. The health transition now records the verdict it actually persisted,
which is what the notification keys on, so the bell and the projection cannot
disagree about which failures count.

Metrics was audited rather than changed. Its counters key only on transition
stage and outcome, both closed unions carrying no application identity, so no
count derived from them can be read as a claim about an application. That is
now pinned by a compile-time exhaustiveness assertion and a behavioural test,
because a settled word reaching that union is the only way it could start
lying.

* fix(gitops): stop the posture consumers contradicting the portfolio

Follows review of the first attempt at this change. Four defects, three of them
introduced by that attempt and one pre-existing.

The health notification was a duplicate. The only producer of a recorded GitOps
health verdict is the health gate, which announces the failure as it records it,
so a failed run produced two bell entries saying the same thing. The GitOps
outbox now stays out of it, and a test pins that a recorded failure yields
exactly one notification and names where it comes from.

The unproven reconcile outcome now does not notify at all, rather than notifying
at a lower level. A settled attempt that proved nothing is evidence the portfolio
reports as in progress or unknown, and the criterion is that the bell must not
report on such evidence. What is unproven is the portfolio's to show, not the
bell's, so the row is suppressed at the point the outbox decision is made. The
insert and the drain carry the same transition record, so the two cannot disagree
about which rows exist.

The posture card warned falsely in two cases. A detached stack still projects a
revision, so an identity was built for it and the portfolio route answered 404,
producing a permanent warning about an application the portfolio does not list. A
Blueprint stack read by a caller without the fleet-wide node grant answered 403
and produced the same permanent warning for a stack that caller is entitled to
read. Both now say nothing: the route answers 404 for missing and not-granted
alike on purpose, so neither status can honestly be reported as a fault, and the
tab keeps the node-local view it always had. The unreadable warning is now
reserved for the one case that is a real fault, a permitted read the server could
not answer.

The metrics invariant test claimed to be an exhaustiveness tripwire while only
checking that its listed values were allowed. It now also asserts no unlisted
member exists, verified by adding a value and watching it fail.

Tests for the new hook cover the answers per outcome, the hub-only read, a slow
answer for a previous identity being discarded, and the announced refetch with
its debounce. Backend 1025 GitOps tests, 479 neighbouring tests, and 3506 frontend
tests pass; both typechecks, both builds, and both lints are clean.

* fix(gitops): scope the posture read and drop the report that no longer fits

Follows the non-blocking findings on the posture work. Six items; four changed
code, two are recorded as needing no change.

The change announcement names the application it came from, so the posture read
filters on it rather than treating every GitOps event in the fleet as this
application changing. That was wasted work and a card that flickered on activity
it had nothing to do with. An announcement that names no application is not
filtered, because it cannot be attributed and dropping it could miss a change to
this very application.

A node change left the previous node's report in state for the length of the new
request, so the new node was composed with the previous node's application and one
identity that cannot exist was probed. Clearing the report was not sufficient on
its own: the render that follows the node change already sees the old report,
because the effect that clears it runs after that render. The report now records
the node it was read from, and the posture read is skipped unless that is the node
now selected. A test drives the switch and asserts the mismatched request is never
sent.

Work in flight and cannot-prove shared a glyph. They are the two postures most
easily read as one state from a distance, and they are exactly the two that must
not look alike on the surface an operator opens to find out which it is, so each
tone now resolves its own icon.

Boot repair drains rows an earlier process inserted, so a build predating the
unproven suppression can leave one behind. The repair now declines it too, rather
than announcing a failed pull the current insert would refuse to write: the one
path meant to reproduce the original outcome must not be the path that diverges
from it. The row is still drained, and the suppression is logged so a silent entry
is a recorded decision rather than something a reader later has to explain.

The health gate being disabled mid-run, which leaves a failed verdict announced
while the projection reports health as not applicable, needs no change here. The
entry it describes is the health gate's own, and the GitOps notification that
would have doubled it is gone.

The reclassification of a stored row is no longer intended but is no longer
reachable either, since the category it was reclassified into does not exist.
2026-09-26 15:04:57 -04:00
Anso 3f2b5348be fix(e2e): stop a failed two-factor spec from cascading the whole suite (#2009)
The E2E suite runs serially against one shared admin account, so a spec that
leaves server-side state dirty breaks every spec scheduled after it. The
two-factor spec was the worst offender: it enrols, consumes backup codes, then
disables, and its cleanup only ran when the block had captured both a secret
and two unused backup codes, with every error swallowed. A test failing
mid-chain destroys exactly that state, so MFA stayed enrolled and each later
login stopped at the challenge screen, paying the full readiness timeout three
times per test with retries until the job hit its ceiling.

Playwright also retries a serial block as a whole in a new worker process, so
the retry could not re-enrol either: the enrol endpoint answers 409 for an
already-enrolled account and the group failed again.

Reset the enrolment from beforeAll and afterAll through the existing emergency
CLI, which is the only path that does not require possession of a second
factor. The block is now self-healing on a retry and cannot leak state.

Two supporting changes:

- loginAs now recognises the challenge screen and a login that never left the
  sign-in form, and names the cause instead of timing out. A cascade costs
  seconds per test and says what to do about it.
- The suite bounds itself in CI, so a cascade fails the test step while the job
  is still alive to upload the report. A job cancelled by its own timeout runs
  no further steps, which is why the previous runs produced no evidence at all.
  The job ceiling is raised above that bound as a backstop.
2026-09-26 13:13:56 -04:00
Anso ebbd504a5a test(e2e): fix the file-explorer Rename locator collision and log failed requests in deploy specs (#2017)
The context-menu assertion matched both the Rename item and a leftover
lifecycle-rename-dst.txt row from an earlier test, failing the first attempt
in most CI runs. Match the menu item by role instead.

The deploy-feedback specs now log aborted requests and attach the browser
logger to the inline-progress group too, to diagnose an intermittent login
failure after deploy tests.
2026-09-26 13:12:54 -04:00
Anso be9d1eacf2 ci: cut backend job time by removing real timeout waits and splitting Docker integration (#2016)
Prune route tests waited out real 12s withTimeout budgets and the monitor
breaker tests waited real 8s janitor timeouts. Route tests now cap the budget
through a test helper and assert the budget each call requested; the breaker
tests advance fake timers. The Docker integration tests run in their own job
alongside the unit tests, with an aggregator keeping the required check name.
2026-09-26 10:18:32 -04:00
Anso a5b938ab93 ci: shard E2E, skip docs-only PRs, and drop the inert visual-regression gate (#2013)
* ci: shard E2E, skip docs-only PRs, and drop the inert visual-regression gate

The visual-regression workflow never had committed baselines, so every run
skipped its comparison and passed. Remove it with its spec and Playwright
project.

E2E now runs as four parallel shards, each with its own app and database,
and no longer waits on the backend job. A single aggregator job keeps the
required check name. CI retries drop from 2 to 1 so a real failure no longer
runs three times.

A change-detection job skips the build, test, and image jobs when a PR only
touches docs/, .claude/, .opencode/, or markdown. It fails closed: an empty
file list, a detection failure, or a rename out of code runs everything.

* test(e2e): create the test admin in global setup so specs do not depend on file order

Sharded runs start each shard on a fresh instance. A spec that logs in
through the API in beforeAll failed with 401 when it ran first, because the
admin was previously created by whichever earlier spec clicked through the
setup screen.
2026-09-25 23:56:05 -04:00
Anso bcc4b7e00a fix(frontend): stop client-side preference changes refetching all settings (#2008)
* fix(frontend): stop client-side preference changes refetching all settings

The Playwright suite was failing intermittently with HTTP 429 from the global
API limiter. Instrumenting a real run showed why: 22,177 API requests over 19.6
minutes, 58% of them 304 Not Modified, and the shared admin bucket averaging
about 970 requests/min against its 1000 ceiling, so ordinary bursts were
rejected. The failures surfaced far from the burst, as the stacks view failing
to load and the Create Stack button never appearing, which reads like an
unrelated UI fault rather than a rate-limit problem.

GET /api/settings was a quarter of all traffic, peaking at 461/min with 3,810 of
4,781 responses being 304s. useDeveloperMode treated a settings-changed event
with no changedKeys as "refetch", and fourteen dispatch sites across ten hooks
broadcast with no detail at all. Every one of them is a client-side preference
backed by localStorage: pane layout (four sites), density, top-nav alignment,
mode and labels, log chip colours, deploy feedback, quick links. So dragging a
panel divider or nudging the top nav pulled the whole server-side settings
document over the network for a change with no server-side effect.

useDeveloperMode now refetches only when changedKeys names developer_mode,
matching the two listeners that already gate this way. All three server-side
write sites dispatch changedKeys, so a real developer_mode toggle still
propagates immediately.

The non-production ceiling of the global limiter moves to 10000 so the suite,
which shares one account across every spec, stops tripping it. Production is
untouched at 200, still configurable with API_RATE_LIMIT. The two changes are
complements: this removes a real defect, the ceiling gives the suite headroom.

* test: rescale global rate-limit assertions to the new non-production ceiling

The pilot enrollment suite asserts which limiter applies by reading the
ratelimit-limit header, and hardcoded the global tier at 1000. That value moved,
so the assertion failed in CI. The enrollment tier's own ceiling of 100 is
unchanged, so the test still proves that a proxy-mode node creation falls back to
the global tier rather than the enrollment tier.

Two floors in the rate-limiting suite were missed in the previous commit. They
read at least 990 remaining, which against a 10000 ceiling no longer constrains
anything: the assertions passed for a reason unrelated to what they were
checking. Rescaled to 9990 so they keep their original meaning, that at most ten
requests were spent from the shared budget.

Note that these two assertions were already weak before this change: one request
per user leaves 9999 remaining when the buckets are independent and 9998 when
they are shared, so a bound of 990 admitted both. They still do. Tightening them
to 9999 would make them prove the independence their name claims, which is worth
doing separately.
2026-09-25 21:41:55 -04:00
Anso b4d01da700 fix(gitops): fail closed on incomplete target evidence (#1998)
* fix(gitops): fail closed on incomplete target evidence

* fix(gitops): harden portfolio evidence validation

Keep tombstoned target history out of live posture, attention, and counts. Require complete cross-facet proof before reporting convergence, and fail closed on malformed remote projections.

* fix(gitops): let converged apps settle on real evidence

Convergence required every current target to report a reachable
connectivity, but that column is seeded to null and no producer ever
wrote it. Every target read back unknown, so no application could ever
show as converged or qualified in a real install; the portfolio and its
header could only ever say unknown. Fixtures that hand-built the value
are the only reason this was not visible.

Connectivity is now projected from the target's own recorded runtime
observation, which exists precisely because the node was reached. A
stored negative claim is still honored verbatim, so it can only withhold
a settled claim, while a stored positive must be earned by a real
observation. There is deliberately no wall-clock window: nothing
re-observes a Direct target on a timer, so a time window would silently
return a healthy, untouched application to unknown.

Making convergence reachable exposed a second fail-open that had been
masked by it. A failed health verdict returned early without clearing
the promotion it had previously earned, so a target kept reporting
healthy after a check had failed. It now withdraws that claim, matching
on generation and scope, and only for a failed verdict: the health gate
records unknown for benign conditions such as a healthcheck still
starting when the window ended, and it re-arms against the already
applied generation on every update, so revoking on those would leave a
healthy application unsettled with nothing re-verifying it. The last
known good pointer is left alone as a high-water mark and rollback
candidate rather than a live health claim.

Tests drive a real Direct application through the whole transition chain
and assert the posture the portfolio would show, so the fail-closed
rule is pinned in both directions: reachable evidence settles, a missing
observation or a failed verdict withdraws it, and a qualified
application settles too, which shows the hub-side re-verification agrees
with real derive output. The previous settle-only fixtures that stored a
reachable value are updated to record a real observation, so they assert
what their names claim.

* fix(gitops): keep a settled claim from outliving its evidence

A failed health check read as work in progress rather than as a failure.
The health facet had no failed branch, so withdrawing a failed check's
promotion left a known-bad workload indistinguishable from one that had
never been checked, and the portfolio showed it as in progress for
ever. The last stack-scope verdict is now persisted on the target row,
bound to the generation it judged, and the facet reports failed for it,
so the posture becomes failed with the health_failed reason that was
already declared but never reachable.

An unknown verdict is recorded only when nothing is recorded yet, and
never overwrites an existing one. The gate reports unknown for benign
conditions such as a healthcheck still starting when the observation
window ended or a newer operation superseding the run, so letting it
clear a failure would resurrect the same in-progress-forever state, and
would apply to a failure the opposite of the rule already applied to a
passing claim.

A Blueprint row also kept claiming it settled after the node holding it
stopped answering. A Blueprint target is projected hub-side from the
hub's rows, and a recorded observation is a statement about the last
time that node answered, so a dark node left the row settled for ever.
The node probe now runs before the Blueprint rows are built, its
results are reused for the remote Direct rows rather than probing every
node twice, and a node that could not be reached marks that row's live
targets unreachable. Only genuinely unreachable nodes are overlaid: an
unsupported node answered but could not be read, which is a different
claim and would unsettle every Blueprint on an older node. The row
still derives its own partial set from the overlaid targets, so no
second derivation of that fact is introduced.

Three columns are added to the target row for the recorded verdict, with
the same CHECK on the additive migration that the fresh-install schema
carries, since SQLite accepts a CHECK when adding a column.

Tests cover both directions of the verdict: a failure is reported as
failed and stays failed across a later unknown verdict, a first unknown
verdict is kept, and a Blueprint target on a node that stops answering
reports unreachable with the node named in its evidence.

* fix(gitops): keep Blueprint detail in step with its portfolio row

A Blueprint application row is projected hub-side, and its recorded
runtime observation is a statement about the last time that node
answered. The portfolio list learned to overlay reachability onto
Blueprint targets, but the application detail panel built its row from
the raw projection and never probed. A Blueprint whose node went dark
therefore showed a failure in the list while its own panel still claimed
a settled state, which is the contradictory public truth this read model
exists to remove.

The detail panel now asks each of that application's remote target nodes
whether it answered and applies the same overlay the list uses. Only
nodes the portfolio also probes are asked, through a shared set of
probeable node ids that both surfaces now derive from, so the hub itself
and a target whose node row has been deleted are excluded from both.
Excluding the hub is what makes this safe on a single-node install: a
probe aimed at the hub answers "no proxy target", which is
indistinguishable from a node that did not answer, and would have
withdrawn the settled claim on the most common shape there is, a
Blueprint applied to the hub it runs on.

A node that produces no probe result also no longer takes down the
page. It degrades to a coverage entry instead, matching the promise the
surrounding probe legs already make that one bad node never fails the
whole portfolio.

Two route tests cover the agreement from both sides: a Blueprint on the
hub itself stays reachable in the list and the detail panel, and a
Blueprint on a silent node reports unreachable in both with the
attention reason named in both. Each fails when its own fix is
reverted.
2026-09-25 20:14:33 -04:00
Anso cb6f7bd512 test(gitops): correct acceptance evidence semantics (#1999) 2026-09-25 08:05:10 -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
Anso e1c044d05b fix(exec): fall back to /bin/sh when a container has no bash (#1997)
Opening a shell on a container without bash (Alpine, BusyBox) failed with
"exec: /bin/bash: no such file or directory" instead of falling back to
/bin/sh. With a TTY attached, Docker accepts exec creation and start for a
missing binary and only reports the failure as stream output, so the
try/catch fallback never ran.

Probe /bin/bash then /bin/sh with a short non-interactive exec and pick
the first that exits 0. Exit 126/127 means the shell is absent; any other
probe failure (timeout, daemon error) is reported with its real message
instead of a misleading "no shell", and the socket is closed on failure.

Also replace the empty catch on the Host Console Copy button with a log
and an error toast.
2026-09-24 19:09:05 -04:00
Anso 423f4e9f11 fix(terminal): support copy and paste in container and host consoles (#1996)
Right-click in the container bash modal and the Host Console did nothing
useful, and Ctrl+V was sent to the shell as a literal ^V, so there was no
reliable way to paste.

- Right-click copies an active selection, otherwise pastes from the
  clipboard in secure contexts. Over plain HTTP the native menu is kept
  and the terminal is focused so its Paste entry reaches xterm.
- Ctrl+V, Ctrl+Shift+V and Shift+Insert skip xterm key translation so
  the browser's native paste event fires.
- rightClickSelectsWord is disabled so macOS right-click pastes instead
  of always selecting and copying a word.
- Clipboard read or copy failures surface a toast; the selection is only
  cleared after a successful copy.

Closes #1988
2026-09-24 18:22:14 -04:00
Anso 698d3f34bc feat(gitops): produce the remaining drift classes (#1979)
The revision projection only emitted runtime and rollout artifact
drift, so the other four classes named in the model never
appeared. Source, placement, managed-project, and health now have
producers, each keyed off the facet it belongs to so a staged
candidate, a pending authority decision, a queued rollout, a
mid-apply manifest cache, or a mid-deploy target emits nothing.

The invocation class stays absent: the observed invocation is
never persisted, so producing it would fabricate drift on every
application.
2026-09-24 12:26:58 -04:00
Anso 3ecb954f28 fix(gitops): guard automatic acceptance against stateful withdrawal (#1975)
* fix(gitops): guard automatic acceptance against stateful withdrawal

Automatic GitOps acceptance had no stateful check: a candidate that
removed, renamed, or stripped the volumes of a stateful service could be
accepted and applied unattended, destroying or orphaning that service's
data. The poll acceptance path and the automatic webhook apply path now
share one guard that diffs the candidate's stateful service set against
the generation in force, reading managed evidence only.

When the candidate withdraws a stateful service, or when that cannot be
proven, the candidate is held for review: nothing is accepted or applied,
a durable block reason is recorded, and the projection names the block as
its own attention reason, so the workplace shows it instead of an
unexplained review. Webhook auto-apply reports the hold in its delivery
result. Repeat pulls of the same held commit reuse the staged candidate,
so the hold survives polling instead of churning generations, and a
stored redelivery keeps its automated authority even if the policy moved
since. First acceptance under a source with no accepted generation still
defines the baseline, since there is no managed state to compare against.

Validation: backend tsc, 9479 backend tests, frontend tsc, lint, and
tests, with new coverage for the poll hold, rename, non-withdrawing
acceptance, no re-evaluation, unreadable evidence, the webhook hold, a
stored redelivery after a policy change, and repeat-pull reuse. The full
backend run has one pre-existing environment failure in the SSH
integration suite because port 22 is already in use on this machine.

* security(gitops): contain generation reads under the managed root

CodeQL flagged three path-injection sinks in the new stateful guard: the
staged compose reads, the staged .env read, and the generation-in-force
compose reads each joined a row- or user-derived relative path into a
read without a containment check the analyzer can see.

Each read now resolves its target and checks it inline at the sink, the
pattern the repository already uses where CodeQL credits the guard, and
every generation directory is contained under the managed root before
anything inside it is read. Escapes are treated as unreadable evidence,
so the guard holds rather than reads outside the managed area. No
behavior change for legitimate paths.

* test(gitops): cover stateful guard edge cases and review block lifecycle

Pin the guard's hold on stripped volumes and unparseable candidates, the
first-acceptance baseline, and every exit from the durable review block,
including that automatic acceptance resumes once a newer candidate clears it.
2026-09-24 12:14:14 -04:00
Anso 13690c01d2 feat(gitops): connect the workplace to stacks, Git sources, Blueprints, and Fleet (#1978) 2026-09-24 09:57:29 -04:00
Anso 09494d2f95 feat(gitops): harden the portfolio workplace and scope stack indicators (#1977)
* fix(gitops): keep failing applications inside the portfolio merge cap

* fix(gitops): repair phone search and paging, bound the attention queue, add clear filters

* feat(gitops): open the portfolio scoped to a stack from its Git indicators

* fix(gitops): list hub-local legacy Git sources and reach hub-only views from a remote node
2026-09-24 08:59:53 -04:00
Anso 39e132512f fix(db): log failed column migrations and cover poll-column upgrades (#1976)
initSchema's column helper swallowed every ALTER TABLE error, so a real
migration failure left no trace and surfaced later as an opaque crash.
It now ignores only duplicate-column errors and logs anything else with
the table and column.

Adds a regression test that starts from a gitops_applications table
missing next_poll_at and poll_interval_secs and asserts startup restores
both columns and the due-scan indexes.
2026-09-24 06:12:49 +00:00
Anso 8ff0f51a1b fix(ui): polish modal footer, quick-link removal, and empty Recent alerts (#1973)
* fix(ui): keep modal actions right-aligned when the footer wraps

* fix(navigation): name the quick-link remove action explicitly

* fix(dashboard): let an empty Recent alerts panel collapse when it stands alone

The eight-row reserved height only matters when the panel sits beside
Fleet Heartbeat; on a single-node install it left a large empty card.
2026-09-24 05:19:08 +00:00
Anso 1347bca5e5 fix(fleet): stop advertising a dev build the node already applied (#1974)
The dev-build availability digest was recorded without the running image it
was measured against, so after an update recreated the container the Fleet
card kept offering the update it had just applied until a later check was
conclusive. Availability is now bound to the running image id.

Mobile Fleet's dev-build update action also now requires the node to be
online with no update in flight, matching the desktop card.
2026-09-24 05:18:33 +00:00
Anso fc6cc62a27 fix(ui): tighten Stack Health row semantics and resizable pane affordances (#1972)
Stack Health rows no longer nest the GitOps badge button inside a
button-role row; the stack name is the keyboard target. Sort headers
announce direction and show a focus ring. Resize handles reset to the
default width on double-click and stay highlighted while dragging.
Modal bodies size against the dynamic viewport height.
2026-09-24 03:30:41 +00:00
Anso 48b0276fab feat(gitops): notify on authority, rollout, and stateful-hold decisions (#1969)
* feat(gitops): notify on authority, rollout, and stateful-hold decisions

Extend the existing GitOps outbox to a second payload kind so a committed
transition becomes a notification-history entry for the decisions an operator
acts on, not only for a settled source attempt:

- source acceptance, placement approval, and rollout authorization;
- rollout pause, resume, and supersede;
- rollout rollback start, completion, and partial failure;
- a stateful deploy held for its first-deploy confirmation.

The v2 payload carries identities and the recorded reason only. The stage to
category/level/message mapping is closed in notifications.ts, one distinct
category per stage, and the insert and the drain share one predicate so the
two cannot disagree about which rows exist. Categories are history-only, like
the git source attempts: they reach the bell and the Activity timeline and are
never dispatched to external channels.

An application-level pause now records its operator reason in the history
delta, and a notification names the Blueprint when an application has no
stack name of its own instead of showing the application id.

* fix(gitops): scope and harden the notification fanout

Review follow-ups on the notification stream:

- one gitOpsOutboxPlan decides which stages write an outbox row, called by
  both the insert and the drain, so the two cannot drift;
- a Direct source acceptance is the controller's automatic bookkeeping and
  is excluded, leaving the operator acceptance notification to Git-managed
  Blueprints while the settled attempt already reports the change;
- the drain dispatches on the exact payload version and reports an unknown
  version with a kind-neutral code, and its body is total so a storage error
  on the macrotask cannot end the process;
- extra history fields can no longer overwrite the compact application
  snapshot, and error text is sanitized before it reaches a log;
- docs state the mute-rule and bell behavior truthfully.

* refactor(gitops): share the outbox insert and drop the unused reason arm

The two payload kinds only differ in the encoded JSON and version, so one
insert helper holds the SQL. The notification reason reader now reads the
pause key only, which is the only free text a notifiable stage records today.

* docs(gitops): scope Blueprint notification entries correctly

Placement approval is recorded for any Blueprint, including an Inline apply,
while source acceptance and rollout authorization are Git-managed only. The
notification label names the Blueprint for these categories; the stack form
belongs to the settled source attempt.
2026-09-24 03:24:26 +00:00
Anso 0ab26529d3 feat(dashboard): rebalance the Home lower half around operational signals (#1953)
* feat(dashboard): rebalance the Home lower half around operational signals

Home now reads as a triage: Recent alerts leads the operational row, with
Fleet heartbeat beside it on multi-node installs, and Configuration summary
closes the page. Every card below the gauges renders through one shared
panel shell and the shared Table primitives; actionable rows stay real
table rows with a native button in the primary cell.

Recent alerts gains a This node / All nodes scope that follows the active
node, a fixed eight-row preview with View all alerts, deterministic
drill-downs (stack, drift tab, container logs, scan history, Fleet updates)
that switch nodes race-safely, and first-class feed gaps so a silent node
never reads as an all-clear. Fleet heartbeat takes the alerts card's
height, orders problem nodes first, separates offline from unknown, and
opens node details from any node. Configuration summary collapses to one
row per domain with a per-setting Node / Account / Instance scope,
reachability-gated drill-downs, stale handling, and fresher invalidation.

The Stack restarts card is removed from Home, along with the
stack-restarts endpoint and query it was the only consumer of.

* test(e2e): scope the node-switch locator to the switcher popover

* docs(dashboard): document the operational Home layout
2026-09-24 03:16:43 +00:00
Anso 1efaa13492 feat(fleet): replace the Status tab with Readiness (#1952)
* refactor(stacks,security): extract status and overview builders into services

Move the bodies of `GET /api/stacks/statuses` and `GET /api/security/overview`
into `services/stackStatusesEvidence.ts` and `services/securityOverview.ts`.
Both routes keep their guards, response shapes, error handling, and timing
logs; they now delegate to the new builders.

The moved logic is verbatim apart from `req.nodeId` becoming a `nodeId`
parameter and `res.json(...)` becoming a return, so the existing route suites
cover it unchanged.

The move does restructure one thing: a builder rejection is now caught by a
`try` in the route that no longer wraps the code which throws. That propagation
was not covered by any test, so `cache-endpoints.test.ts` now pins it, including
that the real error still reaches the console rather than the 500 standing alone.

This is groundwork: the fleet readiness surface needs to read the same evidence
from a batch context rather than through one Express request per node.
Extracting first keeps that change from also being a refactor.

One accepted deviation, on the error path only. `dockerMs` and `enrichmentMs`
were route-scope bindings mutated by the cache closure, so a rejection after a
timing was recorded still let the route log that partial value. They are now
builder-scope, so a rejection logs the initial nulls instead. The HTTP
response is unchanged (500, same message); only the debug timing line differs,
and only when an unexpected throw lands between the Docker call and the
enrichment block with no stale cache entry present.

* feat(readiness): add the fleet readiness wire contract

Define the types the fleet readiness surface speaks in: the six domains,
the per-node domain cells the matrix renders, the prioritized findings
list, and the per-stack summary the tier-2 node route returns. No producer
or consumer lands in this commit; the routes and the aggregator follow.

The contract carries no scoring and no prose headlines. A finding's display
payload is a reason code the frontend maps to copy, and the two free-text
fields (finding detail and per-stack top reason) carry an explicit
redaction requirement, because the canonical contracts they pass through
can embed a materialized input path.

Also exports the reachability classifier from the blueprint preview
projection, along with the contact-source union it already returned and a
closed union for its note, so the fleet surface classifies reachability
with the same cached logic rather than writing a second classifier. The
three existing consumers are unaffected: both substitutions are in type
positions only, and 39 tests across the preview, binding-preview, and
reconciler-gate suites pass unchanged.

* feat(readiness): add the node-local per-stack readiness rollup

Adds `GET /api/stacks/readiness-summary`, the node-local half of the fleet
readiness surface. It reports the canonical per-stack update and rollback
verdicts for one node, because the Docker socket is on that node: the update
preview's remote path aborts at a hard 90s ceiling per stack, which a hub
fan-out cannot wait on.

The route adds no scoring. It selects, bounds, and reports what
`UpdateGuardService` already computes:

- one pass, aborted by a 6s deadline that starts before the compose directory
  scan so the scan is charged to the same budget
- concurrency 3, and a 25-stack cap that keeps the most operationally
  interesting stacks (failed checks, then updates available, then open drift
  findings, then alphabetical)
- a 120s cache entry, invalidated on stack mutation through
  `invalidateNodeCaches` so a fan-out does not re-run the Docker-heavy pass per
  request
- the two verdicts settle independently, so a failing rollback compute cannot
  erase a good update verdict; a stack whose work throws degrades to an
  unavailable row rather than failing the pass

Each row carries the strongest reason behind its verdict, taken from the
canonical signals and items and passed through `redactSensitiveText`, because a
detail there can carry a materialized input path on a `node:read`-gated surface.

The served payload carries `stale`, derived from the pass timestamp rather than
from the cache outcome. The outcome is not truthful for a caller that joined
someone else's in-flight fetch: when that fetch fails and an expired entry is
served, the caller that ran it is told `stale` while every joiner is told
`inflight`, on the same old bytes. An expired entry's own timestamp names the
condition for every reader. The pass type omits the field, so a pass cannot
assert freshness it cannot know.

The contract also names the aggregate's row types (`StackReadinessRow`,
`StackUpdateReadinessRow`, `StackRollbackReadinessRow`), so the producer and its
tests refer to them rather than restating the shape at each call site.

Two registrations move together and each has its own test: the route is mounted
ahead of the `/:stackName` catch-all, and `/stacks/readiness-summary` joins
`STATIC_STACK_PATHS`. Missing the first resolves the request as a stack named
`readiness-summary`; missing the second demands exact `stack:read` on a stack of
that name, for the router gate and for the hub's own fan-out call.

`fleet-readiness-v1` joins the capability registry on both sides, following the
append-only convention there. The hub reader that gates on it lands with the
aggregator.

The route is guarded by `stack:read`, which every role holding `node:read`
already has.

* feat(fleet): aggregate fleet readiness on the hub

Add GET /api/fleet/readiness, a hub-owned aggregate over six domains
(connectivity, workloads, updates, recovery, security, control) that answers
what across the fleet needs attention before an operator acts on it.

The hub answers from its own SQLite for connectivity, stored update facts,
snapshot gaps, and Policy Sync state, and fans out two bounded node-local
reads: GET /api/readiness/evidence (3s budget) for workloads, security, and
stored recovery rows, and GET /api/stacks/readiness-summary (8s budget) for
the canonical per-stack update and rollback verdicts. Every node is wrapped
in its own try/catch into a per-node row, so one slow or unreachable node
degrades one row instead of holding the page, and a disconnected Pilot
target costs no request at all. A build without the evidence route answers
404, which is reported as a missing capability rather than as a fault.

Findings carry a stable id, an enumerated code, and a drill-down target.
Verdicts are read from the existing update and rollback readiness functions
rather than recomputed here, and nothing is scored: a check that could not
run is reported unknown or unavailable, never healthy.

The Control domain is admin-only, matching the sibling Policy Sync read. The
response omits the domain for an account that cannot see it rather than
sending it and relying on the client to hide it.

Retire GET /api/fleet/configuration and the backend normalizer that served
only it. Home's Configuration Summary keeps its own builder, and
GET /api/dashboard/configuration is unchanged.

Document the three endpoints in docs/openapi.yaml.

* feat(fleet): replace the Status tab with Readiness

Swap the Fleet tab strip's configuration inventory for a Readiness tab: a
worst-first fleet summary, a prioritized findings worklist, and a per-node
matrix over the six readiness domains. States, reason codes, and findings
all arrive decided from the hub, so the surface renders the payload and
dispatches each drill-down to the surface that already owns that
remediation.

The matrix lays out the domains the payload carries, so the Control column
is simply absent for an account the server withheld it from. Tier-2 columns
are read from each node's own cells rather than from hasCapability(), which
describes the active node and would misreport every other row.

Findings that restate an update or a rollback verdict reuse the mappings
those surfaces already publish instead of re-declaring them.

Normalize the legacy `configuration` segment to `readiness` in the route
parser so an existing deep link still resolves.

Delete the retired configuration inventory component.

* docs(fleet): document the Readiness tab

Follow the tab from Status to Readiness on the Fleet view page: the summary,
the findings worklist, the per-domain matrix, what happens when a node cannot
be reached, and the admin-only Control column. Update the tab table and the
tab-strip prose, and drop the tab list from the Overview screenshot alt text
so it stops describing a strip the screenshot no longer shows.

Replace the tab screenshot and follow the rename through Fleet Sync, the
deep-link reference, the introduction, and the API reference contents.

* test(fleet): compare readiness mock hosts by origin

The readiness fan-out tests route each mocked fetch by asking whether the
request targeted the proxy host. Decide that on the parsed origin rather
than on a prefix match against the raw URL string, which is the shape the
static analysis flags as incomplete URL handling.

No behavior change: every URL reaching a dispatcher is absolute, and each
of the four tests exercises both sides of the branch.

* fix(readiness): keep ordinary configuration and repeated causes out of findings

The aggregate reported several conditions that are not problems, and one
unreachable node could hold its row for the full verdict budget.

- A stack with no containers (not deployed) is a known state, not missing
  evidence: it is no longer a Workloads concern, and its update and
  rollback verdicts are left out. A stale or degraded status read never
  hides a verdict.
- A fleet that never took a snapshot adds no Recovery concern; snapshots
  are optional. An unreadable skip column is a check that could not run.
- Policy sync (Control) is classified only for the nodes it pushes to.
  The hub's own node and Pilot-agent nodes carry no Control cell instead
  of a permanent "unknown".
- When the reachability probe gets no answer, the slower verdict read to
  the same remote is cancelled, so a dead remote costs the 3s probe
  budget instead of the 8s verdict budget. A verdict read that failed on
  its own keeps its own cause.
- When Connectivity already reports why a node could not be read, the
  gated domains keep their cells but add no finding restating the same
  cause. The hub's own node, whose Connectivity is always healthy, keeps
  every finding.
- Rows are sorted worst first and carry their worst state.

* feat(fleet): rebuild the Readiness tab on the shared page patterns

The tab used a bespoke layout that did not match the rest of the app. It
now uses the same building blocks as the other Fleet tabs and the
Security and GitOps tables:

- The shared Fleet tab heading, then a summary strip with the worst state
  as a short phrase and tiles for nodes needing attention, unverified,
  healthy, and when the check last ran.
- Findings on the shared table shell with the standard filter row
  (search, domain, state, verdict, node) and pagination. The verdict
  filter has presets for rollback partial or not ready, and for updates
  that are blocked or need review.
- A node matrix whose cells name each state in the domain's own words
  ("online", "2 blocked", "never scanned", "in sync", "--" where policy
  sync does not apply) instead of generic state words. Clicking a problem
  cell narrows the findings to it.
- The Fleet toolbar Refresh now re-runs the readiness check.
- A Security finding switches to the finding's node before opening
  Security, using the same pending node-view intent the Networking
  shortcut uses.

* docs(fleet): describe the rebuilt Readiness tab

Rewrite the Readiness section for the new summary strip, findings
filters, and domain-specific matrix values, refresh the screenshot, and
update the Fleet Sync references and the OpenAPI node contract (worst
state per row, Control only for policy sync targets, one finding per
unreachable node).
2026-09-24 03:13:01 +00:00
Anso 199013197e fix(e2e): read the launcher morph after the transition settles (#1971)
The hamburger bars move through a 150ms CSS transition (clamped only under
Reduced motion). The test read the closed computed style immediately after an
Escape, which could land inside the close transition and capture the open
values; reopening then produced an identical read and the morph assertion
failed intermittently depending on the suite account's motion preference.

Capture the closed style while the bar is at rest before any toggle, and poll
the open style instead of sampling once. The assertion still proves the bar
moves between the closed and open states.
2026-09-23 21:56:33 -04:00
Anso 38de9b6d36 fix(gitops): render the portfolio Rollout column through the rollout vocabulary (#1970)
The portfolio table read each row's rollout status through the runtime
state lookup. Facet names shared by both facets (recovery_required,
partially_rolled_out, completion_unknown,
fully_deployed_health_pending) showed per-node wording and tone for a
fleet-wide rollout fact, and rollout-only statuses such as
canary_in_progress fell back to raw text with no tooltip.

Read through ROLLOUT_STATE_LOOKUP, the fleet-wide vocabulary the
application view already uses, and add a component test covering a
colliding status and a rollout-only status. The test fails against the
unfixed table and passes with the swap.
2026-09-23 21:50:31 -04:00
dependabot[bot] a98ef3f26e chore(deps): bump the all-npm-frontend group in /frontend with 7 updates (#1962)
* chore(deps): bump the all-npm-frontend group in /frontend with 7 updates

Bumps the all-npm-frontend group in /frontend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [cronstrue](https://github.com/bradymholt/cRonstrue) | `3.26.0` | `3.27.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.46.0` | `1.47.0` |
| [motion](https://github.com/motiondivision/motion) | `13.3.0` | `13.4.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.5.1` | `26.6.2` |
| [eslint](https://github.com/eslint/eslint) | `10.10.0` | `10.11.0` |
| [jsdom](https://github.com/jsdom/jsdom) | `30.0.1` | `30.1.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.0` | `5.0.1` |


Updates `cronstrue` from 3.26.0 to 3.27.0
- [Release notes](https://github.com/bradymholt/cRonstrue/releases)
- [Changelog](https://github.com/bradymholt/cRonstrue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bradymholt/cRonstrue/compare/v3.26.0...v3.27.0)

Updates `lucide-react` from 1.46.0 to 1.47.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.47.0/packages/lucide-react)

Updates `motion` from 13.3.0 to 13.4.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v13.3.0...v13.4.0)

Updates `@types/node` from 26.5.1 to 26.6.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 10.10.0 to 10.11.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.10.0...v10.11.0)

Updates `jsdom` from 30.0.1 to 30.1.0
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v30.0.1...v30.1.0)

Updates `vitest` from 5.0.0 to 5.0.1
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.1/packages/vitest)

---
updated-dependencies:
- dependency-name: cronstrue
  dependency-version: 3.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: lucide-react
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: motion
  dependency-version: 13.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: "@types/node"
  dependency-version: 26.6.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: eslint
  dependency-version: 10.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: jsdom
  dependency-version: 30.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-frontend
- dependency-name: vitest
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-frontend
...

Signed-off-by: dependabot[bot] <support@github.com>

* test(frontend): flush jsdom 30.1 deferred window blur between tests

jsdom 30.1 drops a removed focused element without firing blur; the next
focus() call synchronously fires a blur retargeted at the window. In the
suite that stale blur lands mid-click in the following test and trips
Radix's close-menu-on-window-blur handler, closing freshly opened menus
(FileTree context menu, TopBar Add menu). The global afterEach now blurs
the active element, cleans up, then forces a focus change on a scratch
element so the stale blur fires before the next test starts.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anso <dev@anso.codes>
2026-09-23 21:12:41 -04:00
Anso bb85e08b36 feat(gitops): add rollout lifecycle controls and rollback recovery (#1965)
* feat(gitops): capture a recovery point before Git-managed rollout applies

A rollout rollback needs a recovery point that names the generation the node
was running before the deploy, and Git-managed rollout applies did not create
one. applyLocalUnderLock now captures the pre-deploy project (with the
hub-supplied GitOps binding) before the incoming compose reaches disk, owns
the recovery lifecycle from acquire through verified, and compensates from
the recovery point when the apply fails. A remote target does the same over
apply-local, and the request carries the binding because a leaf has no GitOps
rows of its own.

The binding resolves the deployed pointer for Direct mode and the ack's
applied pointer for Blueprint mode: nothing writes the deploy-bound pointer
for a Blueprint-managed stack. A re-capture that cannot resolve the owning
application (a leaf backup, for example) carries the previous binding
forward, since re-capturing does not change what the node is running.

* feat(gitops): add rollout lifecycle controls and rollback coordination

Adds the operator lifecycle routes for a Git-managed Blueprint rollout:
pause and resume (application or one target), replan (re-derives placement
and reopens review instead of invalidating an accurate one), supersede
(withdraws the live authorization and reports the abandoned rollout), and
rollback.

Rollback restores an explicit prior application generation on one target, the
failed targets, or every changed target in the frozen set. Selectable
generations come from the application's rollout-generation history; targets
already running the selected generation are out of scope; every target is
authorized before any work starts; and the abandoned generation is superseded
first so nothing can dispatch it mid-restore. Each target is restored from
its own captured recovery point, in process for the local node and through the
node's stack rollback route for a remote, where the node must confirm it
restored the requested generation. Every exit records a terminal per-target
result, and a partial failure lifts the application out of recovery rather
than leaving a restore nobody drives.

The application detail read and the Blueprint summary now carry the prior
generations a rollback can select, plus the bound Blueprint's enabled state so
the surfaces can withhold execution controls.

* feat(gitops): add rollout controls to the GitOps application surfaces

The GitOps application view and the Blueprint sheet's GitOps section gain a
rollout controls row: pause and resume as first-row actions, with replan,
supersede, and rollback behind the overflow menu and each destructive choice
behind a confirmation. Rollback selects a prior generation and a target scope
(one target, the failed targets, or every changed target) and states the
configuration-versus-data boundary before anything runs.

Controls are offered only from the projection's own statuses and gated by the
permission the route requires, and execution controls are withheld while the
bound Blueprint is disabled. The superseded rollout copy now covers an
operator withdrawal as well as replacement.

* docs(gitops): document rollout lifecycle controls and rollback

Covers pausing, resuming, replanning, and superseding a rollout, the rollback
generation and scope selection with its configuration-versus-data caveat, and
the drift-enforce interaction that re-applies the current revision after a
rollback. The GitOps portfolio page names the in-place actions on a Git-managed
application, and What's New gains an entry for the lifecycle controls.

* fix(gitops): sanitize logged error messages on rollout recovery paths

CodeQL flagged log injection on the new rollout lifecycle and recovery
handlers: a remote node's response body, a recovery row, or a candidate
payload can reach the logged Error object, so the logs now record the
sanitized message string instead. No behavior change.

* fix(gitops): sanitize the logged target id on rollout recovery paths

The target node id on these paths can come from the rollback request's scope
selector, so CodeQL reads it as a user-provided value flowing into the log
entry even though it is a number. Pass it through sanitizeForLog, the same
barrier the other logged values use. No behavior change.

* fix(gitops): hold rollout dispatch while it is paused

Pause was recorded and rendered but no execution path honored it: boot-time
reconstruction re-dispatched a paused rollout, and the sequential dispatch
loop kept deploying targets the operator had paused. The adapter now blocks
while the application is paused, re-reads the pause before every target, skips
individually paused targets, and reports a hold instead of progress when
nothing was dispatched because every remaining target is paused;
reconstruction skips paused applications outright. A dispatch that pauses
during the preflight window no longer mints or supersedes the authorization it
is holding.

The continuation a target resume triggers is covered by the route test: the
queue is dispatched again and any still-paused target is skipped.

* fix(gitops): continue the queue on target resume and refuse empty pauses

A per-target resume cleared the pause but never continued the rollout, so the
target it released waited for the next restart to be retried; the route now
runs the same continuation as an application resume, and the adapter skips
targets that are still individually paused.

Pausing an application with no authorized rollout and no partial record is now
refused with ROLLOUT_PAUSE_REFUSED: recording a hold on nothing makes the
surface read as held when no rollout ever ran.

* docs(gitops): state the per-node restore limit for rollout rollback

A node keeps only the recovery generation captured immediately before its
latest rollout, so an older selectable generation has nothing to restore from
and is reported as a failed target. The rollback dialog and the feature page
now say so instead of implying every listed generation is restorable.
2026-09-23 18:52:22 -04:00
dependabot[bot] 60fcfe54d3 chore(deps): bump the all-actions group across 1 directory with 4 updates (#1964)
Bumps the all-actions group with 4 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/autobuild](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.38.0 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

Updates `github/codeql-action/autobuild` from 4.38.0 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

Updates `github/codeql-action/analyze` from 4.38.0 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

Updates `github/codeql-action/upload-sarif` from 4.38.0 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/b96794f015dfd88f77b49b1c93e0fa7110f94c63...1c5b675653bb5c22dbe9b12b556ec555138e09fd)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-actions
- dependency-name: github/codeql-action/autobuild
  dependency-version: 4.38.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-actions
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.38.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 18:27:01 -04:00
dependabot[bot] 38959e1ea8 chore(deps-dev): bump the all-npm-root group with 2 updates (#1961)
Bumps the all-npm-root group with 2 updates: [@commitlint/cli](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/cli) and [@commitlint/config-conventional](https://github.com/conventional-changelog/commitlint/tree/HEAD/@commitlint/config-conventional).


Updates `@commitlint/cli` from 21.2.2 to 21.2.3
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/cli/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.2.3/@commitlint/cli)

Updates `@commitlint/config-conventional` from 21.2.2 to 21.2.3
- [Release notes](https://github.com/conventional-changelog/commitlint/releases)
- [Changelog](https://github.com/conventional-changelog/commitlint/blob/master/@commitlint/config-conventional/CHANGELOG.md)
- [Commits](https://github.com/conventional-changelog/commitlint/commits/v21.2.3/@commitlint/config-conventional)

---
updated-dependencies:
- dependency-name: "@commitlint/cli"
  dependency-version: 21.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-root
- dependency-name: "@commitlint/config-conventional"
  dependency-version: 21.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-root
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 18:26:57 -04:00
dependabot[bot] e5d256f8da chore(deps): bump the all-npm-backend group in /backend with 7 updates (#1963)
Bumps the all-npm-backend group in /backend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [ldapts](https://github.com/ldapts/ldapts) | `9.0.0` | `9.2.0` |
| [systeminformation](https://github.com/sebhildebrandt/systeminformation) | `5.33.10` | `5.33.13` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.5.1` | `26.6.2` |
| [eslint](https://github.com/eslint/eslint) | `10.10.0` | `10.11.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.0` | `5.0.1` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1132.0` | `3.1136.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1132.0` | `3.1136.0` |


Updates `ldapts` from 9.0.0 to 9.2.0
- [Release notes](https://github.com/ldapts/ldapts/releases)
- [Changelog](https://github.com/ldapts/ldapts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ldapts/ldapts/compare/v9.0.0...v9.2.0)

Updates `systeminformation` from 5.33.10 to 5.33.13
- [Release notes](https://github.com/sebhildebrandt/systeminformation/releases)
- [Changelog](https://github.com/sebhildebrandt/systeminformation/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sebhildebrandt/systeminformation/compare/v5.33.10...v5.33.13)

Updates `@types/node` from 26.5.1 to 26.6.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 10.10.0 to 10.11.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.10.0...v10.11.0)

Updates `vitest` from 5.0.0 to 5.0.1
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.1/packages/vitest)

Updates `@aws-sdk/client-ecr` from 3.1132.0 to 3.1136.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1136.0/clients/client-ecr)

Updates `@aws-sdk/client-s3` from 3.1132.0 to 3.1136.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1136.0/clients/client-s3)

---
updated-dependencies:
- dependency-name: ldapts
  dependency-version: 9.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: systeminformation
  dependency-version: 5.33.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@types/node"
  dependency-version: 26.6.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: eslint
  dependency-version: 10.11.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: vitest
  dependency-version: 5.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1136.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1136.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all-npm-backend
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-23 13:20:34 -04:00
Anso 7767fd6f93 fix(gitops): name the unresolved artifact identity and drop the stale source report (#1960)
The operator source-accept route seeds an unresolved artifact set and no
producer resolves one for a Git-managed Blueprint, so authorization refused
with a registry-worded message that named the wrong cause. Refuse before
evaluating registry readiness and report the artifact identity, on both the
route and the placement facet, through one shared predicate.

Placement also reported source acceptance pending from a rollout candidate
bound to the previous authorized generation, which no generation is waiting
on. Only a live staged generation counts as that decision now.

Source acceptance stays available while a Blueprint is disabled, matching the
backend, and the dispatch-failure note no longer points at a surface without a
retry control.
2026-09-23 05:18:58 +00:00
Anso b27fbeeb46 feat(gitops): add decomposed approval actions for Git-managed Blueprints (#1959)
* feat(gitops): add decomposed approval actions for Git-managed Blueprints

Source acceptance, placement approval, and rollout authorization are now
writable as separate operator actions under /api/gitops/applications/:id.
Placement approval re-runs the Blueprint preview and binds the reviewed plan
(409 with a fresh preview on drift); rollout authorization evaluates registry
preflight, records operator authority, and dispatches the accepted generation
through the shared boundary. The placement facet reports a Git-managed
candidate with no placement approval as placement review pending, and a
candidate newer than the accepted generation as source acceptance pending.

* feat(gitops): surface the outstanding authority step on the app view and sheet

The GitOps application view and the Blueprint sheet's GitOps section now offer
the outstanding authority step as one action: Accept source, Review and approve
placement, or Authorize rollout. Placement review opens the rollout preview,
which records the placement approval for a Git-managed Blueprint instead of an
inline apply, so the composed source and artifact evidence is reviewed there.

* docs(gitops): document the decomposed approval actions

The Blueprint page describes where each step appears, what placement approval
binds, and how a blocked preflight or a refused target start reads; the GitOps
portfolio page notes the action in the application view.
2026-09-23 04:26:23 +00:00
Anso a881e425f8 feat(gitops): compose the authority evidence into the Blueprint rollout preview (#1957)
The Blueprint rollout preview now embeds the canonical GitOps revision
projection and binds the confirmation to a digest of the authority evidence
it displays: the recorded approval refs, the rollout generation, and the
source, artifact, placement and rollout facet identities. Apply refuses with
PREVIEW_STALE unless the intent fingerprint and the evidence digest both
still match, so an acceptance, artifact identity, placement approval,
preflight result or rollout authorization that moved under the preview
cannot execute.

The dialog renders the composed evidence through the same chips, cards and
per-target rendering the GitOps application view uses, keeps the legacy
combined approval separate from the decomposed steps, and refreshes or
closes on a stale refusal instead of re-arming a preview the server already
rejected. Volatile observations (timestamps, retry counters, per-target
runtime and health readings) are excluded from the digest so a steady-state
application does not read as permanently stale.
2026-09-23 03:11:28 +00:00
Anso 4453ca552e fix(e2e): ride out the shared API limiter in the sidebar truncation spec (#1958)
The spec shares the app's per-minute API rate limiter with every other spec
in the suite, so a late-run burst can answer 429. Its setup treated that as a
hard failure, and a rate-limited bootstrap leaves the shell without its data
and does not recover on its own, so the retries then failed waiting for the
dashboard.

Retry the login and dashboard readiness, the stack creates (only a 429 is
retried; any other status still fails on the first answer) and the post-setup
reload, waiting out the window between attempts. The describe gets a longer
test budget so a saturated minute cannot time the retries out. The app's
rate-limit behavior is unchanged.
2026-09-23 02:30:01 +00:00
Anso 46937e7db1 feat(gitops): open each portfolio application in a read-only application view (#1956)
* test(gitops): cover every pending approval trigger and guard liveSourceFacet

Parametrize the approval chip tests over each pending and not-applicable
trigger status, and give liveSourceFacet the same missing-facets guard its
sibling facet readers already have.

* feat(gitops): open each portfolio application in a read-only application view

Portfolio rows now drill into a single-application view inside the GitOps
workplace, read from the hub-owned application endpoint: identity, attention
reasons, decomposed authority chips, the source, artifact, placement and
rollout facets, every target, classified drift, and caveats. The view hands
off to the owning surface (stack Git panel or Blueprint deployments) for any
action. The open application lives in a query parameter the GitOps view
owns, so the view is linkable and Back returns to the filtered list. The
phone screen reuses the same view.

* test(gitops): cover the application view, its data hook, and navigation

Also keeps a facet or target whose status this build does not recognize
visible as an explicit unrecognized state in the application view, instead
of dropping the card, and adds first render coverage for the placement and
rollout badge arms.

* fix(gitops): classify application read failures precisely and keep the view linkable

A node too old to answer, a malformed link, and an unreadable application
now read as three different states; retry stays available where a later
answer can differ. A successful answer the view cannot read is reported
instead of rendering blank, unknown target modes, postures and artifact
kinds render as unrecognized, and a failed refresh keeps a retry beside the
last-known banner. A search debounce that fires after a row was opened no
longer replaces the application URL. Documents the application view.

* fix(gitops): return to the portfolio from GitOps navigation and keep phone shell actions

Selecting GitOps in the navigation, or following a contextual GitOps
indicator, while an application view is open now returns to the list
instead of doing nothing. The phone application view keeps the shell
actions (notifications, more menu) in its top bar.

* refactor(gitops): tidy the application view navigation and evidence line

Narrows two hand-off helpers to module scope, reads the application id with
a plain URLSearchParams, folds a duplicated window check, and moves the
partial-evidence sentence into a small helper.

* test(gitops): pin the liveSourceFacet guard and the view's unrecognized and error states

* docs(gitops): show the application view on the portfolio page
2026-09-22 18:36:09 -04:00
Anso 85f5246445 feat(gitops): add the GitOps portfolio workplace (#1954)
* feat(gitops): add the hub-owned GitOps portfolio read API

Adds the canonical aggregate read surface behind the GitOps workplace:

- GET /api/gitops/applications: one bounded response covering Direct
  applications from the hub and every reachable node, plus hub-owned
  Blueprint applications. Remote legs probe each node's git-sources with
  a bounded timeout, rewrite node identity into hub numbering, and
  re-authorize every row against the calling user; unreachable or
  unsupported nodes become named coverage entries rather than vanishing
  rows.
- GET /api/gitops/applications/🆔 the row plus the canonical
  projection, the shared single-application contract (remote ids resolve
  through a single-node probe; legacy ids resolve by stack name).
- Server-owned attention classification (services/gitops/attention.ts):
  a closed reason union with failure/pending tone, computed once from
  canonical facets, so no client re-derives "needs attention".
- Per-application posture (failed/attention/in_progress/converged/
  converged_qualified/unknown) from the same facets; qualified
  convergence never claims exactness, and an unrecognized status from a
  newer node is reported as unknown evidence instead of being
  reinterpreted.
- The surface is Community and hub-only: the prefix is listed in
  HUB_ONLY_PREFIXES and node names are withheld from callers without
  node:read.

* feat(gitops): add the GitOps workplace view and phone screen

A first-class GitOps destination next to Fleet (launcher, command
palette, direct URL /nodes/<hub>/gitops, hub-only via HUB_ONLY_VIEWS):

- Portfolio masthead: verdict word, application/attention/drifted/
  pending/converged counts, and an honesty line that names unreachable
  or too-old nodes and says when a failed refresh left last-known data
  on screen.
- Attention queue: one entry per canonical reason with the explanation
  inline, failures sorted ahead of pending decisions, independent of the
  list filters so narrowing the table cannot hide an exception.
- Application table with server-side search, filters (attention, target
  mode, node involvement, drift class, evidence), cursor pagination, and
  drill-down hand-offs: Direct rows open the stack's Git source panel on
  the owning node, Blueprint rows land on Fleet deployments.
- Bespoke phone screen on the shared data hook: masthead-led, chip
  filters, inline attention reasons, >=44px targets.
- Refresh is event-driven off the gitops invalidate channel (debounced),
  with URL-synced filters that survive reloads; the router no longer
  strips query parameters owned by the view.
- Dashboard badges and the sidebar pending indicator link into the
  workplace through one shared navigate helper.

* docs(gitops): document the portfolio workplace

Adds the operator page for the GitOps portfolio (what the page answers,
how to read the masthead and queue, filters, freshness honesty) with a
screenshot, and registers it in the docs navigation.

* fix(preferences): accept the gitops destination as a quick-link id

The GitOps nav entry is quick-link eligible, but the backend enum that
validates saved quick-link preferences still listed only the previous
ids, so the preference-parity pretest rejected the value-set drift.

* chore(gitops): drop imports orphaned by the review pass

Three dead imports left behind when the aggregator and route code moved
during review: an unused test import, and two unused type imports.

* refactor(gitops): rebuild the workplace on the shared page patterns

The portfolio page was visually drifting from the rest of the app. It now
uses the same building blocks as the Dashboard, Fleet, and Security pages:

- Masthead rebuilt on the Dashboard/Fleet recipe: beveled card with the
  accent tint wash, a rail that shimmers while the data is live, the state
  word with a mono meta line, stat tiles (applications, pending, drifted,
  converged with a qualified sub-line), the attention count in the alerts
  slot, and a live ticker so "updated Xs ago" keeps moving. A failed
  refresh reuses the Dashboard's stale chip.
- Filter row matches the Security Images and Fleet tabs: the collapsible
  search affordance (square outline button expanding into a focused input)
  plus flat combobox filters on one wrapping row, replacing the bespoke
  chip set.
- Table rebuilt on the shared table shell: beveled card, ScrollArea so the
  scrolling stays inside the table, tracked-mono header cells, hover-tinted
  rows with the application name as the drill-down target, and the
  pagination footer in the standard ghost-button style.
- The e2e spec drives the new control shapes.

* test(nav): poll for the settled Navigate panel size

The panel-size assertion read scrollHeight/clientHeight once, immediately
after the panel opened. The popper's available-height variable is applied
after opening, so a single read can observe the pre-positioning clamp and
report a settled panel as clipped; on CI this failed intermittently while
the same check passes consistently once the layout settles. Poll until
the metrics stabilize, which is what the assertion actually means.
2026-09-22 15:52:05 -04:00
Anso 6345e695c4 fix(ui): wrap modal titles on word boundaries (#1950)
Modal headers split long titles at arbitrary character boundaries,
rendering fragments like "Prune Sencho-manage" / "d images". HeaderShell,
which backs all four modal header variants, applied break-all (breaks
between any two characters) plus line-clamp-3 (could hide
destructive-confirm context).

Replace break-all with break-words (normal words wrap only on word
boundaries; a long unbroken token still breaks as a last resort, so no
horizontal overflow) and add text-balance (multi-line titles wrap into
balanced lines, eliminating the orphaned single-letter second line).
Remove line-clamp-3: titles are short enough that clamping only risked
hiding context, and the dialog container caps at max-h-[85dvh] with a
scrolling body, so full titles cannot push actions off-screen.

Covered by class-contract tests across all four header variants and a
Playwright spec asserting computed wrapping styles, dialog containment,
and balanced line wrapping at the confirm-dialog geometry.
2026-09-22 14:37:21 -04:00
Anso 5981355c27 feat(gitops): render decomposed approval authority and placement/rollout state (#1955)
Add placement and rollout facet state maps with lookup views for
wire statuses from newer nodes, a presentational approval-chips
component that reads granted steps only from recorded refs and pending
steps only from facet statuses, and render-only wiring in the blueprint
detail, git source, and drift panels. Preflight-blocked placements now
show the redacted server reason on all three surfaces. No endpoints,
no mutations, no approval-flow changes.
2026-09-22 13:15:22 -04:00
Anso 489e1a9a9a feat(blueprints): detect and enforce runtime digest drift (#1949)
* feat(blueprints): detect and enforce runtime digest drift

Compare frozen expected artifact identity to live container digests so
tag movement surfaces as drift, and Enforce repairs with digest pins
(--pull never) without rewriting authored compose. Unreachable or
incomplete observation stays unverified and never auto-corrects.

* fix(security): keep digest overlay in tmpdir and sanitize observation logs

Write Compose digest-pin overlays under os.tmpdir() instead of the stack
directory so path expressions are not stack-name tainted, and log runtime
observation failures with a fixed format string plus sanitized stack name.

* fix(security): create digest overlay via exclusive mkdtemp

Avoid CodeQL insecure-temporary-file by writing the Compose digest-pin
overlay into an exclusive mkdtemp directory with wx create, then removing
the whole directory after deploy.

* fix: compare Blueprint digest drift by per-target platform identity

Enforce now routes container repair separately from digest pins, keeps
compose.override.yml on overlay deploys, and fails closed when the
target platform is unknown.

* test: expect platformVariants on single-platform digest resolve

* test: drop unused seedApp binding in digest-repair suite

* fix: drop redundant platformLabel nullish coalesce after guard

* fix(blueprints): accept override-only services in apply-local pin guard

* fix(blueprints): freeze and repair remote digests from the leaf

Hub digest freeze and Enforce repair now fetch the target node's rendered
Compose model and Docker platform over the authenticated proxy, so a remote
compose_dir string is never treated as a hub-local path. apply-local pin
validation runs after write against the rendered multi-file model.
2026-09-20 20:26:45 -04:00
Anso 2baf4e32ee fix(notifications): redact webhook URLs on channel reads (#1951)
Discord, Slack, generic webhook, and ntfy destinations embed tokens in
the URL. Return origin-only placeholders on list responses so any
authenticated reader cannot reuse those credentials outside Sencho.
2026-09-20 12:54:21 -04:00
Anso 23baec2a7d fix(gitops): prune provider webhook endpoints when a git source or stack is removed (#1940)
* fix(gitops): prune provider webhook endpoints when a git source or stack is removed

Detaching a git source, deleting its stack, or deleting a single webhook
endpoint left the git_provider_endpoints rows (which hold encrypted
secrets) and their git_provider_deliveries history behind as orphans.
FK enforcement is off, so the schema's ON DELETE CASCADE never fires;
delete the deliveries explicitly, then the endpoints, transactionally,
at all three removal points.

* test(gitops): assert provider-hook rotate does not prune endpoints

Pins the rotate path as update-only so a future prune regression cannot
quietly delete endpoints or deliveries when rotating a webhook secret.
2026-09-19 21:49:58 -04:00
Anso 2fb2c9116c feat(gitops): block Blueprint place until private registry targets are ready (#1946)
* feat(gitops): block Blueprint place until private registry targets are ready

Evaluate per-target registry readiness before minting rollout authorization,
persist blocked and ready evidence for derive, remint on fingerprint drift,
and refuse Direct deploy when private hosts cannot be proven ready.

* fix(gitops): let Direct apply tests override registry preflight

Unresolved artifact fixtures were failing closed before Compose in
git-source-service tests. Move readiness test hooks onto the evaluator
module and override evaluation for Direct suites that are not about
registry readiness.

* fix(gitops): stub registry preflight on apply-recovery mocks

The Direct apply recovery suite mocks GitOpsTransitions without the new
preflight methods, so deploy-after-apply crashed before Compose. Stub the
transitions and override the evaluator for non-registry fixtures.

* fix(gitops): clear code-quality findings on preflight serialization

Use a token for inflight evaluation cleanup instead of comparing promises, and drop a redundant summary guard already narrowed by the Blueprint detail branch.

* fix(gitops): distinguish unevaluated registry preflight from a failed probe

Surface a dedicated reason when placement has no stored evidence yet, document the application-scoped inbound envelope host set, and note the first-run vs failed-evaluation distinction in operator docs.
2026-09-19 20:54:55 -04:00