This fixes#2282. The update check read GitHub's whole release list into
memory behind a 1 MiB bound, but that list is around 9.6 MB now because
every 6.4.x release carries 349 assets. So the main path always failed
and fell through to the Atom feed, which only sees the last three or so
real releases. On main the stable channel check was failing outright,
and 6.4.5 stable would have shipped relying on the feed.
The list and the single release notes response are now decoded as a
token stream. Only the tag, name, body, draft and prerelease flags,
publish time and the Pulse linux server archives are kept, so memory
follows what the updater actually uses rather than the size of the
response. That lets the byte bound go up to 64 MiB and it still returns
the typed error, so the feed stays as a last resort for that case only
and a custom update server is never swapped for github.com. Malformed
metadata is still a hard error.
I ran it against live GitHub and the stable channel resolves v6.4.1 and
the preview channel resolves v6.4.5-rc.3 through the normal path without
touching the feed. The new tests in internal/updates build a
multi-megabyte release list shaped like the real one and cover null
fields, trailing data and the byte bound. The deployment-installability
contract is updated to say the feed can't be the primary source.
GitHub's release list is now ~9.6 MB because every 6.4.x release embeds
349 assets (~600 KB of JSON each). The update check buffered the whole
response behind a 1 MiB bound, so the primary path always failed and fell
through to the Atom feed. That feed holds ten entries and each release now
spends three of them, so stable-channel checks on main failed outright
("no suitable release found for channel stable") and 6.4.5 stable would
have shipped relying on it.
Decode the release list and the single-release notes response as a token
stream, keeping only tag, name, body, draft, prerelease, publish time and
the Pulse linux server archives. Memory now tracks what the updater keeps,
so the byte bound rises to 64 MiB while staying typed; the Atom feed stays
as the last resort for that over-limit condition only.
Verified against live GitHub: stable resolves v6.4.1 and rc resolves
v6.4.5-rc.3 through the primary path with no feed fallback.
The monitor restored notification destinations after constructing a queue whose five-second ticker could consume due jobs. Defer processor installation through destination restore, then wake the backlog before external alert callbacks admit first agent reports. Cover both forced pre-configuration processing and the actual monitor restart path; update the notifications, monitoring and agent-lifecycle contracts.
Change-source: pulse-maintainer
SetProcessor wakes the live queue worker while this test processes a fetched row manually. Defer the row beyond GetPending before installing the processor so a competing worker cannot decide the synchronous assertion.
Change-source: pulse-maintainer
## What and why
Keep Vitest and its V8 coverage provider on matching major versions.
Dependabot's isolated @vitest/coverage-v8 5 proposal (#2277) cannot
install alongside Vitest 4; defer only major updates for both packages
until the pair can be migrated and verified together. Minor and patch
updates, including security updates, remain eligible.
## Included source
- Pulse main:
`6a9db329a7ee88fd890c303812beb9da18b9d178..c0cfa8ef7e00650aade0c35e9b5bf3b717384e07`
(one reviewed commit).
## Validation
- Focused Dependabot configuration tests: `python3 -m unittest
scripts.tests.test_dependabot_config` (7 passed).
- Publication metadata and ancestry checked against current main.
Exact-head hosted checks remain required before merge.
[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
The isolated coverage-v8 5 proposal cannot install beside Vitest 4 because its peer range requires Vitest 5. Ignore only version majors for both packages until a reviewed frontend migration can update and prove the pair together; retain grouped security updates and minor/patch updates.
Change-source: pulse-maintainer
## What changed and why
This batch restores reliable physical-disk wearout alert recovery
without muting other disks (Pulse #2112), shows linked guest disks,
SMART and RAID in VM details (#2263), preserves spaced SSO group
mappings (discussion #2266), and keeps Patrol attention visible when
setup is blocked (#2257). It also defers incompatible major ESLint and
lucide-solid Dependabot updates (#2190, #2191) pending explicit
migrations, while leaving minor, patch and security updates enabled.
The initial proposal's Frontend job exceeded the WorkloadsSurface bundle
budget: 73.93 kB against a 70.23-kB cap. The final additive correction
loads SMART detail and history only when a disk expands, retaining a
loading state and keyboard behavior. This addresses the measured source
defect without raising the budget.
## Reviewed source
`pulse` main:
`27036a244550f3095aa883a74716374107aaa6cd..48af6960a5cfcaa67bf6a5476d8902d9710664fa`
(12 commits). The prior proposal head
`cd43b6c692383865592c17a0853d33a229ae46d6` remains in the ancestry;
`48af6960a5cfcaa67bf6a5476d8902d9710664fa` is one additive correction.
It includes the reviewed Web guest-storage, SSO and Patrol candidates;
Core wearout and physical-disk alert-policy repairs; Dependabot policy
and tests; and the Frontend bundle correction.
## Validation and limits
Focused offline source receipts cover frontend type-check, tests, lint
and build (`source-h1hs29xj`); affected Go packages (`source-0xmvnka7`,
`source-e1_2kz49`); Dependabot policy tests (`source-18_2qa8u`); and the
corrected final head's six targeted UI tests, type-check, Prettier,
production build and bundle-size gate (`source-_y63de_a`). The final
WorkloadsSurface measures 67.46 kB against the 70.23-kB cap, with an
8.09-kB on-demand detail chunk. Browser fixtures cover desktop and phone
collapsed/loading/SMART/History, no-agent and 503 states; earlier
combined SSO/Patrol browser fixtures also passed. `git diff --check` and
provenance checks passed.
The failed GitHub Frontend job was at the prior head, run `36279247432`,
job `108509096089`; its backend, E2E, governance and security checks
passed. Fresh exact-head hosted checks and independent review are
required before merge. This PR does not establish an installed fix,
reporter acceptance or release readiness. Frozen v6.4.5-rc.3 does not
contain this main-line batch.
[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
Keep linked guest physical-disk summaries in the initial Workloads bundle while loading SMART cards and history only when a disk opens. Show a loading status, preserve keyboard and pointer behaviour, and cover desktop and narrow browser states with a same-commit verification receipt.
Change-source: pulse-maintainer
The exact Pulse main proposal failed its required frontend formatting step. Apply the repository Prettier style to the reviewed Patrol attention surface without changing behavior.
Change-source: pulse-maintainer
Keep ESLint 10 and lucide-solid 1.x out of automatic version updates while their lockfile and icon-import contracts fail. Preserve minor and patch updates and guard the rule in Dependabot policy tests.
Change-source: pulse-maintainer
Keep the persisted empty-device alert ID distinct from the root-device ID while excluding incomplete disks from alias claims. Extend the unified-resources contract and identity tests in the same commit.
Change-source: pulse-maintainer
Keep persisted PVE disk alert identities stable while indexing them as exact physical-disk aliases. A muted disk now clears and suppresses its wearout alert without suppressing neighbouring disks. Update both subsystem contracts and add alert-identity, canonical-alias and registry-backed policy proofs.
Change-source: pulse-maintainer
The SSO mapping and Patrol changes were verified together in Chromium at desktop and phone widths. Bind the receipt to all three final frontend source blobs so candidate-wide preflight checks the actual composed source.
Change-source: pulse-maintainer
Retain the focused Patrol setup task while mounting the independent attention inbox when Patrol is off or model readiness is blocked. Reconcile the existing page test and cover responsive list, decision detail, return and remount states.
Refs #2257
Change-source: pulse-maintainer
Parse group-to-role entries by comma or newline, retaining spaces in IdP group names while leaving scopes and allowed-list parsing unchanged. Cover provider edit/save/reload in model, panel, architecture and desktop/narrow browser checks.
Refs #2266
Change-source: pulse-maintainer
A single healthy-looking disk poll could resolve a worn SSD alert, so the next
low reading re-fired its occurrence and notification even after the host-agent
SMART merge correction. Feed reported wearout through the canonical reducer
with three recovery confirmations, retain the failing value during a pending
recovery, and leave absent evidence unchanged. Repeated alternating polls now
keep one active occurrence and one dispatch; sustained recovery still clears it.
Refs #2112
Change-source: pulse-maintainer
Scope physical-disk reads to the opened guest, preserve agent RAID in the drawer, and prove the desktop and narrow interaction states with the performance contract updated in this same commit.
Change-source: pulse-maintainer
A zero-star GitHub fork, which still carries the old repository
description, has been outranking Pulse for Proxmox monitoring searches.
The README headline did not say what Pulse monitors, and search engines
weight the top of the README heavily.
This changes the headline from "Infrastructure monitoring that finds
what needs attention." to "Real-time Proxmox, Docker and Kubernetes
monitoring that finds what needs attention.". It keeps the existing
promise and leads with the platforms people search for. The repository
description was updated to match.
Documentation only.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_015AHsrZFUgqmohpszCMpaPx
A zero-star fork with the old repository description outranked Pulse for
Proxmox monitoring searches. The README headline did not say what Pulse
monitors, and search engines weight the top of the README heavily. Keep the
existing promise and lead with the platforms people search for.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AHsrZFUgqmohpszCMpaPx
The first #2260 benchmark run exposed eager alias indexing during
ingest.
The revised commit passed CI Benchmarks after index construction moved
to
alias lookup. Keep the owning subsystem contract aligned with that
result.
Refs #2199
The first #2260 benchmark run exposed eager alias indexing during ingest.
The revised commit passed CI Benchmarks after index construction moved to
alias lookup. Keep the owning subsystem contract aligned with that result.
Refs #2199
Carry changed resource IDs into the Workloads projection so periodic
deltas
reuse unchanged rows. Index canonical aliases after ingest and resolve
identity-only links without cloning resources. Remove repeated group
payloads
from windowed Storage rows, and keep fleet notices and Agent Doctor
usable at
narrow widths.
Record the browser proof and remaining payload and benchmark
observations in
the owning subsystem contracts.
Refs #2199
Carry changed resource IDs into the Workloads projection so periodic deltas
reuse unchanged rows. Index canonical aliases after ingest and resolve
identity-only links without cloning resources. Remove repeated group payloads
from windowed Storage rows, and keep fleet notices and Agent Doctor usable at
narrow widths.
Record the browser proof and remaining payload and benchmark observations in
the owning subsystem contracts.
Refs #2199
Suppressed alert writes previously returned without insertion while
callers continued adding history, recording firing events and requesting
notification delivery. Make acceptance explicit across detector and
restore paths. Repeated muted, retired and maintenance observations now
stop before those side effects, while expiry and resumed monitoring
admit the condition once.
Startup now reconciles restored alerts as soon as persisted resource
policy becomes available, refreshes shared alert state and persists that
resolution. Reconciliation also runs after resource publication, when
restored native aliases first resolve to their canonical agent policy.
Previously muted or retired resources could retain alerts restored
before either boundary.
Retained alert occurrences now reopen when the reducer explicitly
re-fires. Canonical history records occurrence identity separately from
transition time, preserving one incident after checkpoint loss. Delayed
initial firing, resolution replay and historical read repair cannot
override a newer transition. Older canonical records without the
occurrence key retain their existing matching behavior.
Shared table and card renderers preserve components retained across
scrolling windows. Previously a window shift rebuilt those items,
resetting the node Manage tab and losing unsaved lifecycle edits while
scrolling to Save. Mounted regressions cover retained input identity,
text, global position and the existing row budget. Maintenance banners
now show actual local start and end times instead of formatting future
timestamps as “just now”, and describe the paused attention correctly.
The synthetic host fixtures now keep stable machine identities across
graph rebuilds, so saved policies remain attached through restart
verification. The existing stability regression now checks machine IDs
per host, Docker agent IDs and unified canonical agent IDs. Production
conflict checks for a genuinely changed machine identity are unchanged.
Generated installation and rollback guidance now identifies the
Pulse-owned update helper and directs other installations to the
versioned signed installer flow. Both signed installer examples stop on
download or signature failure and run in a fresh temporary directory.
Validation:
- Suppression regression reproduced on the original source across 15
cases, then passed with the change. Full alert package tests pass.
Extended expiry, reconciliation and restore cases pass with the race
detector.
- Signed installer recipes fail six baseline cases and pass all eight
corrected cases. Seven existing installer pinning/signature/rollback
tests pass.
- Release renderer (39 tests), promotion resolver (47 tests),
release-promotion policy (57 tests), documentation mirrors and links,
and governance audits pass. Installer policy assertions cover helper
ownership, the signed fallback, exact version pinning, rollback
applicability and post-restart version verification. The complete
repository pre-commit hook passes on the worker against the matching
staged source, including repository tooling tests, release-control
helper tests and frontend lint. The global sensitivity check passes
locally.
- Startup restore and late native-alias resolution both fail on their
baselines. Final focused proof, race proof and the full monitoring
package pass.
- Retained-occurrence refire regression reproduced in both incident
projections. The complete memory suite passes on final source, full
monitoring and resource-store suites pass, and final focused
replay/refire proof passes under the race detector.
- Shared window regression failed for both table and card renderers on
the baseline, then all nine tests passed. Related component checks
passed 88 tests across seven files. Typecheck and canonical platform
audit pass.
- Maintenance rendering reproduces both incorrect future-time cases on
the baseline. Final operator-state tests pass 22 cases, and targeted
lint and typecheck pass.
- The fixture identity regression fails on the committed generator and
passes after deterministic IDs, together with four related host-link
tests and the Docker host fixture test.
- Production frontend and backend builds pass. Playwright inspection
covers Proxmox Manage and Alerts at 390x844 and 1440x900, 900px History
controls, and shipped install/upgrade docs. Saved maintenance and mute
survive a graceful same-data restart with stable canonical identity.
Cancelling maintenance preserves mute, resuming monitoring reopens the
retained incident with ordered fire/resolve/refire events.
- Authentication and credential baseline passes, including all 41
required API security regressions. All 11 selected Linux peer-identity,
command approval and observer-isolation regressions pass.
- The existing WebSocket connection-cap test now waits for actual per-IP
slot release before testing slot reuse. It still requires HTTP 429 at
capacity and successful registration after release. The corrected test
passes 100 runs under the race detector, and the full agent-execution
package passes with the race detector on the CI merge source.
The existing resource-payload performance gap remains open. The initial
50-node measurement recorded a 1,037 ms maximum desktop idle long task.
A final-source run still observed multi-second tasks, but its elevated
host load prevents a controlled comparison. Both observations and their
limits are retained. This change does not claim to resolve cross-client
payload or browser responsiveness work.
Refs #2237
Agent removal occurs inside read-loop cleanup before the handler releases
its per-IP connection slot. The flood test could observe the first state
and retry while the slot was still occupied, causing a spurious rejection.
Wait for the guarded admission count to reach zero before reconnecting,
while preserving rejection at capacity and successful registration after
release.
The advisory Benchmarks check failed four pull requests on
`NormalizeSegment` alone in the last 48 paired runs: #2217, #2225, #2252
and #2253. None of them touched the normaliser. Nine other runs moved
the same rows down by more than 10%. The values are quantized (EPYC
7763: `short_name` 2.50 or 3.12ns, `uuid` about 29.6 or 40.5ns). Go
aligns these functions to 32 bytes, so a change anywhere earlier in the
binary moves them between the halves of a 64-byte block.
This drops `BenchmarkNormalizeSegment`. Its guarantees move to
deterministic tests:
- Classification was already pinned by `TestNormalizeSegment`,
`TestLooksLikeUUID` and `TestIsNumeric`.
- The new `TestNormalizeSegment_DoesNotAllocate` pins zero allocations
for the five former inputs. It stores each result in a package-level
sink. Without the sink, an inlinable `normalizeSegment` could keep a
short-segment copy on the stack, and the test would pass.
Timing regressions still show in `BenchmarkNormalizeRoute`, because
every path runs its segments through `normalizeSegment`.
### Proof
These are paired runs of the CI runner's recipe on pulse-dev. The
package list is narrowed to `./internal/api/` and the sample count
raised to 30, because the shared host's load spread these rows by up to
25%.
| Run | `check-bench-regression.sh` | Flagged |
|---|---|---|
| `looksLikeUUID` scans twice | exit 1 | only
`NormalizeRoute/with_uuid`, +13.7% (p=0.005) |
| `normalizeSegment` does its work twice | exit 1 | only
`NormalizeRoute/with_uuid`, +21.9% (p=0.030) |
| candidate vs copy of itself | exit 0 | none |
| candidate vs itself + unrelated init-time function (32-byte shift) |
exit 0 | none (`with_uuid` +9.2%, under threshold) |
| old tree vs itself + same function | exit 1 | all five
`NormalizeSegment` rows, +25% to +40% (p=0.000) |
The deterministic test fails with one allocation per call when the
pass-through return uses `strings.Clone` or `string([]byte(seg))`.
The margin for a UUID-scan slowdown is thin. Doubling the scan reads as
roughly +11% to +14% on `with_uuid`. Layout alone has moved that row by
up to 9.2%, both on CI and in the probe above.
The release-promotion guard still expected the old installer-link wording,
so it rejected the corrected helper-ownership and version-pinning guidance.
Separate the installer safety assertions from support-pack coverage and
check the signed fallback, rollback applicability and version verification.
Rejected active-alert writes still reached history, firing events and
notification dispatch. Make admission explicit across detector and restore
paths so repeated suppressed observations cannot create those side effects.
Reconcile restored alerts when persisted resource policy attaches at startup,
reconcile again after native aliases become canonical, and refresh shared
state before those alerts reach clients.
Preserve occurrence identity when retained alerts re-fire, including durable
history reconstruction and delayed lifecycle replay.
Keep retained table and card components mounted when scrolling shifts the
visible window, so policy editors do not lose unsaved state. Show actual
future maintenance times and the paused-attention effect.
Keep synthetic host machine identities stable across restarts so saved
operator policy remains attached during verification.
Qualify server update and rollback instructions by helper ownership, and
stop signed installer recipes before execution if a download or signature
check fails.
Refs #2237
The advisory Benchmarks check failed four pull requests on
NormalizeSegment alone in the last 48 paired runs: #2217, #2225, #2252
and #2253. None of them touched the normaliser. On #2252 all five
sub-benchmarks rose 25-39% together. Nine other runs moved the rows
down by more than 10% instead, -20% to -28% on #2228, #2230 and #2242.
Those 13 runs used EPYC 7763, EPYC 9V74 and Intel Xeon runners.
The values are quantized. On EPYC 7763 short_name is 2.50ns or 3.12ns
and uuid is about 29.6ns or 40.5ns, a cycle or two per call, or one
per byte of the UUID scan. Go aligns these functions to 32 bytes, so a
change anywhere earlier in the binary can move them between the halves
of a 64-byte block. A 32-byte shift is enough: on pulse-dev, adding an
unrelated init-time function ahead of the normaliser flipped that
parity and failed the old tree the way #2252 did, all five rows +25% to
+40% at p=0.000 and nothing else flagged. More samples cannot separate
that from a real change, and batching does not help, because every
call repeats the same instructions.
The route-level rows do not behave this way. Over the same 48 runs no
NormalizeRoute row except the root case retired in #2255 moved by more
than 10%. with_uuid came closest at 9.2%.
The guarantees the benchmark carried now live in deterministic tests.
TestNormalizeSegment, TestLooksLikeUUID and TestIsNumeric already pin
classification. TestNormalizeSegment_DoesNotAllocate pins zero
allocations for the five former inputs, and fails with one allocation
per call when the pass-through return copies the segment. It stores
each result in a package-level sink: when normalizeSegment was cut
down to an inlinable copy, a discarded result let the compiler keep
short segments on the stack, and the test missed the allocation.
Timing regressions still show in BenchmarkNormalizeRoute, since every
path runs its segments through normalizeSegment. The proofs below are
paired runs of the CI runner's recipe on pulse-dev, narrowed to
./internal/api/ and raised to 30 samples, because the shared host's
load spread these rows by up to 25%, against about 1% on CI runners.
- looksLikeUUID scanning twice: check-bench-regression.sh exited 1,
flagging only NormalizeRoute/with_uuid, +13.7% (p=0.005).
- normalizeSegment doing its work twice: exited 1, flagging only
NormalizeRoute/with_uuid, +21.9% (p=0.030).
- the candidate against a copy of itself: exited 0.
- the candidate against itself plus the same init-time function:
exited 0. The route rows still moved 4-5%, and with_uuid 9.2%
(p=0.000).
The margin for a UUID-scan slowdown is thin. On EPYC 7763 the scan is
about 30ns of a 280ns with_uuid call, so doubling it reads as roughly
+11% to +14%, while layout alone has moved with_uuid by up to 9.2% both
on CI and in the probe above. Giving UUIDs a larger share of a route
would make that row flip the way this one did; a smaller share would
hide the slowdown.
## What changed
Make provider Plan checkout and billing returns report an active paid
plan only after the running control plane serves the matching refreshed
licence. Handle already-applied, trailing, and cancelled billing returns
without falsely claiming a charge or losing a valid plan confirmation.
The portal tests cover these return states.
## Why
A checkout return alone is not proof that the provider's refreshed
licence is active. The Plan page must tell providers what is actually
running, particularly when a payment callback is delayed, repeated, or
cancelled. This is the reviewed provider Plan return correction and its
follow-up verification.
## Included source
`Pulse` main after `ab0ab381a0f1b4e1ffbcc38537667870dec46960` through
`2dd856e9aa7b155032f102bb96fa59e08f582599`. The reviewed local tip
`cb26fb96ec002a40c52b618a6247fd931ff64b4a` remains an ancestor. Its
provider Plan return changes include `d1ea9ce0`, `8a59a8df`, `ac721a26`,
`a631fc80` and their review/integration merges. Commit `2dd856e9`
additively merges current upstream and regenerates the portal
bundle/manifest from the combined source. No previously reviewed commit
was rewritten.
## Validation and limits
The reviewed Plan candidate passed an offline exact-lock VM check: npm
installation, 25 focused Plan tests, typecheck, all 129 portal frontend
tests, build, and generated-file byte comparison. Its Go portal test
passed in a separate exact-source VM, and earlier offline browser
inspection covered its unchanged Plan paths. For this upstream merge,
the publisher verified a clean worktree, no diff whitespace errors, and
that the committed manifest hash matches every combined portal build
input. The exact post-merge source-proof VM was busy on two attempts; no
post-merge VM pass is claimed. Required hosted checks and exact-head
landing validation must complete before merge. This source proposal is
not live payment acceptance, deployment, or release readiness.
[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
Preserve the reviewed provider Plan return history and incorporate current upstream changes. Rebuild the portal bundle and manifest from the combined source.
Change-source: pulse-maintainer
The advisory Benchmarks check keeps failing unrelated pull requests on
`NormalizeRoute/root`: alone on #2228, #2242, #2245 and #2250 (and PR
1973 on 2026-09-08), and beside other results on #2230 and #2244.
**Why it isn't a signal.** The `"/"` path is a length compare and a
constant return. In the last 19 paired PR runs, the 11 on AMD EPYC 7763
measured it at exactly 2.18 ns or 2.50 ns on both sides, each within
0–1%. That is about one CPU cycle. In five of those runs, base and
candidate landed on different values: +14% four times and −13% once
(#2252), though none touched the normaliser. A stable one-cycle shift
passes p<0.05 every time, so more samples can't filter it. A looser
threshold would blunt the gate for every other benchmark.
**Why batching doesn't fix it.** On an amd64 host, a 64-call batch
spread as widely in relative terms as a single call, because every call
repeats the same few instructions.
**Change.**
- Drop the `root` sub-benchmark.
- Add `TestNormalizeRoute_RootFastPathDoesNotAllocate`, which pins the
property that matters: `"/"` and `""` return before splitting. With the
fast path removed, `TestNormalizeRoute` still passes, but the new test
fails with one allocation per call. It also passes under `-race`.
**Real regressions stay detectable.** I ran a paired comparison through
`scripts/run-ci-benchmarks.sh` (10 alternating samples, 100 ms, scoped
to the HTTP metrics benchmarks), with `normalizeRoute` made to do its
work twice:
| Benchmark | Result |
|---|---|
| `NormalizeRoute/*` (6 sub-benchmarks) | +89% to +107%, p=0.000 |
| `NormalizeRoute_Parallel` | +87% |
| `FullMiddlewarePath` | +54% |
| B/op and allocs/op | +100% |
`check-bench-regression.sh` exited 1. The same tree compared with itself
exited 0 with no changes.
**Not in this change.** `NormalizeSegment` shows the same layout flip.
It failed #2252 on its own (+25% to +39%), and in the paired run above
it moved −20% to −38% even though its code was untouched. Its uuid flip
is about 10 ns, so it needs its own fix rather than a small ns floor.
The advisory Benchmarks check kept failing unrelated pull requests on
NormalizeRoute/root alone (#2228, #2242, #2245, #2250, and PR 1973 on
2026-09-08), and flagged it beside other results on #2230 and #2244.
The "/" path is a length compare and a constant return.
In the last 19 paired PR runs, the 11 on AMD EPYC 7763 measured root
at exactly 2.18ns or 2.50ns on both sides, each within 0-1%. The two
values are about one CPU cycle apart. In five of those runs base and
candidate landed on different values, four times up (+14%) and once
down (-13%), though none touched the normaliser. The earlier
investigation found the benchmark loop identical and the normaliser
differing only in relocated addresses. More samples cannot separate a
stable one-cycle shift from a real change, and a looser threshold
would blunt the gate for everything else.
Batching root calls does not escape it. On an amd64 host a 64-call
batch spread as widely in relative terms as a single call, because
every call repeats the same few instructions.
What the path must guarantee is that "/" and "" return before
splitting. TestNormalizeRoute_RootFastPathDoesNotAllocate pins that
deterministically: with the fast path removed, TestNormalizeRoute
still passes, but the new test fails with one allocation per call.
Real regressions stay visible. A paired run using the CI runner's own
alternating recipe (10 samples, 100ms), with normalizeRoute made to do
its work twice, flagged every remaining NormalizeRoute sub-benchmark
at +89% to +107%, FullMiddlewarePath at +54%, and bytes and allocs at
+100%. check-bench-regression.sh exited 1. The same tree compared with
itself exited 0.
A provider-hosted control plane refused to start once its licence was
past
expiry and the 7-day grace, because startup validated it with
ValidateLicense. So at the next restart after a 60-day evaluation ran
out,
or after a paying provider's subscription lapsed, the portal went down,
and
the portal's Plan tab is the only place a provider can buy or renew.
Reproduced on the walkthrough lab.
Startup now accepts an authentic, key-bound licence even when lapsed
(ValidateLicenseAllowingLapse), preferring a current licence and
otherwise
the one that expired later, and logs the lapse. It unlocks nothing:
client
runtimes still verify the provider licence in every lease with
ValidateLicense and drop MSP capabilities, new clients are refused with
provider_msp_license_lapsed, and the refresher still refuses a lapsed
licence. The Plan panel says the evaluation or plan ended and offers the
plans, and provider-msp status reports license_lapsed without failing so
a
lapsed install can still upgrade.
The same run showed the portal printing control plane error codes such
as
provider_msp_license_lapsed instead of their message. The portal now
prefers an error's message over its code, which also fixes
already_subscribed and checkout_unavailable in the Plan tab.
Replaces #2241 and #2250, replayed onto current main as one commit.
Every accepted agent report refreshed the canonical resource store, and
every refresh covers the whole estate, so refresh work grew with agent
count × estate size (#2199). #2235 made each pass linear and cheaper.
This PR bounds how many passes reports can trigger.
**Behaviour.** Accepted host, Docker and Kubernetes reports, including
rejected-report liveness, refresh through
`refreshUnifiedResourceStoreAfterAgentReport`:
- The first report after a quiet window is published before its handler
returns, the same as today.
- Reports within 2 s of the previous refresh fold into one trailing
refresh at the window's end. It reads the latest state and broadcasts it
to websocket clients.
- 2 s matches `readPathRegistryFreshness`, the staleness the read paths
already accept.
- Agent removal and host-agent evaluation still refresh immediately, so
removed inventory retires at once.
- `Monitor.Stop` cancels a pending trailing refresh and waits for a
running one before closing stores.
- Monitors built without `New` keep a zero window and refresh on every
report.
**Cost bound.** At 2,080 resources one refresh costs about 82 ms (after
#2235). 200 agents reporting every 30 s drive 6.7 refreshes/s, roughly
55% of a core; coalesced, reports trigger at most one refresh per 2 s,
about 4%, however many agents report.
**Contract change.** The agent-lifecycle contract promised that an
accepted report is visible when its handler returns. It is now visible
within the 2 s window. The founder approved that trade on 24 September
2026, and the contract records it.
**Tests**
- `TestAgentReportRefreshFoldsReportsWithinWindow`: the leading report
is published synchronously; a burst inside the window is deferred; one
trailing refresh publishes the burst's latest value; after a quiet
window the next report is synchronous again. It fails when the throttle
is removed.
- `TestStopCancelsPendingAgentReportRefresh`: fails if stop does not
cancel the pending refresh.
- The issue-1753 end-to-end test posts two reports back to back, so it
now waits for the published rows instead of reading them at once.
**Verification on pulse-dev**
- `internal/monitoring` runs in 112 s against 107 s on #2235's head. No
test is slower by more than 1.2 s.
- `internal/unifiedresources`, `internal/websocket` and
`internal/api/...` pass. `TestServerInfoEndpointReportsDevelopment`
fails in that source export with and without this change, because the
export has no git checkout.
Refs #2199
Follow-up to #2234. Patrol stopped calling
`ChangeDetector.DetectChanges` when it moved to agentic execution
(1b1c9bb2a3). Recent-change context comes from the unified-resource
timeline, and the detector survives only as a fallback reader of the
legacy `ai_changes.json` that older releases wrote. #2234 removed the
detector's unjoined save. The detection code itself was still there, and
only tests used it.
## Removed
- `DetectChanges` and `detectResourceChanges`, which diffed snapshots.
- `ResourceSnapshot` and its `internal/ai` alias.
- Change-ID generation, and the seven description formatters with their
`formatBytes`/`formatFloat` helpers.
- The detector's lock. The history is loaded once at construction and
never modified, so reads need no lock.
- Tests of the detection logic, its heap stability
(`change_detector_memory_regression_test.go`) and ID uniqueness
(`changes_concurrency_test.go`).
## Unchanged
- `NewChangeDetector`, legacy loading and trimming.
- The read APIs: `GetChangesForResource`, `GetRecentChanges` and
`GetChangesSummary`.
- The `ChangeType` constants, which label the legacy entries.
- `intToString`, which the incident and remediation code share.
## Tests
The `internal/ai` tests that used detection to seed the fallback now
write a legacy `ai_changes.json` and load it. That is the path
production reads.
To check that the new seeding really exercises the load path, I broke
legacy loading through an overlay. 10 of the 12 re-seeded tests then
fail. The other two assert that the fallback is left out of a
type-scoped patrol, or that it loses to the canonical timeline, so empty
history also satisfies them.
That run also exposed three weak assertions, now tightened:
- Two formatter comparisons passed against an empty detector because `""
== ""`. They now reject empty output.
- One summary test used `t.Error` and then indexed `[0]`. It now uses
`t.Fatal`.
The ai-runtime contract now describes the detector as a legacy-history
reader.
A provider-hosted control plane refused to start once its licence was past
expiry and the 7-day grace, because startup validated it with
ValidateLicense. So at the next restart after a 60-day evaluation ran out,
or after a paying provider's subscription lapsed, the portal went down, and
the portal's Plan tab is the only place a provider can buy or renew.
Reproduced on the walkthrough lab.
Startup now accepts an authentic, key-bound licence even when lapsed
(ValidateLicenseAllowingLapse), preferring a current licence and otherwise
the one that expired later, and logs the lapse. It unlocks nothing: client
runtimes still verify the provider licence in every lease with
ValidateLicense and drop MSP capabilities, new clients are refused with
provider_msp_license_lapsed, and the refresher still refuses a lapsed
licence. The Plan panel says the evaluation or plan ended and offers the
plans, and provider-msp status reports license_lapsed without failing so a
lapsed install can still upgrade.
The same run showed the portal printing control plane error codes such as
provider_msp_license_lapsed instead of their message. The portal now
prefers an error's message over its code, which also fixes
already_subscribed and checkout_unavailable in the Plan tab.
setup.sh resolves the image pins in .env to digests once, so upgrade.sh
only ever re-pulled the release a provider first installed. Moving to a
new release meant finding the new digests by hand, so none of a
release's
fixes, and no new feature such as the portal Plan tab, reached an
existing install.
Run from a newly extracted release bundle, upgrade.sh now works on the
existing install: it resolves the bundle's control-plane and runtime
tags
to digests, runs the status gate, preflight and verified backup with the
new release's control plane through a shell override while nothing on
disk changes, then keeps a copy of .env, installs the bundle files and
writes the new pins before starting the new images. --keep-image-pins
keeps hand-set pins.
It also pulls the tenant runtime image before the first status check,
which otherwise failed every upgrade to a new CP_PULSE_IMAGE with "not
present locally", including the documented pin-editing route.
Replaces #2243, which closed when its branch was renamed.
With no custom logo, Settings > General > Application branding previewed
the header with a hand-drawn stand-in: a blue circle holding a "●" text
character, which does not look like the Pulse logo. The header itself
falls back to PulseBrandMark (blue disc, white ring, white centre dot),
so the preview misrepresented what the header shows. Render the same
PulseBrandMark at the header's size; it follows the app theme in light
and dark.
Replaces #2240, which closed when its branch was renamed.
While a standalone agent stays offline across a restart (up to the
72-hour continuity window), every canonical read-state lookup adds it
back from host continuity by building a whole registry clone
(`readStateWithRecords`). Alert evaluation makes one lookup per resource
per poll, so in that state each poll rebuilt the registry once per
resource. Each lookup also called `GetLiveHostsSnapshot` twice, and that
went through `State.GetSnapshot()`, deep-copying every VM and container
just to return the hosts.
**Changes**
- **Overlay cache.** The adapter keeps the last continuity overlay,
keyed by:
- the registry pointer;
- `LastRebuiltAt` (every replace and in-place supplemental ingest
updates it);
- a fingerprint of the requested records. The fingerprint leaves out
`Resource.UpdatedAt`, which `resourceFromHost` stamps with `time.Now()`
at build time.
It reuses the overlay for at most 2 s, because the overlay evaluates
source staleness when it is built. The live adapter is never mutated.
- **Hosts-only copy.** `GetLiveHostsSnapshot` now copies only hosts,
through the existing `State.GetHosts()`. The result is identical,
because `GetSnapshot` sets `Hosts: cloneHosts(s.Hosts)`. This also
removes a full state copy from each agent report (`ApplyHostReport`),
each config fetch and every other caller.
- **Single host copy per lookup.**
`readStateWithStandaloneHostContinuity` takes the host list once instead
of twice.
**One read-state lookup** with one offline standalone agent (synthetic,
8-node clusters, pulse-dev):
| Resources | main | this PR |
|---|---|---|
| 130 | 3.0 ms / 1.9 MB | 13 µs / 10 KB |
| 520 | 15.9 ms / 11.8 MB | 25 µs / 29 KB |
| 1,040 | 41 ms / 28 MB | 39 µs / 54 KB |
| 2,080 | 136 ms / 95 MB | 74 µs / 104 KB |
**Tests.** Each fails when its mechanism is reverted.
- `TestHostContinuityOverlayReusedWithinRegistryGeneration`: reuse
within a generation, including records rebuilt with a fresh `UpdatedAt`;
rebuild after a new generation or different records; the 2 s age bound;
and no mutation of the live adapter.
- `TestStandaloneHostContinuityReadStateReusedAcrossLookups`: end to end
through `GetUnifiedReadStateOrSnapshot` with a real continuity store.
- `TestGetLiveHostsSnapshotCopiesOnlyHosts`: 1 host with 1,000 VMs must
not allocate per guest. The old path made 1,010 allocations.
**Verification on pulse-dev:** `internal/unifiedresources`,
`internal/monitoring`, the `internal/api/...` subpackages and
`cmd/pulse-control-plane` pass.
`TestServerInfoEndpointReportsDevelopment` fails in that source export
with and without this change, because the export has no git checkout.
Refs #2199
Every accepted agent report sends a websocket state invalidation, and
each state broadcast rebuilds and diffs the full frontend state:
| Resources | One broadcast (`BuildFrontendState` + JSON) |
|---|---|
| 130 | 7.5 ms |
| 1,040 | 62 ms |
| 2,080 | 132 ms |
The hub's keyed snapshot adds about 10 ms per 1,000 resources.
The hub coalesced invalidations with a 100 ms debounce that **restarted
on every signal**. That caused two problems:
- Reports spaced more than 100 ms apart (typical for agents on
independent 30 s timers) each produced a full broadcast. With 200 agents
that is roughly 3.4 broadcasts/s, about half a core whenever a browser
is open.
- Signals closer together than the window postponed the broadcast until
they stopped.
**Change: coalescing is now a throttle.**
- The first invalidation after a quiet period still broadcasts after the
100 ms window.
- Later invalidations for the same audience (all clients, or one tenant)
replace the pending request without rescheduling it.
- They broadcast no sooner than `defaultStateBroadcastInterval` (2 s)
after that audience's previous broadcast. That caps broadcast work near
7% of a core at the same scale.
2 s matches the report-driven store refresh interval (#2242), so faster
broadcasts mostly re-sent the same resources. Broadcasts are still
skipped when no client is connected.
**Tests.** Each fails with the old restart-on-signal, unspaced
behaviour.
- `TestStateBroadcastsSpacedByInterval`: the second broadcast waits for
the interval and carries the latest of a burst; the burst produces only
one broadcast.
- `TestTenantStateBroadcastsSpacedByInterval`: the same for tenant
broadcasts.
- `TestStateBroadcastNotStarvedBySteadySignals`: signals every 10 ms
against a 60 ms window still produce broadcasts. The old debounce
produced none in 600 ms.
**Verification on pulse-dev:** `internal/websocket` (existing coalescing
tests unchanged), `internal/monitoring`, `pkg/server` and the
`internal/api/...` subpackages pass.
`TestServerInfoEndpointReportsDevelopment` fails in that source export
with and without this change, because the export has no git checkout.
Refs #2199
Patrol stopped calling DetectChanges when it moved to agentic execution
(1b1c9bb2a3). Recent-change context comes from the unified-resource
timeline, and the detector survives only as a fallback reader of the
legacy ai_changes.json that older releases wrote. The previous change
removed the detector's unjoined save; the detection itself was still
there, kept alive only by tests.
Remove DetectChanges, its snapshot diffing, ResourceSnapshot (and its
internal/ai alias), change-ID generation, the description formatters and
the detector's lock. The history is now loaded once and never modified,
so reads need no lock. The read APIs, trimming and legacy loading are
unchanged.
Tests that used detection to seed the fallback now write a legacy
ai_changes.json and load it, the same path production reads. With
loading broken, 10 of the 12 re-seeded internal/ai tests fail; the other
two assert that the fallback is absent or loses to the canonical
timeline. Two formatter comparisons now reject empty output, which let
them pass against an empty detector. Tests of the detection logic, its
heap stability and ID uniqueness go with it.
Agents poll `/api/agents/agent/{id}/config` every minute, and every
successful poll was written as a signed `agent_config_fetch` audit
event. On a five-agent homelab install that was 483,000 of 483,101 audit
rows (250 MB) over 75 days, burying the 64 logins and 25 CSRF failures
the log exists to show. At the default 90-day retention it comes to
roughly 60 MB per agent, so an install with hundreds of agents would
carry gigabytes of routine polling in its audit log.
**New rule**
- Every **failed** fetch is still audited (missing scope, unknown agent,
signing or serialisation failure).
- A **successful** fetch is audited only when it is new audit
information:
- the agent's first delivery after Pulse starts;
- a different token;
- a different delivered config (`desiredConfig` hash);
- an unchanged delivery last recorded 24 hours earlier.
- Recorded successes now carry `config=<desiredConfig hash>` and
`reason=first_since_start|token_changed|config_changed|daily` in their
details.
That is about 1,440 rows per agent per day down to a handful.
The tracker lives on the handler, is keyed by org and agent, and is
bounded to 4,096 entries. Handlers built without the constructor keep
the old audit-every-success behaviour.
**Existing rows** are left to age out under retention rather than
deleted, because removing entries from a security log would look like
tampering.
**Docs and contracts.** `AUDIT_LOGGING.md` and its shipped mirror
describe the new rule. The api-contracts and agent-lifecycle contracts
record it. storage-recovery gets a boundary note, because it lists
`internal/api/` as an extension point: recovery actions keep their own
action audits, receipts and verification.
**Tests**
- `TestAgentConfigFetchAuditTrackerRecordsOnlyNewDeliveries` walks first
delivery, unchanged poll, config change, token rotation, and the 24-hour
boundary, plus org isolation and the nil tracker.
- `TestAgentConfigFetchAuditsNewDeliveriesAndEveryFailure` makes five
successful fetches around one config change and gets two success events,
and two failed fetches give two failure events. Without the tracker it
records all five successes.
**Verification on pulse-dev:** `internal/api/...` passes.
`TestServerInfoEndpointReportsDevelopment` fails in that source export
with and without this change, because the export has no git checkout.
The agent registration journey posts a second host report about a
second after the first and read /api/state at once. Report-driven store
refreshes now fold into one trailing refresh per 2-second window, so
the update is published within the window rather than before the report
response returns, and the journey read the first report's 42.5% CPU
instead of 75% (Core E2E "Agent registration lifecycle"). Poll
/api/state for up to 10 seconds until the update appears, then check
the remaining fields as before.
Every accepted agent report sends a state invalidation, and each state
broadcast rebuilds and diffs the full frontend state, about 130 ms at
2,080 synthetic resources. The hub coalesced invalidations with a 100 ms
debounce that restarted on every signal, so reports spaced more than
100 ms apart each produced a broadcast, while signals arriving closer
than the window could postpone broadcasts until they stopped (#2199).
With 200 agents reporting every 30 seconds, that was roughly 3.4
broadcasts a second, about half a core whenever a browser was open.
Coalescing is now a throttle. The first invalidation after a quiet
period still broadcasts after the 100 ms window. Later invalidations for
the same audience (all clients, or one tenant) replace the pending
request without rescheduling it, and broadcast no sooner than 2 seconds
after that audience's previous broadcast. That caps broadcast work near
7% of a core at the same scale. Two seconds matches the report-driven
store refresh interval, so faster broadcasts mostly re-sent the same
resources.
Refs #2199