diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 1f1c03c6e..6ea9849cd 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -51,6 +51,7 @@ Patrol-specific presentation helpers. 28. `frontend-modern/src/utils/patrolRuntimeActions.ts` 29. `frontend-modern/src/utils/textPresentation.ts` 30. `tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts` +31. `tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts` ## Shared Boundaries @@ -1035,6 +1036,16 @@ successes: the Patrol popover must keep the saved provider/model visible, render a `Patrol control needs attention` inline state with the returned readiness cause and summary, and hand off to Assistant as a model-only Patrol control issue rather than as a save failure. +A separate browser proof in +`tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts` keeps the +authenticated launch workbench monitor-first. When the runtime has monitored +Proxmox resources, legacy infrastructure entry points must resolve to the +Proxmox monitoring lens before Patrol or Assistant chrome, while the stable +Patrol tab may still expose the open-work count. Calm-day copy such as `No +current issues` and checked-resource coverage belongs inside the Patrol route +after the operator chooses Patrol; the monitor lens must not become a hidden +Patrol all-clear page, and Assistant launchers must stay scoped to the current +surface. The readiness contract now applies before Patrol work is admitted, not only after a page render: recoverable Patrol provider/model settings saves must persist and echo structured readiness cause metadata, manual run requests must diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 7cab44eb7..9e470682a 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4956,7 +4956,8 @@ "frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts", "frontend-modern/src/utils/__tests__/patrolPagePresentation.test.ts", "tests/integration/tests/18-patrol-runtime-state.spec.ts", - "tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts" + "tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts", + "tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts" ] }, { diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index c44e68c15..ac9272521 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -23,6 +23,7 @@ PATROL_PAGE_AND_STATE_EXACT_FILES = [ "frontend-modern/src/utils/__tests__/patrolPagePresentation.test.ts", "tests/integration/tests/18-patrol-runtime-state.spec.ts", "tests/integration/tests/73-patrol-assistant-operator-briefing.spec.ts", + "tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts", ] PLATFORM_CONNECTIONS_WORKSPACE_EXACT_FILES = [ @@ -2916,7 +2917,7 @@ class SubsystemLookupTest(unittest.TestCase): { "heading": "## Shared Boundaries", "path": "internal/api/access_control_handlers.go", - "line": 1093, + "line": 1099, "heading_line": 137, } ], diff --git a/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts b/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts new file mode 100644 index 000000000..2f91b9876 --- /dev/null +++ b/tests/integration/tests/78-monitor-first-patrol-workbench.spec.ts @@ -0,0 +1,514 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test as base, type Page } from "@playwright/test"; + +import { createAuthenticatedStorageState } from "./helpers"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type WorkerFixtures = { + authStorageStatePath: string; +}; + +type PatrolWorkbenchFixtureOptions = { + findingCount?: number; + resourcesChecked?: number; + runStatus?: "healthy" | "issues_found"; +}; + +const test = base.extend<{}, WorkerFixtures>({ + storageState: async ({ authStorageStatePath }, use) => { + await use(authStorageStatePath); + }, + authStorageStatePath: [ + async ({ browser }, use, workerInfo) => { + const storageStatePath = path.resolve( + __dirname, + "..", + "..", + "tmp", + "playwright-auth", + `monitor-first-patrol-workbench-${workerInfo.project.name}.json`, + ); + fs.mkdirSync(path.dirname(storageStatePath), { recursive: true }); + await createAuthenticatedStorageState(browser, storageStatePath); + try { + await use(storageStatePath); + } finally { + fs.rmSync(storageStatePath, { force: true }); + } + }, + { scope: "worker" }, + ], +}); + +const monitoredProxmoxResource = { + id: "node:pve-main", + type: "agent", + name: "pve-main", + displayName: "pve-main", + status: "online", + lastSeen: "2026-06-30T08:00:00Z", + platformType: "proxmox-pve", + sourceType: "agent", + sources: ["agent", "proxmox"], + platformScopes: ["proxmox-pve"], + metrics: { + cpu: { value: 12, unit: "%" }, + memory: { + used: 4_294_967_296, + total: 17_179_869_184, + percent: 25, + unit: "bytes", + }, + disk: { + used: 68_719_476_736, + total: 274_877_906_944, + percent: 25, + unit: "bytes", + }, + }, + proxmox: { + nodeName: "pve-main", + clusterName: "homelab", + temperature: 42, + }, + agent: { + agentId: "agent-pve-main", + agentVersion: "6.0.0", + hostname: "pve-main", + osName: "Proxmox VE", + uptimeSeconds: 86_400, + }, +}; + +const buildRuntimeState = () => ({ + connectedInfrastructure: [], + metrics: [], + performance: { + apiCallDuration: {}, + lastPollDuration: 0, + pollingStartTime: "", + totalApiCalls: 0, + failedApiCalls: 0, + cacheHits: 0, + cacheMisses: 0, + }, + connectionHealth: {}, + stats: { + startTime: "2026-06-30T08:00:00Z", + uptime: 3600, + pollingCycles: 1, + webSocketClients: 1, + version: "6.0.0-rc.7", + }, + activeAlerts: [], + recentlyResolved: [], + pveTagColors: {}, + pveTagStyles: {}, + lastUpdate: Date.parse("2026-06-30T08:00:00Z"), + resources: [monitoredProxmoxResource], +}); + +const buildRunHistory = (options: Required) => [ + { + id: options.findingCount > 0 ? "run-active-work" : "run-calm-day", + started_at: "2026-06-30T08:05:00Z", + completed_at: "2026-06-30T08:05:42Z", + duration_ms: 42_000, + type: "full", + trigger_reason: "scheduled", + resources_checked: options.resourcesChecked, + nodes_checked: 1, + guests_checked: 0, + docker_checked: 0, + storage_checked: 0, + hosts_checked: 1, + pbs_checked: 0, + pmg_checked: 0, + kubernetes_checked: 0, + new_findings: options.findingCount, + existing_findings: 0, + rejected_findings: 0, + resolved_findings: 0, + auto_fix_count: 0, + findings_summary: + options.findingCount > 0 ? "1 active finding" : "No active findings", + finding_ids: options.findingCount > 0 ? ["finding-active-work"] : [], + error_count: 0, + status: options.runStatus, + triage_flags: 0, + tool_call_count: 1, + }, +]; + +const buildPatrolStatus = ( + options: Required, +) => ({ + runtime_state: "active", + running: false, + enabled: true, + last_patrol_at: "2026-06-30T08:05:42Z", + next_patrol_at: "2026-06-30T14:05:42Z", + last_duration_ms: 42_000, + resources_checked: options.resourcesChecked, + findings_count: options.findingCount, + error_count: 0, + healthy: options.findingCount === 0, + interval_ms: 21_600_000, + fixed_count: 0, + blocked_reason: "", + blocked_at: "", + license_required: false, + license_status: "active", + summary: { + critical: 0, + warning: options.findingCount, + watch: 0, + info: 0, + }, +}); + +const buildIntelligenceSummary = ( + options: Required, +) => ({ + timestamp: "2026-06-30T08:06:00Z", + overall_health: { + score: options.findingCount > 0 ? 74 : 100, + grade: options.findingCount > 0 ? "C" : "A", + trend: "stable", + factors: [], + prediction: + options.findingCount > 0 + ? "Patrol found one current issue for review." + : "Patrol checked monitored resources and found no current issues.", + }, + findings_count: { + critical: 0, + warning: options.findingCount, + watch: 0, + info: 0, + total: options.findingCount, + }, + predictions_count: 0, + recent_changes_count: 0, + recent_changes: [], + learning: { + resources_with_knowledge: 1, + total_notes: 0, + resources_with_baselines: 1, + patterns_detected: 0, + correlations_learned: 0, + incidents_tracked: 0, + }, +}); + +const buildPatrolFindingsResponse = ( + options: Required, +) => { + if (options.findingCount === 0) { + return { findings: [], count: 0, active_count: 0 }; + } + + return { + findings: [ + { + id: "finding-active-work", + severity: "warning", + category: "reliability", + resource_id: monitoredProxmoxResource.id, + resource_name: monitoredProxmoxResource.displayName, + resource_type: monitoredProxmoxResource.type, + title: "High CPU pressure on pve-main", + description: + "Patrol detected sustained CPU pressure on the monitored Proxmox host.", + detected_at: "2026-06-30T08:05:00Z", + last_seen_at: "2026-06-30T08:05:42Z", + auto_resolved: false, + times_raised: 1, + suppressed: false, + investigation_attempts: 0, + }, + ], + count: 1, + active_count: 1, + }; +}; + +const buildPatrolFindings = ( + options: Required, +) => buildPatrolFindingsResponse(options).findings; + +async function mockMonitorFirstPatrolWorkbench( + page: Page, + options: PatrolWorkbenchFixtureOptions = {}, +): Promise { + const resolved = { + findingCount: options.findingCount ?? 0, + resourcesChecked: options.resourcesChecked ?? 1, + runStatus: + options.runStatus ?? (options.findingCount ? "issues_found" : "healthy"), + } satisfies Required; + + await page.route("**/api/security/status", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + hasAuthentication: true, + hasHTTPS: true, + apiTokenConfigured: true, + exportProtected: true, + hideLocalLogin: false, + publicAccess: false, + requiresAuth: true, + ssoEnabled: false, + ssoProviders: [], + tokenScopes: ["*"], + sessionCapabilities: { + assistantEnabled: true, + demoMode: false, + }, + presentationPolicy: { + demoMode: false, + readOnly: false, + hideCommercial: false, + hideUpgrade: false, + }, + }), + }); + }); + + await page.route("**/api/state", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(buildRuntimeState()), + }); + }); + + await page.route("**/api/resources**", async (route) => { + const requestUrl = new URL(route.request().url()); + if (requestUrl.pathname !== "/api/resources") { + await route.continue(); + return; + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: [monitoredProxmoxResource], + meta: { + page: 1, + limit: 100, + total: 1, + totalPages: 1, + }, + links: { + next: null, + }, + }), + }); + }); + + await page.route("**/api/replication/jobs", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }); + }); + + await page.route("**/api/ai/patrol/status", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(buildPatrolStatus(resolved)), + }); + }); + + await page.route("**/api/ai/patrol/runs*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(buildRunHistory(resolved)), + }); + }); + + await page.route("**/api/ai/patrol/autonomy", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + autonomy_level: "monitor", + full_mode_unlocked: false, + investigation_budget: 15, + investigation_timeout_sec: 300, + }), + }); + }); + + await page.route("**/api/ai/patrol/findings*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(buildPatrolFindings(resolved)), + }); + }); + + await page.route("**/api/ai/unified/findings*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(buildPatrolFindingsResponse(resolved)), + }); + }); + + await page.route("**/api/ai/intelligence", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(buildIntelligenceSummary(resolved)), + }); + }); + + await page.route("**/api/ai/intelligence/correlations*", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ correlations: [], count: 0 }), + }); + }); + + await page.route("**/api/ai/circuit/status", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + state: "closed", + can_patrol: true, + consecutive_failures: 0, + total_successes: 4, + total_failures: 0, + }), + }); + }); + + await page.route("**/api/ai/approvals", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ approvals: [] }), + }); + }); + + await page.route("**/api/settings/ai", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + patrol_enabled: true, + patrol_interval_minutes: 360, + patrol_model: "", + model: "", + alert_triggered_analysis: false, + patrol_alert_triggers_enabled: true, + patrol_anomaly_triggers_enabled: false, + patrol_event_triggers_enabled: true, + patrol_auto_fix: false, + auto_fix_model: "", + }), + }); + }); + + await page.route("**/api/ai/models", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ models: [] }), + }); + }); +} + +test.describe("Monitor-first Patrol workbench browser contract", () => { + test.setTimeout(180_000); + + test("legacy infrastructure launch opens the monitor lens before Patrol or Assistant chrome", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name.startsWith("mobile-"), + "Desktop workbench routing proof", + ); + + await mockMonitorFirstPatrolWorkbench(page, { + findingCount: 1, + runStatus: "issues_found", + }); + await page.goto("/infrastructure", { waitUntil: "domcontentloaded" }); + + await expect(page).toHaveURL(/\/proxmox\/overview$/, { timeout: 30_000 }); + await expect(page.getByTestId("proxmox-page")).toBeVisible({ + timeout: 30_000, + }); + + const desktopNav = page.getByRole("tablist", { + name: "Primary navigation", + }); + await expect( + desktopNav.getByRole("tab", { name: "Proxmox" }), + ).toBeVisible(); + await expect( + desktopNav.getByRole("tab", { name: "Patrol: 1 open work item" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Ask Pulse Assistant about Proxmox" }), + ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Open work" })).toHaveCount( + 0, + ); + await expect( + page.getByRole("heading", { name: /^Pulse Assistant$/ }), + ).toHaveCount(0); + }); + + test("calm-day Patrol posture stays inside Patrol context after infrastructure launch", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name.startsWith("mobile-"), + "Desktop workbench routing proof", + ); + + await mockMonitorFirstPatrolWorkbench(page, { + findingCount: 0, + runStatus: "healthy", + }); + await page.goto("/infrastructure", { waitUntil: "domcontentloaded" }); + + await expect(page).toHaveURL(/\/proxmox\/overview$/, { timeout: 30_000 }); + await expect(page.getByTestId("proxmox-page")).toBeVisible({ + timeout: 30_000, + }); + await expect( + page.getByRole("heading", { name: "No current issues" }), + ).toHaveCount(0); + + await page.getByRole("tab", { name: "Patrol" }).click(); + await expect(page).toHaveURL(/\/patrol$/); + await expect( + page.getByRole("heading", { level: 1, name: "Patrol" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { level: 2, name: "Open work" }), + ).toBeVisible(); + await expect(page.getByText("No current issues").first()).toBeVisible(); + await expect(page.getByText("Checked 1 resource.")).toBeVisible(); + await expect( + page.getByRole("button", { name: "Ask Pulse Assistant about Patrol" }), + ).toBeVisible(); + await expect(page.getByText(/Nothing needs attention/i)).toHaveCount(0); + }); +});