Commit Graph

9298 Commits

Author SHA1 Message Date
rcourtman 3088f52dc0 feat(patrol): redesign the operator workbench 2026-08-17 23:41:03 +01:00
rcourtman 3f32148b50 Stop holding the canonical fetch behind the websocket hydration wait
Websocket-first routes delayed their REST fetch by 1.2s to give the
realtime snapshot a chance to paint first and save a request. That
trade only exists for `prefer-ws` routes. Every current platform
surface is `prefer-ws-then-rest`, which always follows its websocket
paint with a REST revalidation, so the wait never saved the request.
It only postponed the canonical snapshot, and left the route blank
whenever the websocket snapshot was slower than the wait.

That failure mode scales the wrong way: the larger the estate, the
bigger the initial websocket frame, the more likely the wait is lost
outright, and the more the blank screen costs. On the public demo the
websocket snapshot is 1.9MB and lands ~2.5s after mount, so the wait
was pure dead time on every cold load.

Routes that revalidate over REST now race the two transports and paint
from whichever arrives first. `prefer-ws` routes keep the wait, which
is where it earns its keep.

Measured on a production bundle against the demo estate with the
realtime transport unavailable, so the canonical fetch is the only path
to paint: bootstrap-to-fetch gap 1720-1882ms to 519-613ms, first paint
5083-6384ms to 3349-4860ms.

Covered by a regression test asserting the fetch is issued without the
hydration timer advancing, plus one pinning that `prefer-ws` still
waits.

Contract-Neutral: Scheduling-only change to initial hydration: identical endpoints, params and payloads; unified-resources contract documents no initial-fetch timing clause
2026-08-17 22:59:04 +01:00
rcourtman 8e32afa185 Fetch unified resource pages concurrently after the first
fetchUnifiedResources walked its pages nose-to-tail, awaiting each
response before requesting the next. Only the first page is actually
needed to learn how many there are; the rest are independent, so the
walk spent a full network round trip per page before the first
canonical snapshot could land and the page could paint.

On the public demo (209 resources, 3 pages at limit=100) the serial
walk measured 1672-2317ms of the roughly 6s cold-load blank screen.
Fanning the remaining pages out collapses that to one round trip plus
the slowest straggler, measured at 1217-1246ms against the same
backend, with pages 2 and 3 leaving in the same tick.

The outer loop still re-reads totalPages from the fetched pages, so an
estate that grows a page mid-flight is picked up exactly as before.

Covered by a regression test that holds pages 2 and 3 unresolved and
asserts both were requested while the first was still outstanding; it
fails against the serial implementation.

Contract-Neutral: Concurrency-only change to unified resource pagination: identical endpoints, params, payloads and assembled result; unified-resources contract documents no page-walk scheduling clause
2026-08-17 22:42:26 +01:00
rcourtman 2c59fb742e Prepare v6.3.0-rc.1 release 2026-08-17 22:04:54 +01:00
rcourtman ba90475e59 Fix subscription agent timeout cleanup 2026-08-17 21:34:30 +01:00
rcourtman b1fc6c7dca Prepare v6.2.2-rc.3 release 2026-08-17 20:57:22 +01:00
rcourtman 6ab45a900c Reject contradicted Patrol causal proposals 2026-08-17 20:02:48 +01:00
rcourtman da8537b8e6 Reject incoherent Patrol finding identities 2026-08-17 20:02:47 +01:00
rcourtman a83e15176a Bound app-container Patrol scope to selected workloads 2026-08-17 20:02:47 +01:00
rcourtman 9f23d40c43 Canonicalize container health finding vocabulary 2026-08-17 20:02:47 +01:00
rcourtman 21e0b40363 Preserve Patrol controls during full-mode activation
Contract-Neutral: Patrol autonomy behavior is documented in its primary ai-runtime and shared api-contracts contracts plus the relevant agent-lifecycle boundary; storage-recovery only has a broad internal/api extension reference and does not own this wire contract
2026-08-17 20:02:47 +01:00
rcourtman 59b0ee188b Repair scoped Patrol finding identity safely 2026-08-17 20:02:47 +01:00
rcourtman 39ceeda604 Scope Patrol health queries to resources 2026-08-17 20:02:47 +01:00
rcourtman 31f89bb430 Canonicalize app container health check findings 2026-08-17 20:02:47 +01:00
rcourtman 5eae262363 Canonicalize unhealthy container finding keys 2026-08-17 20:02:47 +01:00
rcourtman 7422da843a Enable Patrol qualification on fresh installs 2026-08-17 20:02:47 +01:00
rcourtman 8dc771f09b Keep default monitor token inventory current 2026-08-17 20:02:47 +01:00
rcourtman f6aa8db93a Bound Patrol subscription continuation latency 2026-08-17 20:02:47 +01:00
rcourtman 37d6e29e84 Preserve partial-response byte ranges 2026-08-17 20:02:47 +01:00
rcourtman 91737ee207 Canonicalize Patrol health finding keys 2026-08-17 20:02:47 +01:00
rcourtman 5821eafb05 fix(api): log when the agent command config gate suppresses remote commands
The gate that mirrors command-channel admission
(commandConfigAllowedForToken) runs on every agent report, before the
agent ever attempts channel registration. When it refuses, the server
silently serves commandsEnabled=false, the agent never tries to
register, so none of the channel rejection warnings fire, and an
operator who enabled lifecycle management and granted agent:exec sees
"Remote commands: Not enabled" with nothing in any log. Emit the
refusal and the binding metadata it was judged against so the mismatch
is diagnosable from journalctl.

Refs #1728

Contract-Neutral: diagnostic logging only in agent command config gate, no behavioral or public contract change
2026-08-17 06:36:52 +01:00
rcourtman b53d96ee54 fix(unifiedresources): derive canonical identity on stored registry resources
Read paths refresh canonical identity on every clone, so views always
showed the derived aliases, but the stored registry entries never carried
them: snapshot ingest built resources without the canonical block and only
the IngestResources seed path stored refreshed clones. GetByReference's
canonical-alias fallback reads the stored entries directly, so prefixed
refs like "agent:{hostID}" never resolved on a production rebuild and
per-resource alert grace overrides on merged PVE-node + pulse-agent hosts
silently fell back to factory policy. The earlier alias fix (dd008f1c2)
only proved the IngestResources-seeded path. Recompute identities in each
ingest finalize pass, after links and merges settle.

Proof: TestMergedProxmoxHostAgentGraceOverrideHoldsCPUAlert drives the
real populate path (node plus linked agent merging into one resource),
saves a 600s metric.cpu override under the registry resource ID the UI
uses, and asserts CheckHost holds the alert while a control manager
without the override fires immediately.

Refs #1497

Contract-Neutral: behavioral fix #1497: populate derived canonical identity on stored registry entries, clones already exposed identical data, no public contract delta
2026-08-17 06:27:38 +01:00
rcourtman 29ad80870d Preserve per-provider semantics on batch metric misses
A second adversarial review round surfaced two robustness gaps. The
incident recorder treated any ID absent from a metrics batch as an
empty successful sample, which silently rewrites the semantics of a
provider whose per-ID method errors or synthesizes for unknown IDs;
batch misses now fall through to the per-ID call, keeping the batch
for the hot path only. And one bySource mutation site, the PBS host
disk association helper, predated the source-target cache invalidation
invariant; it now invalidates like every other mutation path. The gap
was unobservable today because the adjacent ingest already invalidated,
but the invariant is only useful if it is total.

Contract-Neutral: robustness fixes inside existing cache and sampling semantics, no resolved values change
2026-08-16 23:03:12 +01:00
rcourtman 2fc47df47c Invalidate the source-target index on every mapping mutation
The cached inverse source-target index assumed a clean viewsDirty flag
meant current mappings, but batch ingests release the registry lock
between records and only mark views dirty in their epilogue. A reader
interleaving mid-batch was served pre-batch metrics targets from the
cache, where the pre-index code always scanned live state. Every
bySource mutation path now drops the cache inside its critical
section, sending mid-batch readers to the live scan until the next
view rebuild, which restores the exact pre-index freshness. A test
pins the invalidation to the mutation itself rather than the epilogue
flag.

Contract-Neutral: staleness fix inside the cache lifecycle, resolved values and selection semantics unchanged
2026-08-16 22:41:05 +01:00
rcourtman db4ef39d87 Warm registry view caches before resolving mock metrics targets
The mock unified snapshot resolved a metrics target per resource on a
freshly ingested registry whose view caches were still dirty, so every
resolution fell back to a scan over all source mappings, turning the
loop quadratic at demo scale. One view read builds the caches first,
making each resolution an index lookup.

Contract-Neutral: performance-only cache warm before target resolution, resolved targets unchanged
2026-08-16 22:01:55 +01:00
rcourtman d18fcf55ad Bucket agent candidates for the child-count parent pass
The derived-parent recompute in buildChildCounts may hit the agent
fallback scan once per guest, and that scan walked every resource with
per-entry string checks, an O(n^2) pass per registry ingest. The pass
now buckets agent resources by node name up front and the fallback
walks only the matching bucket. Selection is order-independent (best
score with smallest-ID tie-break) and scores read live through shared
pointers, so results are unchanged; callers outside the pass keep the
full scan.

Contract-Neutral: performance-only scan bucketing in the parent recompute pass, selection semantics unchanged
2026-08-16 22:01:55 +01:00
rcourtman 81de801e58 Batch incident recorder metrics lookups
The incident recorder asks for current metrics for every monitored
resource every five seconds, and the adapter answered each request by
scanning all views with a formatted VMID comparison per candidate. At
thousands of resources that is millions of comparisons and allocations
per tick, forever. The adapter now exposes a batch method that builds
the full lookup in one pass over the views, keyed by every ID form the
per-ID method matches with the same first-match precedence, and the
recorder uses it when the provider offers it. Per-ID behavior is pinned
to the batch by an equivalence test, including VMID collisions.

Contract-Neutral: performance-only batch lookup addition, per-ID metrics resolution is pinned unchanged by equivalence tests
2026-08-16 21:48:31 +01:00
rcourtman 7f444fc29a Prune the top-level system hostname fallback candidate scan
The fallback attachment pass compared every group against every other
group with pairwise hostname normalization, allocating sorted root and
host sets per call. Resolving top-level systems runs inside the state
request path, so large estates paid a quadratic normalization storm per
request. Hostname equivalence requires the two names to share either a
comparable form or a short form, so an index of groups by those forms
prunes each group's candidates to the provable superset and the
original pairwise matcher still decides every surviving pair. Attachment
decisions are unchanged.

Contract-Neutral: performance-only candidate pruning, the original matcher still decides every attachment
2026-08-16 21:48:31 +01:00
rcourtman fc50f61cdb Index source targets instead of scanning every mapping per resource
Rebuilding the registry view caches resolved each resource's metrics
target by scanning every bySource mapping, an O(resources x mappings)
pass holding the write lock. At about five thousand resources that is
tens of millions of map iterations per rebuild, and rebuilds run on
every poll cycle. The rebuild now inverts the mappings once and the
cached index also serves single-resource lookups while the views are
clean, with the legacy scan kept for dirty-state callers. An
equivalence test pins index results to the legacy scan.

The agent-parent fallback scan also ran its per-entry string
canonicalization before the type check, so every guest in the estate
paid allocation cost in a scan that only ever matches agents. The
cheap checks now run first.

Contract-Neutral: performance-only rework of metrics-target resolution and scan ordering, resolved values are pinned unchanged by equivalence tests
2026-08-16 21:48:31 +01:00
rcourtman 90081dacb4 Handle informational and bodyless gzip responses 2026-08-16 17:24:01 +01:00
rcourtman 2e4e85d9af docs: add focused security review packet 2026-08-16 17:13:02 +01:00
rcourtman a682af0b7b Harden large-estate response and polling limits 2026-08-16 17:07:59 +01:00
rcourtman d08ca46194 Expose Docker health-check dependencies to Patrol 2026-08-16 16:58:44 +01:00
rcourtman 25368abcdb Merge remote-tracking branch 'origin/main' 2026-08-16 16:52:08 +01:00
rcourtman bbd67e0ae0 docs: clarify security review scope 2026-08-16 16:28:19 +01:00
rcourtman c4b736850e Merge origin/main into main
Contract-Neutral: merge reviewed origin/main changes without altering their contracts
2026-08-16 16:02:38 +01:00
rcourtman 40897a46e6 Preserve causal evidence in Patrol conclusions 2026-08-16 15:48:03 +01:00
rcourtman ab34168731 Disambiguate Docker service evidence for Patrol 2026-08-16 15:26:12 +01:00
rcourtman 71347eac4c Decide gzip eligibility before a pre-write flush commits headers
A handler that flushes before its first write commits the response
headers with an implicit 200. The gzip wrapper deferred its
compress-or-not decision to the first write, so such a handler would
send headers without Content-Encoding and then a compressed body,
which clients cannot decode. Every current flusher is an SSE handler
whose content type never compresses, so this was latent, but any
future streaming JSON endpoint would have corrupted silently. Flush
now runs the decision first, matching net/http header-commit
semantics.
2026-08-16 14:08:55 +01:00
rcourtman 3fe1f7f181 Quiet the broadcast ticker and allow larger poll worker pools
The broadcast ticker logged seven fields at Info level every tick and
built a full state snapshot to do it, before checking whether any
WebSocket subscriber existed. An idle server paid a snapshot copy plus
roughly 8600 log lines a day for nothing. The log is now Debug and both
the log and the snapshot build sit behind the subscriber check.

The poll task worker pool was also fixed at ten workers no matter how
many instances an estate monitors. POLL_TASK_WORKERS now overrides the
count and the cap, bounded at 128, following the existing env knob
pattern. Behavior without the variable is unchanged.

Contract-Neutral: operational log demotion and an opt-in worker pool env knob, no monitoring or agent-lifecycle contract semantics change
2026-08-16 13:24:08 +01:00
rcourtman 17ccb8240d Preserve causal evidence before Patrol proposals 2026-08-16 11:45:24 +01:00
rcourtman 64db84a7e3 Clarify governed restart proposal policy 2026-08-16 11:32:36 +01:00
rcourtman 870e2f7f97 Rewire Patrol investigation after chat restart
Contract-Neutral: AI chat lifecycle rewiring does not change dependent agent, performance, security, or storage contracts
2026-08-16 11:15:16 +01:00
rcourtman 4195943300 Harden Patrol investigation evidence traversal 2026-08-16 10:56:34 +01:00
rcourtman 85db074eff copy: simplify security guidance punctuation
Contract-Neutral: Punctuation-only copy revision with no lifecycle behavior or contract change
2026-08-16 10:36:54 +01:00
rcourtman 20cd89dcc2 Raise the client WebSocket frame ceiling to 32 MiB
The browser advertises max_message_bytes on the upgrade request and the
server withholds any state frame larger than that, falling back to REST
recovery. The advertised value was still the historical 8 MiB, which
pushed estates past roughly 3100 resources off the cheap socket delta
path and onto a 30 second full-state REST poll loop. 32 MiB keeps
estates about four times that size on socket deltas. The server honors
whatever the client advertises, so no backend change is needed.


Contract-Neutral: transport frame-limit constant, no alert lifecycle semantics change
2026-08-16 10:28:30 +01:00
rcourtman 205df735c7 Compress API responses for clients that accept gzip
Large estates that exceed the WebSocket frame ceiling recover over an
uncompressed /api/state poll loop, which made the biggest deployments
pay the heaviest transfer cost. Wrap the main HTTP handler in a gzip
middleware that compresses JSON and other text responses roughly an
order of magnitude, while leaving WebSocket upgrades, SSE streams,
Range requests, small declared bodies, and binary content untouched.


Contract-Neutral: transport-only gzip response middleware, no RBAC or persistence semantics change
2026-08-16 10:28:08 +01:00
rcourtman 787a191a5e docs: publish security review evidence 2026-08-16 10:23:15 +01:00
rcourtman effcf2d50c Harden Patrol qualification lifecycle 2026-08-16 10:14:16 +01:00
rcourtman 88c380b514 docs: add production security deployment guide 2026-08-16 09:21:36 +01:00