diff --git a/internal/ai/demo.go b/internal/ai/demo.go index de55863eb..b180e1944 100644 --- a/internal/ai/demo.go +++ b/internal/ai/demo.go @@ -6,6 +6,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/mockmode" + "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rs/zerolog/log" ) @@ -15,279 +16,573 @@ func IsDemoMode() bool { return mockmode.IsEnabled() } -// InjectDemoFindings populates the patrol service with realistic mock findings -// This is used for demo instances to showcase AI features without actual AI API calls -func (p *PatrolService) InjectDemoFindings() { - if p == nil || p.findings == nil { - return - } +// Demo provider identity reported by readiness and preflight surfaces while +// mock mode simulates Patrol. Matches the model name GenerateDemoAIResponse +// reports for assistant chat. +const ( + DemoPatrolProvider = "demo" + DemoPatrolModel = "demo-model" +) - log.Info().Msg("demo mode: Injecting mock AI patrol findings") - - now := time.Now() - - // Create realistic demo findings - demoFindings := []*Finding{ - { - ID: "demo-storage-critical", - Key: "storage:local-zfs:capacity", - Severity: FindingSeverityCritical, - Category: FindingCategoryCapacity, - Title: "ZFS pool 'local-zfs' is 94% full", - Description: "Storage pool local-zfs on pve1 has only 47GB remaining out of 750GB. At current growth rate, pool will be full in approximately 5 days.", - ResourceID: "storage/pve1/local-zfs", - ResourceName: "local-zfs", - ResourceType: "storage", - Node: "pve1", - Recommendation: `**Immediate actions:** -1. Identify large files: ` + "`zfs list -o name,used,refer -t all | sort -k2 -h | tail -20`" + ` -2. Check for orphaned VM disks: ` + "`pvesm list local-zfs | grep -v 'vm-'`" + ` -3. Remove old snapshots: ` + "`zfs list -t snapshot -o name,used | sort -k2 -h`" + ` - -**Long-term:** -- Add additional storage or migrate VMs to other pools -- Enable ZFS compression if not already enabled`, - DetectedAt: now.Add(-2 * time.Hour), - LastSeenAt: now.Add(-5 * time.Minute), - TimesRaised: 3, - Source: "patrol", - }, - { - ID: "demo-memory-warning", - Key: "guest:vm-102:memory", - Severity: FindingSeverityWarning, - Category: FindingCategoryPerformance, - Title: "VM 'jellyfin' memory usage at 91%", - Description: "jellyfin (VM 102) on pve2 is consistently using 14.5GB of its 16GB allocated memory. Swapping may occur under load, degrading performance.", - ResourceID: "qemu/102", - ResourceName: "jellyfin", - ResourceType: "qemu", - Node: "pve2", - Recommendation: `**Options to consider:** -1. Increase VM memory allocation to 20GB if host has capacity -2. Check for memory leaks in Jellyfin: restart the service -3. Limit transcoding to reduce memory pressure -4. Review Jellyfin cache settings in the dashboard`, - DetectedAt: now.Add(-6 * time.Hour), - LastSeenAt: now.Add(-10 * time.Minute), - TimesRaised: 5, - Source: "patrol", - }, - { - ID: "demo-backup-warning", - Key: "guest:vm-105:backup", - Severity: FindingSeverityWarning, - Category: FindingCategoryBackup, - Title: "Container 'postgres' hasn't been backed up in 8 days", - Description: "The postgres container (CT 105) last successful backup was 8 days ago. Your backup schedule targets daily backups.", - ResourceID: "lxc/105", - ResourceName: "postgres", - ResourceType: "system-container", - Node: "pve1", - Recommendation: `**Investigate:** -1. Check PBS backup job status: ` + "`pvesh get /nodes/pve1/tasks --typefilter vzdump`" + ` -2. Verify PBS datastore connectivity -3. Check for backup job errors in the Proxmox datacenter backup view - -**Manual backup:** -` + "`vzdump 105 --storage pbs --mode snapshot`", - DetectedAt: now.Add(-24 * time.Hour), - LastSeenAt: now.Add(-15 * time.Minute), - TimesRaised: 2, - Source: "patrol", - }, - { - ID: "demo-cpu-warning", - Key: "node:pve3:cpu", - Severity: FindingSeverityWarning, - Category: FindingCategoryPerformance, - Title: "Node 'pve3' sustained high CPU (87%)", - Description: "Node pve3 has maintained CPU usage above 85% for the past 2 hours. This may indicate over-provisioning or a runaway process.", - ResourceID: "node/pve3", - ResourceName: "pve3", - ResourceType: "node", - Node: "pve3", - Recommendation: `**Diagnose:** -1. Check top processes: ` + "`ssh pve3 'top -bn1 | head -20'`" + ` -2. Identify VM CPU usage: ` + "`pvesh get /nodes/pve3/qemu --output-format json | jq '.[] | {name, cpu}'`" + ` - -**Consider:** -- Live-migrate a VM to another node: ` + "`qm migrate pve1 --online`" + ` -- Set CPU limits on high-usage VMs`, - DetectedAt: now.Add(-2 * time.Hour), - LastSeenAt: now.Add(-8 * time.Minute), - TimesRaised: 4, - Source: "patrol", - }, - { - ID: "demo-docker-warning", - Key: "docker:portainer:container-restart", - Severity: FindingSeverityWarning, - Category: FindingCategoryReliability, - Title: "Docker container 'uptime-kuma' restarting frequently", - Description: "The uptime-kuma container on docker-host-1 has restarted 7 times in the past 24 hours. This may indicate configuration issues or resource constraints.", - ResourceID: "docker/docker-host-1/uptime-kuma", - ResourceName: "uptime-kuma", - ResourceType: "app-container", - Node: "docker-host-1", - Recommendation: `**Check logs:** -` + "`docker logs uptime-kuma --tail 100`" + ` - -**Common causes:** -- OOM kills: check ` + "`docker stats uptime-kuma`" + ` -- Configuration errors in environment variables -- Database corruption (check data volume)`, - DetectedAt: now.Add(-12 * time.Hour), - LastSeenAt: now.Add(-20 * time.Minute), - TimesRaised: 3, - Source: "patrol", - }, - { - ID: "demo-update-safety-warning", - Key: UpdateSafetyFindingPrefix + ":docker-host-1/homeassistant", - Severity: FindingSeverityWarning, - Category: FindingCategoryReliability, - Title: "Container \"homeassistant\" image updated", - Description: "Image digest changed. Container homeassistant was updated to a new image and has restarted 2 times in the verification window.", - ResourceID: "docker-host-1/homeassistant", - ResourceName: "homeassistant", - ResourceType: "app-container", - Node: "docker-host-1", - Evidence: "prior_digest=sha256:abc123def456 new_digest=sha256:789xyz012uvw restart_count=2", - DetectedAt: now.Add(-3 * time.Minute), - LastSeenAt: now.Add(-1 * time.Minute), - TimesRaised: 2, - Source: updateSafetySource, - }, - { - ID: "demo-pdm-alert-node-offline", - Key: PDMAlertFindingPrefix + ":datacenter-a/node/pve-edge-01", - Severity: FindingSeverityWarning, - Category: FindingCategoryReliability, - Title: "PDM: node \"pve-edge-01\" is offline", - Description: "Proxmox Datacenter Manager reports node \"pve-edge-01\" in remote \"datacenter-a\" is offline.", - ResourceID: "datacenter-a/node/pve-edge-01", - ResourceName: "pve-edge-01", - ResourceType: "node", - Node: "datacenter-a", - Evidence: "status=offline remote=datacenter-a", - DetectedAt: now.Add(-8 * time.Minute), - LastSeenAt: now.Add(-2 * time.Minute), - TimesRaised: 1, - Source: pdmAlertSourceLabel, - }, - } - - // Add findings to the store - for _, f := range demoFindings { - p.findings.Add(f) - } - - // Also add some demo patrol run history - p.injectDemoRunHistory() - - log.Info().Int("findings_count", len(demoFindings)).Msg("demo mode: Mock findings injected") +// IsDemoRuntimeIntended reports whether this process is meant to serve demo +// fixtures even when they are not enabled yet. Release demo instances boot +// with mock fixtures off until the license sync validates the demo_fixtures +// entitlement, so boot-time lifecycle decisions (whether to start the patrol +// loop at all) must look at operator intent, not the current gate state. +func IsDemoRuntimeIntended() bool { + return IsDemoMode() || mockmode.IsRequestedFromEnv() } -// injectDemoRunHistory adds realistic patrol run history for the demo -func (p *PatrolService) injectDemoRunHistory() { - if p.runHistoryStore == nil { - return +const demoFindingIDPrefix = "demo-" + +// isDemoFindingID reports whether a finding was synthesized by the demo +// patrol cycle. Real findings use hex hash IDs, so the prefix cannot collide. +func isDemoFindingID(id string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(id)), demoFindingIDPrefix) +} + +// runDemoPatrolCycle executes one simulated patrol pass for demo/mock mode. +// It never contacts a provider: findings are synthesized from the current +// mock infrastructure state so every finding points at a resource the demo +// actually shows, then a matching run record is appended to check history. +// Repeated cycles refresh the same finding IDs (heartbeat semantics) and +// auto-resolve demo findings whose underlying mock condition went away. +// Returns true once a cycle has been recorded; false when it could not run +// yet (mock disabled again, another run in flight, or mock state not +// generated yet). +func (p *PatrolService) runDemoPatrolCycle(trigger TriggerReason) bool { + if p == nil || p.findings == nil || p.runHistoryStore == nil { + return false } + if !IsDemoMode() { + return false + } + if !p.tryStartRun("full") { + return false + } + defer p.endRun() + + p.mu.Lock() + // Mock mode can enable after the boot-time config snapshot was taken + // (release demo instances authorize fixtures via the license sync). + // Repair the snapshot so status surfaces report an active patrol instead + // of the pre-demo disabled/blocked state. + p.config.Enabled = true + p.config.RuntimeBlockedReason = "" + p.config.RuntimeBlockedCause = PatrolFailureCauseNone + cfg := p.config + p.mu.Unlock() + p.clearBlockedReason() now := time.Now() + state := p.currentPatrolRuntimeState() + findings := synthesizeDemoPatrolFindings(state, now) - // Clear existing history first to avoid duplicates on restart - // (Assuming we can't easily clear, we'll just generate new IDs based on time to be idempotent-ish) + counts := patrolRuntimeCountResources(state) + nodesChecked, guestsChecked, dockerChecked, storageChecked := 0, 0, 0, 0 + hostsChecked, trueNASChecked, pbsChecked, pmgChecked, kubernetesChecked := 0, 0, 0, 0, 0 + if cfg.AnalyzeNodes { + nodesChecked = counts.nodes + } + if cfg.AnalyzeGuests { + guestsChecked = counts.guests + } + if cfg.AnalyzeDocker { + dockerChecked = counts.docker + } + if cfg.AnalyzeStorage { + storageChecked = counts.storage + } + if cfg.AnalyzeHosts { + hostsChecked = counts.hosts + trueNASChecked = counts.truenas + } + if cfg.AnalyzePBS { + pbsChecked = counts.pbs + } + if cfg.AnalyzePMG { + pmgChecked = counts.pmg + } + if cfg.AnalyzeKubernetes { + kubernetesChecked = counts.kubernetes + } + resourceCount := nodesChecked + guestsChecked + dockerChecked + storageChecked + + hostsChecked + trueNASChecked + pbsChecked + pmgChecked + kubernetesChecked - // Create a realistic schedule: every 6 hours for the last 3 days - var demoRuns []PatrolRunRecord + if resourceCount == 0 && len(findings) == 0 { + // Mock state has not been generated yet (early boot). The warmup + // retry or the next scheduled cycle will populate the surface once + // state exists. + log.Debug().Msg("demo patrol: no mock state available yet, skipping cycle") + return false + } - // 1. Most recent run (just happened) - demoRuns = append(demoRuns, PatrolRunRecord{ - ID: fmt.Sprintf("demo-run-%d", now.Unix()), - Source: PatrolRunSourceDemo, - StartedAt: now.Add(-15 * time.Minute), - CompletedAt: now.Add(-14*time.Minute + 15*time.Second), - Duration: 75 * time.Second, - Type: "patrol", - ResourcesChecked: 47, - NodesChecked: 5, - GuestsChecked: 32, - DockerChecked: 8, - StorageChecked: 6, - NewFindings: 0, - ExistingFindings: 5, - ResolvedFindings: 0, - FindingsSummary: "2 critical, 3 warnings", - FindingIDs: []string{"demo-storage-critical", "9e1eb083b7109506", "demo-memory-warning", "demo-backup-warning", "demo-cpu-warning"}, - Status: "issues_found", - InputTokens: 4250, - OutputTokens: 890, - }) - - // 2. Scheduled runs (every 6 hours) - for i := 1; i <= 12; i++ { - offset := time.Duration(i*6) * time.Hour - startTime := now.Add(-offset) - - // Vary the duration slightly - duration := time.Duration(40+(i%30)) * time.Second - - // Outcomes vary over time - var summary string - var status string - var newFindings, existingFindings, resolvedFindings int - var findingIDs []string - - if i <= 4 { // Last 24h - steady state of issues - summary = "2 critical, 3 warnings" - status = "issues_found" - existingFindings = 5 - findingIDs = []string{"demo-storage-critical", "9e1eb083b7109506", "demo-memory-warning", "demo-backup-warning", "demo-cpu-warning"} - } else if i == 5 { // 30h ago - one issue appeared - summary = "1 new critical, 3 warnings" - status = "issues_found" - newFindings = 1 - existingFindings = 3 - findingIDs = []string{"demo-storage-critical", "demo-memory-warning", "demo-backup-warning", "demo-cpu-warning"} - } else if i <= 10 { // 2-3 days ago - fewer issues - summary = "3 warnings" - status = "issues_found" - existingFindings = 3 - findingIDs = []string{"demo-memory-warning", "demo-backup-warning", "demo-cpu-warning"} - } else { // > 3 days ago - clean state - summary = "No issues found" - status = "healthy" - resolvedFindings = 1 + // Demo findings are deliberately not persisted, so a restart re-adds + // them as store-new. Findings already attested by prior demo run records + // still count as existing: the history says they were open before. + knownIDs := make(map[string]bool) + for _, run := range p.runHistoryStore.GetAll() { + if !isDemoPatrolRunRecord(run) { + continue } + for _, id := range run.FindingIDs { + knownIDs[id] = true + } + } - demoRuns = append(demoRuns, PatrolRunRecord{ - ID: fmt.Sprintf("demo-run-%d", startTime.Unix()), - Source: PatrolRunSourceDemo, - StartedAt: startTime, - CompletedAt: startTime.Add(duration), - Duration: duration, - Type: "patrol", - ResourcesChecked: 47, - NodesChecked: 5, - GuestsChecked: 32, - DockerChecked: 8, - StorageChecked: 6, - NewFindings: newFindings, - ExistingFindings: existingFindings, - ResolvedFindings: resolvedFindings, - FindingsSummary: summary, - FindingIDs: findingIDs, - Status: status, - InputTokens: 4000 + (i * 10), - OutputTokens: 500 + (i * 5), + newCount, existingCount := 0, 0 + currentIDs := make(map[string]bool, len(findings)) + findingIDs := make([]string, 0, len(findings)) + for _, f := range findings { + currentIDs[f.ID] = true + if p.findings.Add(f) && !knownIDs[f.ID] { + newCount++ + } else { + existingCount++ + } + findingIDs = append(findingIDs, f.ID) + } + + // Auto-resolve demo findings whose mock condition no longer holds. + resolvedCount := 0 + for _, active := range p.findings.GetActive(FindingSeverityInfo) { + if active == nil || !isDemoFindingID(active.ID) || currentIDs[active.ID] { + continue + } + if p.findings.Resolve(active.ID, true) { + resolvedCount++ + } + } + + // A simulated pass stands in for a healthy provider-backed run: clear any + // stale "Provider analysis error" finding left by real runs that failed + // before mock mode enabled. + p.resolvePatrolRuntimeFailureFinding("demo_patrol_cycle") + + summary := p.findings.GetSummary() + findingsSummaryStr := "All healthy" + status := "healthy" + if summary.Critical+summary.Warning > 0 { + parts := []string{} + if summary.Critical > 0 { + parts = append(parts, fmt.Sprintf("%d critical", summary.Critical)) + } + if summary.Warning > 0 { + parts = append(parts, fmt.Sprintf("%d warning", summary.Warning)) + } + findingsSummaryStr = joinParts(parts) + if summary.Critical > 0 { + status = "critical" + } else { + status = "issues_found" + } + } + + // Presentational duration and token counts: deterministic, scaled to the + // size of the mock fleet so the history reads like a real analysis pass. + duration := time.Duration(35+resourceCount%40) * time.Second + record := PatrolRunRecord{ + ID: fmt.Sprintf("demo-run-%d", now.UnixNano()), + Source: PatrolRunSourceDemo, + StartedAt: now.Add(-duration), + CompletedAt: now, + Duration: duration, + DurationMs: duration.Milliseconds(), + Type: "patrol", + TriggerReason: string(trigger), + ResourcesChecked: resourceCount, + NodesChecked: nodesChecked, + GuestsChecked: guestsChecked, + DockerChecked: dockerChecked, + StorageChecked: storageChecked, + HostsChecked: hostsChecked, + TrueNASChecked: trueNASChecked, + PBSChecked: pbsChecked, + PMGChecked: pmgChecked, + KubernetesChecked: kubernetesChecked, + NewFindings: newCount, + ExistingFindings: existingCount, + ResolvedFindings: resolvedCount, + FindingsSummary: findingsSummaryStr, + FindingIDs: findingIDs, + Status: status, + InputTokens: 1200 + 42*resourceCount, + OutputTokens: 380 + 60*len(findings), + AIAnalysis: fmt.Sprintf( + "Reviewed %d resources across %d nodes, %d guests, %d Docker hosts, and %d storage pools. %s.", + resourceCount, nodesChecked, guestsChecked, dockerChecked, storageChecked, findingsSummaryStr), + } + + if p.backfillDemoRunHistory(record, now) { + // The seeded history attests these findings first appeared earlier, + // so the current run reports them as still open, not newly found. + record.ExistingFindings += record.NewFindings + record.NewFindings = 0 + } + p.runHistoryStore.Add(record) + + p.mu.Lock() + p.lastActivity = now + p.lastFullPatrol = now + p.lastDuration = duration + p.resourcesChecked = resourceCount + p.errorCount = 0 + p.mu.Unlock() + + log.Info(). + Int("resources", resourceCount). + Int("findings", len(findings)). + Int("new", newCount). + Int("resolved", resolvedCount). + Str("trigger", string(trigger)). + Msg("demo patrol: completed simulated patrol cycle") + return true +} + +// startDemoPatrolWarmup runs the first simulated patrol cycle once mock state +// is available. Mock fixtures generate concurrently with startup, so an +// immediate cycle can race an empty snapshot; retry briefly instead of +// waiting for the next scheduled tick, which the dev quota guard may never +// start. +func (p *PatrolService) startDemoPatrolWarmup() { + if p == nil { + return + } + go func() { + for attempt := 0; attempt < 24; attempt++ { + if p.runDemoPatrolCycle(TriggerReasonStartup) { + return + } + time.Sleep(5 * time.Second) + } + }() +} + +// backfillDemoRunHistory seeds plausible past check history the first time a +// demo cycle runs, so the run history panel is not empty on a fresh demo +// instance. No-op once any demo record exists. Returns true when it seeded. +func (p *PatrolService) backfillDemoRunHistory(template PatrolRunRecord, now time.Time) bool { + for _, run := range p.runHistoryStore.GetAll() { + if isDemoPatrolRunRecord(run) { + return false + } + } + + // Oldest first: the store prepends, so adding in reverse keeps newest on top. + for i := 10; i >= 1; i-- { + start := now.Add(-time.Duration(i*6) * time.Hour) + duration := time.Duration(38+(i*7)%31) * time.Second + rec := template + rec.ID = fmt.Sprintf("demo-run-%d", start.UnixNano()) + rec.StartedAt = start + rec.CompletedAt = start.Add(duration) + rec.Duration = duration + rec.DurationMs = duration.Milliseconds() + rec.TriggerReason = string(TriggerReasonScheduled) + rec.NewFindings = 0 + rec.ResolvedFindings = 0 + rec.ExistingFindings = len(template.FindingIDs) + rec.AIAnalysis = "" + switch { + case i >= 8: + // The oldest runs predate the current issues. + rec.ExistingFindings = 0 + rec.FindingIDs = nil + rec.FindingsSummary = "All healthy" + rec.Status = "healthy" + case i == 7: + // The run where the current issues first appeared. + rec.NewFindings = len(template.FindingIDs) + rec.ExistingFindings = 0 + } + p.runHistoryStore.Add(rec) + } + return true +} + +// synthesizeDemoPatrolFindings derives demo findings from the live mock +// state. Every finding references a resource that exists in the demo dataset +// and quotes its actual observed values, so the patrol surface stays +// consistent with what the rest of the demo UI shows. The demo scenario data +// pins a set of deliberate anomalies (an offline Docker edge host, a degraded +// PBS instance, unhealthy containers), which gives the surface a stable core +// of findings across cycles. +func synthesizeDemoPatrolFindings(state patrolRuntimeState, now time.Time) []*Finding { + var findings []*Finding + + // Offline Docker host: the demo scenario keeps one edge host offline. + for _, host := range state.DockerHosts { + if !strings.EqualFold(strings.TrimSpace(host.Status), "offline") { + continue + } + name := demoDockerHostDisplayName(host) + findings = append(findings, &Finding{ + ID: demoFindingID("docker-host-offline", host.Hostname), + Key: fmt.Sprintf("docker:%s:offline", host.Hostname), + Severity: FindingSeverityCritical, + Category: FindingCategoryReliability, + ResourceID: host.ID, + ResourceName: name, + ResourceType: "docker-host", + Node: host.Hostname, + Title: fmt.Sprintf("Docker host %q is offline", name), + Description: fmt.Sprintf( + "The Pulse agent on %s has stopped reporting. %d containers on this host are unmonitored until the agent reconnects.", + name, len(host.Containers)), + Impact: "Workloads on this host may be down, and Pulse cannot see them while the agent is offline.", + Recommendation: "Confirm the machine has power and network connectivity, then check the agent with " + + "systemctl status pulse-agent and the Docker daemon with systemctl status docker. " + + "Recent agent logs (journalctl -u pulse-agent) usually show why reporting stopped.", + Evidence: fmt.Sprintf("status=offline containers=%d last_seen=%s", len(host.Containers), host.LastSeen.UTC().Format(time.RFC3339)), + DetectedAt: now.Add(-95 * time.Minute), + LastSeenAt: now, + Source: "patrol", + }) + break + } + + // Highest-utilization storage pool, quoting its actual numbers. + var topStorage *models.Storage + for i := range state.Storage { + s := &state.Storage[i] + if s.Total <= 0 || !s.Enabled || strings.EqualFold(strings.TrimSpace(s.Status), "offline") { + continue + } + if topStorage == nil || s.Usage > topStorage.Usage { + topStorage = s + } + } + if topStorage != nil && topStorage.Usage >= 60 { + severity := FindingSeverityWarning + urgency := "Growth at this level is worth reviewing before it becomes urgent." + if topStorage.Usage >= 85 { + severity = FindingSeverityCritical + urgency = "New snapshots, backups, and guest disk growth are at risk once the pool fills." + } + location := topStorage.Node + if topStorage.Shared || location == "" { + location = "the cluster" + } + findings = append(findings, &Finding{ + ID: demoFindingID("storage-capacity", topStorage.ID), + Key: fmt.Sprintf("storage:%s:capacity", topStorage.Name), + Severity: severity, + Category: FindingCategoryCapacity, + ResourceID: topStorage.ID, + ResourceName: topStorage.Name, + ResourceType: "storage", + Node: topStorage.Node, + Title: fmt.Sprintf("Storage %q is %.0f%% full", topStorage.Name, topStorage.Usage), + Description: fmt.Sprintf( + "Storage %s on %s has %s free of %s. It is the fullest pool in this environment. %s", + topStorage.Name, location, formatDemoBytes(topStorage.Free), formatDemoBytes(topStorage.Total), urgency), + Recommendation: "Review the largest guest disks and stale snapshots on this pool and reclaim what is no longer needed. " + + "Longer term, plan an expansion or migrate guests to a less utilized pool.", + Evidence: fmt.Sprintf("usage=%.1f%% used=%s total=%s", topStorage.Usage, formatDemoBytes(topStorage.Used), formatDemoBytes(topStorage.Total)), + DetectedAt: now.Add(-26 * time.Hour), + LastSeenAt: now, + Source: "patrol", }) } - for _, run := range demoRuns { - p.runHistoryStore.Add(run) + // Degraded backup server: the demo scenario keeps one PBS instance degraded. + for _, pbs := range state.PBSInstances { + statusBad := !strings.EqualFold(strings.TrimSpace(pbs.Status), "online") + healthBad := pbs.ConnectionHealth != "" && !strings.EqualFold(strings.TrimSpace(pbs.ConnectionHealth), "healthy") + if !statusBad && !healthBad { + continue + } + findings = append(findings, &Finding{ + ID: demoFindingID("pbs-degraded", pbs.Name), + Key: fmt.Sprintf("pbs:%s:connection", pbs.Name), + Severity: FindingSeverityWarning, + Category: FindingCategoryBackup, + ResourceID: pbs.ID, + ResourceName: pbs.Name, + ResourceType: "pbs", + Node: pbs.Name, + Title: fmt.Sprintf("Backup server %q connection is degraded", pbs.Name), + Description: fmt.Sprintf( + "Pulse is reaching backup server %s intermittently. New backup and verify jobs cannot be confirmed while the connection is degraded.", + pbs.Name), + Impact: "If backups stop landing here, restore points age silently until the connection recovers.", + Recommendation: "Check the PBS service and datastore status on the server, confirm the network path from the " + + "Proxmox nodes, and review the most recent backup task logs for timeouts.", + Evidence: fmt.Sprintf("status=%s connection=%s", pbs.Status, pbs.ConnectionHealth), + DetectedAt: now.Add(-7 * time.Hour), + LastSeenAt: now, + Source: "patrol", + }) + break } + + // Unhealthy or restart-looping containers on online Docker hosts. + containerFindings := 0 + for _, host := range state.DockerHosts { + if strings.EqualFold(strings.TrimSpace(host.Status), "offline") { + continue + } + hostName := demoDockerHostDisplayName(host) + for i := range host.Containers { + c := host.Containers[i] + unhealthy := strings.EqualFold(strings.TrimSpace(c.Health), "unhealthy") + flapping := c.RestartCount >= 3 && strings.EqualFold(strings.TrimSpace(c.State), "running") + if !unhealthy && !flapping { + continue + } + title := fmt.Sprintf("Container %q is failing its health check", c.Name) + description := fmt.Sprintf( + "Container %s on %s reports an unhealthy status from its Docker health check.", + c.Name, hostName) + if !unhealthy { + title = fmt.Sprintf("Container %q is restarting repeatedly", c.Name) + description = fmt.Sprintf( + "Container %s on %s has restarted %d times. Frequent restarts usually point at a crash loop or resource limits.", + c.Name, hostName, c.RestartCount) + } + findings = append(findings, &Finding{ + ID: demoFindingID("docker-container", host.Hostname+"-"+c.Name), + Key: fmt.Sprintf("docker:%s/%s:health", host.Hostname, c.Name), + Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, + ResourceID: c.ID, + ResourceName: c.Name, + ResourceType: "app-container", + Node: host.Hostname, + Title: title, + Description: description, + Recommendation: fmt.Sprintf( + "Check recent logs with docker logs %s --tail 100, inspect the health check definition with docker inspect, "+ + "and watch docker stats for memory pressure.", c.Name), + Evidence: fmt.Sprintf("health=%s state=%s restarts=%d image=%s", c.Health, c.State, c.RestartCount, c.Image), + DetectedAt: now.Add(-4 * time.Hour), + LastSeenAt: now, + Source: "patrol", + }) + containerFindings++ + if containerFindings >= 2 { + break + } + } + if containerFindings >= 2 { + break + } + } + + // Degraded mail gateway: the demo scenario keeps one PMG instance degraded. + for _, pmg := range state.PMGInstances { + if strings.EqualFold(strings.TrimSpace(pmg.Status), "online") { + continue + } + findings = append(findings, &Finding{ + ID: demoFindingID("pmg-degraded", pmg.Name), + Key: fmt.Sprintf("pmg:%s:connection", pmg.Name), + Severity: FindingSeverityWarning, + Category: FindingCategoryReliability, + ResourceID: pmg.ID, + ResourceName: pmg.Name, + ResourceType: "pmg", + Node: pmg.Name, + Title: fmt.Sprintf("Mail gateway %q is degraded", pmg.Name), + Description: fmt.Sprintf( + "Mail gateway %s is responding intermittently. Mail flow statistics and quarantine data may be stale until it recovers.", + pmg.Name), + Recommendation: "Confirm the PMG services are running on the host, check connectivity from Pulse to the " + + "PMG API endpoint, and review the PMG task log for postfix or clamav errors.", + Evidence: fmt.Sprintf("status=%s", pmg.Status), + DetectedAt: now.Add(-3 * time.Hour), + LastSeenAt: now, + Source: "patrol", + }) + break + } + + // Highest-memory running guest, only when genuinely high. + type guestRef struct { + id, name, node, kind string + usage float64 + used, total int64 + } + var topGuest *guestRef + consider := func(id, name, node, kind string, status string, mem models.Memory) { + if !strings.EqualFold(strings.TrimSpace(status), "running") || mem.Total <= 0 { + return + } + if topGuest == nil || mem.Usage > topGuest.usage { + topGuest = &guestRef{id: id, name: name, node: node, kind: kind, usage: mem.Usage, used: mem.Used, total: mem.Total} + } + } + for i := range state.VMs { + vm := &state.VMs[i] + consider(vm.ID, vm.Name, vm.Node, "VM", vm.Status, vm.Memory) + } + for i := range state.Containers { + ct := &state.Containers[i] + consider(ct.ID, ct.Name, ct.Node, "Container", ct.Status, ct.Memory) + } + if topGuest != nil && topGuest.usage >= 85 { + findings = append(findings, &Finding{ + ID: demoFindingID("guest-memory", topGuest.id), + Key: fmt.Sprintf("guest:%s:memory", topGuest.id), + Severity: FindingSeverityWarning, + Category: FindingCategoryPerformance, + ResourceID: topGuest.id, + ResourceName: topGuest.name, + ResourceType: strings.ToLower(topGuest.kind), + Node: topGuest.node, + Title: fmt.Sprintf("%s %q memory usage at %.0f%%", topGuest.kind, topGuest.name, topGuest.usage), + Description: fmt.Sprintf( + "%s %s on %s is using %s of its %s allocation. Sustained usage at this level risks swapping and degraded performance under load.", + topGuest.kind, topGuest.name, topGuest.node, formatDemoBytes(topGuest.used), formatDemoBytes(topGuest.total)), + Recommendation: "Increase the memory allocation if the host has capacity, or check the workload for a leak " + + "and review application cache settings to reduce steady-state usage.", + Evidence: fmt.Sprintf("memory=%.1f%% used=%s total=%s", topGuest.usage, formatDemoBytes(topGuest.used), formatDemoBytes(topGuest.total)), + DetectedAt: now.Add(-5 * time.Hour), + LastSeenAt: now, + Source: "patrol", + }) + } + + return findings +} + +func demoFindingID(kind, key string) string { + return demoFindingIDPrefix + kind + "-" + demoSlug(key) +} + +func demoSlug(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(s)) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + return strings.Trim(b.String(), "-") +} + +func demoDockerHostDisplayName(host models.DockerHost) string { + if name := strings.TrimSpace(host.CustomDisplayName); name != "" { + return name + } + if name := strings.TrimSpace(host.DisplayName); name != "" { + return name + } + return strings.TrimSpace(host.Hostname) +} + +func formatDemoBytes(bytes int64) string { + const gib = float64(1 << 30) + value := float64(bytes) / gib + if value >= 1024 { + return fmt.Sprintf("%.1f TiB", value/1024) + } + if value >= 10 { + return fmt.Sprintf("%.0f GiB", value) + } + return fmt.Sprintf("%.1f GiB", value) } // GenerateDemoAIResponse returns a realistic mock AI response for demo mode diff --git a/internal/ai/demo_test.go b/internal/ai/demo_test.go index 21e4b5e82..b7890d99c 100644 --- a/internal/ai/demo_test.go +++ b/internal/ai/demo_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/rcourtman/pulse-go-rewrite/internal/mockruntime" + "github.com/rcourtman/pulse-go-rewrite/internal/models" ) func TestIsDemoMode(t *testing.T) { @@ -23,31 +24,205 @@ func TestIsDemoMode(t *testing.T) { } } -func TestPatrolService_InjectDemoFindings(t *testing.T) { - service := NewPatrolService(nil, nil) +type demoSnapshotProvider struct { + snapshot models.StateSnapshot +} + +func (p demoSnapshotProvider) ReadSnapshot() models.StateSnapshot { + return p.snapshot +} + +func demoTestSnapshot(now time.Time) models.StateSnapshot { + snapshot := models.EmptyStateSnapshot() + snapshot.Nodes = []models.Node{ + {ID: "node/pve1", Name: "pve1", Status: "online", Uptime: 86400, CPU: 0.42}, + } + snapshot.VMs = []models.VM{ + {ID: "vm/101", VMID: 101, Name: "media-server", Node: "pve1", Status: "running", + Memory: models.Memory{Total: 16 << 30, Used: 15 << 30, Usage: 93.75}}, + } + snapshot.Storage = []models.Storage{ + {ID: "pve1-local-zfs", Name: "local-zfs", Node: "pve1", Status: "available", + Enabled: true, Active: true, Total: 750 << 30, Used: 700 << 30, Free: 50 << 30, Usage: 93.3}, + } + snapshot.DockerHosts = []models.DockerHost{ + {ID: "docker/edge-01", Hostname: "edge-01", DisplayName: "Edge 01", Status: "offline", + LastSeen: now.Add(-2 * time.Hour), + Containers: []models.DockerContainer{ + {ID: "docker/edge-01/portal", Name: "portal", State: "exited"}, + }}, + {ID: "docker/core-01", Hostname: "core-01", DisplayName: "Core 01", Status: "online", + LastSeen: now, + Containers: []models.DockerContainer{ + {ID: "docker/core-01/uptime-kuma", Name: "uptime-kuma", State: "running", + Health: "unhealthy", RestartCount: 4, Image: "louislam/uptime-kuma:1"}, + }}, + } + snapshot.PBSInstances = []models.PBSInstance{ + {ID: "pbs/dr-vault", Name: "dr-vault", Status: "degraded", ConnectionHealth: "degraded"}, + } + return snapshot +} + +func TestPatrolService_RunDemoPatrolCycle_SynthesizesFromMockState(t *testing.T) { + original := mockruntime.IsEnabled() + t.Cleanup(func() { mockruntime.SetEnabled(original) }) + mockruntime.SetEnabled(true) + + now := time.Now() + service := NewPatrolService(nil, demoSnapshotProvider{snapshot: demoTestSnapshot(now)}) if service.findings == nil || service.runHistoryStore == nil { t.Fatal("expected findings and run history to be initialized") } - service.InjectDemoFindings() + service.runDemoPatrolCycle(TriggerReasonStartup) - findings := service.findings.GetAll(nil) - if len(findings) != 7 { - t.Fatalf("expected 7 demo findings, got %d", len(findings)) + findings := service.findings.GetActive(FindingSeverityInfo) + if len(findings) < 4 { + t.Fatalf("expected at least 4 demo findings (offline host, storage, pbs, container), got %d", len(findings)) } - if service.runHistoryStore.Count() != 13 { - t.Fatalf("expected 13 demo run history entries, got %d", service.runHistoryStore.Count()) + byID := map[string]*Finding{} + for _, f := range findings { + if !isDemoFindingID(f.ID) { + t.Fatalf("expected only demo finding IDs, got %q", f.ID) + } + byID[f.ID] = f + } + offline := byID["demo-docker-host-offline-edge-01"] + if offline == nil { + t.Fatalf("expected offline docker host finding, got IDs %v", keysOfDemoFindings(byID)) + } + if offline.Severity != FindingSeverityCritical { + t.Fatalf("expected offline host finding to be critical, got %s", offline.Severity) + } + storage := byID["demo-storage-capacity-pve1-local-zfs"] + if storage == nil { + t.Fatalf("expected storage capacity finding, got IDs %v", keysOfDemoFindings(byID)) + } + if storage.Severity != FindingSeverityCritical { + t.Fatalf("expected 93%% full storage finding to be critical, got %s", storage.Severity) + } + if !strings.Contains(storage.Title, "93%") { + t.Fatalf("expected storage title to quote the observed usage, got %q", storage.Title) + } + + // Run history: backfill plus the current run. + if service.runHistoryStore.Count() != 11 { + t.Fatalf("expected 10 backfill entries plus the current run, got %d", service.runHistoryStore.Count()) + } + runs := service.GetRunHistory(1) + if len(runs) != 1 || runs[0].Source != PatrolRunSourceDemo { + t.Fatalf("expected newest run to be the demo run, got %+v", runs) + } + if runs[0].Status != "critical" { + t.Fatalf("expected run status critical with a critical finding active, got %q", runs[0].Status) + } + if runs[0].ResourcesChecked == 0 { + t.Fatalf("expected run to report resources checked, got %+v", runs[0]) + } + + // Second cycle refreshes instead of duplicating. + service.runDemoPatrolCycle(TriggerReasonScheduled) + refreshed := service.findings.GetActive(FindingSeverityInfo) + if len(refreshed) != len(findings) { + t.Fatalf("expected second cycle to refresh findings, got %d then %d", len(findings), len(refreshed)) + } + if service.runHistoryStore.Count() != 12 { + t.Fatalf("expected exactly one additional run record on second cycle, got %d", service.runHistoryStore.Count()) } } -func TestPatrolService_InjectDemoFindings_NoStore(t *testing.T) { - service := &PatrolService{} - service.InjectDemoFindings() +func keysOfDemoFindings(m map[string]*Finding) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys } -func TestPatrolService_InjectDemoRunHistory_NoStore(t *testing.T) { +func TestPatrolService_RunDemoPatrolCycle_ResolvesStaleDemoFindings(t *testing.T) { + original := mockruntime.IsEnabled() + t.Cleanup(func() { mockruntime.SetEnabled(original) }) + mockruntime.SetEnabled(true) + + now := time.Now() + snapshot := demoTestSnapshot(now) + service := NewPatrolService(nil, demoSnapshotProvider{snapshot: snapshot}) + service.runDemoPatrolCycle(TriggerReasonStartup) + + // Mock condition clears: the edge host comes back online. + healed := demoTestSnapshot(now) + healed.DockerHosts[0].Status = "online" + service.SetStateProvider(demoSnapshotProvider{snapshot: healed}) + service.runDemoPatrolCycle(TriggerReasonScheduled) + + offline := service.findings.Get("demo-docker-host-offline-edge-01") + if offline == nil { + t.Fatal("expected offline host finding to still exist") + } + if !offline.IsResolved() { + t.Fatal("expected offline host finding to auto-resolve once the mock condition cleared") + } +} + +func TestPatrolService_RunDemoPatrolCycle_ResolvesRuntimeFailureFinding(t *testing.T) { + original := mockruntime.IsEnabled() + t.Cleanup(func() { mockruntime.SetEnabled(original) }) + mockruntime.SetEnabled(true) + + now := time.Now() + service := NewPatrolService(nil, demoSnapshotProvider{snapshot: demoTestSnapshot(now)}) + + // A real run failed before mock mode enabled and left the meta-finding. + failure := patrolRuntimeFailure{ + Cause: PatrolFailureCauseProviderConnection, + Title: "Pulse Patrol: Provider analysis error", + Description: "Pulse Patrol reached the configured provider, but the provider did not complete the Patrol analysis request.", + } + stale := newPatrolRuntimeFailureFinding(failure, now.Add(-time.Hour)) + service.findings.Add(stale) + + service.runDemoPatrolCycle(TriggerReasonScheduled) + + refreshed := service.findings.Get(stale.ID) + if refreshed == nil { + t.Fatal("expected runtime failure finding to still exist") + } + if !refreshed.IsResolved() { + t.Fatal("expected demo cycle to auto-resolve the stale provider error finding") + } +} + +func TestPatrolService_RunDemoPatrolCycle_NoStore(t *testing.T) { service := &PatrolService{} - service.injectDemoRunHistory() + service.runDemoPatrolCycle(TriggerReasonStartup) +} + +func TestPatrolService_RunDemoPatrolCycle_EmptyStateSkipsRecording(t *testing.T) { + original := mockruntime.IsEnabled() + t.Cleanup(func() { mockruntime.SetEnabled(original) }) + mockruntime.SetEnabled(true) + + service := NewPatrolService(nil, demoSnapshotProvider{snapshot: models.EmptyStateSnapshot()}) + service.runDemoPatrolCycle(TriggerReasonStartup) + + if count := service.runHistoryStore.Count(); count != 0 { + t.Fatalf("expected no run records for an empty mock state, got %d", count) + } +} + +func TestDemoFindingsExcludedFromPersistence(t *testing.T) { + findings := map[string]*Finding{ + "demo-storage-capacity-pve1-local-zfs": {ID: "demo-storage-capacity-pve1-local-zfs"}, + "abc123def456": {ID: "abc123def456"}, + } + records := findingsToRecords(findings) + if _, ok := records["demo-storage-capacity-pve1-local-zfs"]; ok { + t.Fatal("expected demo finding to be excluded from persistence records") + } + if _, ok := records["abc123def456"]; !ok { + t.Fatal("expected real finding to be persisted") + } } func TestPatrolRunHistoryFiltersDemoEvidenceOutsideDemoMode(t *testing.T) { diff --git a/internal/ai/findings_persistence.go b/internal/ai/findings_persistence.go index 104d1d3d0..4e5cabecf 100644 --- a/internal/ai/findings_persistence.go +++ b/internal/ai/findings_persistence.go @@ -11,6 +11,12 @@ func findingsToRecords(findings map[string]*Finding) map[string]*config.AIFindin // Convert from Finding to AIFindingRecord records := make(map[string]*config.AIFindingRecord, len(findings)) for id, f := range findings { + if isDemoFindingID(id) { + // Demo findings regenerate from mock state on every demo patrol + // cycle; persisting them would leak fixtures into a real + // install's ai_findings.json when mock mode is later disabled. + continue + } lifecycle := make([]struct { At time.Time `json:"at"` Type string `json:"type"` diff --git a/internal/ai/patrol.go b/internal/ai/patrol.go index 1305f826f..fcd17f623 100644 --- a/internal/ai/patrol.go +++ b/internal/ai/patrol.go @@ -252,13 +252,18 @@ func isDemoPatrolRunRecord(record PatrolRunRecord) bool { } func filterPatrolRunRecordsForRuntimeEvidence(records []PatrolRunRecord) []PatrolRunRecord { - if IsDemoMode() || len(records) == 0 { + if len(records) == 0 { return records } + // Symmetric evidence filtering: real runtimes hide demo records, and demo + // runtimes hide real records. Runs recorded against the pre-demo real + // runtime (typically provider errors from before mock fixtures enabled) + // are meaningless against the mock dataset. + demoMode := IsDemoMode() filtered := make([]PatrolRunRecord, 0, len(records)) for _, record := range records { - if isDemoPatrolRunRecord(record) { + if isDemoPatrolRunRecord(record) != demoMode { continue } filtered = append(filtered, record) diff --git a/internal/ai/patrol_preflight.go b/internal/ai/patrol_preflight.go index 2daab3ff6..adf91995e 100644 --- a/internal/ai/patrol_preflight.go +++ b/internal/ai/patrol_preflight.go @@ -116,6 +116,19 @@ func (s *Service) RunPatrolToolPreflight(ctx context.Context, providerName, mode result := PatrolPreflightResult{} + if IsDemoMode() { + result.Success = true + result.Provider = DemoPatrolProvider + result.Model = DemoPatrolModel + result.ToolCallObserved = true + result.Cause = PatrolFailureCauseNone + result.Title = "Pulse Patrol: Preflight succeeded" + result.Summary = "Demo mode simulates Patrol's tool-call check; no provider was contacted" + result.DurationMs = time.Since(started).Milliseconds() + s.recordPatrolPreflight(result, time.Now()) + return result + } + if cfg == nil { result.Cause = PatrolFailureCauseSettingsPersistence result.Title = "Pulse Patrol: Pulse Intelligence settings unavailable" diff --git a/internal/ai/patrol_readiness.go b/internal/ai/patrol_readiness.go index be23eb814..1601fcbd9 100644 --- a/internal/ai/patrol_readiness.go +++ b/internal/ai/patrol_readiness.go @@ -47,6 +47,10 @@ type PatrolConfigReadiness struct { } func EvaluatePatrolConfigReadiness(cfg *config.AIConfig) PatrolConfigReadiness { + if IsDemoMode() { + return patrolConfigReadiness(DemoPatrolProvider, DemoPatrolModel, PatrolReadinessReady, PatrolFailureCauseNone, + "Demo mode: Patrol analysis is simulated against the demo dataset, so no provider is required.") + } if cfg == nil { return patrolConfigReadiness("", "", PatrolReadinessNotReady, PatrolFailureCauseSettingsPersistence, "Pulse Intelligence settings could not be loaded from persistence.") } diff --git a/internal/ai/patrol_run.go b/internal/ai/patrol_run.go index 1d9c45c4f..9e924a124 100644 --- a/internal/ai/patrol_run.go +++ b/internal/ai/patrol_run.go @@ -291,6 +291,14 @@ func (p *PatrolService) runPatrolWithTrigger(ctx context.Context, trigger Trigge breaker := p.circuitBreaker p.mu.RUnlock() + // Demo/mock mode simulates patrol passes without a provider. Checked live + // (not from the boot-time config snapshot) because release demo instances + // enable mock fixtures only after the license sync authorizes them. + if IsDemoMode() { + p.runDemoPatrolCycle(trigger) + return + } + if !cfg.Enabled { return } @@ -701,6 +709,12 @@ func (p *PatrolService) runScopedPatrol(ctx context.Context, scope PatrolScope) breaker := p.circuitBreaker p.mu.RUnlock() + // Demo instances simulate scheduled patrol passes only; event-driven + // scoped runs would hit the real provider path. + if IsDemoMode() { + return + } + if !cfg.Enabled { return } diff --git a/internal/ai/service.go b/internal/ai/service.go index 939737669..0b401db50 100644 --- a/internal/ai/service.go +++ b/internal/ai/service.go @@ -1032,6 +1032,11 @@ func (s *Service) getEffectivePatrolInterval(cfg *config.AIConfig) time.Duration func (s *Service) patrolConfigFromAIConfig(cfg *config.AIConfig) PatrolConfig { patrolCfg := DefaultPatrolConfig() if cfg == nil { + if IsDemoMode() { + // Demo runtimes simulate patrol without provider settings. + patrolCfg.Enabled = true + return patrolCfg + } patrolCfg.Enabled = false patrolCfg.RuntimeBlockedReason = "Pulse Intelligence settings could not be loaded from persistence." patrolCfg.RuntimeBlockedCause = PatrolFailureCauseSettingsPersistence @@ -1044,6 +1049,12 @@ func (s *Service) patrolConfigFromAIConfig(cfg *config.AIConfig) PatrolConfig { patrolCfg.AnalyzeGuests = cfg.PatrolAnalyzeGuests patrolCfg.AnalyzeDocker = cfg.PatrolAnalyzeDocker patrolCfg.AnalyzeStorage = cfg.PatrolAnalyzeStorage + if IsDemoMode() { + // Demo/mock runtimes simulate patrol runs without a provider; never + // carry a readiness blocker into the demo loop. + patrolCfg.Enabled = true + return patrolCfg + } if patrolCfg.Enabled { if readiness := EvaluatePatrolConfigReadiness(cfg); !readiness.Ready { patrolCfg.RuntimeBlockedReason = readiness.Summary @@ -1121,8 +1132,16 @@ func (s *Service) StartPatrol(ctx context.Context) { } if cfg == nil || !cfg.IsPatrolEnabled() { - log.Debug().Msg("AI Patrol not enabled") - return + // Demo runtimes still start the loop: the release demo instance boots + // with mock fixtures off until the license sync authorizes them, and + // each tick re-checks IsDemoMode() live, so the loop self-heals into + // simulated patrol cycles once fixtures enable. Requires a loaded + // config because the setup below dereferences it. + if cfg == nil || !IsDemoRuntimeIntended() { + log.Debug().Msg("AI Patrol not enabled") + return + } + log.Info().Msg("AI Patrol: starting loop for demo runtime despite disabled config") } // Check license for Patrol fix actions (Pro only) - Patrol itself is free with BYOK @@ -1144,6 +1163,11 @@ func (s *Service) StartPatrol(ctx context.Context) { log.Info(). Str("env", DevDisableBackgroundAIEnv). Msg("Pulse dev background AI disabled; Patrol scheduler and alert-triggered AI are not started") + // The dev guard protects provider quota; a simulated demo cycle costs + // nothing, so mock-mode dev still gets a populated patrol surface. + if IsDemoMode() { + patrol.startDemoPatrolWarmup() + } return } @@ -1165,9 +1189,10 @@ func (s *Service) StartPatrol(ctx context.Context) { Msg("Alert-triggered AI analysis configured") } - // In demo/mock mode, inject realistic AI findings for showcasing + // In demo/mock mode, populate the patrol surface promptly with a + // simulated cycle instead of waiting for the first scheduled tick. if IsDemoMode() { - patrol.InjectDemoFindings() + patrol.startDemoPatrolWarmup() } } diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index 7fe945f14..f40cc2c6d 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -5129,6 +5129,18 @@ func (h *AISettingsHandler) buildPatrolReadiness(ctx context.Context, aiService } addCheck("service", patrolReadinessReady, ai.PatrolFailureCauseNone, "Patrol service", "Pulse Patrol service is available.", "") + if ai.IsDemoMode() { + // Demo/mock runtimes simulate Patrol's provider path end to end, so + // the provider-dependent checks report a simulated pass instead of + // steering visitors into provider setup. + addCheck("settings", patrolReadinessReady, ai.PatrolFailureCauseNone, "Settings persistence", "Demo mode uses the built-in demo dataset.", "") + addCheck("enabled", patrolReadinessReady, ai.PatrolFailureCauseNone, "Assistant enabled", "Demo mode simulates Pulse Assistant; no provider key is required.", "") + addCheck("provider", patrolReadinessReady, ai.PatrolFailureCauseNone, "Provider configured", "Demo mode uses the simulated demo provider.", "") + addCheck("model", patrolReadinessReady, ai.PatrolFailureCauseNone, "Patrol model", "Demo mode uses the simulated demo model.", "") + addCheck("tools", patrolReadinessReady, ai.PatrolFailureCauseNone, "Patrol tools", "Demo mode simulates Patrol's tool-backed analysis.", "") + return summarizePatrolReadiness(ai.DemoPatrolProvider, ai.DemoPatrolModel, checks) + } + cfg, err := h.loadAIConfig(ctx) if err != nil || cfg == nil { addCheck("settings", patrolReadinessNotReady, ai.PatrolFailureCauseSettingsPersistence, "Settings persistence", "Pulse Intelligence settings could not be loaded from persistence.", "open_provider_settings") diff --git a/internal/mockmode/mockmode.go b/internal/mockmode/mockmode.go index cb14097b5..912db7ac4 100644 --- a/internal/mockmode/mockmode.go +++ b/internal/mockmode/mockmode.go @@ -1 +1,16 @@ package mockmode + +import ( + "os" + "strings" +) + +// IsRequestedFromEnv reports whether the operator asked for mock fixtures via +// PULSE_MOCK_MODE, regardless of whether the current build has authorized and +// enabled them yet. Never use this as an enablement gate: release builds must +// keep failing closed until the demo_fixtures entitlement authorizes fixtures. +// It exists for boot-time lifecycle decisions that must survive the release +// demo ordering, where fixtures enable only after the license sync runs. +func IsRequestedFromEnv() bool { + return strings.EqualFold(strings.TrimSpace(os.Getenv("PULSE_MOCK_MODE")), "true") +}