Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 27 additions & 23 deletions src/cli/tui/screens/insights-jobs/InsightsJobsScreen.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -44,8 +45,8 @@ function InsightsJobsListView({
onExit,
availableHeight,
}: {
records: InsightsRunRecord[];
onSelect: (record: InsightsRunRecord) => void;
records: InsightsJobRecord[];
onSelect: (record: InsightsJobRecord) => void;
onExit: () => void;
availableHeight: number;
}) {
Expand Down Expand Up @@ -79,11 +80,11 @@ function InsightsJobsListView({
const date = rec.createdAt ? formatShortDate(rec.createdAt) : 'unknown';

return (
<Text key={rec.batchEvaluationId} wrap="truncate-end">
<Text key={rec.id} wrap="truncate-end">
<Text color={selected ? 'cyan' : undefined}>{selected ? '>' : ' '} </Text>
<Text dimColor>{date.padEnd(16)}</Text>
<Text color={statusColor(rec.status)}>{rec.status.padEnd(12)}</Text>
<Text dimColor>{rec.name || rec.batchEvaluationId}</Text>
<Text dimColor>{rec.name || rec.id}</Text>
</Text>
);
})}
Expand All @@ -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<string | null>(null);
const [failureAnalysis, setFailureAnalysis] = useState<FailureAnalysisResult | undefined>(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') {
Expand All @@ -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') {
Expand Down Expand Up @@ -179,7 +182,7 @@ function InsightsResultsView({ record, onBack }: { record: InsightsRunRecord; on
return (
<Panel fullWidth>
<Box flexDirection="column">
<Text bold>Insights Results: {record.name || record.batchEvaluationId}</Text>
<Text bold>Insights Results: {record.name || record.id}</Text>
<Text dimColor>
Sessions: {totalSessions} | Clusters: {categories.length}
</Text>
Expand Down Expand Up @@ -236,7 +239,7 @@ function InsightsJobDetailView({
onBack,
onViewResults,
}: {
record: InsightsRunRecord;
record: InsightsJobRecord;
onBack: () => void;
onViewResults: () => void;
}) {
Expand All @@ -255,7 +258,7 @@ function InsightsJobDetailView({
<Panel fullWidth>
<Box flexDirection="column">
<Text>
<Text bold>ID:</Text> {record.batchEvaluationId}
<Text bold>ID:</Text> {record.id}
</Text>
<Text>
<Text bold>Status:</Text> <Text color={statusColor(record.status)}>{record.status}</Text>
Expand All @@ -282,18 +285,19 @@ function InsightsJobDetailView({
<Box marginTop={1} flexDirection="column">
<Text bold>Sessions:</Text>
<Text>
{' '}total: {record.sessionCount ?? 'N/A'}
{record.sessionsCompleted != null && <Text>, completed: {record.sessionsCompleted}</Text>}
{record.sessionsFailed != null && record.sessionsFailed > 0 && (
<Text color="red">, failed: {record.sessionsFailed}</Text>
{' '}total: {record.evaluationResults?.totalNumberOfSessions ?? 'N/A'}
{record.evaluationResults?.numberOfSessionsCompleted != null && (
<Text>, completed: {record.evaluationResults.numberOfSessionsCompleted}</Text>
)}
{record.evaluationResults?.numberOfSessionsFailed != null &&
record.evaluationResults.numberOfSessionsFailed > 0 && (
<Text color="red">, failed: {record.evaluationResults.numberOfSessionsFailed}</Text>
)}
</Text>
</Box>

<Box marginTop={1}>
<Text dimColor>
To generate a recommendation: agentcore run recommendation --from-insights {record.batchEvaluationId}
</Text>
<Text dimColor>To generate a recommendation: agentcore run recommendation --from-insights {record.id}</Text>
</Box>

<Box marginTop={1}>
Expand All @@ -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<InsightsRunRecord | null>(null);
const [selectedRecord, setSelectedRecord] = useState<InsightsJobRecord | null>(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;
}
}, []);

Expand Down
46 changes: 36 additions & 10 deletions src/cli/tui/screens/run-insights/RunInsightsFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) });
}
Expand All @@ -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 () => {
Expand All @@ -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,
});
}
})();
},
Expand All @@ -104,10 +115,12 @@ export function RunInsightsFlow({ isInteractive = true, onExit, onBack, onViewJo
if (flow.name === 'wizard') {
return (
<RunInsightsScreen
agentNames={flow.agentNames}
onlineEvalConfigArns={flow.onlineEvalConfigArns}
onComplete={handleComplete}
agentNames={flow.project.agentNames}
onlineEvalConfigArns={flow.project.onlineEvalConfigArns}
onComplete={cfg => handleComplete(cfg, flow.project)}
onExit={onBack}
initialConfig={flow.resume?.config}
initialStep={flow.resume?.step}
/>
);
}
Expand All @@ -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 (
<ErrorPrompt
message="Failed to start insights job"
detail={flow.message}
onBack={() => 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}
/>
);
Expand Down
56 changes: 47 additions & 9 deletions src/cli/tui/screens/run-insights/RunInsightsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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()}
/>
)}
Expand All @@ -187,6 +223,8 @@ export function RunInsightsScreen({ agentNames, onlineEvalConfigArns, onComplete
<TextInput
key="name"
prompt="Job name (leave blank for auto-generated)"
allowEmpty
customValidation={validateJobName}
onSubmit={value => wizard.setName(value)}
onCancel={() => wizard.goBack()}
/>
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/cli/tui/screens/run-insights/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export const RUN_INSIGHTS_STEP_LABELS: Record<RunInsightsStep, string> = {

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',
Expand Down
Loading
Loading