From c2c5ce04880e80a4501f0f9bd8aa3628dfb1a404 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 7 Jun 2026 11:25:19 +0100 Subject: [PATCH] Unify Discovery readiness onto a single verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery re-derived its prerequisite state (disabled / provider / commands / connectivity) ad hoc across surfaces, so the agent connection-status banners could nag about commands while the more fundamental gap — no AI provider configured — went unsaid. Wire the (previously orphaned, unwired) computeDiscoveryReadiness module in as the canonical verdict: useDiscoveryTabState now derives aiProviderConfigured + a single discoveryReadiness memo, and the DiscoveryTab connection-status block renders from readiness().status, surfacing the most-fundamental missing prerequisite first. Adds the missing needs_ai_provider banner. The green 'connected' banner stays literal by design (the verdict treats unknown command state as ready). Tests: existing command-state cases now configure a provider to reach those states; new case asserts the provider prerequisite precedes command guidance. Full frontend suite + type-check + lint green (the 2 unrelated guardrail failures pre-exist on the branch, proven via scoped stash). --- .../src/components/Discovery/DiscoveryTab.tsx | 49 ++++++++++++++++-- .../Discovery/__tests__/DiscoveryTab.test.tsx | 34 +++++++++++++ .../__tests__/discoveryReadiness.test.ts | 50 +++++++++++++++++++ .../Discovery/discoveryReadiness.ts | 47 +++++++++++++++++ .../Discovery/useDiscoveryTabState.ts | 22 ++++++++ 5 files changed, 198 insertions(+), 4 deletions(-) create mode 100644 frontend-modern/src/components/Discovery/__tests__/discoveryReadiness.test.ts create mode 100644 frontend-modern/src/components/Discovery/discoveryReadiness.ts diff --git a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx index d0cdace8d..992995958 100644 --- a/frontend-modern/src/components/Discovery/DiscoveryTab.tsx +++ b/frontend-modern/src/components/Discovery/DiscoveryTab.tsx @@ -99,6 +99,7 @@ export const DiscoveryTab: Component = (props) => { copiedDiscoveryValue, discovery, discoveryFeatureKnownDisabled, + discoveryReadiness, discoveryInfo, editingNotes, handleCopyDiscoveryValue, @@ -516,9 +517,45 @@ export const DiscoveryTab: Component = (props) => { - {/* Connection Status Warning - Show when commands are needed but not available */} - - + {/* Connection Status Warning - driven by the canonical readiness + verdict so the most-fundamental missing prerequisite (provider → + commands → connectivity) is the one surfaced, in one place. */} + + +
+
+ + + +
+

+ AI provider not configured +

+

+ Discovery needs an AI provider to analyze what is running. Configure one in + Settings -> AI before scanning. +

+
+
+
+
+
= (props) => {
- +
= (props) => {
+ {/* Kept literal, not `status === 'ready'`: the readiness verdict + treats unknown command state as ready (don't-block), but this + green "connected" claim must require a genuinely connected + agent with commands explicitly enabled. */}
diff --git a/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx b/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx index da5c70f04..7e592b7ea 100644 --- a/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx +++ b/frontend-modern/src/components/Discovery/__tests__/DiscoveryTab.test.tsx @@ -35,6 +35,20 @@ import { getDiscoveryProvenanceTitle } from '@/utils/discoveryPresentation'; const aiSettingsWithDiscovery = (discovery_enabled: boolean) => ({ discovery_enabled }) as Awaited>; +// A configured analysis provider — the most-fundamental discovery prerequisite. +// Command/connectivity guidance only surfaces once a provider exists, so tests +// that exercise those later states must establish this first. +const discoveryInfoWithProvider = () => ({ + ai_provider: { + provider: 'anthropic', + model: 'claude-haiku-4-5', + is_local: false, + label: 'Cloud (Anthropic)', + }, + commands: [], + command_categories: [], +}); + describe('DiscoveryTab', () => { afterEach(() => { cleanup(); @@ -171,8 +185,27 @@ describe('DiscoveryTab', () => { }); }); + it('surfaces the AI-provider prerequisite before command guidance', async () => { + vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null); + // Default getDiscoveryInfo mock returns null → no provider configured. + + render(() => ( + + )); + + expect(await screen.findByText('AI provider not configured')).toBeInTheDocument(); + expect(screen.queryByText('Commands not enabled')).not.toBeInTheDocument(); + }); + it('uses canonical settings copy for disabled command guidance', async () => { vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null); + vi.mocked(discoveryApi.getDiscoveryInfo).mockResolvedValue(discoveryInfoWithProvider()); render(() => ( { it('uses canonical API Access copy for missing command connection guidance', async () => { vi.mocked(discoveryApi.getDiscovery).mockResolvedValue(null); + vi.mocked(discoveryApi.getDiscoveryInfo).mockResolvedValue(discoveryInfoWithProvider()); render(() => ( { + it('is ready when every prerequisite is met', () => { + expect(computeDiscoveryReadiness(base)).toEqual({ status: 'ready', ready: true }); + }); + + it('reports disabled first, even when other prerequisites are unmet', () => { + expect( + computeDiscoveryReadiness({ ...base, discoveryEnabled: false, aiProviderConfigured: false }), + ).toEqual({ status: 'disabled', ready: false }); + }); + + it('reports a missing AI provider before a command-disabled agent', () => { + expect( + computeDiscoveryReadiness({ + ...base, + aiProviderConfigured: false, + commandsEnabled: false, + }), + ).toEqual({ status: 'needs_ai_provider', ready: false }); + }); + + it('reports commands disabled when the host agent has them off', () => { + expect(computeDiscoveryReadiness({ ...base, commandsEnabled: false })).toEqual({ + status: 'needs_commands', + ready: false, + }); + }); + + it('reports a disconnected agent when commands are on but nothing is connected', () => { + expect( + computeDiscoveryReadiness({ ...base, commandsEnabled: true, hasConnectedAgent: false }), + ).toEqual({ status: 'needs_connected_agent', ready: false }); + }); + + it('does not block on commands when their state is unknown', () => { + expect( + computeDiscoveryReadiness({ ...base, commandsEnabled: undefined, hasConnectedAgent: false }), + ).toEqual({ status: 'ready', ready: true }); + }); +}); diff --git a/frontend-modern/src/components/Discovery/discoveryReadiness.ts b/frontend-modern/src/components/Discovery/discoveryReadiness.ts new file mode 100644 index 000000000..8ff5aab02 --- /dev/null +++ b/frontend-modern/src/components/Discovery/discoveryReadiness.ts @@ -0,0 +1,47 @@ +// Single source of truth for "can Discovery actually work here?" +// +// Discovery has real prerequisites — the feature toggle, a configured AI +// provider, and an agent with command execution ("Pulse Commands") enabled and +// connected. These were previously re-checked ad hoc in ~5 places (the settings +// section, the per-resource tab, the run gate), which is why the feature could +// be silently on-but-useless and the UI was inconsistent. Compute the verdict +// once here and let every surface render from it. + +export type DiscoveryReadinessStatus = + | 'disabled' // user turned the feature off + | 'needs_ai_provider' // no AI provider configured to analyze evidence + | 'needs_commands' // AI ready, but the host agent has Pulse Commands disabled + | 'needs_connected_agent' // commands enabled, but no agent connected to run them + | 'ready'; + +export interface DiscoveryReadinessInputs { + /** The discovery feature toggle (Settings → AI → Workload Discovery). */ + discoveryEnabled: boolean; + /** Whether at least one AI provider has credentials configured. */ + aiProviderConfigured: boolean; + /** + * Whether the relevant host agent has command execution enabled. `undefined` + * means "not known for this context" (don't block on it). + */ + commandsEnabled: boolean | undefined; + /** Whether an agent is connected and able to run commands. */ + hasConnectedAgent: boolean; +} + +export interface DiscoveryReadiness { + status: DiscoveryReadinessStatus; + ready: boolean; +} + +// Ordered most-fundamental-first: a missing AI provider matters before a +// command-disabled agent, which matters before connectivity. The first unmet +// prerequisite is the one to surface, so the user fixes them in a sensible order. +export function computeDiscoveryReadiness(input: DiscoveryReadinessInputs): DiscoveryReadiness { + if (!input.discoveryEnabled) return { status: 'disabled', ready: false }; + if (!input.aiProviderConfigured) return { status: 'needs_ai_provider', ready: false }; + if (input.commandsEnabled === false) return { status: 'needs_commands', ready: false }; + if (input.commandsEnabled === true && !input.hasConnectedAgent) { + return { status: 'needs_connected_agent', ready: false }; + } + return { status: 'ready', ready: true }; +} diff --git a/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts b/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts index 1e46a5c77..b097455d6 100644 --- a/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts +++ b/frontend-modern/src/components/Discovery/useDiscoveryTabState.ts @@ -16,6 +16,7 @@ import { } from '@/utils/discoveryPresentation'; import { copyToClipboard } from '@/utils/clipboard'; import { toDiscoveryAPIResourceType } from '@/utils/discoveryTarget'; +import { computeDiscoveryReadiness, type DiscoveryReadiness } from './discoveryReadiness'; export interface DiscoveryTabStateProps { resourceType: ResourceType; @@ -106,6 +107,26 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) { return agents.length === 1; }); + + // Whether an AI provider is configured to analyze discovery evidence. The + // info fetch only resolves an `ai_provider` when one has credentials, so an + // absent provider (or a still-loading fetch) reads as "not configured". + const aiProviderConfigured = createMemo( + () => !discoveryInfo.loading && Boolean(discoveryInfo()?.ai_provider), + ); + + // Single prerequisite verdict — the canonical source every surface should + // render from instead of re-deriving disabled/provider/commands/connectivity + // ad hoc. Ordered most-fundamental-first inside computeDiscoveryReadiness. + const discoveryReadiness = createMemo(() => + computeDiscoveryReadiness({ + discoveryEnabled: discoveryFeatureEnabled(), + aiProviderConfigured: aiProviderConfigured(), + commandsEnabled: props.commandsEnabled, + hasConnectedAgent: hasConnectedAgent(), + }), + ); + const canTriggerDiscovery = createMemo( () => discoveryFeatureEnabled() && Boolean(targetAgentId()), ); @@ -320,6 +341,7 @@ export function useDiscoveryTabState(props: DiscoveryTabStateProps) { copiedDiscoveryValue, discovery, discoveryFeatureKnownDisabled, + discoveryReadiness, discoveryInfo, editingNotes, handleSaveNotes,