Commit Graph

7617 Commits

Author SHA1 Message Date
rcourtman 92f20dafdb Hide Re-investigate while a planned fix is queued
A Patrol investigation can plan a fix and then fail, normally on the
final summary call. The run then shows a queued fix with a failed
status, and the Investigation section offered Re-investigate because
it only looked at the status. Another run would plan a second action
for the same problem while the first still waits for approval.

The section now hides Re-investigate while the outcome is fix_queued,
as it already did for a completed run with a queued fix. The failed
run's note uses the muted style of an interrupted run, because the fix
awaiting approval is what the operator acts on, not the error. The
Pro orchestrator keeps the plan's outcome on such a run and refuses the
re-investigation request too.
2026-10-23 17:43:57 +01:00
rcourtman a32356ae94 Describe the Assistant surface as Pulse Mobile chat
With the web chat window and its backend routes retired (#3126,
#3137), the pulse_assistant surface only serves Pulse Mobile's chat.
Its manifest description still called it the explanation and handoff
surface for Patrol findings, and that text is public: the docs/AI.md
overview, the MCP README surface block, and the External agents
settings panel all show it. It now names the Pulse Mobile role and says
the web chat is retired.

The browser's fallback affordances for an Assistant surface with none
declared still offered interactive questions. They now match the
backend default of tools only, so no client path advertises a question
tool nobody can answer.
2026-10-23 16:10:15 +01:00
rcourtman 070d2a0e53 Remove chat backend routes only the web chat used (#3137)
## Why

The in-app Assistant chat window was retired in #3126. Several backend
routes existed only for that window, and Pulse Mobile, the remaining
chat client, never called them. This removes them and the code that only
they reached.

## What changes

- **Routes removed:**
  - `GET /api/ai/assistant/surface-tools`
  - `POST /api/ai/workflow-prompts/render`
  - `POST /api/ai/question/{id}/answer`
- the `/summarize`, `/fork`, `/undo`, `/redo`, `/steer`, `/diff`,
`/revert` and `/unrevert` sub-resources under `/api/ai/sessions/{id}`.
These now return `404` after the existing `ai:chat` scope check.
- **Code removed behind them:**
  - the handlers and their `AIService` methods;
- the chat service's fork, undo, redo, steer, summarize, answer-question
and surface-tool methods;
- the session store's fork and turn-undo operations and the persisted
redo stack (`can_redo` leaves session summaries);
- session compaction, the agent loop's steering inbox and
question-answer wait, the `steered` message flag, and the `question` and
`steer_applied` stream events.
- **The question tool is no longer offered.** Interactive chat still
offered `pulse_question`, and Pulse Mobile has no way to answer it, so a
Mobile turn that called it waited until the request ended. Now:
  - no profile offers the tool;
- the agent loop answers any call with a refusal telling the model to
ask in its reply text, the same path Patrol runs already took;
  - the chat system prompt no longer mentions a clarification tool;
- the agent capabilities manifest's Pulse Assistant surface no longer
declares the `interactiveQuestions` affordance, so the prompt's
affordance text, the fallback governance names, and the generated
`docs/AI.md` and `cmd/pulse-mcp/README.md` surface blocks drop it. The
External agents settings panel stops showing an "Interactive questions"
affordance for Pulse Assistant. The shared `pulse_question` declaration
stays.
- **Docs and contracts:**
- `docs/API.md` lists the remaining routes under "Chat Sessions (Pulse
Mobile)", including the rename route Mobile already used.
- The ai-runtime and api-contracts rules for the removed workflows are
rewritten, and the dependent contracts carry dated notes.

## Unchanged

- `/api/ai/chat` and the session list, create, rename, delete, messages
and abort routes, which are the Pulse Mobile relay route list.
- `/api/ai/status`, which the dev health check uses.
- `/api/ai/workflow-prompts/activity`, which Patrol uses.
- Patrol investigations, provider routing, model-boundary sanitizing and
approvals.
- Old session files: they load as before and drop the redo stack and
`steered` flags on their next write.

## Not in this PR

- The Pulse Assistant surface description in the manifest, which still
calls it a handoff surface for Patrol findings. It now describes Pulse
Mobile chat and can be reworded with the surface contract when Mobile
retires.
- The browser's legacy fallback in
`frontend-modern/src/api/agentCapabilities.ts`. It still gives Pulse
Assistant `interactiveQuestions` when a manifest declares no affordances
at all. A same-version server always declares them, so that path is
unused; it changes with the surface description.
- The dead `highlight.js` chunk rule in `vite.config.ts`.

## Release note

> Removed API routes the retired in-app chat window used: Assistant
surface tools, workflow-prompt rendering, question answers, and the
session summarize, fork, undo, redo, steer, diff, revert and unrevert
routes. Pulse Mobile's chat is unaffected.
2026-10-23 14:39:19 +01:00
rcourtman dcbfd2d86d Remove chat backend routes only the web chat used
The in-app Assistant chat window was retired in #3126. Several backend
routes existed only for it, and Pulse Mobile, the remaining chat client,
never called them: assistant surface-tools, workflow-prompt render,
question answers, and the session summarize, fork, undo, redo, steer,
diff, revert and unrevert sub-resources. This removes them along with
the handlers, service and session-store methods, the persisted redo
stack, session compaction, mid-turn steering and the question-answer
wait behind them.

The question tool needed more than a route removal. Interactive chat
still offered pulse_question, and Pulse Mobile has no way to answer it,
so a Mobile turn that called it waited until the request ended. No
profile offers it now. The agentic loop answers any call with a refusal
that tells the model to ask in its reply text, the path Patrol runs
already took, and the chat prompt drops its clarification-tool advice.
The capabilities manifest's Assistant surface stops declaring
interactive questions, so the generated docs and fallback governance
names follow it.

/api/ai/chat and the session list, create, rename, delete, messages and
abort routes Pulse Mobile uses are unchanged, as is /api/ai/status. Old
session files load as before and drop the redo stack and steered flag
on their next write. docs/API.md lists the remaining chat routes, and
the subsystem contracts record the removal.
2026-10-23 14:07:20 +01:00
rcourtman ae19fcf5d6 Preserve PVE authentication endings while reading responses
A password login can end while its response is being read: the caller
cancels, its deadline passes, or the client's own login timeout expires.
Go's HTTP transport can still hand back the server's answer to that
abandoned request. The construction fixture answers a departed client
with an empty 200, which surfaced as "invalid authentication response"
and failed TestClientConstructionHonoursContext and its client-timeout
twin intermittently in the required backend check.

The shared password exchange now reports the ending instead of
diagnosing that answer, keeps transport errors that already carry the
ending, and starts no form attempt after an ended JSON refusal. This
mirrors the PBS client fix from 18 October. Renewal shares the exchange,
so the same rule applies when its own bounded timeout ends a login.
Complete sessions are still accepted, and construction still discards a
client whose caller has ended.

A new control ends the login during response reading, by caller
cancellation and by a simulated login deadline, for every unusable
answer; without this change all of its cases fail. It also checks that
both encoding attempts carry the one login budget the client timeout
sets. The TLS fixture now aborts a request whose client has left rather
than answering it with an empty 200.
2026-10-23 13:50:03 +01:00
rcourtman d167d7f492 Count each finished Patrol investigation once in the weekly digest (#3134)
## Why

The Patrol weekly digest's **Investigated** line, on the Patrol page and
in the weekly email, was unreliable:

- It counted `investigation_outcome` lifecycle rows and read their
outcome from `metadata["outcome"]`. The only writer of those rows
(`updateInvestigationOutcomeLocked`) writes
`prev_outcome`/`next_outcome`, so every counted row was labelled
`unknown`.
- Those rows are later steps of an earlier run's fix (approved, run,
verified), not investigations. Meanwhile the `investigation_updated`
rows every run writes were ignored, apart from one `LastInvestigatedAt`
fallback count per finding.
- So two runs on one finding counted once, a fix approved after last
week's run counted as this week's investigation, and the email could
print "Investigated: 3" followed by "No issues needed a closer look."

## What changes

- `digestInvestigationRuns` counts one investigation per run that
finished in the window, from the rows the findings store already writes
for every run. That includes a run whose own fix was verified before it
closed, and a run whose status a regression cleared mid-run. A fix that
moves on afterwards relabels its run instead of counting again.
- A run that failed with no outcome is labelled `failed`, the bucket
name telemetry already uses. It sits after `timed_out` in
`PatrolDigestOutcomeOrder`.
- The summary-field fallback is removed. Every run since the v6 release
writes lifecycle rows, and the fallback could count one run in two
adjacent weeks.
- Email: `failed` reads "ended with an error". When the count is above
zero but no outcome in the breakdown has wording, the line shows the
count alone instead of "No issues needed a closer look." or the
watch-only sentence.
- `internal/ai/findings.go` is unchanged; the digest reads the rows the
store writes today.

Known limits: lifecycle rows carry no session ID, so a late end written
by an older run is read as the end of whichever run is open. That's the
store-side stale-write problem, which is being closed separately with
session checks. A run whose rows the 50-row lifecycle has already
dropped is not counted.

## Proof

- `internal/ai/patrol_digest_test.go` drives the store's own writers:
the orchestrator's start and end updates, `UpdateInvestigationOutcome`,
`ReconcileInvestigationOutcome`, `RecoverStuckInvestigation`, and
`Resolve` + `Add` for a mid-run regression. The scenario covers ten
finished runs plus one still running. The old code reports 8
investigations, 3 of them `unknown`.
- `internal/api/contract_test.go` follows the scheduled email's path:
store writers → `AISettingsHandler.BuildPatrolDigest` →
`renderPatrolDigestEmail`. The old code reports two runs as `1 unknown`.
- `internal/api/patrol_digest_email_test.go` checks the `failed` wording
and the non-zero-count guard.
- On pulse-dev, `go vet` passes for `./internal/ai/` and
`./internal/api/`, and the full `./internal/ai/` package and every
`PatrolDigest` test in `./internal/api/` pass. The new tests fail
against the previous digest and email (negative controls via
`-overlay`).

## Related

The Patrol page's This week card gets the same `failed` wording and the
same non-zero-count guard in #3132. Until that lands, a week with only
failed runs reads "No issues needed a closer look." on the card. That's
what those runs already showed as `unknown`, so it isn't a regression.
2026-10-23 13:16:09 +01:00
rcourtman 3b5d789aeb Show unclassified and interrupted Patrol investigation outcomes (#3132)
Since rcourtman/Pulse#3129, Patrol records two more investigation
outcomes:

- `unclassified`: a run that finished without a readable conclusion. It
stays retry-eligible and presents as needs-attention in the loop state.
- `interrupted`: a run that a restart or shutdown cut short. It has no
cooldown and runs again.

The frontend's outcome lists did not know either value, so the findings
store dropped both to "no outcome".

## What changes

- `stores/aiIntelligence.ts` keys the outcomes it accepts by the
`InvestigationOutcome` union in `api/patrol.ts`. `api/ai.ts` now reuses
that union, so the next backend outcome fails type-check instead of
being dropped. The import is type-only because several tests mock
`@/api/patrol` wholesale.
- Every outcome map in `aiFindingPresentation.ts` gains both values:
- `unclassified` reads "No clear conclusion" in the warning tone, joins
the Needs Attention queue and sorts beside `needs_attention`. Its
workflow step asks the operator to read the summary.
- `interrupted` reads "Interrupted — will retry" in the neutral tone and
stays out of the attention queue. Its collapsed row says "Will retry"
instead of "Needs review".
  - Neither counts as a failed fix.
- `InvestigationSection` offers Re-investigate after both outcomes. An
interrupted run's note renders as information, not in the error style.
- The This week card names both outcomes with the digest email's wording
and order (`PatrolDigestOutcomeOrder`, `patrol_digest_email.go`).
- Two drawer defects the new labels exposed are fixed:
- The Patrol record showed a red "Failed" run status beside "will
retry". Like the investigation header, it now shows the run status only
until the run has an outcome.
- The record badge used `formatIdentifierLabel` ("Unclassified", "Fix
Executed", "Cannot Fix"). It now uses the shared outcome labels that the
header uses.
- On phones the longer badge broke "Re-investigate" across two lines.
The header row now wraps.

Telemetry is unchanged: the two outcomes already map to `in_progress`
and `other`.

A second commit lines the This week card up with the digest-count
backend change:

- The card names the digest's new `failed` bucket ("ended with an
error"), for a run that ended on an error with no outcome. It sits right
after timeouts, as in `PatrolDigestOutcomeOrder` and the email.
- When none of a week's investigations has a worded outcome, the
Investigated tile shows the count alone. It used to say "No issues
needed a closer look" under a non-zero count. The email already does
this.
- That commit has its own receipt (`196f770aad8a3198.json`) from a pass
over live and fulfilled digest responses at 1440 and 390.

## Before and after

Private Pulse Pro stack: pulse-enterprise#139 built against this tree on
`ec40662af4`, with a mock estate and seeded findings, investigation
sessions and a week of runs.

| Surface | main | this branch |
| --- | --- | --- |
| This week, Investigated tile | 5, "No issues needed a closer look." |
5, "3 finished without a clear conclusion, 2 interrupted by a restart."
|
| Needs Attention filter | 1 | 4 (three unclassified, one
needs_attention) |
| Interrupted finding row | Needs review | Will retry |
| Unclassified drawer | raw `unclassified`, no Re-investigate, green
"Completed" record badge | "No clear conclusion", Re-investigate |
| Interrupted drawer | raw `interrupted`, red "Failed" record badge, red
restart note | "Interrupted — will retry" (neutral), muted restart note
|
| 390px drawer header | Re-investigate splits over two lines | the row
wraps; the button stays whole |

## Verification

- Vitest on pulse-dev:
  - The 9 touched test files pass, 447/447.
- The wider api/stores/utils/AI/patrol/pages/shared run passed 8,858
tests. The one failure was `agentInstallCommand.test.ts`, caused by the
script's umask; it passes 90/90 with the default umask.
- Negative control: 26 of the new assertions fail against main's
sources.
- The Frontend CI job was replayed on pulse-dev:
- Format check, headers, cpd, type-check, build and bundle size passed.
- The lint chain caught a semicolon in the new "Read summary" copy.
After the fix every audit passed.
- The full suite passed 22,156 tests. Three docs-reading files timed out
at 5 s under load (`shippedDocFragments`, `operationalUpgradeHelp`,
`externalAgentGuide`); they pass alone, 8/8.
- Browser receipt
`frontend-modern/browser-verification/f1f2d9ca0f32c251.json`: headless
Chromium at 1440 (dark and light) and 390 on the final bytes, exercising
the This week card, the finding list and the review drawers of an
unclassified, an interrupted and a needs_attention finding.
- Governance: the per-commit canonical completion guard (with the
Contract-Neutral trailer), the receipt range check, and the contract and
registry audits all pass.

The presentation is documented in `patrol-intelligence.md`. ai-runtime
and api-contracts already describe both outcomes from #3129, so the
commit carries a Contract-Neutral trailer for them and for
frontend-primitives.
2026-10-23 12:19:51 +01:00
rcourtman b633e54105 Count each finished Patrol investigation once in the weekly digest
The weekly digest counted investigation_outcome lifecycle rows and read
their outcome from a metadata["outcome"] key the findings store never
writes, so every one of them showed as "unknown". Those rows are later
steps of an earlier run's fix (approved, run or verified), not new
investigations, while the investigation_updated rows every run writes
were ignored apart from one fallback count per finding. Two runs on one
finding counted once, a fix approved after last week's run counted as
this week's investigation, and the email could print "No issues needed
a closer look." under a non-zero count.

The digest now counts one investigation per run that finished in the
window, from the rows the store already writes for every run, including
a run whose own fix was verified before it closed. A fix that moves on
afterwards relabels its run instead of counting again. A run that failed
with no outcome is "failed", the bucket telemetry already uses; the
email words it "ended with an error" and no longer denies a non-zero
count. The summary-field fallback is removed: every run since the v6
release writes lifecycle rows, and the fallback could count one run in
two adjacent weeks.

The tests drive the findings store's own writers rather than hand-built
rows, which is how the wrong metadata key went unnoticed.
2026-10-23 12:04:23 +01:00
rcourtman a408736bc4 Keep stale investigation runs from overwriting newer finding state
The private investigation orchestrator writes a finding's investigation
fields through orchestratorFindingsAdapter, and every write was applied
unconditionally. A run still in flight when its finding was resolved
and detected again, or when the stuck-run sweep gave it up and a retry
started another run, overwrote the newer state with its own session,
status, outcome and attempts when it finished. A stale "resolved" made
the regressed occurrence terminal, so Patrol stopped investigating it.

Closing writes now go through FindingsStore.CompleteInvestigation,
which records them only while the finding still names the run: the
session must be the finding's current one and the run must not predate
the finding's latest regression. Admission goes through
StartInvestigation, which refuses a run whose session started before
that regression, so a delayed admission cannot install an earlier
occurrence's attempt count or replace a newer run.

Both compare a run's start only with an occurrence start this process
observed: a regression, or the finding's removal by ClearAll or
Cleanup, kept unpersisted with the clock's monotonic reading for a day.
A regression an earlier process recorded predates every run of this
one, so a clock set back across a restart cannot make every new run
look stale. Records and action outcomes keep comparing with
LastRegressionAt, since they also judge sessions of earlier processes.
The adapter takes each run's start from the orchestrator's session
store, so the aicontracts boundary and the enterprise orchestrator are
unchanged.
2026-10-23 11:51:53 +01:00
rcourtman 04a49ed72a Name failed digest runs and keep the Investigated tile honest
The digest's investigation counts gain a "failed" bucket for a run that
ended on an error with no outcome, the name telemetry already uses. The
digest email words it "ended with an error" and places it right after
timeouts. The This week card now does the same.

When none of a week's investigations had an outcome the card has wording
for, the Investigated tile still said "No issues needed a closer look"
(or the watch-only sentence) under a non-zero count. Like the email, the
tile now shows the count alone and keeps those sentences for a week with
no investigations.
2026-10-23 11:17:26 +01:00
rcourtman 5c7d2c6e67 Show unclassified and interrupted Patrol investigation outcomes
Since rcourtman/Pulse#3129 Patrol records two more investigation
outcomes: unclassified, for a run that finished without a readable
conclusion, and interrupted, for a run that a restart or shutdown cut
short. The frontend's outcome lists did not know either value, so the
findings store dropped both to no outcome. An unclassified run never
reached Needs Attention and offered no Re-investigate, an interrupted
row asked the operator to review it, the drawer showed the raw keys, and
the This week card said "No issues needed a closer look" under five
investigations.

The store now keys the outcomes it accepts by the InvestigationOutcome
union in api/patrol.ts, which api/ai.ts reuses, so the next outcome fails
type-check instead of vanishing. Every presentation map gains both
values. Unclassified reads "No clear conclusion" in the warning tone and
joins the attention queue. Interrupted reads "Interrupted — will retry"
in the neutral tone, its row says "Will retry", and its run note is no
longer styled as an error. The This week card uses the digest email's
wording and order.

A browser pass on a Pro stack found two drawer defects the new labels
exposed. The Patrol record showed a red Failed run status beside "will
retry", and on phones the longer badge broke Re-investigate across two
lines. Like the investigation header, the record now shows its run
status only until the run has an outcome, its badge uses the shared
outcome labels, and the header row wraps.

Contract-Neutral: ai-runtime and api-contracts already record the unclassified and interrupted outcomes (#3129); the API clients only mirror them and the record badge reuses the shared outcome label. The Patrol presentation is documented in patrol-intelligence.md.
2026-10-23 11:01:12 +01:00
rcourtman 85fb199ff5 Merge main into feat/patrol-typed-conclusions
Main gained the Assistant retirement (#3126), which also appended notes
to the agent-lifecycle, ai-runtime, api-contracts and storage-recovery
contracts. Keep both: main's notes, then this change's.
2026-10-23 09:36:06 +01:00
rcourtman b3b5ad6e1f Record Patrol investigation conclusions as a typed tool call
Patrol investigations inferred their outcome from prose markers, so a
bold or bulleted RESOLVED line became the terminal needs_attention and
the finding was never investigated again. Timeouts clustered on
subscription agents. Patrol's 60s stream idle timeout bounds a whole
buffered CLI turn there, three investigations queued on one serial CLI
slot, and the final summary call was capped at 20s.

- Add patrol_conclude_investigation, an investigation-only,
  side-effect-free capture of resolved, cannot_fix or needs_attention.
  It is carried on InvestigationRunResult and
  OrchestratorInvestigationResult.
- After a conclusion, the next turn is the summary. The final turn
  offers only the tools that end a run. A deadline reserve makes the
  current turn final before the run times out, and the summary call
  may use the time left.
- Give investigation turns on subscription agents a 4-minute turn
  budget, a 25-minute default timeout and one investigation at a time.
  These limits are republished on config reload without holding the
  service lock across the Patrol lock.
- Derive Patrol's outer deadline from the effective timeout. Sessions
  now carry their own deadline, and the stuck-run sweep measures a live
  run against it, so neither a lowered timeout nor the previous run's
  time resets a running investigation.
- Record a run orphaned by a restart as interrupted, not timed out. A
  first investigation cut short that way used to stay running for
  good, which also blocked a manual re-run. Recovery applies only while
  the finding still shows that run, takes a result the run's session
  already recorded, and keeps an action outcome such as fix_queued.
- Add unclassified and interrupted outcomes, with lifecycle, telemetry
  bucket, digest and record handling.
2026-10-23 01:26:33 +01:00
rcourtman f330c76481 Merge the dompurify and vitest bump into the Assistant retirement
Both branches edit the frontend manifest, lockfile and dependency floor
list: this branch drops highlight.js, the bump raises dompurify and
vitest past the 22 October advisories that fail the required audit.
Each conflict keeps the bump's versions without highlight.js.
2026-10-23 01:19:21 +01:00
rcourtman 2d0232ce69 Bump dompurify and vitest past the 22 October advisories
npm audit started failing the required Frontend check on every branch
once GHSA-cg2g-xh64-v4q7 (critical, SAFE_FOR_TEMPLATES bypass),
GHSA-mrv2-3pqr-j5jr and GHSA-c6gr-qvv4-5v3f were published against
dompurify <=3.4.16, and GHSA-qxg6-89mg-rqv8 and GHSA-xp26-3454-mjgp
against @vitest/mocker <4.1.12. dompurify moves to 3.4.17, vitest and
@vitest/coverage-v8 to 4.1.12, and the dependency security test raises
its floors to match. The deployment-installability contract records
the reviewed floors, as the 30 September DOMPurify bump did.

The lockfile takes only what those updates resolve: the vitest family,
the chai, obug, std-env and tinyrainbow versions its ranges now pick,
and one deduplicated picomatch 4.0.7 in place of the nested copies.
The bundled wasm entries newer npm writes for
@tailwindcss/oxide-wasm32-wasi are left out, as they are on main.

Dependabot's group PR 3058 carries these bumps among others but has
been blocked on unrelated failures since 21 October.
2026-10-23 01:11:50 +01:00
courtmanr@gmail.com cd514df7f9 Keep record drawer headings whole on phones
A record drawer's owner link repeated the object's name next to the
heading that already shows it. On a phone the button took about half
the header, so a long heading such as a Kubernetes pod's broke mid-word
across four lines ("paymen / ts-worker- / 58484d44f7 / -f6gzh").

The link now reads "Open" below the sm breakpoint and "Open <name>"
above it, keeping the full label as its accessible name and tooltip.
On a 390px phone the button drops from 192px to 70px and the pod
heading wraps once, at a hyphen. Desktop is unchanged.
2026-10-23 00:28:02 +01:00
rcourtman 28172a907b Retire the Assistant chat window; alert Investigate runs Patrol
Pulse Assistant was a chat drawer beside the monitoring UI. Operators who
want to talk to their infrastructure already use Claude Code, Codex, or
another MCP client, and Pulse MCP gives those agents the same governed
tools. Keeping a second, weaker chat in the web app cost a 66 kB chunk,
a launcher on every page, hand-off buttons on alerts, findings, approvals
and drawers, and thousands of lines of OpenCode-parity drawer code, for a
feature the maintainer judged ineffective.

This removes the browser chat and everything that only fed it:

- The drawer, its launcher (desktop and phone), the command-palette
  Assistant commands, the Escape and blocking-dialog shell plumbing, and
  the chat store, client, dev stream fixture and generated SSE types.
- Every "Open in Assistant", "Explain with Assistant", "Discuss" and
  "Ask Assistant" hand-off on alerts, incident timelines, Patrol findings,
  approvals, run history, the attention workbench and resource drawers,
  with their context models and the chat-only helpers they used.
- The Assistant settings page and its session maintenance section. The
  page becomes "External agents" at /settings/pulse-intelligence/agents;
  the old /assistant path, ?tab=assistant and system-ai-assistant redirect
  there with the hash kept.

Alert Investigate now starts a targeted Patrol run (resource, alert ID and
type) and says where the findings land, and it ignores repeat clicks while
the request is in flight. The run route needs an admin with ai:execute, so
security status gains settingsCapabilities.patrolRun, derived from the same
snapshot as the other admin capabilities but following the route for
org-scoped admins, organization-bound tokens and a cookie beside a token,
and Investigate is offered only where it is true. The Patrol
tool transcript block moves to components/patrol. A small aiAvailability
store holds the session assistantEnabled fact that gates Patrol entry
points and Investigate; it now clears and refreshes on organization switch,
refreshes when organization loading settles on another org and after
provider removal, and ignores answers that arrive after a newer one.

The backend chat service stays: Patrol investigations run on it and
Pulse Mobile chat uses it until 31 March 2027, so /api/ai/chat, the
session endpoints and the stream contract are unchanged. A new contract
snapshot pins the steer_applied event that only the deleted TypeScript
projection used to cover.

highlight.js, used only by the chat's code highlighter, leaves the
dependencies, as does the DOMPurify carve-out that kept its fence hints.

Contracts, registry and readiness proofs drop the retired surfaces; the
ai-runtime contract loses about 1,600 lines of drawer rules while keeping
every backend session, stream and provider rule. The production build
ships about 100 kB less gzipped JavaScript.
2026-10-23 00:15:04 +01:00
courtmanr@gmail.com 3745f4738d Let Docker and vSphere host rows show their workloads
On the Proxmox overview a node row narrows the guest table to that
node and the chevron opens the node's drawer. Docker and vSphere host
rows opened the drawer instead, so seeing one host's containers or VMs
meant scrolling past every other host's group or finding the Host
filter.

Docker and vSphere host rows now work like Proxmox nodes: a click
narrows the containers or VMs to that host, titles the table "...on
<host>" with Show all hosts, marks the row and moves focus to the
heading; a second click clears it. The chevron opens the drawer and
stays visible on phones, where the host column takes the width Uptime
did not need so names stay readable.

Docker scopes through the ?host= filter its containers table already
read; every host now stays listed and the scoped one is marked, where
that filter used to hide the others (the Docker link from a VM drawer
lands the same way). A vSphere host scopes to the scope the VM table
already gave its own VMs, matched within its vCenter connection, so
host and VM rows cannot disagree.

The name button, scoped heading and focus handling move into shared
PlatformHostScope pieces that the Proxmox overview now uses too. The
Docker Swarm tab's host table has nothing under it to scope and keeps
opening the drawer on a row click.
2026-10-22 22:11:51 +01:00
courtmanr@gmail.com 177325cb03 Let Coverage restore evidence details wrap
The Coverage drawer's restore evidence ran long snapshot descriptions
("checkout-web-02 local rollback snapshot") straight past the drawer
edge with no tooltip. The cells asked for whitespace-normal, but the
global `.table-fixed td` rule forces nowrap and outranks a utility
class on the cell, so the request did nothing.

Put the wrap on each value's span instead, the way the shared drawer
detail tables already do, and pin it in the Coverage test.
2026-10-22 20:47:20 +01:00
courtmanr@gmail.com 30024b0f85 Wrap drawer sentences and open fleet details under the tile
Two leftovers from the drawer consistency pass.

TrueNAS pool and disk drawers cut their summary sentences off with an
ellipsis ("ZFS pool archive is DEGRADED and ..."), so the one line that
explains the pool's state was the one a user could not read. Those
rows, and vSphere's hardware upgrade and Tools install errors, now
wrap; short values stay on one line.

The availability fleet view opened its drawer below the whole grid, so
clicking a tile near the top of a large fleet showed nothing nearby.
The drawer is now a full-width grid item right after the tile it
belongs to, so it opens beneath that tile's row. The fleet list is
reconciled by id so live updates refresh the open drawer instead of
remounting it, and closing it returns focus to the tile.
2026-10-22 19:13:57 +01:00
rcourtman de2c9729c2 Let TrueNAS record drawers open the object they are about (#3119)
Record drawers on the other platforms link to their owner ("Open sdc")
wherever the owner's page can narrow to it. TrueNAS tabs only read a
storage kind filter, so its alert and protection drawers had no link:
a link that lands on a whole tab leaves the user hunting for the row.

TrueNAS tabs now keep their table search in the URL (?q=), the way the
Kubernetes and Machines pages do. The page-path helper emits it for
TrueNAS pools, datasets, disks, apps, VMs and shares (not for a disk
named only by an alert, which may not match a row), so the alert and
protection drawers gain the owner link and land on the tab already
narrowed, with the filter visible in the search box. Systems keep an
unfiltered link-free Overview because that table has no search box.

Also:
- The Coverage drawer's restore evidence wraps long snapshot and
  archive names instead of cutting them; table rows still use one line.
- The disk-live-refresh browser proof waited for the full "Replace Now"
  verdict on the phone, where the health column shows "Replace"; it now
  waits for the label the layout shows, and passes on phone WebKit.
2026-10-22 18:21:10 +01:00
courtmanr@gmail.com 3704f006f3 Let TrueNAS record drawers open the object they are about
Record drawers on the other platforms link to their owner ("Open sdc")
wherever the owner's page can narrow to it. TrueNAS tabs only read a
storage kind filter, so its alert and protection drawers had no link:
a link that lands on a whole tab leaves the user hunting for the row.

TrueNAS tabs now keep their table search in the URL (?q=), the way the
Kubernetes and Machines pages do. The page-path helper emits it for
TrueNAS pools, datasets, disks, apps, VMs and shares (not for a disk
named only by an alert, which may not match a row), so the alert and
protection drawers gain the owner link and land on the tab already
narrowed, with the filter visible in the search box. Systems keep an
unfiltered link-free Overview because that table has no search box.

Also:
- The Coverage drawer's restore evidence wraps long snapshot and
  archive names instead of cutting them; table rows still use one line.
- The disk-live-refresh browser proof waited for the full "Replace Now"
  verdict on the phone, where the health column shows "Replace"; it now
  waits for the label the layout shows, and passes on phone WebKit.
2026-10-22 17:54:53 +01:00
pulse-triage[bot] 7737469045 Release v6.6.0-rc.6 (#3115)
Release preparation for `v6.6.0-rc.6`. After this merges, the release
workflow runs at this exact head
`63d35086b19ebd76043ddbad3e246d42826c1d66`.
2026-10-22 16:51:26 +00:00
courtmanr@gmail.com 4aa3d7aa5f Finish moving the last bespoke drawers onto the shared shape
The earlier drawer changes left a few surfaces that still looked or
behaved differently, plus code nothing used any more:

- Proxmox Mail Gateway had two drawers for one gateway; the row now
  opens the shared resource drawer with mail flow as its Mail tab. The
  Ceph cluster drawer gains the canonical header, Overview (alerts,
  Worth checking, capacity history, cards, closed Details) and Manage,
  and its dot follows the row instead of reading grey on missing health.
- The Kubernetes cluster drawer's Deployments tab repeated the Workloads
  table with dead-end rows; it is gone, and Namespaces opens the
  Workloads tab scoped to the cluster and namespace with one action.
  The Docker Swarm drawer tab could not be reached from any page; gone.
- On phones, windowed tables (Kubernetes deployments, pods) and the
  Storage tab's grouped pools stopped at 70-90% of the width: a
  colspan=100 spacer or colspan=99 group row created phantom columns
  that took the width hidden columns left. Those rows now span exactly
  the visible header cells.
- The node and Docker host drawers gain the guest drawer's Open action;
  the Docker host withholds readings while offline or stale, lists
  operator state first in Manage and shows its action history. A
  guest's physical disks open the same disk Overview as the Storage tab,
  with the guest agent's disk temperature alerts.
- One "No active alerts" line, one timestamp row and wrapping alert
  subjects replace per-drawer copies; PBS and PMG alerts match on their
  instance id so drawers find them without a metrics target.
- Removes the old disk detail and its presentation code, the Swarm and
  Kubernetes Deployments drawers and their presentation helpers, and
  drawer state nothing read.
2026-10-22 16:35:11 +01:00
pulse-triage[bot] 63d35086b1 Prepare v6.6.0-rc.6
Release candidate cut from main at e67fff71d5. It carries every change merged since stable v6.5.0.

Contract-Neutral: Release-only v6.6.0-rc.6 version, release notes and distribution metadata; no public install or runtime contract changes.
Change-source: pulse-maintainer
2026-10-22 15:57:13 +01:00
courtmanr@gmail.com 750292e21d Build every record drawer on the shared record panel
Alerts, events, tasks, services and backup rows open a "record" drawer:
something that happened to an object, not an object to manage. Many of
them were hand-built grids that looked like a different product: no
heading, no status, no way to close from inside, raw timestamps, labels
cut to three, and no link to the object they were about.

- The five Docker Swarm tables, Docker networks and storage usage,
  Proxmox replication and coverage now open the shared record panel:
  the subject's name and the row's own status dot, a one-line summary,
  wrapping sections with human dates, and an "Open <name>" link where
  the app can focus the owning object (and none where it would only
  land on a whole tab).
- Recoverable restore points had no drawer, and on phones lost their
  location entirely; they now open one.
- Kubernetes events, ConfigMaps, Secrets and RBAC objects opened the
  full object drawer, which never showed an event's message and offered
  maintenance windows on it. They open record drawers instead; a
  Secret's key names and values are never shown.
- Coverage allowed several rows open at once; it now opens one, like
  every other table.
- Agent Doctor targets open one at a time with a proper header, shared
  notices and a closed Details for connection evidence.
- Availability fleet tiles behave as disclosures and open the same
  table-row drawer as the table view.
2026-10-22 15:11:15 +01:00
courtmanr@gmail.com a98b5a69dd Bring storage and platform drawer content up to the guest drawer
The Proxmox guest and node drawers lead with what is wrong, chart the
last day and keep the long tail in one closed Details. Other drawers
did not:

- Storage pool and physical-disk details on the Proxmox Storage tab had
  no heading, status dot or collapse control, no alerts line or Worth
  checking, a pool chart defaulting to 7 days with its own picker, a
  disk "History" and "Live I/O" section, and every config, topology and
  SMART table open by default. Both are now object drawers in the shared
  detail row, with Overview (alerts, Worth checking, 24-hour charts,
  facts, ZFS/SMART cards, closed Details) and Manage (operator state).
  A disk alert that already states the verdict is not repeated beneath
  it, and worn flash is stated once by the verdict.
- TrueNAS ZFS errors and disk risk, vSphere datastores that are not
  accessible, Kubernetes containers that are not ready and cordoned
  nodes sat two or three closed disclosures deep and never reached
  Worth checking. Platform sections now join the same split as every
  other drawer: warnings lift, cards show, the rest waits in Details.
- Kubernetes Deployments, autoscalers, Services, Ingresses and volumes
  had no section at all, so columns hidden on phones were unreachable.
  Docker image and volume drawers showed nothing about the image or
  volume.

No field is dropped; it moves to where the guest drawer keeps it.
2026-10-22 14:25:19 +01:00
courtmanr@gmail.com 5d6d9e2dcf Give every drawer the Proxmox drawer's heading and shell
The Proxmox guest and node drawers were reworked into a clear shape, but
the drawers on other pages kept older pieces that disagree with them:

- The generic resource drawer coloured its status dot from the raw
  status while the row above it used each platform's own verdict, so a
  succeeded pod read red, a stale machine read Online and a running but
  failing container read green in the drawer while the row said
  otherwise. Tables now pass the indicator behind their row dot, and the
  drawer repeats it.
- Record drawers (alerts, activity, services, protection jobs) were
  headed by a generic label such as "Alert detail", had no status dot,
  named their collapse button after an internal id, and cut alert
  sentences and identifiers with an ellipsis that phones cannot hover.
  They are now headed by their subject with its status and a one-line
  summary, take an actions slot for owner links, and wrap their values.
- Five tables (Ceph, Mail Gateway, Replication, Coverage, Docker
  Networks) replaced the detail row's content class to change padding
  and silently dropped its phone width cap. The shell is no longer
  overridable, and a guard keeps it that way.
- DrawerSubjectHeading is now the one subject heading: names wrap rather
  than truncate, and the generic drawer uses it with its source badges
  beside the name, an Open action for a saved web URL, and no duplicate
  health chip (the issue already leads Worth checking).

Verified on a full mock estate at 1440 and 390 against origin/main: every
drawer dot now matches its row and no route logs a console error.
2026-10-22 13:39:35 +01:00
rcourtman a5b8917331 fix(mock): re-sight shared storage and Ceph on the PVE cadence (#3110)
Stacked on #3109 (`fix/mock-pve-guest-sighting-cadence`); once that
merges, this PR's diff reduces to its own commit.

## Problem

In mock mode, the update loop picks PVE inventory by node name, one
cohort at a time. Shared storage belongs to no single node: the PBS
stores use the owner `"cluster"`, and `shared-storage`, `ceph-rbd` and
`cephfs-data` use `"shared"`. The mock Ceph cluster has no node either.
None of them ever matched a cohort, so the loop never re-sighted them.
They kept their startup stamp and turned into stale Proxmox sources
within minutes, at every `PULSE_MOCK_UPDATE_INTERVAL`, the default 2s
included. The Ceph cluster was worse. `generateCephClusters` set its
`LastUpdated` from the wall clock and nothing touched it again, not even
the full `UpdateMetrics` refresh. Both readings show on the public
demo's Storage and Ceph views.

## Fix

Cluster-scoped inventory is sighted on the first cohort of each PVE
rotation. That is the cadence `mock.ProxmoxRefreshInterval` reports
(#3109), and the mock Proxmox stale threshold derives from it. A row is
sighted only while one of its reporting nodes is online; when a row
lists no reporting nodes, any online node of its instance counts. The
same selection also resamples shared-storage usage, which the cohort
loop skipped as well. The full refresh path keeps sighting everything.

**Mock-only.** The real storage poll stamps cluster storage with the
poll's start time on every poll (`monitor_polling_storage.go`,
`LastSeen: startTime.UTC()` for the `Node: "cluster"` rows).
`pollCephCluster` runs after every storage refresh and sets
`LastUpdated: time.Now()` (`ceph.go`).

## Proof

**Regression test:**
`TestFixtureGraphSlowTicksKeepProxmoxClusterSightingsFresh` drives the
loop's own cohort sequence through two rotations at 2s, 7s, 15s and 60s
ticks. It records each shared store's and the Ceph cluster's worst
sighting age just before a tick. It fails on #3109's head: at 15s ticks
every shared store reaches 5m0s and Ceph ~1h43m, against a 30s bound. It
passes with this change. The rest of the cohort and cadence tests in
`internal/mock` still pass, and the full `internal/mock` suite passes.

**Live before/after on pulse-dev.** Two builds ran side by side: #3109's
head (before) and this branch (after). Both used fresh data dirs and the
demo env (`PULSE_MOCK_MODE=true DEMO_MODE=true PULSE_MOCK_NODES=8 ...
PULSE_MOCK_UPDATE_INTERVAL=15s PULSE_MOCK_SEED_METRICS_STORE=true
PULSE_TELEMETRY=false`). Storage and Ceph resources from
`/api/resources` were tallied once a minute for 10 minutes:

| t | before: storage/ceph with a stale Proxmox source | before: oldest
sighting | after: stale | after: oldest sighting |
|---|---|---|---|---|
| +0m | 3 (Ceph, ceph-rbd, cephfs-data) | 167s | 0 | 20s |
| +1m | 8 | 227s | 0 | 20s |
| +2m | 8 | 287s | 0 | 20s |
| +5m | 8 | 467s | 0 | 5s |
| +10m | 8 | 767s | 0 | 20s |

On the before build, the 8 stale sources were the Ceph cluster, two
`ceph-rbd` and two `cephfs-data` rows, `shared-backup-fabric`,
`west-backup-primary` and `west-backup-offsite`. From t+1m onwards, the
before build showed `storage/warning/proxmox:stale=7` and
`ceph/warning/proxmox:stale=1`. The after build stayed at
`storage/online/proxmox:online=22` and `ceph/online/proxmox:online=1`
all through; its one Proxmox storage in `warning` has an online source.
2026-10-22 12:04:57 +01:00
rcourtman a4971e9873 fix(mock): keep PVE guest sightings fresh on slow mock ticks (#3109)
## Why

A mock stack with the public demo's settings
(`PULSE_MOCK_UPDATE_INTERVAL=15s`, 8 nodes, 6 VMs + 4 LXCs per node)
drifts healthy running guests into Attention within minutes: `status:
"warning"`, `health.reasons: telemetry_stale`,
`sourceStatus.proxmox.status: "stale"`.

Root cause: the mock update loop sights PVE nodes and their guests
through ten fixed cohorts, sized for the default two-second tick (every
node within 20s). The count never followed the tick interval, so at 15s
each node's guests were sighted only every 150s, while the registry
judges Proxmox sightings stale after 60s.

## What

- `internal/mock`: the PVE estate rotates through the largest divisor of
the ten cohorts that still sights every node within 30s
(`proxmoxCohortCount`). At the default 2s tick nothing changes (10
cohorts). At 15s it uses 2 cohorts, and from 16s up every node is
sighted on every tick. A divisor keeps the loop's cohort index mapping
each node to exactly one tick per rotation. Per-second churn never
exceeds the 2s default.
- `internal/monitoring`: when a single tick is slower than that budget,
the mock Proxmox stale threshold is derived from the actual sighting
cadence (`mock.ProxmoxRefreshInterval`), never below the configured PVE
derivation. This mirrors how 1d9e66b24c derived the supplemental
provider thresholds.
- Contract notes in `monitoring.md` and
`performance-and-scalability.md`.

**Mock-only.** Real PVE polling builds every guest of an instance with
that poll's `sampleTime := time.Now()`, and its threshold already
derives from the poll interval or the adaptive scheduler's maximum. No
real poller goes through the cohort rotation.

## Proof

- `TestFixtureGraphSlowTicksKeepProxmoxGuestSightingsFresh` drives the
loop's own cohort sequence through two rotations at 2s/7s/15s/60s. It
fails on `main` (15s: "last sighted 45s ago, want within 30s"; 60s:
"2m0s ago") and passes with the fix.
- `TestProxmoxRefreshCadenceTracksSightingBudget` and
`TestResourceStaleThresholdsDeriveMockProxmoxCadence` pin the cadence
table and the threshold derivation.
- `internal/mock` and `internal/unifiedresources` pass in full.
`internal/monitoring` passes in full except one expected assertion
change: `TestMockUnifiedViewAppliesOperatorManualLinks` holds a 5-minute
mock tick, so its Proxmox threshold now derives to 10m instead of the
configured 4m. That test was updated and passes. `internal/api`
mock/stale/demo tests pass.

Live before/after on pulse-dev: `main` 08217d112e vs the same plus this
patch, both run with the demo's exact mock env and a fresh data dir. The
tally covers PVE guests across all `/api/resources` pages:

| t | main: warning / telemetry_stale (of 77) | fix: warning /
telemetry_stale |
|---|---|---|
| +60s | 25 / 28 | 0 / 0 |
| +180s | 53 / 58 | 0 / 0 |
| +300s | 64 / 70 | 0 / 0 |
| +480s | 49 / 53 | 0 / 0 |
| +600s | 64 / 70 | 0 / 0 |

The oldest live-guest sighting was 137–152s on `main` and at most 21s
with the fix.

Not in this change: shared `cluster` storage (`ceph-rbd`, `cephfs-data`)
and the mock Ceph cluster are never re-sighted by any cohort, so they
read stale on both builds at any tick rate. That needs its own fix.
2026-10-22 11:42:47 +01:00
rcourtman 63ae0eb6f8 fix(mock): re-sight shared storage and Ceph on the PVE cadence
The mock update loop selects PVE inventory by node name, one cohort at a
time. Shared storage merged under a cluster-level owner ("cluster" for
the PBS stores, "shared" for shared-storage, ceph-rbd and cephfs-data)
and the mock Ceph cluster match no node, so the cohort loop never
re-sighted them. They kept their startup stamp and read as stale Proxmox
sources within minutes of start, on every tick rate. The Ceph cluster
was worse: its LastUpdated was set from the wall clock at generation and
never touched again, even by the full refresh path. The public demo's
Storage and Ceph views show this.

Sight cluster-scoped inventory once per PVE cohort rotation, the cadence
mock.ProxmoxRefreshInterval reports and the mock Proxmox stale threshold
derives from, while any of its reporting nodes (or any node of its
instance when none are listed) is online. The same selection now
resamples shared storage usage, which the cohort loop also skipped.

Mock-only: the real storage poll stamps cluster storage with the poll's
start time on every poll, and pollCephCluster sets LastUpdated each
poll.
2026-10-22 11:32:40 +01:00
rcourtman f69860b153 fix(mock): keep PVE guest sightings fresh on slow mock ticks
The mock update loop sights PVE nodes and their guests through ten fixed
cohorts, sized for the default two-second tick (every node within 20s).
The count did not follow PULSE_MOCK_UPDATE_INTERVAL, so the public demo's
15s tick sighted each node's guests only every 150s while the registry
judges Proxmox sightings stale after 60s. Within minutes most running
guests read as warning/telemetry_stale with proxmox source "stale", and
the Proxmox overview's Attention chip climbed to 64 of 77 guests.

Rotate the PVE estate through the largest divisor of the cohort count
that still sights every node within 30s, down to every node per tick. A
divisor keeps the loop's cohort index mapping each node to one tick per
rotation, and per-second churn never exceeds the two-second default. For
ticks slower than that budget, derive the mock Proxmox stale threshold
from the actual sighting cadence (mock.ProxmoxRefreshInterval), never
below the configured PVE derivation, the same way 1d9e66b24c derived the
supplemental provider thresholds.

Mock-only: real PVE polling rebuilds every guest of an instance with the
poll's sample time, and its threshold already derives from the poll
interval or the adaptive scheduler's maximum.
2026-10-22 11:10:48 +01:00
rcourtman 46a87e1a46 Point localized READMEs at the current licence screen
The German and Spanish getting-started guides told paid customers to
activate under Settings -> Plans -> Existing purchases. That heading
left the app in June (27ba85ddc6); the key field now sits under
Settings -> Plans & Billing -> License recovery, so customers following
the guide looked for a section that does not exist. Update both guides,
their shipped mirrors, and the machine-facing string the localized docs
test pins.
2026-10-22 11:00:31 +01:00
rcourtman 4809e1085f Mute the workload history hover hint
The one-time "Hover a guest to preview history" tip in the guest
toolbar had no colour class, so it rendered in body-text white while
every control around it (View, Add filter, the History label) is muted
slate. A passive tip outshone the controls and read as a stray label.
It now uses the muted token, so it reads as a caption for the History
range control it explains. Reveal, clear-on-hover and phone hiding are
unchanged.

The README workspace capture and social preview are refreshed from
the same build so they show the muted hint.

Contract-Neutral: Hint colour token only; no change to what the workloads toolbar shows or when
2026-10-22 10:24:07 +01:00
rcourtman 5a1c31bdc6 docs: refresh README product screenshots
The README and SCREENSHOTS captures dated from 6 August. Since then the
Proxmox overview moved the node table above the guest filters and gained
the history range picker, and the Patrol page was rebuilt around a
decision inbox with Protection and Activity tabs, so the Patrol image
showed a page that no longer exists.

The new captures come from a main build running the public demo's mock
estate, at 2x for high-density screens, and the social preview reuses
the new workspace capture. SCREENSHOTS.md no longer gives demo
credentials because the demo signs visitors in automatically.
2026-10-22 10:09:01 +01:00
pulse-triage[bot] 6cc795a27c Prepare v6.6.0-rc.6
Release candidate cut from main at 045e7af651. It carries every change merged since stable v6.5.0.

Contract-Neutral: Release-only v6.6.0-rc.6 version, release notes and distribution metadata; no public install or runtime contract changes.
Change-source: pulse-maintainer
2026-10-22 03:15:00 +01:00
rcourtman 045e7af651 alerts: keep only the newest evidence envelope in history entries (#3096)
## Why
After #3093, the remaining weight of the alert history fold is evidence
nothing reads. On the long-running dogfood install, a resolved snapshot
averages 21 KB, and 83% of that is `evidence`: a trail of up to 64
envelopes (35 on average), across 1,895 occurrences kept for 90 days
(#1803).

Nothing in Pulse reads `Alert.evidence` from history. The incident
panel's evidence comes from the incident timeline endpoint, the AI's
alert history uses recently resolved alerts, and the attention summary
and quality telemetry never touch it.

## What changed
- The fold trims each decoded snapshot to its newest envelope, by
`observedAt` and then position. An updated envelope keeps its slot, so
the last one isn't always the newest. Merging takes the incoming
snapshot's evidence, so the fold never holds a full trail.
- History output applies the same rule to live overlays and the JSON
fallback, so every `GET /api/alerts/history` entry carries at most one
envelope.
- `operationalRecord.evidenceIds` and `transitions[].evidenceIds` keep
their full lists. The event log keeps every snapshot whole, and `GET
/api/alerts/active` still returns an open alert's full evidence.
- The api-contracts history clause, `docs/API.md` (and its shipped
mirror) and the alerts contract say so.

This is an intentional change to a public API response, approved by the
maintainer. A client that read full evidence from history should read
the active alert, the incident timeline or the event log.

## Measured
On a log shaped like the dogfood install's (1,900 occurrences, 35
envelopes each), the history fold retains 2.1 MB of heap, down from 27.2
MB.

## Checks (pulse-dev)
- New tests:
  - newest by time and tie-breaking;
  - the fold holds one envelope while the record keeps all 64 IDs;
  - the event log stays whole;
  - live overlay and JSON fallback;
  - direct output shaping, with its input untouched;
  - `docs/API.md` states the rule.
- Negative controls: keeping full evidence in the fold, in the output,
or choosing by position each fail their test.
- `internal/alerts/...`, `internal/api/alerting/...` and
`docsLinks.test.ts` pass. Prettier, vet, the completion, contract,
docs-mirror and browser-verification guards all pass.

Related to #1803
2026-10-21 17:52:27 +01:00
rcourtman e19c97c6cb agents: name command access plainly and ask for it before the mint (#3094)
## Problem

Docker and Podman container updates need an agent with command access.
The only way to grant it was a checkbox called **Enable legacy combined
command profile**. It sat behind **Show advanced connection and install
options**, which only appears after the install token is minted. The
word "legacy" told the users who want updates not to use it. In #1728 a
user missed it entirely and hand-edited token scopes instead.

## Is the option being retired for container updates?

Not yet, so it should not be labelled as if it were.

- `docker_container_action_executor.go` sends updates through the agent
command session.
- The separate action runner implements `container.update`, but
`AGENT_SECURITY.md` lists its container actions as unqualified.
- The runner needs the safe collector profile. Under that profile,
rootful Docker gets summary inventory only, with no update checks or
actions.

The security posture is unchanged. Command access is off by default.
Choosing it mints an execution-scoped token and records
`command-capable`. A `monitoring-only` install still cannot be promoted
remotely.

## Changes

- The checkbox now reads **Allow Pulse to run commands on this host**
and sits in the token step, so the choice is made before the token is
minted. Its description says it is needed for container updates, start,
stop and restart, and Patrol actions. It also says Pulse can then run
commands as root, and that the option cannot be switched on later from
Pulse.
- The token summary no longer claims command actions can be enabled per
host from Settings. It now states the real choice.
- One shared label (`getUnifiedAgentCommandAccessOptionLabel`) feeds the
installer, the locked Docker update pill and the Proxmox setup guide, so
their wording cannot drift apart.
- `DOCKER.md`, `AGENT_SECURITY.md` and `PRODUCTION_SECURITY.md` name the
option the same way.

## Verification

- Private mock stack, 1400 px and 375 px:
  - The option appears in step 1 before a token exists.
- Ticking it and then generating a token mints with command execution,
and every install command carries `--enable-commands`.
  - Unticking it regenerates the token without that permission.
  - The advanced options no longer hold it.
  - The Docker and Proxmox notes name it.
- Mock Docker hosts carry no agent facet, so the "installed without
command access" pill cannot render on mock data. Its copy is covered by
the `containerUpdateBadgeModel` and `ContainerUpdateBadge` tests.
- Settings, shared components, Docker, the container drawer and utils
suites pass: 4436 tests on a rerun. The first run on a heavily loaded
pulse-dev had one unrelated failure, and the rerun passed in full.
Typecheck, ESLint and Prettier are clean.
- Browser receipt included. Contract notes added for agent-lifecycle,
api-contracts and frontend-primitives.
2026-10-21 17:39:07 +01:00
rcourtman 9bb421f230 alerts: keep only the newest evidence envelope in history entries
Most of the alert history fold was evidence nothing reads. On the
dogfood install a resolved snapshot averaged 21 KB, 83% of it the
evidence trail of up to 64 envelopes (35 on average), across 1,895
occurrences kept for 90 days (#1803). No Pulse UI, AI tool or API
handler reads Alert.evidence from history: the incident panel uses
the incident timeline, and the AI uses recently resolved alerts.

The fold now trims each decoded snapshot to its newest envelope, by
observedAt and then position (an updated envelope keeps its slot, so
the last one is not always the newest). History output applies the
same rule to live overlays and the JSON fallback, so every
/api/alerts/history entry carries at most one envelope.
operationalRecord.evidenceIds and transitions keep their full lists,
the event log keeps every snapshot whole, and /api/alerts/active still
returns an open alert's full evidence.

On a log shaped like the dogfood install's, the history fold went from
27.2 MB to 2.1 MB of retained heap. Negative controls: keeping full
evidence in the fold, in the output, or choosing the newest by position
each fail their test. The alerts tree passes on pulse-dev.

Related to #1803
2026-10-21 17:30:17 +01:00
rcourtman 487ab8a7c4 alerts: answer history reads from the shared fold without full copies (#3093)
## Why
A heap profile of the long-running dogfood install (77.6 MB live after a
forced GC) shows the alert history projection holding 36 MB (47%). It
keeps one merged alert per occurrence for the event log's 90-day
retention: 1,895 occurrences there, with resolved snapshots averaging
about 20 KB. Reads made it much worse (#1803):
- every history read deep-copied the whole projection before applying
its limit, so the attention summary's 500-row read copied all 1,895
occurrences;
- every read with a `since` (the 30-day alert-quality window,
date-filtered history) built a fresh projection and re-decoded every
snapshot in its window.

That churn is why the same install holds about 158 MB of Go heap for 78
MB of live data.

## What changed
- One full-history fold, built and extended as before. Each occurrence
also keeps a small mark per folded event (time, durable ID, and the
`LastSeen` it contributed) and the index of its latest acknowledged
snapshot.
- A `since` read is answered from that fold. The window starts at each
occurrence's first event that the log's own `occurred_at >= since` text
comparison admits. `LastSeen` and acknowledgement come from that point
on, and occurrences are ordered by their first in-window event, exactly
as a walk of only the window would produce.
- Overlay, sort and limit run on lightweight rows under the projection
lock. Only returned rows are copied, once, by
`canonicalizeAlertHistoryForOutput`.
- The alerts contract notes the windowed-read rule and the pinning
tests.

## Measured (pulse-dev, log shaped like the dogfood install's: about
1,900 occurrences, 40k events)
| Read | main | this PR |
|---|---|---|
| 30-day window | 68.9 ms, 19.8 MB allocated | 5.2 ms, 2.8 MB |
| Latest 500 | 12.7 ms, 8.5 MB | 3.6 ms, 2.0 MB |
| Full history | 20.8 ms, 12.9 MB | 13.4 ms, 6.9 MB |

The 36 MB the fold itself holds is unchanged. The new marks add about 56
bytes per lifecycle event, about 2 MB at 40k events.

## Checks
- `TestWindowedHistoryMatchesAWalkOfOnlyTheWindow` compares every window
and limit against an independent walk of only the window (the original
algorithm). It covers 25 random histories with acknowledgements on both
sides of the boundary, out-of-order `LastSeen`, uneven fractional
seconds where RFC3339Nano text order and time order disagree,
same-instant events, a history clear, and live overlays.
- `TestWindowedHistoryReusesTheFullHistoryFold`,
`TestLimitedHistoryReadCopiesOnlyReturnedRows` and
`TestHistoryReadResultsDoNotAliasTheSharedFold` pin reuse, copying and
isolation.
- Negative controls: ignoring the window, building a fresh fold per
`since` read, and copying every row before the limit each fail their
test.
- Removing the per-row clone did not fail the aliasing test, because the
output copy already isolates results. That double copy was dropped, and
the test guards isolation end to end.
- Vet and the whole `internal/alerts/...` tree pass on pulse-dev.

## Not established
How much held heap this saves on the dogfood install itself. That needs
this build running there.

Related to #1803
2026-10-21 16:13:31 +01:00
rcourtman 43efbb9152 agents: name command access plainly and ask for it before the mint
The only way to give an agent the command access that Docker and Podman
container updates need was a checkbox called "Enable legacy combined
command profile", behind "Show advanced connection and install options",
which only appeared after the install token was minted. The name told the
users who want updates not to use it, and #1728 shows a user missing it
entirely and hand-editing token scopes instead.

It is not being retired for container updates yet. Updates dispatch
through the agent command session; the separate action runner's container
actions are unqualified, and rootful Docker under the safe collector
profile has no update checks or actions. So the option stays, with the
same security posture: off by default, an explicit choice mints an
execution-scoped token and records command-capable, and a monitoring-only
install still cannot be promoted remotely.

The checkbox now reads "Allow Pulse to run commands on this host" and sits
in the token step, so the choice is made before the credential is minted.
Its description says what it enables, that Pulse can then run commands as
root, and that it cannot be switched on later from Pulse. The token summary
no longer claims commands can be enabled per host from Settings. One shared
label feeds the installer, the locked Docker update pill and the Proxmox
setup guide, and the docs name the option the same way.
2026-10-21 15:57:49 +01:00
rcourtman 68a59805c9 build: move to Go 1.26.10 and Vite 6.4.5 for October advisories (#3092)
Go 1.26.10 (20 October) distrusts the compromised eMudhra intermediate
CAs in `crypto/x509` (CVE-2026-111943). Pulse binaries built with 1.26.9
still accept chains through those intermediates on every verified
outbound TLS connection. govulncheck does not report a trust-store
change as a called symbol, so the scheduled scan stayed green.

This moves the governed toolchain floor and every pin together:
- `go.mod`, `scripts/.go-version`, the toolchain installer and the
release-script guards
- the source-built container stages (`Dockerfile`, provider control
plane, integration mock), using the `golang:1.26.10-alpine`
manifest-list digest, plus the Debian dev container
- the root-pair diagnostic workflow and script, and their tests
- the deployment-installability contract floor

It also moves Vite from 6.4.3 to 6.4.5, which fixes the three
`launch-editor` advisories behind today's failed dependency advisory
watch and adds the 6.4.5 `server.fs` query check. Both affect only the
local dev server. The lockfile changes only the `vite` entry, and
`dependencySecurity.test.ts` now pins the floor.

Checked on pulse-dev against this exact tree: the go.mod toolchain
resolves to go1.26.10, `go build ./...` succeeds, the root-pair
diagnostic tests pass, govulncheck is clean, the dependency floor test
passes, `npm audit` reports 0 vulnerabilities, and `vite build`
succeeds. `scripts/installtests` passed except
`TestAutoUpdateRunningExecutableIdentity`, which hit its 15-second
subprocess deadline while pulse-dev was at a load average of about 20.
Rerun alone, it passed 3 out of 3 times on this tree and failed 3 out of
3 times on unchanged `origin/main` under the same load, so the timeout
is load-sensitive and not caused by this change.
2026-10-21 15:51:24 +01:00
rcourtman 2494930f04 alerts: answer history reads from the shared fold without full copies
On a long-running install the alert history projection was the largest
part of the Go heap: 36 of 78 MB live on the dogfood install, one merged
alert per occurrence for the event log's 90 days (#1803). Every read
then deep-copied the whole projection before applying its limit, so the
attention summary's 500-row read copied all 1,895 occurrences, and every
Since read (the 30-day alert-quality window, date-filtered history)
built a fresh projection and re-decoded every snapshot in its window.

Each occurrence now keeps one small mark per folded event (time, id and
the LastSeen it contributed) and the index of its latest acknowledged
snapshot. A Since window starts at the first event the log's own
occurred_at >= since text comparison admits, takes LastSeen and
acknowledgement from there and orders occurrences by their first
in-window event, so it equals a walk of only the window. Overlay, sort
and limit run on lightweight rows under the projection lock, and only
returned rows are copied, once, by canonicalizeAlertHistoryForOutput.

On a log shaped like the dogfood install's (about 1,900 occurrences,
40k events), a 30-day read went from 68.9 ms and 19.8 MB allocated to
5.2 ms and 2.8 MB, a 500-row read from 12.7 ms and 8.5 MB to 3.6 ms and
2.0 MB, and a full read from 20.8 ms and 12.9 MB to 13.4 ms and 6.9 MB.

A randomized test holds every window and limit equal to an independent
walk of only the window, across acknowledgements on both sides of the
boundary, uneven fractional seconds, same-instant events, a history
clear and live overlays. Negative controls: ignoring the window, a
fresh fold per Since read and copying every row each fail their test.

Related to #1803
2026-10-21 15:43:32 +01:00
rcourtman 02e53bc777 build: move to Go 1.26.10 and Vite 6.4.5 for October advisories
Go 1.26.10 (20 October) distrusts the compromised eMudhra intermediate
CAs in crypto/x509 (CVE-2026-111943). Binaries built with 1.26.9 still
accept chains through them, so every verified outbound TLS connection
Pulse makes (licence, updates, webhooks, AI providers) stays open to
interception by a holder of those keys. govulncheck does not flag a
trust-store change as a called symbol, so the scheduled scan stayed
green while the gap existed.

Advance the governed toolchain floor and every pin together: go.mod,
the toolchain installer and release-script guards, the source-built
container stages (manifest-list digests for golang:1.26.10-alpine and
the Debian dev container image), the root-pair diagnostic, and their
tests.

Vite 6.4.3 bundles a launch-editor with three advisories
(GHSA-587h-7wmj-h5pm, GHSA-r825-w8mm-5pxw, GHSA-698v-4r3x-3vcv) that
failed the daily advisory watch. 6.4.4 bundles the fixed launch-editor
and 6.4.5 adds the dev-server server.fs query check. Both reach only
the local dev server, not the built product. The lockfile changes only
the vite entry, and dependencySecurity.test.ts now pins the floor.
2026-10-21 15:19:02 +01:00
rcourtman 6517a9d891 docker: offer container updates only where Pulse can apply them
The Docker table offered a live Update button for containers whose update
the server had already refused, most often because the host's agent was
installed without command access. The click then failed with "command
agent is not connected", so on such hosts the feature never worked and
never said why.

The server's refusal arrives only over REST. Opening the Docker page from
another platform page seeded its cache from the websocket-fed
all-resources cache, which read as fresh, and websocket ticks kept it
fresh, so the page never fetched REST and never learned of the refusal.
Each cache entry now records when it last received a REST snapshot, and a
prefer-ws-then-rest route revalidates at mount when that snapshot is
missing or stale. The table also builds each row's update action
reactively, so readiness that arrives after mount reaches the row.

The update control now requires the server to advertise the update
capability, so a host with stale inventory no longer offers it either.
The locked pill reads "Update" to fit the column and explains the cause:
for an agent without command access it names the host and the install
option that grants it.

Locally built images have no RepoDigest, so no registry holds them. The
agent asked Docker Hub anyway and reported its 401 as "Check failed" on
every cycle. It now reports a "local image" state without a registry
request, shown as Local beside Pinned.

Contract-Neutral: performance-and-scalability gains one mock fixture state with no hot-path change; storage-recovery routes keep REST-first hydration, which this change does not alter.
2026-10-21 15:06:04 +01:00
pulse-triage[bot] 29bf5e8a47 Release v6.6.0 (#3089)
Release preparation for `v6.6.0`. After this merges, the release
workflow runs at this exact head
`25100090d2d07f9f34daad75f098cd9bc7638c5e`.
2026-10-21 13:44:52 +00:00
pulse-triage[bot] 25100090d2 Prepare v6.6.0
Promote v6.6.0-rc.5 to stable after its 24-hour soak. Only release metadata changes.

Contract-Neutral: Release-only v6.6.0 version, release notes and distribution metadata; no public install or runtime contract changes.
Change-source: pulse-maintainer
2026-10-21 14:23:36 +01:00
rcourtman c3fcddf94c sqlite: bound every store's page cache to SQLite's default
Five stores set private page caches of 16 to 64 MB per connection.
The modernc driver allocates that cache with anonymous mmap outside
the Go heap, so GC and GOMEMLIMIT never see it, and each page costs
about twice its size. Measured: reading a 107 MB database through a
64 MB cache_size added 117 MB of resident memory, against 6 MB at
SQLite's 2 MB default. On a long-running dogfood install about 165 MB
of 360 MB resident sat outside the Go heap, with a 126 MB audit
database behind a 64 MB cache (#1803).

Audit, RBAC, notification queue, unified resources and the alert
event log (writer and both readers) now take pdb.PageCachePragma.
Pages that miss it come from the kernel file cache, which the kernel
can reclaim. The audit suite, including its large legacy migration,
takes the same time as before (309 s on both trees).

pkg/db tests reject any other cache_size in production code and bound
the resident growth of a 30 MB scan. Each store's test pins its
effective value. Negative controls: restoring the audit cache fails
the source scan, and a 64 MB reader cache grows a scan by 55 MB
against a 16 MB budget. modernc sorts _pragma values before applying
them, so a second cache_size in one DSN cannot reliably override.

Related to #1803
2026-10-21 14:16:08 +01:00
rcourtman 49d437331e commercial: give the Pro plan card a price, groups and a clear action
The Pro comparison card on Plans & Billing was a half-width box of small grey text: no price, every feature at the same weight, and a small outline link as the only way to buy. It now has a header with the plan name, a one-line tagline, the price and a primary View plans button, then the features in three labelled groups with check marks: for your team, for larger estates, and Patrol and history.
2026-10-21 11:49:17 +01:00
rcourtman 197242b568 commercial: present Pro as the plan for teams and larger estates
The in-app Pro comparison card, now shown to free installs, led with Patrol modes and 90-day history: value an individual needs for their own setup, which reads as a withheld free version (issue #1409 called the earlier caps greedy). Paid features for an organisation read as normal, and paying installs skew to larger estates (45% against 8.5% of free installs). The card now leads with team controls, reporting and agent profiles, external probes and support, with Patrol modes, Patrol fixes and 90-day history after. Prices and entitlements are unchanged.
2026-10-21 11:21:21 +01:00