Commit Graph

15578 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 c67f8bd500 Describe the Assistant surface as Pulse Mobile chat (#3139)
## Why

The web chat window and its backend routes are retired (#3126, #3137),
so the `pulse_assistant` surface now only serves Pulse Mobile's chat.
Its manifest description still called it "the contextual explanation,
approval, and handoff surface for Patrol findings". That text is public
in three places: the overview at the top of `docs/AI.md`, the MCP README
surface block, and the External agents settings panel.

## What changes

- **Manifest description.** It now reads "The chat surface Pulse Mobile
uses for operator questions over the same governed tools, approvals, and
verification; the in-app web chat window is retired." The generated docs
blocks follow it. The surface ID, label, affordances and tools are
unchanged.
- **Browser fallback.** `frontend-modern/src/api/agentCapabilities.ts`
no longer gives an undeclared Assistant surface `interactiveQuestions`.
That matches the backend default of tools only.
- **Tests and contracts.** The Go, Python docs-policy and frontend tests
that pinned the old text and fallback are updated. The settings panel
test now asserts that no "Interactive questions" badge appears. The
ai-runtime and api-contracts contracts carry dated notes.

Checked on a private stack built from this branch, at desktop and phone
sizes:
- **What it shows:** under External agents → Developer details → Patrol
access model, Pulse Assistant has "Native surface" and "Actions" badges
and the new description, and no "Interactive questions" badge appears.
- **Layout and errors:** no horizontal overflow and no console errors.

The receipt is included.

## Not in this PR

- The dead `highlight.js` chunk rule in `vite.config.ts`. It matches
nothing since the package was removed. The commit guard requires a
build-proof file change for any edit there, which isn't worth it for a
no-op.
2026-10-23 16:38:43 +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 e7c765c9ec Bound Unix teardown fixture controls independently (#3138)
The macOS Intel job in Unified Agent Native Verification has been
failing `TestInstallSHUpstartTeardownRequiresObservedQuiescence` with
`signal: killed` at 60.01s (main run 39883120153, and run 39952586718 on
#3135). It is not a required check, so nobody noticed.

## Cause

The test ran all 17 `agent_upstart_teardown.py` controls in one Python
process under one `time.Minute` context. In both failing logs every
control that finished was `ok`, and the process was killed partway
through
`test_unconfirmed_job_retirement_restores_exact_config_and_retains_manual`,
the 16th control. That control finishes in about 0.2s on its own.

The same suite takes 41.3s on a passing macOS Intel run (39956150130),
23.0s on macOS ARM64 and 13.9s on Linux x64. So the one-minute limit was
measuring how fast the runner was, not catching a stuck Upstart
teardown. `scripts/install.sh` is not at fault. The Legacy teardown
suite in the same block (18 controls) takes 35.1s on Intel against the
same limit.

The NAS suite hit this exact problem earlier and was fixed in 0b3261e1aa
with `runPythonFixtureControls`.

## Change

The systemd, OpenRC, Legacy and Upstart teardown tests now use the
shared helper. It discovers every control in the class, makes sure each
required control is present, and runs each one in its own process with
the same one-minute limit. Skipped, missing or failed controls still
fail the test. The systemd test keeps its check that `install.sh` runs
the quiesce step before remote removal. Installer deadlines are
unchanged.

## Verification

- On pulse-dev, `go test` for the four tests plus
`TestPythonFixtureControlsRetainDiscoveryAndRejectFalsePasses` passed:
55 per-control subtests (8 + 12 + 18 + 17).
- The pre-commit hook battery passed, including the 165 governance
guardrail tests.
2026-10-23 16:08:08 +01:00
rcourtman c909b4db79 Bound Unix teardown fixture controls independently
The macOS Intel native job killed the DSM Upstart suite at its single
one-minute alarm after sixteen controls had passed (main 39883120153,
PR 39952586718). Passing runs take 41s on Intel, 23s on ARM and 14s on
Linux, so the budget measured runner speed, not a stuck teardown. The
Legacy suite sits at 35s against the same alarm.

Run each discovered systemd, OpenRC, legacy and Upstart control through
the shared helper under its existing bound, as the NAS and TrueNAS suites
already do. Required controls stay mandatory, and skipped, absent or
failed controls still fail. Installer deadlines are unchanged.
2026-10-23 15:45:44 +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 b044d3cdec Preserve PVE authentication endings while reading responses (#3136)
## Summary

`TestClientConstructionHonoursContext` (`pkg/proxmox`) has been failing
the required "Backend tests (rest-0)" check on unrelated PRs: #3132
(`json/cancel`) and #3134 (`form/cancel`) on 23 Oct, each with
`authentication failed: invalid authentication response: expected a
complete ticket and CSRF token`.

**Cause.** When a PVE password login ends while its response is being
read (the caller cancels, the caller's deadline passes, or the client's
own login timeout expires), Go's HTTP transport can still return the
server's answer to the abandoned request. The fixture answers a departed
client with an empty 200, and the PVE client reported that answer as a
malformed session instead of the context error. The PBS client got this
fix on 18 Oct (b6e56c5baa); the PVE client never did.
`TestClientConstructionHonoursClientTimeout` hits the same race while
its caller stays live, so checking only the caller's context in
`NewClientWithContext` would not have fixed it.

**Change.**
- `authenticatePassword`, shared by initial login and detached renewal:
when an exchange fails after its login context ended, report the ending;
keep transport errors that already carry it; start no form attempt after
an ended JSON refusal. Complete sessions are still accepted,
construction still discards a client whose caller ended, and renewal's
detached login can only be ended by its own bounded timeout.
- New deterministic control
`TestClientEndedAuthResponseOwnsErrorAndSession` (wrapper in the
registered proof file `host_diagnostics_regression_test.go`). It ends
the login from inside the response read, by caller cancellation and by a
simulated login deadline, for JSON/form × empty 200, invalid 200, 415,
401 and body-read failure, with no wall-clock timers. It also checks
that both encoding attempts carry the one login budget that
`authenticate` derives from the client timeout.
- The TLS fixture now aborts with `http.ErrAbortHandler` instead of
answering a departed client with an empty 200.
- Contract note in `monitoring.md`, next to the PBS note.

## Evidence

pulse-dev, go1.26.10 (the CI toolchain), `go test -race -count=200 -run
<test> ./pkg/proxmox/`:

| Tree | `TestClientConstructionHonoursContext` |
`TestClientConstructionHonoursClientTimeout` |
|---|---|---|
| main at 3b5d789aeb | **4/200 failed** (form/cancel ×2, json/cancel,
json/deadline) | **2/200 failed** (form/client-timeout ×2) |
| fix only, original empty-200 fixture | 0/200 | 0/200 |
| this PR (fix + aborting fixture) | 0/200 | 0/200 |

Every baseline failure was `invalid authentication response: expected a
complete ticket and CSRF token`. The race needs CPU contention (the
first baseline failures all landed while race builds compiled on the
same box), so further loops ran beside niced CPU burners and at
GOMAXPROCS 1 and 2. Across all loops, main failed 19 of 1,400 runs. The
fix with the original empty-200 fixture failed 0 of 1,600. In an
instrumented copy, the new rule caught two live occurrences of that
exact error, and both runs passed. In the one run paired with main under
identical contention, main failed 4 of 200 and the fix 0 of 200.

New control: against main's `client.go`, all 20 cases fail on each of
three runs, including the CI error itself. With the fix, 200 of 200 runs
pass under `-race`. A mutation in which `authenticate` passes the
caller's context instead of its login budget fails all 10 cancellation
cases on the budget check. The full `pkg/proxmox` suite passes under
`-race`.
2026-10-23 14:12:43 +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 751dfcce0a Read action token file modes portably on macOS (#3135)
The agent installer checked an --action-token-file's permissions with
GNU-only stat -c. BSD stat on macOS rejects it, the mode read came back
empty, and every action token file was refused as group- or
world-accessible. It now uses the existing portable_path_mode helper,
which the collector token path already relies on.

The ingress test runs both token options through the real installer
functions and the compiled token reader; on the macOS runner it is the
proof for this fix.

Contract-Neutral: portability fix restoring the documented
--action-token-file option; no contract change
2026-10-23 13:48:37 +01:00
rcourtman 47dc675165 Keep the token ingress fixture independent of umask
t.TempDir creates its directory through the process umask. Under 0002
the directory is group-writable, the installer correctly refuses to
trust the reader inside it, and every valid case failed on hosts with
that umask while passing on CI runners.

Contract-Neutral: test fixture hardening; no contract change
2026-10-23 13:20:22 +01:00
rcourtman bf7e66e045 Drop installer NUL cases main does not implement
The ingress test came from a line whose installer also refused NUL bytes
in token files. Main's installer does not, so those cases failed on
every runner. The remaining cases cover what this branch fixes: both
token options read through the real installer and token reader.

Contract-Neutral: test scope correction for the portability fix; no contract change
2026-10-23 13:18:20 +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 e78f17979e Read action token file modes portably on macOS
The agent installer checked an --action-token-file's permissions with
GNU-only stat -c. BSD stat on macOS rejects it, the mode read came back
empty, and every action token file was refused as group- or
world-accessible. It now uses the existing portable_path_mode helper,
which the collector token path already relies on.

The ingress test runs both token options through the real installer
functions and the compiled token reader; on the macOS runner it is the
proof for this fix.

Contract-Neutral: portability fix restoring the documented --action-token-file option; no contract change
2026-10-23 13:03:55 +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 bd5a2946de Keep stale investigation runs from overwriting newer finding state (#3133)
The private investigation orchestrator writes a finding's investigation
fields through `orchestratorFindingsAdapter`
(`internal/api/ai_handlers.go`), 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. A stale `resolved` made the regressed
occurrence terminal, so Patrol stopped investigating it.

- Closing writes go through `FindingsStore.CompleteInvestigation`. It
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. If the finding still shows a run whose
result does not count as running, the run is closed through
`RecoverStuckInvestigation`, the way the sweep would close it.
- Admission goes through `FindingsStore.StartInvestigation`. It 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`, after which the same ID detected again is a new finding. The
store keeps these unpersisted, with the clock's monotonic reading, for a
day, which no run outlives. 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` (`acceptsInvestigationResult` is
unchanged), since they also judge sessions of earlier processes.
- The adapter takes each run's start from the orchestrator's session
store, so no `aicontracts` type changes and pulse-enterprise needs no
change.

Tests: `TestOrchestratorRunResultSkipsALaterOccurrence` and
`TestOrchestratorRunResultSkipsANewerRun` failed on the previous code
(the stale `resolved` landed; an older run overwrote the run that
replaced it). Store-level cases cover both new methods, regressions this
process observed and ones an earlier process recorded. Mutation controls
that remove each guard each fail a test: the session check, the
regression boundary, the interrupted close, the start lookup, the
admission fence, judging live runs against the persisted time, recording
the observed regression, stamping removals in `ClearAll` and `Cleanup`,
and the retention prune. `go test ./internal/ai/... ./internal/api/`
passed on pulse-dev.

Not covered here:
- A run whose finding snapshot predates a regression, but whose session
is recorded after it, is still installed with the snapshot's attempt
count.
- When two runs are dispatched for one finding at once, the run admitted
last is the finding's run, and only its result is recorded.
- Patrol's post-run sync can repeat a newer run's push notification
after a stale run's close is refused, because the sync cannot tell which
run it started.
2026-10-23 12:19:45 +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 ec40662af4 Record Patrol investigation conclusions as a typed tool call (#3129)
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 10:05:22 +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 af89e4a7d5 Retire the Assistant chat window; alert Investigate runs Patrol (#3126)
## Why

The in-app Pulse Assistant chat window was a bet that did not pay off:
usage looked like curiosity rather than a daily tool, and it was
shrinking. People 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 launcher on every page, hand-off buttons on alerts, findings,
approvals and drawers, and tens of thousands of lines of drawer code.

## What changes

- **Gone from the web app:**
  - The Assistant drawer and its launcher (desktop and phone).
  - The command-palette Assistant commands.
- Every "Open in Assistant", "Explain with Assistant", "Discuss" and
"Ask Assistant" hand-off. These were on alerts, incident timelines,
Patrol findings, approvals, run history, the attention workbench and
resource drawers.
  - The Assistant settings page with its session maintenance section.
- The chat store, the chat client, the dev stream fixture and the
generated SSE types.
  - Helpers and tests that only those surfaces used.
- **Alert Investigate runs Patrol.** On alert cards and History rows,
Investigate starts a targeted Patrol run (`POST /api/ai/patrol/run` with
the alert's resource, identifier and type). It confirms where the
findings will appear and ignores repeat clicks while the request is in
flight. The run route needs an admin with `ai:execute`. Security status
therefore gains `settingsCapabilities.patrolRun`, and Investigate only
appears where it is true. The field is derived from the same snapshot as
the other admin capabilities, with no new token validation. Where that
snapshot and the route disagree for org-scoped admins,
organization-bound tokens and a cookie beside a token, it follows the
route.
- **External agents settings.** The page that hosts Pulse MCP setup is
now **External agents** at `/settings/pulse-intelligence/agents`. The
old `/settings/pulse-intelligence/assistant`, `?tab=assistant` and
`system-ai-assistant` links redirect there with the hash kept.
- **AI availability store.** `stores/aiAvailability.ts` holds the
session `assistantEnabled` fact that gates Patrol entry points and
Investigate. It now clears and refreshes on organization switch,
refreshes after provider removal, and drops answers that arrive after a
newer one. The old chat store did none of these.
- **Chat backend unchanged.** Patrol investigations run on the chat
service, and Pulse Mobile chat uses it until 31 March 2027, so
`/api/ai/chat`, the session endpoints and the stream contract stay. A
new contract snapshot pins `steer_applied`, which only the deleted
TypeScript projection used to cover.
- **Dependencies.** `highlight.js` is removed (only the chat's code
highlighter used it).
- **Governance.**
- Subsystem contracts, `registry.json` and the readiness proofs drop the
retired surfaces.
- ai-runtime loses about 1,600 lines of drawer rules and keeps every
backend session, stream and provider rule.
  - `docs/AI.md` points chat users at MCP.

The production build is about 100 kB of gzipped JavaScript smaller. The
`Chat`, `vendor-highlight` and `aiControlLevelPresentation` chunks are
gone.

## Not in this PR

- Backend endpoints that only the web chat called. Which endpoints Pulse
Mobile still needs must be checked first.
- The "Pulse Assistant" surface entry in the agent capabilities
manifest, which also generates the overview block at the top of
`docs/AI.md`. That surface still describes the chat engine Pulse Mobile
uses, so it changes together with the endpoint cleanup.
- The dead `highlight.js` chunk rule in `vite.config.ts`.
- Landing-page copy in pulse-pro.

## Release note

> Pulse Assistant, the chat window inside the web app, is retired. To
ask about your infrastructure in plain language, connect Claude Code,
Codex, or another MCP client under **Settings → Pulse Intelligence →
External agents**. On an alert, **Have Patrol investigate** now starts a
Patrol check on that resource, and the findings appear on the Patrol
page. Pulse Mobile's chat keeps working until the app retires on 31
March 2027.
2026-10-23 02:17:03 +01:00
rcourtman af97a87b47 Bump dompurify and vitest past the 22 October advisories (#3128)
## Why

`npm audit` now fails the required **Frontend** check on every branch.
Advisories published on 22 October cover dompurify ≤3.4.16
(GHSA-cg2g-xh64-v4q7 critical, GHSA-mrv2-3pqr-j5jr, GHSA-c6gr-qvv4-5v3f)
and @vitest/mocker <4.1.12 (GHSA-qxg6-89mg-rqv8, GHSA-xp26-3454-mjgp).

## What changes

- `frontend-modern/package.json`: dompurify `^3.4.17`, vitest and
@vitest/coverage-v8 `^4.1.12`.
- `package-lock.json`: only what those updates resolve. That is the
vitest family; the chai, obug, std-env and tinyrainbow versions its
ranges now pick; and a single picomatch 4.0.7 instead of the nested
copies. Newer npm's bundled wasm entries for
`@tailwindcss/oxide-wasm32-wasi` are left out, as on main.
- `dependencySecurity.test.ts`: the vitest floor moves to 4.1.12 and the
dompurify floor to 3.4.17, with the advisory IDs.

`npm audit` reports 0 vulnerabilities after a clean install, and the
dependency security and markdown sanitizer tests pass.

Dependabot's group PR #3058 includes these bumps among unrelated ones
and has been blocked since 21 October.
2026-10-23 01:57:34 +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
rcourtman 1d0f7f1f78 Keep record drawer headings whole on phones (#3127)
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:57:33 +01:00
rcourtman 89f8de5b65 Pin the MCP-first AI overview in the docs contract test
The Pulse Intelligence overview contract still required docs/AI.md to say
the Assistant is the in-app Pro surface beside pulse-mcp. That paragraph
went with the chat window, so the test now pins what the doc says
instead: Pulse has no in-app chat window, MCP clients connect through
pulse-mcp, and capabilities land in the canonical manifest first.
2026-10-23 00:57:17 +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
rcourtman 86ff402d11 Let Docker and vSphere host rows show their workloads (#3124)
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:39:14 +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
rcourtman 904f1104be Let Coverage restore evidence details wrap (#3121)
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 21:09:20 +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
rcourtman 863bdbfbec Wrap drawer sentences and open fleet details under the tile (#3120)
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:46:08 +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 2130d8962b Stop release CI depending on Microsoft's package server (#3118)
Stop release CI depending on Microsoft's package server

GitHub's Ubuntu runner image ships an apt source for
packages.microsoft.com. Every job that runs `apt-get update` therefore
fails when that server does. On 22 October it returned 403 Forbidden for
about an hour, failing the required Repo Boundary Audit on the
v6.6.0-rc.6 preparation twice, so the release train abandoned the
candidate and will only retry 12 hours later.

None of these jobs installs anything from that source: the audit needs
ripgrep, the release build zip/unzip, the dry run docker-compose and the
browser qualification jq/curl/ssh plus Playwright's dependencies, all
from Ubuntu. Before refreshing apt, remove only source files that name
packages.microsoft.com. A runner without one is left alone, and a
cleanup failure never fails the step.

Contract-Neutral: CI runner package-source hygiene only; no runtime,
release artifact or public contract change.
2026-10-22 18:22:52 +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
rcourtman 0149b8a85f Stop release CI depending on Microsoft's package server
GitHub's Ubuntu runner image ships an apt source for
packages.microsoft.com. Every job that runs `apt-get update` therefore
fails when that server does. On 22 October it returned 403 Forbidden for
about an hour, failing the required Repo Boundary Audit on the
v6.6.0-rc.6 preparation twice, so the release train abandoned the
candidate and will only retry 12 hours later.

None of these jobs installs anything from that source: the audit needs
ripgrep, the release build zip/unzip, the dry run docker-compose and the
browser qualification jq/curl/ssh plus Playwright's dependencies, all
from Ubuntu. Before refreshing apt, remove only source files that name
packages.microsoft.com. A runner without one is left alone, and a
cleanup failure never fails the step.

Contract-Neutral: CI runner package-source hygiene only; no runtime, release artifact or public contract change.
2026-10-22 17:52:20 +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
rcourtman df7a9004bd refactor/drawer cleanup (#3117)
- **Build every record drawer on the shared record panel**
- **Record the Kubernetes chunk size and new record drawer labels**
- **Finish moving the last bespoke drawers onto the shared shape**
2026-10-22 17:25:00 +01:00
courtmanr@gmail.com b08fbd8a24 Match the Mail Gateway window check to the shared drawer
The PMG help says to compare the same time window in PMG, and its docs
check pinned the old drawer's `stats()?.timeframe` read. The Mail tab is
now headless mail-flow content inside the shared resource drawer and
names the window on its Mail flow card from the same field
(`stats?.timeframe`); the check now accepts either spelling, so the
help still has to describe a window the drawer actually shows.
2026-10-22 17:00:53 +01:00
rcourtman c58668b610 Build every record drawer on the shared record panel (#3114)
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 17:00:11 +01: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 345babc6a8 Record the Kubernetes chunk size and new record drawer labels
The Kubernetes events, ConfigMap/Secret/RBAC and policy rows now build
record drawers in the page chunk, which measures 21732 bytes gzipped on a
fresh build of this branch. Its recorded baseline (18267) predated
earlier growth on the Kubernetes tabs, so the chunk crossed its limit by
0.71 kB. Lazy-loading the record panels was tried and made the chunk
larger (their shared pieces already live in common chunks), so this
records the measured size and leaves every other chunk's baseline
unchanged.

Two docs checks pinned the old hand-written markup: replication help
matched `>Guest</dt>`-style labels, and Agent Doctor help matched a
"· Privilege:" sentence fragment. The same labels are now shared detail
rows and section labels; the checks match those, so the help still has
to name labels the drawers actually show.
2026-10-22 15:33:15 +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
rcourtman e67fff71d5 Bring storage and platform drawer content up to the guest drawer (#3113)
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:51:54 +01:00