diff --git a/src/cli/tui/screens/insights-jobs/InsightsJobsScreen.tsx b/src/cli/tui/screens/insights-jobs/InsightsJobsScreen.tsx index edaba804c..756c583b8 100644 --- a/src/cli/tui/screens/insights-jobs/InsightsJobsScreen.tsx +++ b/src/cli/tui/screens/insights-jobs/InsightsJobsScreen.tsx @@ -1,7 +1,8 @@ import type { FailureAnalysisResult, GetBatchEvaluationResult } from '../../../aws/agentcore-batch-evaluation'; import { getBatchEvaluation } from '../../../aws/agentcore-batch-evaluation'; -import type { InsightsRunRecord } from '../../../operations/insights'; -import { listInsightsRuns } from '../../../operations/insights'; +import { regionFromArn } from '../../../operations/jobs'; +import { listRecords } from '../../../operations/jobs/shared/storage'; +import type { InsightsJobRecord } from '../../../operations/jobs/shared/types'; import { Panel, Screen } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; @@ -44,8 +45,8 @@ function InsightsJobsListView({ onExit, availableHeight, }: { - records: InsightsRunRecord[]; - onSelect: (record: InsightsRunRecord) => void; + records: InsightsJobRecord[]; + onSelect: (record: InsightsJobRecord) => void; onExit: () => void; availableHeight: number; }) { @@ -79,11 +80,11 @@ function InsightsJobsListView({ const date = rec.createdAt ? formatShortDate(rec.createdAt) : 'unknown'; return ( - + {selected ? '>' : ' '} {date.padEnd(16)} {rec.status.padEnd(12)} - {rec.name || rec.batchEvaluationId} + {rec.name || rec.id} ); })} @@ -100,19 +101,21 @@ function InsightsJobsListView({ // Results view // ───────────────────────────────────────────────────────────────────────────── -function InsightsResultsView({ record, onBack }: { record: InsightsRunRecord; onBack: () => void }) { +function InsightsResultsView({ record, onBack }: { record: InsightsJobRecord; onBack: () => void }) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [failureAnalysis, setFailureAnalysis] = useState(undefined); const [totalSessions, setTotalSessions] = useState(0); + const region = regionFromArn(record.arn) ?? ''; + useEffect(() => { let cancelled = false; void (async () => { try { const result: GetBatchEvaluationResult = await getBatchEvaluation({ - region: record.region, - batchEvaluationId: record.batchEvaluationId, + region, + batchEvaluationId: record.id, }); if (cancelled) return; if (result.status !== 'COMPLETED' && result.status !== 'COMPLETEDWITHERRORS') { @@ -132,7 +135,7 @@ function InsightsResultsView({ record, onBack }: { record: InsightsRunRecord; on return () => { cancelled = true; }; - }, [record.batchEvaluationId, record.region]); + }, [record.id, region]); useInput((input, key) => { if (key.escape || input === 'b') { @@ -179,7 +182,7 @@ function InsightsResultsView({ record, onBack }: { record: InsightsRunRecord; on return ( - Insights Results: {record.name || record.batchEvaluationId} + Insights Results: {record.name || record.id} Sessions: {totalSessions} | Clusters: {categories.length} @@ -236,7 +239,7 @@ function InsightsJobDetailView({ onBack, onViewResults, }: { - record: InsightsRunRecord; + record: InsightsJobRecord; onBack: () => void; onViewResults: () => void; }) { @@ -255,7 +258,7 @@ function InsightsJobDetailView({ - ID: {record.batchEvaluationId} + ID: {record.id} Status: {record.status} @@ -282,18 +285,19 @@ function InsightsJobDetailView({ Sessions: - {' '}total: {record.sessionCount ?? 'N/A'} - {record.sessionsCompleted != null && , completed: {record.sessionsCompleted}} - {record.sessionsFailed != null && record.sessionsFailed > 0 && ( - , failed: {record.sessionsFailed} + {' '}total: {record.evaluationResults?.totalNumberOfSessions ?? 'N/A'} + {record.evaluationResults?.numberOfSessionsCompleted != null && ( + , completed: {record.evaluationResults.numberOfSessionsCompleted} )} + {record.evaluationResults?.numberOfSessionsFailed != null && + record.evaluationResults.numberOfSessionsFailed > 0 && ( + , failed: {record.evaluationResults.numberOfSessionsFailed} + )} - - To generate a recommendation: agentcore run recommendation --from-insights {record.batchEvaluationId} - + To generate a recommendation: agentcore run recommendation --from-insights {record.id} @@ -317,14 +321,14 @@ export function InsightsJobsScreen({ onExit }: InsightsJobsScreenProps) { const terminalHeight = stdout?.rows ?? 24; const availableHeight = Math.max(6, terminalHeight - CHROME_LINES); - const [selectedRecord, setSelectedRecord] = useState(null); + const [selectedRecord, setSelectedRecord] = useState(null); const [viewingResults, setViewingResults] = useState(false); const [records, loaded, error] = useMemo(() => { try { - return [listInsightsRuns(), true, null] as const; + return [listRecords('insights'), true, null] as const; } catch (err) { - return [[] as InsightsRunRecord[], true, err instanceof Error ? err.message : String(err)] as const; + return [[] as InsightsJobRecord[], true, err instanceof Error ? err.message : String(err)] as const; } }, []); diff --git a/src/cli/tui/screens/run-insights/RunInsightsFlow.tsx b/src/cli/tui/screens/run-insights/RunInsightsFlow.tsx index a043f3552..e2c71b53d 100644 --- a/src/cli/tui/screens/run-insights/RunInsightsFlow.tsx +++ b/src/cli/tui/screens/run-insights/RunInsightsFlow.tsx @@ -6,15 +6,20 @@ import type { InsightsJobRecord } from '../../../operations/jobs/shared/types'; import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; import { ErrorPrompt, GradientText, SuccessPrompt } from '../../components'; import { RunInsightsScreen } from './RunInsightsScreen'; -import type { RunInsightsConfig } from './types'; +import type { RunInsightsConfig, RunInsightsStep } from './types'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; +interface ProjectData { + agentNames: string[]; + onlineEvalConfigArns: string[]; +} + type FlowState = | { name: 'loading' } - | { name: 'wizard'; agentNames: string[]; onlineEvalConfigArns: string[] } + | { name: 'wizard'; project: ProjectData; resume?: { config: RunInsightsConfig; step: RunInsightsStep } } | { name: 'submitting' } | { name: 'success'; record: InsightsJobRecord } - | { name: 'error'; message: string }; + | { name: 'error'; message: string; project?: ProjectData; failedConfig?: RunInsightsConfig }; interface RunInsightsFlowProps { isInteractive?: boolean; @@ -51,7 +56,7 @@ export function RunInsightsFlow({ isInteractive = true, onExit, onBack, onViewJo const onlineEvalConfigArns = extractOnlineEvalConfigArns(deployedState); - setFlow({ name: 'wizard', agentNames, onlineEvalConfigArns }); + setFlow({ name: 'wizard', project: { agentNames, onlineEvalConfigArns } }); } catch (err) { if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); } @@ -69,7 +74,7 @@ export function RunInsightsFlow({ isInteractive = true, onExit, onBack, onViewJo }, [isInteractive, flow.name, onExit]); const handleComplete = useCallback( - (config: RunInsightsConfig) => { + (config: RunInsightsConfig, project: ProjectData) => { setFlow({ name: 'submitting' }); void (async () => { @@ -88,9 +93,15 @@ export function RunInsightsFlow({ isInteractive = true, onExit, onBack, onViewJo if (!startResult.success) { throw startResult.error ?? new Error('Failed to start insights job'); } + setFlow({ name: 'success', record: startResult.record }); } catch (err) { - setFlow({ name: 'error', message: getErrorMessage(err) }); + setFlow({ + name: 'error', + message: getErrorMessage(err), + project, + failedConfig: config, + }); } })(); }, @@ -104,10 +115,12 @@ export function RunInsightsFlow({ isInteractive = true, onExit, onBack, onViewJo if (flow.name === 'wizard') { return ( handleComplete(cfg, flow.project)} onExit={onBack} + initialConfig={flow.resume?.config} + initialStep={flow.resume?.step} /> ); } @@ -124,11 +137,24 @@ export function RunInsightsFlow({ isInteractive = true, onExit, onBack, onViewJo ); } + // Error: if we still have project data + the user's prior input, jump back + // into the wizard at the Name step with their config preserved. Otherwise + // (catastrophic load failure) fall back to the loading→error cycle. return ( setFlow({ name: 'loading' })} + onBack={() => { + if (flow.project && flow.failedConfig) { + setFlow({ + name: 'wizard', + project: flow.project, + resume: { config: flow.failedConfig, step: 'name' }, + }); + } else { + setFlow({ name: 'loading' }); + } + }} onExit={onExit} /> ); diff --git a/src/cli/tui/screens/run-insights/RunInsightsScreen.tsx b/src/cli/tui/screens/run-insights/RunInsightsScreen.tsx index 9384d83f9..13fb53c32 100644 --- a/src/cli/tui/screens/run-insights/RunInsightsScreen.tsx +++ b/src/cli/tui/screens/run-insights/RunInsightsScreen.tsx @@ -10,8 +10,14 @@ import { import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; -import { AVAILABLE_INSIGHTS, RUN_INSIGHTS_STEP_LABELS, SESSION_MODE_OPTIONS, SOURCE_OPTIONS } from './types'; -import type { RunInsightsConfig } from './types'; +import { + AVAILABLE_INSIGHTS, + JOB_NAME_PATTERN, + RUN_INSIGHTS_STEP_LABELS, + SESSION_MODE_OPTIONS, + SOURCE_OPTIONS, +} from './types'; +import type { RunInsightsConfig, RunInsightsStep } from './types'; import { useRunInsightsWizard } from './useRunInsightsWizard'; import React, { useMemo } from 'react'; @@ -20,10 +26,42 @@ interface RunInsightsScreenProps { onlineEvalConfigArns: string[]; onComplete: (config: RunInsightsConfig) => void; onExit: () => void; + /** Pre-seed wizard state, e.g. when returning from a validation/API error. */ + initialConfig?: RunInsightsConfig; + /** Step to land on at mount (default: 'source'). */ + initialStep?: RunInsightsStep; +} + +function validateJobName(value: string): true | string { + // Empty is allowed — the CLI auto-generates a name when omitted. + if (!value) return true; + if (!JOB_NAME_PATTERN.test(value)) { + return 'Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).'; + } + return true; +} + +function validateLookbackInput(value: string): true | string { + if (!value) return true; + const days = parseInt(value, 10); + if (!Number.isInteger(days) || String(days) !== value.trim()) { + return 'Lookback must be a whole number.'; + } + if (days < 1 || days > 90) { + return 'Lookback must be between 1 and 90 days.'; + } + return true; } -export function RunInsightsScreen({ agentNames, onlineEvalConfigArns, onComplete, onExit }: RunInsightsScreenProps) { - const wizard = useRunInsightsWizard(agentNames.length); +export function RunInsightsScreen({ + agentNames, + onlineEvalConfigArns, + onComplete, + onExit, + initialConfig, + initialStep, +}: RunInsightsScreenProps) { + const wizard = useRunInsightsWizard(agentNames, initialConfig, initialStep); const isSourceStep = wizard.step === 'source'; const isAgentStep = wizard.step === 'agent'; @@ -157,10 +195,8 @@ export function RunInsightsScreen({ agentNames, onlineEvalConfigArns, onComplete key="lookback" prompt="Lookback window (days)" initialValue="7" - onSubmit={value => { - const days = parseInt(value, 10); - wizard.setLookbackDays(isNaN(days) || days <= 0 ? 7 : days); - }} + customValidation={validateLookbackInput} + onSubmit={value => wizard.setLookbackDays(parseInt(value, 10))} onCancel={() => wizard.goBack()} /> )} @@ -187,6 +223,8 @@ export function RunInsightsScreen({ agentNames, onlineEvalConfigArns, onComplete wizard.setName(value)} onCancel={() => wizard.goBack()} /> @@ -197,7 +235,7 @@ export function RunInsightsScreen({ agentNames, onlineEvalConfigArns, onComplete fields={ wizard.config.source === 'agent' ? [ - { label: 'Agent', value: wizard.config.agent ?? agentNames[0] ?? '' }, + { label: 'Agent', value: wizard.config.agent !== '' ? wizard.config.agent : (agentNames[0] ?? '') }, { label: 'Insights', value: wizard.config.insights diff --git a/src/cli/tui/screens/run-insights/types.ts b/src/cli/tui/screens/run-insights/types.ts index 01dc0fd1b..c003e008b 100644 --- a/src/cli/tui/screens/run-insights/types.ts +++ b/src/cli/tui/screens/run-insights/types.ts @@ -36,6 +36,13 @@ export const RUN_INSIGHTS_STEP_LABELS: Record = { export const DEFAULT_LOOKBACK_DAYS = 7; +/** + * Validation pattern for job names. Mirrors the service-side BatchEvaluationName + * shape (`^[a-zA-Z][a-zA-Z0-9_]{0,47}$`) so we reject locally instead of + * surfacing a 400 from startBatchEvaluation. + */ +export const JOB_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; + export const AVAILABLE_INSIGHTS = [ { id: 'Builtin.Insight.FailureAnalysis', diff --git a/src/cli/tui/screens/run-insights/useRunInsightsWizard.ts b/src/cli/tui/screens/run-insights/useRunInsightsWizard.ts index df2a16bc4..606c77c6f 100644 --- a/src/cli/tui/screens/run-insights/useRunInsightsWizard.ts +++ b/src/cli/tui/screens/run-insights/useRunInsightsWizard.ts @@ -12,10 +12,12 @@ function getStepsForSource(source: RunInsightsSource, agentCount: number): RunIn return ['source', 'agent', 'insights', 'sessions', 'lookbackDays', 'name', 'confirm']; } -function getDefaultConfig(): RunInsightsConfig { +function getDefaultConfig(soleAgent: string): RunInsightsConfig { return { source: 'agent', - agent: '', + // Pre-populate when only one agent exists so the confirm screen shows it + // even though the agent selection step is skipped. + agent: soleAgent, insights: [], sessionMode: 'lookback', lookbackDays: DEFAULT_LOOKBACK_DAYS, @@ -25,9 +27,15 @@ function getDefaultConfig(): RunInsightsConfig { }; } -export function useRunInsightsWizard(agentCount: number) { - const [config, setConfig] = useState(getDefaultConfig); - const [step, setStep] = useState('source'); +export function useRunInsightsWizard( + agentNames: string[], + initialConfig?: RunInsightsConfig, + initialStep: RunInsightsStep = 'source' +) { + const agentCount = agentNames.length; + const soleAgent = agentCount === 1 ? (agentNames[0] ?? '') : ''; + const [config, setConfig] = useState(() => initialConfig ?? getDefaultConfig(soleAgent)); + const [step, setStep] = useState(initialStep); const allSteps = useMemo(() => getStepsForSource(config.source, agentCount), [config.source, agentCount]); const currentIndex = allSteps.indexOf(step); @@ -80,13 +88,8 @@ export function useRunInsightsWizard(agentCount: number) { const setSessionMode = useCallback( (sessionMode: RunInsightsSessionMode) => { setConfig(c => ({ ...c, sessionMode })); - if (sessionMode === 'lookback') { - const next = nextStep('sessions'); - if (next) setStep(next); - } else { - const next = nextStep('sessions'); - if (next) setStep(next); - } + const next = nextStep('sessions'); + if (next) setStep(next); }, [nextStep] ); @@ -128,9 +131,9 @@ export function useRunInsightsWizard(agentCount: number) { ); const reset = useCallback(() => { - setConfig(getDefaultConfig()); + setConfig(getDefaultConfig(soleAgent)); setStep('source'); - }, []); + }, [soleAgent]); return { config,