The portal shipped with a GitHub-Primer palette, light-only, while every
"Open client" hands the operator into the slate/blue Pulse product UI
(which most monitoring users run dark). The portal now uses the product's
Tailwind slate/blue semantic tokens so both halves of the MSP workflow
read as one product:
- Light theme mirrors the product exactly: slate-100 page, white surfaces,
slate borders/inks, blue-600 solid buttons.
- Full dark theme (slate-900/800 surfaces, blue-400 links, product-style
translucent status chip backgrounds with readable 300-series text),
applied via prefers-color-scheme by default with a persisted header
toggle (data-theme override in localStorage, nonce'd no-flash bootstrap
in the page template).
- Hardcoded badge/status border literals, the toast, the danger-button
hover, and border-only panels that relied on the old white page are all
tokenized so every state renders in both themes.
- Dropped two dead Roboto @font-face blocks and their data-URI payloads
(nothing referenced the family): portal_app.css shrinks 135KB -> 43KB.
Verified live in both themes: workspace roster, setup queue, client
onboarding panel chips, Access invite panel, signed-out instruction card,
and the error toast; theme choice survives reload and sign-out. Portal
vitest suite 103/103 incl. new theme tests; frontend/dist sync test green.
pkg/aicontracts gains the plan-only OrchestratorActionBroker contract:
ActionProposal (typed capability reference, no command, host, risk, or
approval fields), a read-only ActionCapabilityCatalog with parameter
sensitivity, ActionDisposition over the existing safe ActionPlanInfo
projection, and an additive Action *ActionReference on
InvestigationSession and InvestigationRecord. ProposedFix/ApprovalID are
documented as migration-only; OrchestratorDeps gains the ActionBroker
seam while CmdExecutor/ApprovalStore are marked legacy pending removal.
internal/api/patrol_action_broker.go implements the seam tenant-bound
over ResourceHandlers.ActionLifecycle(): fixed pulse_patrol actor,
broker-owned ActionOrigin stamped through the service's internal
PlanWithOptions (the public plan endpoint cannot claim an origin),
plan-only submission even for ApprovalNone capabilities, and refusal of
proposals that populate IsSensitive parameters before any persistence.
The lifecycle service adds Capabilities (same registry resolution and
typed errors as planning) and an OnActionTransition persisted-state
callback covering plan, decision, and terminal execution transitions,
published only after the store write succeeds, so Patrol can reconcile
decisions and outcomes deterministically. ActionAuditRecord carries the
new broker-owned Origin, persisted in action_audits.origin_json with a
schema migration and round-trip normalization.
Contracts updated across api-contracts, ai-runtime, unified-resources,
agent-lifecycle, and storage-recovery; proofs added in
pkg/aicontracts/contracts_test.go (propose-only method set, command-free
wire shape, additive reference), internal/api/contract_test.go
(plan-only broker pins), broker behavior tests, lifecycle origin and
transition tests, and a SQLite origin round-trip test.
Slice 2a of the typed-lifecycle enforcement ratchet: additive core
fabric only; enterprise migration and side-door deletion follow.
A fresh install with no AI provider looped: the Patrol zero state sent
users to the Patrol model check, the check told them to enable Pulse
Assistant in a settings page that does not exist by that name, and the
Patrol model field silently degraded to a bare text input with no hint
that a provider key or Ollama server was the missing step (#1463, #847).
- Preflight and readiness copy now names the real surfaces (Provider &
Models, Patrol settings) and the real first step: add an API key or an
Ollama server.
- The Patrol zero-state CTA, header Fix setup link, and readiness
banners route config-level causes (assistant_disabled,
provider_not_configured) to Provider & Models; model-level causes
keep the Check Patrol model action.
- The Patrol model field shows a linked zero-provider notice instead of
a bare text input when no provider is configured.
- The preflight result box no longer renders the same failure message
twice.
Exercised the full provider portal as a pilot MSP would and fixed what
made it feel broken:
- The signed-out portal promised "a sign-in link is on the way" even when
the control plane has no email provider (the bundle default), and team
invitations silently sent nothing. The portal bootstrap now carries
email_sign_in_available and provider_hosted_mode; the sign-in page shows
the host command that actually prints a link, and the invite panel says
invitation emails are not sent and how to hand over a link instead.
- New "provider-msp portal-link --email" CLI mints a one-time portal link
for an account member or pending invitee, so teammates can sign in at
all on email-less installs (bootstrap only covers the owner).
- Portal sessions were fixed at 12h; CP_SESSION_TTL now configures them
and provider-hosted MSP mode defaults to 7 days.
- Creating a client past the license cap showed a generic "Failed to
create workspace." toast: the limit error is now a JSON payload with
current/limit, the API client no longer drops non-JSON error bodies
(double body read), and the toast explains the license limit.
- Copy polish: provider-mode sign-in intro (no refunds/privacy register),
least-privilege default invite role, queue tile label matches "Client
onboarding", softer Support tab with a docs/MSP.md pointer, setup.sh
summary now prints the bootstrap next step and day-2 sign-in commands,
.env.example and docs/MSP.md document portal sign-in and sessions.
Contracts updated (cloud-paid, api-contracts, deployment-installability,
security-privacy) with verification pins in tenant_handlers_test,
config_test, magiclink_test, and provider_msp_deploy_test.
Verified live against a dockerless control plane: portal-link for an
invitee redeems, promotes the invitation, and sets a 7-day session;
the at-cap toast shows the license copy; portal vitest suite and
cloudcp/auth/account/installtests Go suites pass.
Once anything requests a test module through the Vite dev server it joins
the client module graph, and every later save of that file dead-ends HMR
into a full page reload broadcast to all connected clients. With parallel
agents saving test files continuously, the dev UI reloaded out from under
interactive sessions every few seconds during bursts (31k+ reloads in the
hot-dev log), which read as "tabs sometimes don't load when clicked".
Ignore test files in the dev watcher, scoped off vitest's test mode so
watch-mode re-runs still see changes. Verified live: test-file touch no
longer reloads a connected page, source HMR unaffected, vitest run green.
Planning, approval decisions, and execution for typed resource actions
move out of the HTTP handlers in internal/api/actions.go into a new
internal/actionlifecycle.Service owned by api-contracts. The REST
handlers become thin decode/actor/error-mapping adapters over the one
shared service, and ResourceHandlers.ActionLifecycle() exposes the same
service for in-process consumers, so a future Patrol action broker
inherits identical resource lookup, availability checks, plan hashing,
audit persistence, remediation locks, plan-drift revalidation,
execution, and terminal publication instead of loopback HTTP or a
parallel lifecycle.
Behavior is preserved: same status codes, error codes, and audit/
lifecycle persistence ordering, backed by the existing api contract
tests plus new fail-closed proofs for the service itself (unknown
resource/capability, availability refusal, unapproved execution,
remediation lock, plan drift, missing executor, missing store).
Contract text in api-contracts, agent-lifecycle, and storage-recovery
now names the service alongside actions.go and planner.go; the
subsystem registry owns internal/actionlifecycle/ under api-contracts
with a dedicated path policy; the code-standards and contract source
pins follow the moved invariants; and the subsystem_lookup line-number
pin shifts with the api-contracts canonical-files list insertion.
This is the first slice of making the typed action lifecycle the only
autonomous execution route for Patrol, Assistant, and MCP.
The typed action planner and the agent command-execution server were
unowned in the subsystem registry even though the contracts already
claim them in prose: api-contracts declares the action plan contract
API-owned through internal/actionplanner/planner.go, and
agent-lifecycle documents internal/agentexec request normalization and
semaphore semantics as one shared protocol contract.
Encode that ownership: internal/agentexec/ joins agent-lifecycle with
a command-execution path policy pinned to the package's test files,
and internal/actionplanner/ joins api-contracts pinned to
planner_test.go. registry_audit and contract_audit pass; both test
packages are green.
The legacy v5 callback path /api/oidc/callback carries no provider ID,
so extractOIDCProviderID hard-maps it to the legacy-oidc sentinel.
Providers created in the v6 UI get a UUID ID, and when their IdP is
still registered with the legacy redirect URI the callback missed the
provider lookup and returned provider_not_found with no log, breaking
SSO login (authentik and similar).
Peek the pending authorization state across providers to recover the
provider that actually initiated the flow, re-validate it, and proceed.
State stays single-use (peek never consumes), unknown/disabled/expired/
replayed all still fail closed, and the 4-part provider_mismatch guard
is unchanged. The fail-closed path now logs instead of failing silently.
Refs #1533, #1535
Covers previously-uncovered branches in proxmox backup recovery, AI finding
presentation, chat tool/transcript formatting, docker page model, alert
history/overrides models, and storage page state - 275 assertions through
public entry points, no source changes.
Commit 20b310201 renamed the Patrol provider-setup action copy
('Check model' -> 'Fix setup', 'Open Provider & Models' ->
'Check Patrol model' with href /settings/pulse-intelligence/patrol)
and updated the sibling patrol tests, but missed these four, leaving
build-and-test.yml red on main. Point them at the shipped copy.
The in-app updater validated download URL and channel but never compared
the target against the running version, so any valid older release asset
URL installed silently while the UI presented it as an update. ApplyUpdate
now rejects targets at or below the running version on both the community
and Pro broker paths before any history entry or download, with an explicit
allowDowngrade opt-in on POST /api/updates/apply for sanctioned cases.
The rollback half already existed but nothing reached it: createBackup
retains three backups, restoreBackup works, and history records BackupPath,
yet no endpoint or UI called restoreBackup. RollbackToBackup restores the
retained backup recorded on a history entry after re-validating the path
against the managed backup roots, shares the update-in-flight slot with
ApplyUpdate, records an Action rollback history entry linked to the source
update, marks that update rolled_back, streams a restoring stage through
the existing status/SSE machinery, and restarts via the exit-for-systemd
path. POST /api/updates/rollback carries it with the same RequireAdmin plus
settings:write gating as apply. Rollback is purely local, so the Pro
edition gate never applies to it.
Settings now has the update history surface that was missing entirely:
nothing called /api/updates/history before. The Updates panel lists recent
updates with a Roll back action on successful entries whose backup is still
retained, behind a confirmation dialog naming the restore version, and the
rollback rides updateStore's shared pending-apply marker for the
post-restart toast. restoreBackup also honors PULSE_INSTALL_DIR now instead
of hardcoding /opt/pulse, matching createBackup.
Contract deltas ride along: api-contracts picks up the rollback transport
and downgrade-conflict semantics, agent-lifecycle and storage-recovery pin
rollback as server self-update plumbing, ai-runtime and cloud-paid pin the
update watcher stage vocabulary as non-assistant non-paid shell chrome, and
frontend-primitives adds UpdateHistorySection as the history/rollback
presentation owner with matching architecture proofs.
The only production consumer of the UpdaterRegistry is the plan endpoint
(GET /api/updates/plan); every real apply runs through the in-Go pipeline
in manager.go ApplyUpdate, with concurrency held by updateMu/updateInFlight.
The InstallShAdapter Execute/Rollback path (install.sh piping, rollback
binary download, health wait) and the UpdateQueue were never called outside
tests, so fixes made there silently did nothing. Adapters are now plan
providers only: the Updater interface keeps SupportsApply, PrepareUpdate,
and GetDeploymentType, pinned by the new registry proof in
internal/api/updates_test.go.
The orphaned fetchAndVerifyReleaseSignature helper goes with it; its
fail-closed sidecar tests now exercise the manager's
downloadAndVerifyReleaseSignature directly. The deployment-installability
sshsig invariant now names the manager pipeline as the only surface that
fetches release artifacts, and api-contracts, storage-recovery, and
agent-lifecycle pin the same plan-provider-only shape at their update
transport boundaries. Stale adapter references in build-release.sh,
validate-release.sh, and the installtests comments are updated to match.
Both the update banner and the Settings update panel duplicated the
confirm/apply POST with divergent error handling; the banner's failure
path was a native alert(). updateStore.applyUpdate() now owns the POST,
the shared error toast, and a persisted pending-apply marker recording
the pre-update version. On the next version fetch after the post-update
reload, the store confirms the running version moved and shows a
one-time 'Updated to vX.Y.Z' toast, clearing the marker either way.
Checksum and SSHSIG verification prove the downloaded artifact matches
what was published, not that it can run on this host or is the version
the user approved. The apply pipeline previously swapped the binary and
exited, relying on systemd to restart into an unproven executable; a
wrong-arch fallback asset or unstamped build would take Pulse down with
the backup left unused.
ApplyUpdate now locates the extracted binary and probes it with
--version before the backup and swap stages, failing the update with
zero changes applied when the probe fails or reports a version other
than the apply target. Same pattern the agent updater already uses.
Pinned in the deployment-installability contract with
internal/updates/selftest_test.go as the owned proof surface.
Long custom titles on generate-multi covers overflowed the page edges;
the cover now shrinks to fit, wraps at the minimum size, and flows the
subtitle and scope blocks below a wrapped title. Also drop the stale
frontend-primitives contract and registry references to the
Settings/reportingResourceTypes.ts re-export removed in c862fb0ca; that
frontend-only commit skipped the governance audit, leaving the stale
references blocking every Go commit.
Dead-code audit batch 5: types/responsive.ts (all 8 exports had zero
non-definition references) and the one-line
components/Settings/reportingResourceTypes.ts barrel (all consumers
import @/utils/reportingResourceTypes directly). Both were referenced
only via ?raw text imports in guardrail tests; those imports and their
assertions are dropped here in the same commit. The TYPE_COLUMN_*
contract remains pinned against the live sources
(utils/typeColumnContract.ts, utils/typeColumnDefinition.ts).
Dead-code audit batch 3: hasPrefix in internal/ai/service_test.go and
the staticExternalAgentActivityProvider test double in
internal/api/agent_resource_context_test.go have no callers in any
test or production code.
Skipped from the audit list after re-verification:
createTestEncryptionKey in cmd/pulse/test_helpers_test.go (called nine
times by commands_integration_test.go under the integration build tag).
Dead-code audit batch 1: five functions with zero references from main,
tests, or pulse-enterprise (deadcode -test, grep-verified per symbol,
enterprise compile-checked). Also renames the tlsutil test that
exercised the deleted compat alias's target to name the Unverified
function it actually calls.
Skipped from the audit list after re-verification: IsNilAlertPayload
(live pulse-enterprise consumer in internal/aialertanalysis).
docker restart reuses the image the container was created with, so the
pulled update never takes effect. Point users at docker compose pull &&
docker compose up -d, with pull/stop/rm and re-run for non-compose
installs, matching docs/DOCKER.md.
The AI action broker treated an unreadable operator lock as unlocked:
isResourceRemediationLocked returned (false, nil) with no audit store
wired, and the caller logged store errors then dispatched anyway. An
operator's NeverAutoRemediate=true could be silently ignored whenever
the policy store was missing or erroring, which is unacceptable while
Patrol and Assistant run at assisted or full autonomy.
Posture change at the dispatch decision point:
- isResourceRemediationLocked now reports unknown state (nil store or
lookup failure) as an ErrRemediationLockStateUnknown-wrapped error
instead of silently defaulting to unlocked.
- New checkRemediationLockForDispatch gate: dispatches without an
approved human decision fail CLOSED on unknown lock state and
surface "remediation lock state unknown; operator approval
required". Human-approved dispatches keep the historical fail-open
behavior with a warning log. A confirmed lock still refuses even
approved dispatches, as before.
- executeNativeActionWithAudit (TrueNAS app start/stop/restart) now
enforces the lock too; it previously skipped the check entirely.
- Refusals persist Failed audit records with stable
remediation_lock_state_unknown: / resource_remediation_locked:
ErrorMessage prefixes.
- ai-runtime subsystem contract updated to pin the new posture.
Tests cover store-error and nil-store at both autonomy postures on
both dispatch paths; routing/control tests now wire an in-memory
audit store since autonomous dispatch without one is refused.
Two AI write paths asserted unverified success while the Proxmox guest
and Docker start/stop/restart handlers already do read-after-write
checks. Bring both in line with that idiom:
- pulse_kubernetes scale: re-read the deployment's spec/ready replicas
via kubectl through the same agent (bounded settle-and-retry window)
and return a JSON response with a verification block instead of
"Action complete - no verification needed".
- pulse_docker update: the docker agent already recreates the container,
health-checks it, rolls back on failure, and acks a terminal command
status; expose that status through a new Monitor lookup
(GetDockerCommandStatus) plus UpdatesProvider.GetCommandStatus, and
poll it within a bounded window. Responses now report verified
success, verified failure (is_error), or an explicit inconclusive,
never unverified success.
kubernetes_control_test.go also carries a small in-flight fix from the
parallel remediation-lock work (in-memory ActionAuditStore in the test
helper) that these tests require to run on this tree.
Pure re-export shim over pkg/licensing with zero importers in pulse or
pulse-enterprise (internal/ is unreachable from external modules anyway).
Canonical implementations stay in pkg/licensing.
Nightly GLM grunt wave: new Vitest coverage for pure logic that had no
test file or only shallow export coverage. 436 test cases, all green;
tsc and eslint clean. Tests characterize current behavior only; no
source was modified.
Untested modules now covered:
- api/responseUtils (19 coercion/guard fns)
- utils/workloads (8 predicates/builders)
- features/storageBackups/storageSearchQuery, storageMetricsIdentity
- utils/platformSupportManifest (12 manifest lookups)
- stores/sessionCapabilities, content/help registry
Weakly-tested modules strengthened (new *.coverage.test.ts):
- utils/format (5 formatters), features/alerts/helpers (apprise round-trip)
- utils/cloudPlans (parseCloudTier), utils/alerts, utils/agentResources