Commit Graph

3090 Commits

Author SHA1 Message Date
rcourtman 23e65040cc Register filter button group governance 2026-06-12 14:38:43 +01:00
rcourtman 36af6620d3 Register selection card group governance 2026-06-12 14:24:56 +01:00
rcourtman 52928706d9 Register column picker template governance 2026-06-12 14:17:33 +01:00
rcourtman 40af69bdb8 Register chart visibility toggle governance 2026-06-12 14:11:59 +01:00
rcourtman c5afa2e52f Register grouped table mode control governance 2026-06-12 14:06:19 +01:00
rcourtman 77134632e2 Register platform section tabs governance 2026-06-12 14:01:39 +01:00
rcourtman 51ac42870c Canonicalize platform error states 2026-06-12 13:52:53 +01:00
rcourtman 0109361296 Register platform loading template governance 2026-06-12 13:38:05 +01:00
rcourtman f90ae48814 Register FilterBar template governance 2026-06-12 13:29:25 +01:00
rcourtman 1e634709a2 Canonicalize platform table empty states 2026-06-12 13:08:40 +01:00
rcourtman 97bb7035d9 Canonicalize inline detail table rows 2026-06-12 12:55:56 +01:00
rcourtman 43bb0184ba Canonicalize node header web interface links 2026-06-12 12:31:31 +01:00
rcourtman ddb5bd5e1a Canonicalize Proxmox web interface links 2026-06-12 12:22:20 +01:00
rcourtman 2169d94b41 Canonicalize platform row detail toggles 2026-06-12 12:11:50 +01:00
rcourtman b179b1782c Stabilize Proxmox guest Docker inventory 2026-06-12 12:10:54 +01:00
rcourtman d221640086 Retire platform table frame allowlist 2026-06-12 11:36:18 +01:00
rcourtman 6dc931aac6 Migrate host tables onto PlatformTableShell 2026-06-12 11:07:14 +01:00
rcourtman eab067171c Clarify Proxmox Docker LXC host setup 2026-06-12 10:46:38 +01:00
rcourtman 048db1c91c Enforce shared template registry for platform tables 2026-06-12 10:44:11 +01:00
rcourtman 93420753aa Enforce shared templates for web interface links
Refs #1276
2026-06-12 10:06:01 +01:00
rcourtman 841100caa7 Clarify Proxmox LXC Docker install path 2026-06-12 09:57:58 +01:00
rcourtman ff5a0b4957 memory(cache): extend the reclaimable split to standalone host agents
f62f35e24 restored the v5 used | cache | free memory split for Proxmox
nodes and guests, but standalone host agents still reported a flat
used/free pair, so the Machines page memory bar could not show the
reclaimable segment. Flagged by the Machines page v5 parity audit.

- Host agent reports cacheBytes (gopsutil Available minus Free); the
  ZFS ARC adjustment recomputes free so used + cache + free still
  covers the total.
- ApplyHostReport maps the field into models.Memory.Cache and clamps
  inconsistent or older-agent reports so used + cache never exceeds
  total.
- AgentMemoryMeta carries cache onto unified resources so the frontend
  agent payload exposes it.
- Mock generic hosts split a third of non-used pages as cache, and the
  node-linked host conversion now holds the invariant instead of
  stacking the node's cache on top of a recomputed free.
- Contracts: monitoring, unified-resources, and storage-recovery now
  document the split (also covering the f62f35e24 node/guest surface,
  which landed without contract deltas).
2026-06-11 21:47:30 +01:00
rcourtman d38b8a6d00 Preserve Docker container metadata in unified resources 2026-06-11 21:03:14 +01:00
rcourtman 0b065f2103 storage(pools): demote the Source column, fix PBS host labels, tighten rows to 32px
Three more findings from the v5-parity audit of the pools table, all
mirroring the disks-table fixes in 809e2c900:

- The Source column rendered an identical PVE badge on every row: the
  surface's only live embedding (the Proxmox page) forces
  source=proxmox-pve, so the column was pure noise. Drop it and surface
  the record's platform as a Source row in the expansion's Configuration
  card; the FilterBar source chip still covers unforced embeddings, and
  persisted sort-by-source selections fold back to priority.
- PVE-configured PBS storage rows showed raw agent/storage resource ids
  in the Host column because the backup-repository host-label chain
  skipped the owning node and fell through to parentId. Name-bearing
  candidates (parent name, PBS instance name, PVE node, storage nodes)
  now come before raw ids.
- Rows tightened from 38px to the 32px platform table standard, and the
  freed Source width redistributed across Storage/State/Type/Host/Usage.

Also drops the row model's never-rendered platformLabel/platformToneClass/
compactImpact/compactIssue/compactIssueSummary fields - the same
extracted-but-unrendered drift flagged in #1471.

Verified live in mock mode: headers Storage/State/Type/Host/Protection/
Usage/Growth, backup-vault hosts show pve1-3, expansion shows Source PVE,
rows measure 32px.
2026-06-11 19:25:43 +01:00
rcourtman 8ce0f4a872 Show context-window usage in the last-turn summary (OpenCode parity)
OpenCode shows how full the model's context is next to the composer;
Pulse showed raw token counts with no sense of scale. The done event
now carries context_limit_tokens (providers.ContextWindowTokens for
the selected route) and the drawer's last-turn summary renders input
tokens as a percentage of the window: '8,500 tokens (6% of context)',
with the full breakdown in the hover detail. Without the limit the
summary renders exactly as before. Pinned in the plain-answer corpus
scenario.
2026-06-11 17:18:42 +01:00
rcourtman e6250fe697 storage(pools): collapse duplicate Ceph rows and lift cluster health onto the mount
Every Ceph-backed storage appeared twice on the pools table: once as the
PVE storage entry (online, mounted capacity) and once as the
cluster-internal pool row synthesized from Ceph telemetry (degraded, raw
pool accounting) - same name, conflicting state and usage, no visual
relationship. Found by the v5-parity audit; v5 rendered one row with a
cluster-health badge.

consolidateCephClusterPoolRecords now collapses the synthesized pool
rows (type ceph on the cluster pseudo-node) into the PVE rows that
mount them, matched by backing-pool detail or name. The pool row's
worse health is lifted onto the survivor (state cell, status detail,
issue summary) so a degraded cluster stays visible on the table, and
unmounted pool rows are kept so standalone-monitored clusters do not
lose their only capacity row. Raw per-pool accounting stays on the
Ceph tab's cluster drawer. Registry synthesis and pool alerting are
untouched.

Also pins the zfsSummary devices field in useStoragePoolDetailModel's
test, missed by b53e1a3e0.
2026-06-11 17:14:43 +01:00
rcourtman da10d9a5dd Mark the post-tool status handoff so silent reasoning never shows a stale tool status
After a tool round, the next model turn can reason server-side for tens
of seconds without streaming a single event. The footer kept the last
tool's status counting up ('Reading inventory. (40s)') while the model
was already composing the answer — observed live on the OpenRouter
reasoning route. OpenCode always shows a thinking state here; Pulse now
emits a model_processing workflow state at the start of every post-tool
turn ('Working on the response with the gathered results.'), which
model_thinking upgrades when reasoning deltas actually arrive.

Pinned by a new interaction-quality corpus scenario.
2026-06-11 16:52:02 +01:00
rcourtman b53e1a3e0e storage(pools): carry the full ZFS pool payload to the storage surface
The unified-resource adapter flattened models.ZFSPool to four scalars
(state + R/W/C totals), so scan status and the per-device report never
reached the frontend. The storage detail's ZFS card, the usage bar's
scrub/resilver overlay and ZFS tooltip, and ZFS-device-to-disk matching
were all dead code on the unified path: a degraded pool rendered as a
one-word State cell with no way to see which device failed or that a
resilver was running. Found by the v5-parity audit of the pools table
(same pipeline disease as #1471 on the disks table).

- Add StorageMeta.ZFSPool and populate it in resourceFromStorage
  (normalized copy, not aliased).
- Serialize it in monitorStoragePlatformData so the /api/state path
  carries it alongside the existing flat scalars.
- Map it through normalizeStorageMeta and the storage record adapter
  (meta first, flat platformData fallback).
- Render the per-device report (name, vdev type, state, R/W/C errors,
  message) in the detail's ZFS Pool card - the v6 home for what v5
  showed via the health-map dots and the degraded-pool warning row.
- Pin the end-to-end path in code_standards_test and update the
  unified-resources, monitoring, storage-recovery, and
  frontend-primitives contracts.

Verified live in mock mode: degraded pool now shows the resilver scan,
pool error totals, per-device states, and the bar pulse overlay.
2026-06-11 16:48:57 +01:00
rcourtman 70a20f6ac7 Generate session titles from the first exchange (OpenCode parity)
Session titles were the truncated first prompt, so the picker filled
with 'Reply with exactly: OK' and five identical 'hows esphome' rows.
OpenCode background-generates a short title after the first exchange;
Pulse now does the equivalent: once the done event has reached the
client, the chat runtime asks the configured chat model for a <=50-char
title from the first user message and reply, then renames the session.

- Runs only on the first exchange; any customized title is left alone,
  with a re-check before persisting to lose races against a concurrent
  rename. Failure or timeout keeps the placeholder.
- Title requests pass the model-boundary sanitizer like normal turns
  and record usage as assistant_session_title.
- Synchronous after done (no user-visible latency, no goroutine racing
  service teardown), bounded at 20s for slow reasoning routes.

Verified by tests end-to-end (hook through Rename); live verification
on a real provider route pending while the dev backend sits in mock
assistant mode, which intercepts turns before the agentic path.
2026-06-11 16:21:58 +01:00
rcourtman d26f9da709 Flag pre-rc.6 SSH sensor setups and surface a re-run-setup notice
Nodes that set up SSH temperature monitoring before v6.0.0-rc.6 have an
authorized_keys entry locked to `sensors -j`, so the pulse-sensors wrapper
never runs and SMART (SATA/SAS) disk temperatures silently never arrive;
only NVMe shows via kernel hwmon (what the #1471 reporters hit). The server
already detected the legacy payload but only logged it.

- models.Temperature gains LegacySensorsFormat, set by parseSensorsJSON
  when the payload is raw sensors -j, and preserved across the host-agent
  merge; it rides the existing temperatureDetails projection on unified
  node resources.
- New PlatformOutdatedSensorSetupNotice on the Proxmox page, data-gated:
  collection succeeded AND payload was legacy AND the node has SATA/SAS
  disks without temperatures, so nodes whose disk temps arrive via a host
  agent stay clean. Copy points at re-running the node setup script, which
  replaces the locked key entry and installs the wrapper.
- monitoring.md contract extended with the legacy-format marker behavior.
2026-06-11 16:00:25 +01:00
rcourtman ab01e56db0 Make session listing O(changed files), not O(all transcript bytes)
GET /api/ai/sessions fully re-parsed every session transcript on every
call, roughly three times over (handoff refresh List, per-session action
loads, final List). On a long-lived install the endpoint took tens of
seconds, pegged the CPU, and the dev watchdog kill-looped the backend.

- SessionStore.List() now serves summaries from a per-file cache
  validated by (modTime, size); only changed files are re-read, with a
  raw-skip decode that never deep-parses message payloads.
- The cache persists as .sessions_index.json next to the session files,
  so the first list after a restart is index-speed too (18ms on a 662MB
  legacy directory vs 23s before).
- writeSession/Delete keep cache and index coherent; external edits
  self-heal via the (modTime, size) check.
- ListSessions refreshes handoff action status only for the 20 newest
  sessions that carry actions, matching what the recent-sessions UI can
  surface; the send path already refreshes the active session on load.
- ai-runtime contract updated: listing-cost clause and bounded-refresh
  clause replace the unbounded refresh promise.
2026-06-11 14:11:17 +01:00
rcourtman f9b10316b8 fix(licensing): surface unreadable persisted v5 licenses and retry failed startup exchanges
Two silent-downgrade paths in the v5→v6 migration are now visible and
self-healing:

License load/decrypt failure (was: one log line, no UI state): when
license.enc exists but cannot be read, getTenantComponents now persists
a terminal commercial_migration state with reason
persisted_license_unreadable, so the licence panel and global banner
tell the customer to re-enter their v5 key instead of leaving them to
discover missing Pro features.

Failed startup exchange (was: once per process, Community until manual
restart): exchange failures classified as pending — license-server
blips, DNS failures, rate limits — now schedule a background retry loop
with backoff (30s → 30m cap) that re-attempts the exchange until it
succeeds, hits a terminal classification, or the org's service activates
through another path. The loop stops cleanly on manual activation and
StopAllBackgroundLoops.

Registers the new files in the subsystem registry (cloud-paid
commercial-migration verification policy + shared ownership) and pins
both behaviors in the cloud-paid and api-contracts contracts.
2026-06-11 08:44:35 +01:00
rcourtman cce1b3c783 fix(licensing): decrypt v5.0.0-era license.enc sealed with raw machine-id material
v5.0.0 derived the license.enc AES key as sha256("pulse-license-" +
material) where material was the raw /etc/machine-id file content —
trailing newline included — with hostname and a fixed dev fallback as
alternates. v6 trims machine-id before hashing and dropped both
fallbacks, so licenses activated on v5.0.0 (before .license-key shipped
in v5.0.1) could never be decrypted and the December 2025 lifetime
cohort silently booted as Community.

Collect the raw v5 key materials at construction and try them verbatim
as legacy sha256 candidates during decrypt, alongside the existing
trimmed HKDF/legacy derivations. The write path is unchanged: persistent
random key + HKDF only. Pins the cloud-paid persistence contract to the
full v5.0.x compatibility-loader material set.
2026-06-11 08:36:11 +01:00
rcourtman e8455e84b7 fix(resources): pin canonical host IDs to durable identity so they survive restarts
Canonical IDs for merged-source hosts (PVE node + pulse-agent) were minted
from whichever identity keys the creating record happened to carry: the
agent record knows the machine ID, the Proxmox node record only knows
cluster+hostname. The registry rebuilds from scratch every tick, so a boot
window where the agent had not checked in yet minted a cluster-keyed ID
(agent-7a62... for delly) while steady state minted a machine-keyed one
(agent-bdd4...). Every restart re-ran the race, fragmenting the
resource_changes journal into per-boot eras (9.4k vs 6.1k rows for the
same host) and silently truncating report availability and UI timelines.

Fix, in the layer that owns identity:
- Persist identity pins (canonical_id <-> machine_id/dmi/cluster/hostname)
  in the previously schema-only resource_identities table, written by the
  store-backed registry after monitor-adapter rebuilds, diff-aware so
  steady-state ticks cost no writes.
- Complete weak incoming identities from the pins before matching and ID
  derivation, so a node-only boot window derives the same machine-keyed
  canonical ID as steady state. Derivation itself is unchanged; ephemeral
  nil-store registries behave exactly as before.
- Expand change-journal reads (Get/Count families, SQLite and memory) to
  the full era set recomputed from the pinned identity keys, healing
  historical journals at query time with no row migration. Reads keyed by
  a stale era ID resolve to the same merged timeline.

Regression tests cover both ingest orders, restart simulation via the
monitor adapter, era ID derivation, and era-merged journal reads on both
store implementations. Contracts updated: unified-resources obligation 25
(durable identity pins), monitoring obligation 10 (adapter rebuild
persistence).
2026-06-11 08:33:00 +01:00
rcourtman 3a51429a58 fix(licensing): honest installation-limit copy and v6 connectivity disclosure
Docs and frontend half of the licensing-policy client work from the v6
upgrade sweep (license findings 5 and 6); the Go classifier and grant
refresh fix land separately once the shared pre-commit guard unblocks.

A 409 MAX_INSTALLATIONS during v5 key migration rendered as 'another v6
activation handoff is still settling. Retry activation from this
instance.' Retrying can never succeed until a slot is freed server-side,
so that copy stranded users. The notice now says the key is already
active on its maximum number of v6 installations and points at
support@pulserelay.pro to free a slot, via the new
exchange_installation_limit reason and free_installation_slot action.

docs/UPGRADE_v6.md gains a 'Breaking Change: Paid Licensing Requires
Connectivity' section: v5 validated keys fully offline, v6 needs
periodic connectivity to license.pulserelay.pro, with roughly 10 days of
offline tolerance (72 hour grant plus 7 day grace) before paid features
drop to Community, plus air-gap guidance and the migrated-key
installation cap. docs/releases/V6_CHANGELOG.md gains the matching
breaking-change bullet.

Note: the two frontend files also carry two small additive hunks from
the parallel persisted-license-unreadable slice (a copy case and its
test, inert until its Go side lands); they share these files and are
included to avoid splitting them mid-flight.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-11 08:32:07 +01:00
rcourtman 5e78825f54 fix(install): make unattended auto-updates actually run after install and upgrade
Two GA-blocking bugs in the auto-update install path:

The pulse-update.service unit was rendered through an unquoted heredoc
containing $${PULSE_SERVICE_NAME}, which bash expands to the installer's
PID, so the unit shipped with ExecCondition checking a service like
"24757{PULSE_SERVICE_NAME}". The condition always failed and systemd
silently skipped every scheduled run, for fresh installs and upgrades
alike. The heredoc now renders the detected service name directly:
ExecCondition=/bin/sh -c 'systemctl is-active --quiet pulse'.

Upgrades also never refreshed the updater assets: a v5 box with
auto-updates enabled keeps pulse-update.timer and autoUpdateEnabled=true,
so the update/reinstall flows never re-ran setup_auto_updates and the
v5.1-pinned /usr/local/bin/pulse-auto-update.sh stayed in place, logging
"Already running latest version" forever instead of selecting v6
releases. The asset-install half of setup_auto_updates is now a shared
install_auto_update_assets(), and a new refresh_auto_updates() rewrites
the helper script and units unconditionally whenever the timer already
exists, without touching system.json or the timer's enabled state. All
five install flows (update, reinstall, --version, --source, fresh) are
wired.

Tests now render the real unit and execute the rendered ExecCondition
against a recording systemctl stub instead of asserting source-text
fragments, pin refresh_auto_updates behavior (stale helper replaced,
system.json and enablement untouched), and pin the call-site wiring.
The deployment-installability contract records both invariants.

Note: rewritten message only — the original local commit carried a
parallel agent's licensing commit message due to a shared-index race;
the tree is byte-identical to the hook-verified original.
2026-06-10 22:43:08 +01:00
rcourtman 43bc15c12f fix(config): restore legacy PORT fallback for FRONTEND_PORT with deprecation warning
v5 documented PORT as a legacy alias for FRONTEND_PORT. v6 dropped the
fallback silently, so an install configured with PORT=8080 upgrades to
v6 and comes up on 7655 with no explanation, unreachable behind its
existing port mapping or reverse proxy.

Honor PORT again at lowest precedence (FRONTEND_PORT wins when both are
set) and log a deprecation warning at startup, mirroring the existing
legacy DISABLE_AUTH handling. Document PORT as deprecated in
CONFIGURATION.md and note the compatibility behavior in the v6
changelog.
2026-06-10 22:32:28 +01:00
rcourtman 3229ed5bd1 Document connecting client Proxmox/PBS over the provider VPN in MSP.md
Polling direction (provider to client site, 8006/8007) versus agent
check-in direction, per-client steps with the guided pveum setup command,
privilege-separated token caveat, TOFU certificate pinning, and the
overlapping-RFC1918 note that container-per-client isolation makes moot.
2026-06-10 14:35:14 +01:00
rcourtman a17882458a Rename CP_TRIAL_ACTIVATION_PRIVATE_KEY to CP_ENTITLEMENT_SIGNING_PRIVATE_KEY in the provider MSP bundle
The variable signs hosted entitlement leases; the trial-activation name is
left over from the retired trial era and reads as trial machinery to an
operator generating their licensing root key. The provider MSP bundle has
no installed base yet, so the canonical rename is free today and frozen
the moment the first design partner installs.

- Control plane reads CP_ENTITLEMENT_SIGNING_PRIVATE_KEY first and falls
  back to CP_TRIAL_ACTIVATION_PRIVATE_KEY, so existing Pulse-hosted cloud
  deployments (deploy/cloud, hibernated snapshot) keep working unchanged.
- deploy/provider-msp (.env.example, compose, setup.sh), MSP.md, and the
  install-test pins use the canonical name; error messages name it too.
- deploy/cloud intentionally keeps the legacy name: that stack historically
  signed hosted trial activations, and its snapshot predates the rename.
2026-06-10 14:08:52 +01:00
rcourtman 2482d4acb6 Document provider-MSP deploy bundle as canonical install; surface lease signing public key in setup.sh
- MSP.md now leads with deploy/provider-msp/ (compose stack, setup.sh,
  upgrade.sh, run-install-proof.sh), documents the HTTPS requirement
  (__Host- portal session cookie) and the pulse.provider-msp.role labels
  workspace provisioning requires, and explains the licence/lease chain
  including licence-expiry behavior.
- setup.sh derives and prints the lease signing public key the provider
  MSP licence must bind (also via --print-lease-signing-public-key), and
  the missing-licence error now includes it with request instructions.
- .env.example documents the CP_TRIAL_ACTIVATION_PRIVATE_KEY binding.
2026-06-10 14:00:11 +01:00
rcourtman 96001d134e Harden MSP tenant isolation: scope org-bound tokens away from default org, propagate webhook allowlist to all tenants
Two gaps found by exercising the MSP pilot path live on a throwaway
multi-tenant instance:

1. CheckAccess granted any authenticated principal access to the default
   org, so a token bound to a client org could read the provider's own
   default-org estate if it leaked from a client site. Org-bound tokens
   now fall through to the explicit binding check for the default org;
   authenticated users and legacy unbound tokens keep default-org access,
   and binding "default" explicitly still grants it.

2. The webhook private-target allowlist (instance-wide system setting)
   only ever reached the default org's notification manager on
   startup/reload, and only the request-context org on settings update.
   Tenant orgs' webhooks to private targets (per-client Gotify over VPN,
   the canonical MSP alert route) failed SSRF validation with no org-side
   remedy, and any allowlist died with a restart. Settings updates and
   reloads now fan out to every live tenant manager via the new
   MultiTenantMonitor.ForEachMonitor, and tenant monitors inherit the
   persisted allowlist and public URL at creation.

Both fixes verified live: org-bound token vs default org returns 403;
client-org webhooks to a private target succeed after restart and for
orgs created after the allowlist was saved. MSP.md validation checklist
gains the default-org probe and the allowlist guidance; MULTI_TENANT.md
documents the binding semantics. Contracts updated for api-contracts,
security-privacy, and monitoring with adjacency notes for
agent-lifecycle, storage-recovery, and performance-and-scalability.
2026-06-10 11:37:39 +01:00
rcourtman cbd0311055 Inject workspace display name into hosted tenant runtime env
Hosted tenant containers received PULSE_TENANT_ID but not
PULSE_TENANT_NAME, so alert webhook payloads from provider-hosted client
runtimes fell back to the raw tenant ID instead of a human-readable
workspace label. Resolve the display name from the tenant registry at
container-create time via a ManagerConfig resolver and stamp it
alongside the tenant ID. Display-name changes after creation apply on
the next runtime rollout, which recreates the container with freshly
resolved env.
2026-06-10 11:13:37 +01:00
rcourtman 1cf92e6c10 Pin metadata GET zero-record payload contract
Adds TestContract_MetadataGetPayloadsUseZeroRecordsInsteadOf404: empty
guest/docker metadata maps must serialize as {} (never null) and a
missing resource must return a 200 zero record echoing the requested ID
(never a 404). This is the proof companion to the
metadata_handlers_shared.go consolidation in the previous commit — it
was authored with that change but lost to a shared-index race at commit
time.
2026-06-10 10:53:57 +01:00
rcourtman 6340cd36f2 Dedupe internal/api handler families behind shared flows and generics
Clears the sixteen dupl pair groups in internal/api plus the pkg/pulsecli
pair:

- router.go: privileged settings endpoints share the
  serveSetupTokenOrSettingsWrite gate; patrol findings convert via one
  unifiedFindingFromAI; the five infrastructure-summary chart loops share
  collectGuestChartData / fillChartSeriesFromBatch; the VM/LXC workloads
  summary loops share appendGuestWorkloadSummaries.
- deploy_handlers.go: preflight and job status/SSE handlers share
  handleDeployJobStatus / handleDeployJobEvents.
- recovery_handlers.go: points series/facets share
  parseRecoveryListPointsOptions.
- truenas_handlers.go / vmware_handlers.go / router_routes_registration.go:
  the connection update flow (locate, decode-with-fallback, normalize,
  preserve masked secrets, validate, save, redact) moves to the new
  platform_connection_shared.go (updatePlatformConnection +
  decodeOptionalInstanceRequest + the admin-gated item-route builder);
  per-platform wrappers carry nolint'd declarative wiring only.
- docker_metadata.go / guest_metadata.go: GET/PUT payload semantics move
  to metadata_handlers_shared.go; the zero-record-instead-of-404 contract
  is pinned by TestContract_MetadataGetPayloadsUseZeroRecordsInsteadOf404.
- kubernetes_agents.go / docker_agents.go: lifecycle PUTs share
  per-handler action helpers.
- config_node_handlers.go: PBS/PMG probes share
  testProxmoxPlatformConnection.
- cloud_handoff_handlers.go / purchase_return_redemptions.go: secrets
  sqlite stores open through openHardenedSecretsDB so permission
  hardening stays single-sourced.
- ai_handlers.go / chat_service_adapter.go: the GetMessages adapters are
  deliberate contract mirrors — suppressed with nolint and enforced by
  TestOrchestratorAndChatAdaptersMapTheSameMessageFields.
- pkg/pulsecli/actions.go: action subcommands seed env defaults via
  actionAPIDefaults (tested); the audit/events cobra registration pair is
  nolint'd parallel wiring.
- .golangci.yml: exclude gitignored tmp/ from ./... typechecking.
- subsystem_lookup_test.py: refresh the pinned api-contracts.md line
  numbers shifted by the contract additions.

golangci-lint run ./... is now fully green. Full internal/api and
pkg/pulsecli test suites pass.
2026-06-10 10:52:05 +01:00
rcourtman 26c7ca910c Document the MSP delivery surface: webhook contract, PSA samples, provider ops guide
- WEBHOOKS.md: stable Delivery Contract section (alert/resolved events,
  two-level severity model, alert types, tenant identity fields, retry
  layers, X-Pulse-Event-ID idempotency, signingSecret HMAC verification
  with a receiver example), plus full PSA sample payloads for critical,
  warning, and resolved events and ConnectWise mapping guidance.
- New MSP.md provider operations guide: deployment models, split-port
  ingress topology with firewall baseline, a no-host-jump validation
  checklist (agent port 404s, agent-token scope rejection, cross-tenant
  403 probe), per-client alert routing and reports, branding scope per
  deployment model, licensing and limits.
- docs index links the new guide.
2026-06-10 10:44:34 +01:00
rcourtman a223b02dd5 Stamp tenant identity into alert webhook payloads
Alert webhook payloads now carry the tenant that fired them, so MSP/PSA
receivers (ConnectWise and similar) can route tickets by client without
inferring the tenant from which webhook endpoint fired.

- WebhookPayloadData gains TenantID/TenantName, exposed to custom
  templates as {{.TenantID}}/{{.TenantName}}.
- Defaults come from PULSE_TENANT_ID/PULSE_TENANT_NAME (already injected
  into provider-hosted client runtimes; name falls back to ID).
- Shared-process multi-tenant orgs override via a lazy org-backed
  resolver wired in MultiTenantMonitor.GetMonitor, so display-name
  renames are picked up without restart.
- Generic service template emits a tenant block when identity is set,
  omits it otherwise; single-tenant payloads are unchanged.
- Notifications and monitoring subsystem contracts updated with the
  tenant-identity ownership boundary; guardrail test pins the org
  wiring.
2026-06-10 10:03:39 +01:00
rcourtman 8439ce6e6b Dedupe AI runtime guest-family, tool-pipeline, and provider clones
Clears the ten dupl pairs across internal/ai:

- patrol_intelligence.go, tools_query.go, tools_storage.go: VM and LXC
  system-container paths collapse into generics over read-state view
  method subsets (gatherGuestIntelligenceFromViews, canonicalGuestGetResult
  + guestViewGetResult, addCanonicalGuestSearchMatches +
  addGuestViewSearchMatches, appendGuestDiskSummaries).
- tools_file.go: append/write share executeFileMutation driven by
  fileMutationSpec (approval-command text, shell redirect, verification
  strategy stay per-action and verbatim).
- tools_kubernetes.go: deployment restart / pod delete share
  executeKubernetesResourceAction driven by kubernetesResourceAction.
- providers/anthropic.go + anthropic_oauth.go: message conversion shared
  via convertMessagesToAnthropic (the OAuth copy was annotated 'same as
  regular client').
- memory/changes.go + memory/remediation.go: history loading shared via
  the generic loadMemoryHistory in memory/paths.go (10 MiB cap, sort,
  missing-file semantics preserved via a found flag).
- findings.go and unified/alerts.go: Finding/findingJSON and
  UnifiedFinding/unifiedFindingJSON are deliberate marshal-mirror twins
  (AlertIdentifier json:"-" vs alert_identifier round-trip); merging
  would break every public literal. Suppressed with nolint:dupl and
  enforced instead by new reflect-based mirror-sync tests.

Contract Extension Points name the shared helpers and the mirror
invariant. Full ./internal/ai/... test tree passes.
2026-06-10 09:38:52 +01:00
rcourtman b855b21c7f Dedupe monitoring pollers behind shared provider, lifecycle, and seeding helpers
Clears the six dupl pairs in internal/monitoring:

- poll_providers.go: PVE/PBS/PMG listInstances / describeInstances /
  connectionStatus closures collapse into generic sortedClientNames,
  describeProviderInstances, and providerConnectionStatuses helpers; the
  PBS/PMG adapters are now built by newPrefixedPollProvider from a
  prefixedPollProviderSpec (prefix drives status keys, health keys, and
  the fallback instance name).
- truenas_poller.go / vmware_poller.go: the Start scheduling loop
  (double-start guard, stopped-channel handshake, sync+poll cadence)
  moves to startPollerLoop, and the active-connection config policy
  (enabled instances keyed by trimmed connection ID, defaults applied)
  moves to loadActiveInstanceConfigs, both in the new
  platform_poller_shared.go.
- monitor_polling_vm.go / monitor_polling_containers.go: traditional
  polling now records guest series via the existing canonical
  recordGuestMetric helper (io/network sentinels preserve the historical
  cpu/memory/disk-only behavior on this path); the source-shape
  guardrails in canonical_guardrails_test.go and
  memory_source_catalog_test.go pin the new delegation.
- mock_metrics_history.go: native and TrueNAS disk seeding share one
  seedDiskTelemetry closure.

Proof: new TestTrueNASPollerDoubleStartKeepsSingleLoop pins the shared
lifecycle loop; new TestSeedMockMetricsHistory_DiskTelemetryParityAcrossNativeAndTrueNAS
pins four-series parity for both disk sources. Contract Extension Points
name the shared wiring layer and poller scaffold.

Full internal/monitoring test suite passes.
2026-06-10 09:19:51 +01:00
rcourtman 74417de150 Share one Resource scaffold across namespaced Kubernetes adapters
The k8s workload/config adapters in unifiedresources each hand-rolled the
same Resource literal (Technology, LastSeen, UpdatedAt, Kubernetes facet,
label tags) plus namespacedKubernetesIdentity return; dupl flagged the
StatefulSet/Job and ReplicaSet/DaemonSet clones. Fold the scaffold into
namespacedKubernetesResource and delegate all 19 namespaced adapters
(Service included) to it; cluster-scoped kinds (PV, StorageClass,
Namespace) keep bespoke identity construction.

Contract: unified-resources Extension Points now name the shared scaffold
as the way new namespaced kinds assemble resources. Proof: new
TestNamespacedKubernetesResourceScaffold pins the scaffold fields and
namespaced hostname identity.

Full internal/unifiedresources test suite passes.
2026-06-10 09:16:14 +01:00
rcourtman f4d57c6836 Label key-collision lifecycle events in the finding timeline
Completes the deferred UI half of dad1152fc: the content_replaced
lifecycle event (emitted when a same-key re-detection's text is
substantially different from the existing finding — a key collision)
now renders as "Re-detected with different details" in the finding
timeline instead of the identifier-formatter fallback, so the operator
reads what actually happened in plain language. The event metadata
already carries the previous and new titles.

Ceremony this line deferred from the backend commit: patrol-intelligence
contract Current State entry (lifecycle label map rule + the new label),
ai-runtime cross-reference updated, and the label pinned in
FindingsPanel.test.ts lifecycleLabels (the subsystem's accepted proof).
111 FindingsPanel tests green.
2026-06-09 22:10:53 +01:00