From 49be7fea7a9584b74be0f47def8236c032b205e9 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Fri, 3 Apr 2026 17:11:46 +0000 Subject: [PATCH 01/18] feat(import): add evaluator import subcommand with TUI wizard Add `agentcore import evaluator` to import existing AWS evaluators into CLI projects. Refactor import types and utilities for extensibility so future resource types require minimal new code. Changes: - Add import-evaluator.ts handler with toEvaluatorSpec mapping (LLM-as-a-Judge and code-based evaluators), duplicate detection, and CDK import pipeline - Enhance getEvaluator API wrapper to extract full evaluatorConfig (model, instructions, ratingScale) and tags from SDK tagged unions - Add listAllEvaluators pagination helper filtering out built-in evaluators - Widen ImportableResourceType union and shared utilities for evaluator support - Add evaluator to TUI import flow (select, ARN input, progress screens) - Add 17 unit tests covering spec conversion, template lookup, and error cases Tested end-to-end against real AWS evaluator (bugbash_eval_1775226567-zrDxm7Gpcw) with verified field mapping for all config fields, tags, and deployed state. --- src/cli/aws/agentcore-control.ts | 93 ++++ .../import/__tests__/import-evaluator.test.ts | 458 ++++++++++++++++++ src/cli/commands/import/command.ts | 2 + src/cli/commands/import/constants.ts | 1 + src/cli/commands/import/import-evaluator.ts | 313 ++++++++++++ src/cli/commands/import/import-runtime.ts | 6 +- src/cli/commands/import/import-utils.ts | 39 +- src/cli/commands/import/types.ts | 19 +- src/cli/tui/screens/import/ArnInputScreen.tsx | 14 +- src/cli/tui/screens/import/ImportFlow.tsx | 4 +- .../screens/import/ImportProgressScreen.tsx | 6 +- .../tui/screens/import/ImportSelectScreen.tsx | 7 +- 12 files changed, 934 insertions(+), 28 deletions(-) create mode 100644 src/cli/commands/import/__tests__/import-evaluator.test.ts create mode 100644 src/cli/commands/import/import-evaluator.ts diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index 108f13971..3e8642e5e 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -424,6 +424,19 @@ export interface GetEvaluatorOptions { evaluatorId: string; } +export interface GetEvaluatorLlmConfig { + model: string; + instructions: string; + ratingScale: { + numerical?: { value: number; label: string; definition: string }[]; + categorical?: { label: string; definition: string }[]; + }; +} + +export interface GetEvaluatorCodeBasedConfig { + lambdaArn: string; +} + export interface GetEvaluatorResult { evaluatorId: string; evaluatorArn: string; @@ -431,6 +444,11 @@ export interface GetEvaluatorResult { level: string; status: string; description?: string; + evaluatorConfig?: { + llmAsAJudge?: GetEvaluatorLlmConfig; + codeBased?: GetEvaluatorCodeBasedConfig; + }; + tags?: Record; } export async function getEvaluator(options: GetEvaluatorOptions): Promise { @@ -446,6 +464,62 @@ export async function getEvaluator(options: GetEvaluatorOptions): Promise ({ + value: n.value ?? 0, + label: n.label ?? '', + definition: n.definition ?? '', + })); + } else if ('categorical' in llm.ratingScale && llm.ratingScale.categorical) { + ratingScale.categorical = llm.ratingScale.categorical.map(c => ({ + label: c.label ?? '', + definition: c.definition ?? '', + })); + } + } + evaluatorConfig = { + llmAsAJudge: { model, instructions: llm.instructions ?? '', ratingScale }, + }; + } else if ('codeBased' in response.evaluatorConfig && response.evaluatorConfig.codeBased) { + const cb = response.evaluatorConfig.codeBased; + if ('lambdaConfig' in cb && cb.lambdaConfig) { + evaluatorConfig = { + codeBased: { lambdaArn: cb.lambdaConfig.lambdaArn ?? '' }, + }; + } + } + } + + // Fetch tags (non-fatal if it fails) + let tags: Record | undefined; + if (response.evaluatorArn) { + try { + const tagsResponse = await client.send(new ListTagsForResourceCommand({ resourceArn: response.evaluatorArn })); + if (tagsResponse.tags && Object.keys(tagsResponse.tags).length > 0) { + tags = tagsResponse.tags; + } + } catch { + // Tags are optional — continue without them + } + } + return { evaluatorId: response.evaluatorId, evaluatorArn: response.evaluatorArn ?? '', @@ -453,6 +527,8 @@ export async function getEvaluator(options: GetEvaluatorOptions): Promise { + const evaluators: EvaluatorSummary[] = []; + let nextToken: string | undefined; + + do { + const result = await listEvaluators({ region: options.region, maxResults: 100, nextToken }); + evaluators.push(...result.evaluators.filter(e => !e.evaluatorName.startsWith('Builtin.'))); + nextToken = result.nextToken; + } while (nextToken); + + return evaluators; +} + // ============================================================================ // Online Eval Config // ============================================================================ diff --git a/src/cli/commands/import/__tests__/import-evaluator.test.ts b/src/cli/commands/import/__tests__/import-evaluator.test.ts new file mode 100644 index 000000000..d2c1475d8 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-evaluator.test.ts @@ -0,0 +1,458 @@ +/** + * Import Evaluator Unit Tests + * + * Covers: + * - toEvaluatorSpec conversion: LLM-as-a-Judge (numerical + categorical), code-based (external) + * - Evaluator with description and tags + * - Missing config error handling + * - Template logical ID lookup for evaluators + * - Phase 2 import resource list construction for evaluators + * - ARN validation for evaluator resource type + */ +import type { GetEvaluatorResult } from '../../../aws/agentcore-control'; +import { toEvaluatorSpec } from '../import-evaluator'; +import { buildImportTemplate, findLogicalIdByProperty, findLogicalIdsByType } from '../template-utils'; +import type { CfnTemplate } from '../template-utils'; +import type { ResourceToImport } from '../types'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// toEvaluatorSpec Conversion Tests +// ============================================================================ + +describe('toEvaluatorSpec', () => { + it('maps LLM-as-a-Judge evaluator with numerical rating scale', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-123', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-123', + evaluatorName: 'my_evaluator', + level: 'SESSION', + status: 'ACTIVE', + description: 'Test evaluator', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate the response quality', + ratingScale: { + numerical: [ + { value: 1, label: 'Poor', definition: 'Low quality response' }, + { value: 5, label: 'Excellent', definition: 'High quality response' }, + ], + }, + }, + }, + tags: { env: 'test' }, + }; + + const result = toEvaluatorSpec(detail, 'my_evaluator'); + + expect(result.name).toBe('my_evaluator'); + expect(result.level).toBe('SESSION'); + expect(result.description).toBe('Test evaluator'); + expect(result.config.llmAsAJudge).toBeDefined(); + expect(result.config.llmAsAJudge!.model).toBe('anthropic.claude-3-5-sonnet-20241022-v2:0'); + expect(result.config.llmAsAJudge!.instructions).toBe('Evaluate the response quality'); + expect(result.config.llmAsAJudge!.ratingScale.numerical).toHaveLength(2); + expect(result.config.llmAsAJudge!.ratingScale.numerical![0]).toEqual({ + value: 1, + label: 'Poor', + definition: 'Low quality response', + }); + expect(result.tags).toEqual({ env: 'test' }); + }); + + it('maps LLM-as-a-Judge evaluator with categorical rating scale', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-456', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-456', + evaluatorName: 'categorical_eval', + level: 'TRACE', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Rate as pass or fail', + ratingScale: { + categorical: [ + { label: 'Pass', definition: 'Response meets criteria' }, + { label: 'Fail', definition: 'Response does not meet criteria' }, + ], + }, + }, + }, + }; + + const result = toEvaluatorSpec(detail, 'categorical_eval'); + + expect(result.level).toBe('TRACE'); + expect(result.config.llmAsAJudge).toBeDefined(); + expect(result.config.llmAsAJudge!.ratingScale.categorical).toHaveLength(2); + expect(result.config.llmAsAJudge!.ratingScale.categorical![0]).toEqual({ + label: 'Pass', + definition: 'Response meets criteria', + }); + // No description or tags + expect(result.description).toBeUndefined(); + expect(result.tags).toBeUndefined(); + }); + + it('maps code-based evaluator as external with Lambda ARN', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-code-789', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-code-789', + evaluatorName: 'code_eval', + level: 'TOOL_CALL', + status: 'ACTIVE', + evaluatorConfig: { + codeBased: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-eval-function', + }, + }, + }; + + const result = toEvaluatorSpec(detail, 'code_eval'); + + expect(result.name).toBe('code_eval'); + expect(result.level).toBe('TOOL_CALL'); + expect(result.config.codeBased).toBeDefined(); + expect(result.config.codeBased!.external).toBeDefined(); + expect(result.config.codeBased!.external!.lambdaArn).toBe( + 'arn:aws:lambda:us-west-2:123456789012:function:my-eval-function' + ); + expect(result.config.llmAsAJudge).toBeUndefined(); + }); + + it('uses provided local name instead of evaluator name from AWS', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-rename', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-rename', + evaluatorName: 'original_name', + level: 'SESSION', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate', + ratingScale: { numerical: [{ value: 1, label: 'Low', definition: 'Low' }] }, + }, + }, + }; + + const result = toEvaluatorSpec(detail, 'custom_local_name'); + + expect(result.name).toBe('custom_local_name'); + }); + + it('throws when evaluator has no recognizable config', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-no-config', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-no-config', + evaluatorName: 'broken_eval', + level: 'SESSION', + status: 'ACTIVE', + }; + + expect(() => toEvaluatorSpec(detail, 'broken_eval')).toThrow('Evaluator "broken_eval" has no recognizable config'); + }); + + it('throws when evaluatorConfig is empty object', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-empty', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-empty', + evaluatorName: 'empty_config_eval', + level: 'SESSION', + status: 'ACTIVE', + evaluatorConfig: {}, + }; + + expect(() => toEvaluatorSpec(detail, 'empty_config_eval')).toThrow('has no recognizable config'); + }); + + it('omits description when not present', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-no-desc', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-no-desc', + evaluatorName: 'no_desc_eval', + level: 'SESSION', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate', + ratingScale: { numerical: [{ value: 1, label: 'Low', definition: 'Low' }] }, + }, + }, + }; + + const result = toEvaluatorSpec(detail, 'no_desc_eval'); + + expect(result.description).toBeUndefined(); + }); + + it('omits tags when empty', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-empty-tags', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-empty-tags', + evaluatorName: 'empty_tags_eval', + level: 'SESSION', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate', + ratingScale: { numerical: [{ value: 1, label: 'Low', definition: 'Low' }] }, + }, + }, + tags: {}, + }; + + const result = toEvaluatorSpec(detail, 'empty_tags_eval'); + + expect(result.tags).toBeUndefined(); + }); + + it('defaults level to SESSION when not provided', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-no-level', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-no-level', + evaluatorName: 'no_level_eval', + level: '', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate', + ratingScale: { numerical: [{ value: 1, label: 'Low', definition: 'Low' }] }, + }, + }, + }; + + // level defaults to 'SESSION' via the ?? in getEvaluator, but toEvaluatorSpec takes what it gets + const result = toEvaluatorSpec(detail, 'no_level_eval'); + expect(result.level).toBe(''); + }); +}); + +// ============================================================================ +// Template Logical ID Lookup Tests for Evaluators +// ============================================================================ + +describe('Template Logical ID Lookup for Evaluators', () => { + const synthTemplate: CfnTemplate = { + Resources: { + MyEvaluatorResource: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'my_evaluator', + Level: 'SESSION', + }, + }, + PrefixedEvaluatorResource: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'TestProject_prefixed_eval', + Level: 'TRACE', + }, + }, + MyAgentRuntime: { + Type: 'AWS::BedrockAgentCore::Runtime', + Properties: { + AgentRuntimeName: 'TestProject_my_agent', + }, + }, + MyIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { + RoleName: 'MyRole', + }, + }, + }, + }; + + it('finds evaluator logical ID by EvaluatorName property', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'my_evaluator' + ); + expect(logicalId).toBe('MyEvaluatorResource'); + }); + + it('finds prefixed evaluator by full name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'TestProject_prefixed_eval' + ); + expect(logicalId).toBe('PrefixedEvaluatorResource'); + }); + + it('finds all evaluator logical IDs by type', () => { + const logicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Evaluator'); + expect(logicalIds).toHaveLength(2); + expect(logicalIds).toContain('MyEvaluatorResource'); + expect(logicalIds).toContain('PrefixedEvaluatorResource'); + }); + + it('returns undefined for non-existent evaluator name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'nonexistent_evaluator' + ); + expect(logicalId).toBeUndefined(); + }); + + it('falls back to single evaluator logical ID when name does not match', () => { + const singleEvalTemplate: CfnTemplate = { + Resources: { + OnlyEvaluator: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'some_eval', + Level: 'SESSION', + }, + }, + }, + }; + + let logicalId = findLogicalIdByProperty( + singleEvalTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'different_name' + ); + + // Primary lookup fails + expect(logicalId).toBeUndefined(); + + // Fallback: if there's only one evaluator resource, use it + if (!logicalId) { + const evaluatorLogicalIds = findLogicalIdsByType(singleEvalTemplate, 'AWS::BedrockAgentCore::Evaluator'); + if (evaluatorLogicalIds.length === 1) { + logicalId = evaluatorLogicalIds[0]; + } + } + expect(logicalId).toBe('OnlyEvaluator'); + }); +}); + +// ============================================================================ +// Phase 2 Resource Import List Construction for Evaluators +// ============================================================================ + +describe('Phase 2: ResourceToImport List Construction for Evaluators', () => { + const synthTemplate: CfnTemplate = { + Resources: { + EvaluatorLogicalId: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'my_evaluator', + Level: 'SESSION', + }, + }, + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + it('builds ResourceToImport list for evaluator', () => { + const evaluatorName = 'my_evaluator'; + const evaluatorId = 'eval-123'; + + const resourcesToImport: ResourceToImport[] = []; + + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + evaluatorName + ); + + if (logicalId) { + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::Evaluator', + logicalResourceId: logicalId, + resourceIdentifier: { EvaluatorId: evaluatorId }, + }); + } + + expect(resourcesToImport).toHaveLength(1); + expect(resourcesToImport[0]!.resourceType).toBe('AWS::BedrockAgentCore::Evaluator'); + expect(resourcesToImport[0]!.logicalResourceId).toBe('EvaluatorLogicalId'); + expect(resourcesToImport[0]!.resourceIdentifier).toEqual({ EvaluatorId: 'eval-123' }); + }); + + it('returns empty list when evaluator not found in template', () => { + const emptyTemplate: CfnTemplate = { + Resources: { + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + const logicalId = findLogicalIdByProperty( + emptyTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'my_evaluator' + ); + + expect(logicalId).toBeUndefined(); + }); +}); + +// ============================================================================ +// buildImportTemplate Tests for Evaluator Resources +// ============================================================================ + +describe('buildImportTemplate with Evaluator', () => { + it('adds evaluator resource to deployed template with Retain deletion policy', () => { + const deployedTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + }, + }; + + const synthTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + EvaluatorLogicalId: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'my_evaluator', + Level: 'SESSION', + }, + DependsOn: 'ExistingIAMRole', + }, + }, + }; + + const importTemplate = buildImportTemplate(deployedTemplate, synthTemplate, ['EvaluatorLogicalId']); + + // Verify evaluator resource was added + expect(importTemplate.Resources.EvaluatorLogicalId).toBeDefined(); + expect(importTemplate.Resources.EvaluatorLogicalId!.Type).toBe('AWS::BedrockAgentCore::Evaluator'); + expect(importTemplate.Resources.EvaluatorLogicalId!.DeletionPolicy).toBe('Retain'); + expect(importTemplate.Resources.EvaluatorLogicalId!.UpdateReplacePolicy).toBe('Retain'); + + // DependsOn should be removed for import + expect(importTemplate.Resources.EvaluatorLogicalId!.DependsOn).toBeUndefined(); + + // Original resource should still be there + expect(importTemplate.Resources.ExistingIAMRole).toBeDefined(); + }); +}); diff --git a/src/cli/commands/import/command.ts b/src/cli/commands/import/command.ts index 4b043e696..9a98871d4 100644 --- a/src/cli/commands/import/command.ts +++ b/src/cli/commands/import/command.ts @@ -1,4 +1,5 @@ import { handleImport } from './actions'; +import { registerImportEvaluator } from './import-evaluator'; import { registerImportMemory } from './import-memory'; import { registerImportRuntime } from './import-runtime'; import type { Command } from '@commander-js/extra-typings'; @@ -151,4 +152,5 @@ export const registerImport = (program: Command) => { // Register subcommands for importing individual resource types from AWS registerImportRuntime(importCmd); registerImportMemory(importCmd); + registerImportEvaluator(importCmd); }; diff --git a/src/cli/commands/import/constants.ts b/src/cli/commands/import/constants.ts index 780719330..e2e8160fd 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -5,6 +5,7 @@ export const CFN_RESOURCE_IDENTIFIERS: Record = { 'AWS::BedrockAgentCore::Runtime': ['AgentRuntimeId'], 'AWS::BedrockAgentCore::Memory': ['MemoryId'], 'AWS::BedrockAgentCore::Gateway': ['GatewayIdentifier'], + 'AWS::BedrockAgentCore::Evaluator': ['EvaluatorId'], }; /** diff --git a/src/cli/commands/import/import-evaluator.ts b/src/cli/commands/import/import-evaluator.ts new file mode 100644 index 000000000..9a542c6e9 --- /dev/null +++ b/src/cli/commands/import/import-evaluator.ts @@ -0,0 +1,313 @@ +import type { AgentCoreProjectSpec, Evaluator } from '../../../schema'; +import type { GetEvaluatorResult } from '../../aws/agentcore-control'; +import { getEvaluator, listAllEvaluators } from '../../aws/agentcore-control'; +import { executeCdkImportPipeline } from './import-pipeline'; +import { + failResult, + findResourceInDeployedState, + parseAndValidateArn, + resolveImportContext, + toStackName, +} from './import-utils'; +import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; +import type { ImportResourceOptions, ImportResourceResult } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +const green = '\x1b[32m'; +const dim = '\x1b[2m'; +const reset = '\x1b[0m'; + +/** + * Map an AWS GetEvaluator response to the CLI Evaluator spec format. + */ +export function toEvaluatorSpec(detail: GetEvaluatorResult, localName: string): Evaluator { + const level = (detail.level ?? 'SESSION') as Evaluator['level']; + + let config: Evaluator['config']; + + if (detail.evaluatorConfig?.llmAsAJudge) { + const llm = detail.evaluatorConfig.llmAsAJudge; + config = { + llmAsAJudge: { + model: llm.model, + instructions: llm.instructions, + ratingScale: llm.ratingScale, + }, + }; + } else if (detail.evaluatorConfig?.codeBased) { + config = { + codeBased: { + external: { + lambdaArn: detail.evaluatorConfig.codeBased.lambdaArn, + }, + }, + }; + } else { + throw new Error( + `Evaluator "${detail.evaluatorName}" has no recognizable config. ` + + 'Only LLM-as-a-Judge and code-based evaluators can be imported.' + ); + } + + return { + name: localName, + level, + ...(detail.description && { description: detail.description }), + config, + ...(detail.tags && Object.keys(detail.tags).length > 0 && { tags: detail.tags }), + }; +} + +/** + * Handle `agentcore import evaluator`. + */ +export async function handleImportEvaluator(options: ImportResourceOptions): Promise { + // Rollback state + let configSnapshot: AgentCoreProjectSpec | undefined; + let configWritten = false; + + let importCtx: Awaited> | undefined; + + const rollback = async () => { + if (configWritten && configSnapshot && importCtx) { + try { + await importCtx.ctx.configIO.writeProjectSpec(configSnapshot); + } catch (err) { + console.warn(`Warning: Could not restore agentcore.json: ${err instanceof Error ? err.message : String(err)}`); + } + } + }; + + try { + // 1-2. Validate project context and resolve target + importCtx = await resolveImportContext(options, 'import-evaluator'); + const { ctx, target, logger, onProgress } = importCtx; + + // 3. Get evaluator details from AWS + logger.startStep('Fetch evaluator from AWS'); + let evaluatorId: string; + + if (options.arn) { + const parsed = parseAndValidateArn(options.arn, 'evaluator', target); + evaluatorId = parsed.resourceId; + } else { + onProgress('Listing evaluators in your account...'); + const evaluators = await listAllEvaluators({ region: target.region }); + + if (evaluators.length === 0) { + return failResult(logger, 'No custom evaluators found in your account.', 'evaluator', ''); + } + + if (evaluators.length === 1) { + evaluatorId = evaluators[0]!.evaluatorId; + onProgress(`Found 1 evaluator: ${evaluators[0]!.evaluatorName} (${evaluatorId}). Auto-selecting.`); + } else { + console.log(`\nFound ${evaluators.length} evaluator(s):\n`); + for (let i = 0; i < evaluators.length; i++) { + const e = evaluators[i]!; + console.log(` ${dim}[${i + 1}]${reset} ${e.evaluatorName} — ${e.status}`); + console.log(` ${dim}${e.evaluatorArn}${reset}`); + } + console.log(''); + + return failResult( + logger, + 'Multiple evaluators found. Use --arn to specify which evaluator to import.', + 'evaluator', + '' + ); + } + } + + onProgress(`Fetching evaluator details for ${evaluatorId}...`); + const evaluatorDetail = await getEvaluator({ region: target.region, evaluatorId }); + + if (evaluatorDetail.status !== 'ACTIVE') { + onProgress(`Warning: Evaluator status is ${evaluatorDetail.status}, not ACTIVE`); + } + + // Derive local name + let localName = options.name ?? evaluatorDetail.evaluatorName; + const prefix = `${ctx.projectName}_`; + if (localName.startsWith(prefix)) { + localName = localName.slice(prefix.length); + } + const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; + if (!NAME_REGEX.test(localName)) { + return failResult( + logger, + `Invalid name "${localName}". Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).`, + 'evaluator', + localName + ); + } + onProgress(`Evaluator: ${evaluatorDetail.evaluatorName} → local name: ${localName}`); + logger.endStep('success'); + + // 4. Check for duplicates + logger.startStep('Check for duplicates'); + const projectSpec = await ctx.configIO.readProjectSpec(); + const existingNames = new Set((projectSpec.evaluators ?? []).map(e => e.name)); + if (existingNames.has(localName)) { + return failResult( + logger, + `Evaluator "${localName}" already exists in the project. Use --name to specify a different local name.`, + 'evaluator', + localName + ); + } + const targetName = target.name ?? 'default'; + const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'evaluator', evaluatorId); + if (existingResource) { + return failResult( + logger, + `Evaluator "${evaluatorId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, + 'evaluator', + localName + ); + } + logger.endStep('success'); + + // 5. Add to project config + logger.startStep('Update project config'); + configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; + const evaluatorSpec = toEvaluatorSpec(evaluatorDetail, localName); + (projectSpec.evaluators ??= []).push(evaluatorSpec); + await ctx.configIO.writeProjectSpec(projectSpec); + configWritten = true; + onProgress(`Added evaluator "${localName}" to agentcore.json`); + logger.endStep('success'); + + // 6-10. CDK build → synth → bootstrap → phase 1 → phase 2 → update state + logger.startStep('Build and synth CDK'); + const stackName = toStackName(ctx.projectName, targetName); + + const pipelineResult = await executeCdkImportPipeline({ + projectRoot: ctx.projectRoot, + stackName, + target, + configIO: ctx.configIO, + targetName, + onProgress, + buildResourcesToImport: synthTemplate => { + // Try matching by EvaluatorName property (plain name first, then prefixed) + let logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + localName + ); + + if (!logicalId) { + const prefixedName = `${ctx.projectName}_${localName}`; + logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + prefixedName + ); + } + + // Fall back to single evaluator by type + if (!logicalId) { + const evaluatorLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Evaluator'); + if (evaluatorLogicalIds.length === 1) { + logicalId = evaluatorLogicalIds[0]; + } + } + + if (!logicalId) { + return []; + } + + return [ + { + resourceType: 'AWS::BedrockAgentCore::Evaluator', + logicalResourceId: logicalId, + resourceIdentifier: { EvaluatorId: evaluatorId }, + }, + ]; + }, + deployedStateEntries: [ + { + type: 'evaluator', + name: localName, + id: evaluatorId, + arn: evaluatorDetail.evaluatorArn, + }, + ], + }); + + if (pipelineResult.noResources) { + const error = `Could not find logical ID for evaluator "${localName}" in CloudFormation template`; + await rollback(); + return failResult(logger, error, 'evaluator', localName); + } + + if (!pipelineResult.success) { + await rollback(); + logger.endStep('error', pipelineResult.error); + logger.finalize(false); + return { + success: false, + error: pipelineResult.error, + resourceType: 'evaluator', + resourceName: localName, + logPath: logger.getRelativeLogPath(), + }; + } + logger.endStep('success'); + + logger.finalize(true); + return { + success: true, + resourceType: 'evaluator', + resourceName: localName, + resourceId: evaluatorId, + logPath: logger.getRelativeLogPath(), + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + await rollback(); + if (importCtx) { + importCtx.logger.log(message, 'error'); + importCtx.logger.finalize(false); + } + return { + success: false, + error: message, + resourceType: 'evaluator', + resourceName: options.name ?? '', + logPath: importCtx?.logger.getRelativeLogPath(), + }; + } +} + +/** + * Register the `import evaluator` subcommand. + */ +export function registerImportEvaluator(importCmd: Command): void { + importCmd + .command('evaluator') + .description('Import an existing AgentCore Evaluator from your AWS account') + .option('--arn ', 'Evaluator ARN to import') + .option('--name ', 'Local name for the imported evaluator') + .option('-y, --yes', 'Auto-confirm prompts') + .action(async (cliOptions: ImportResourceOptions) => { + const result = await handleImportEvaluator(cliOptions); + + if (result.success) { + console.log(''); + console.log(`${green}Evaluator imported successfully!${reset}`); + console.log(` Name: ${result.resourceName}`); + console.log(` ID: ${result.resourceId}`); + console.log(''); + } else { + console.error(`\n\x1b[31m[error]${reset} ${result.error}`); + if (result.logPath) { + console.error(`Log: ${result.logPath}`); + } + process.exit(1); + } + }); +} diff --git a/src/cli/commands/import/import-runtime.ts b/src/cli/commands/import/import-runtime.ts index 393ba53ac..eefd57569 100644 --- a/src/cli/commands/import/import-runtime.ts +++ b/src/cli/commands/import/import-runtime.ts @@ -11,7 +11,7 @@ import { toStackName, } from './import-utils'; import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; -import type { ImportResourceOptions, ImportResourceResult } from './types'; +import type { ImportResourceResult, RuntimeImportOptions } from './types'; import type { Command } from '@commander-js/extra-typings'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -93,7 +93,7 @@ function toAgentEnvSpec( /** * Handle `agentcore import runtime`. */ -export async function handleImportRuntime(options: ImportResourceOptions): Promise { +export async function handleImportRuntime(options: RuntimeImportOptions): Promise { // Rollback state let configSnapshot: AgentCoreProjectSpec | undefined; let configWritten = false; @@ -381,7 +381,7 @@ export function registerImportRuntime(importCmd: Command): void { .option('--entrypoint ', 'Entrypoint file (auto-detected from runtime, e.g. main.py)') .option('--name ', 'Local name for the imported runtime') .option('-y, --yes', 'Auto-confirm prompts') - .action(async (cliOptions: ImportResourceOptions) => { + .action(async (cliOptions: RuntimeImportOptions) => { const result = await handleImportRuntime(cliOptions); if (result.success) { diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index 0148163b8..3e9bc369d 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -4,7 +4,7 @@ import { detectAccount, validateAwsCredentials } from '../../aws/account'; import { ExecLogger } from '../../logging'; import { setupPythonProject } from '../../operations/python/setup'; import { getTemplatePath } from '../../templates/templateRoot'; -import type { ImportResourceOptions, ImportResourceResult } from './types'; +import type { ImportResourceOptions, ImportResourceResult, ImportableResourceType } from './types'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -60,7 +60,7 @@ export async function resolveImportContext(options: ImportResourceOptions, comma export function failResult( logger: ExecLogger, error: string, - resourceType: 'runtime' | 'memory', + resourceType: ImportableResourceType, resourceName: string ): ImportResourceResult { logger.endStep('error', error); @@ -128,9 +128,9 @@ export async function resolveImportTarget(options: ResolveTargetOptions): Promis const { configIO, targetName, arn, onProgress } = options; // Validate ARN format early if provided - if (arn && !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory)\/(.+)$/.test(arn)) { + if (arn && !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator)\/(.+)$/.test(arn)) { throw new Error( - `Not a valid ARN: "${arn}".\nExpected format: arn:aws:bedrock-agentcore:::/` + `Not a valid ARN: "${arn}".\nExpected format: arn:aws:bedrock-agentcore:::/` ); } @@ -206,7 +206,7 @@ export interface ParsedArn { resourceId: string; } -const ARN_PATTERN = /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory)\/(.+)$/; +const ARN_PATTERN = /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator)\/(.+)$/; /** * Parse and validate a BedrockAgentCore ARN. @@ -214,7 +214,7 @@ const ARN_PATTERN = /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory) */ export function parseAndValidateArn( arn: string, - expectedResourceType: 'runtime' | 'memory', + expectedResourceType: ImportableResourceType, target: { region: string; account: string } ): ParsedArn { const match = ARN_PATTERN.exec(arn); @@ -268,7 +268,7 @@ export function toStackName(projectName: string, targetName: string): string { export async function findResourceInDeployedState( configIO: ConfigIO, targetName: string, - resourceType: 'runtime' | 'memory', + resourceType: ImportableResourceType, resourceId: string ): Promise { /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any */ @@ -276,11 +276,22 @@ export async function findResourceInDeployedState( const targetState = state.targets?.[targetName]; if (!targetState?.resources) return undefined; - const collection = resourceType === 'runtime' ? targetState.resources.runtimes : targetState.resources.memories; + const collectionKeyMap: Record = { + runtime: 'runtimes', + memory: 'memories', + evaluator: 'evaluators', + }; + const idFieldMap: Record = { + runtime: 'runtimeId', + memory: 'memoryId', + evaluator: 'evaluatorId', + }; + + const collection = targetState.resources[collectionKeyMap[resourceType]]; if (!collection) return undefined; + const idField = idFieldMap[resourceType]; for (const [name, entry] of Object.entries(collection)) { - const idField = resourceType === 'runtime' ? 'runtimeId' : 'memoryId'; if ((entry as any)[idField] === resourceId) return name; } /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any */ @@ -289,7 +300,7 @@ export async function findResourceInDeployedState( } export interface ImportedResource { - type: 'runtime' | 'memory'; + type: ImportableResourceType; name: string; id: string; arn: string; @@ -324,6 +335,12 @@ export async function updateDeployedState( memoryId: resource.id, memoryArn: resource.arn, }; + } else if (resource.type === 'evaluator') { + targetState.resources.evaluators ??= {}; + targetState.resources.evaluators[resource.name] = { + evaluatorId: resource.id, + evaluatorArn: resource.arn, + }; } } @@ -377,7 +394,6 @@ export function fixPyprojectForSetuptools(pyprojectPath: string): void { const content = fs.readFileSync(pyprojectPath, 'utf-8'); - // Already has [tool.setuptools] section — don't touch it if (content.includes('[tool.setuptools]')) return; // Append the fix @@ -409,7 +425,6 @@ export async function copyAgentSource(options: CopyAgentSourceOptions): Promise< onProgress?.(`Copying agent source from ${sourcePath} to ./${APP_DIR}/${agentName}`); copyDirRecursive(sourcePath, appDir); - // Also copy pyproject.toml from the parent of source_path if it exists const parentPyproject = path.join(path.dirname(sourcePath), 'pyproject.toml'); const destPyproject = path.join(appDir, 'pyproject.toml'); if (fs.existsSync(parentPyproject) && !fs.existsSync(destPyproject)) { diff --git a/src/cli/commands/import/types.ts b/src/cli/commands/import/types.ts index d2b07ef83..d9da95b5e 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -63,6 +63,11 @@ export interface ParsedStarterToolkitConfig { }; } +/** + * Resource types supported by the import subcommands. + */ +export type ImportableResourceType = 'runtime' | 'memory' | 'evaluator'; + /** * Resource to be imported via CloudFormation IMPORT change set. */ @@ -86,12 +91,12 @@ export interface ImportResult { } /** - * Result for single-resource import (runtime or memory). + * Result for single-resource import (runtime, memory, evaluator, etc.). */ export interface ImportResourceResult { success: boolean; error?: string; - resourceType: 'runtime' | 'memory'; + resourceType: ImportableResourceType; resourceName: string; resourceId?: string; logPath?: string; @@ -102,10 +107,16 @@ export interface ImportResourceResult { */ export interface ImportResourceOptions { arn?: string; - code?: string; target?: string; name?: string; - entrypoint?: string; yes?: boolean; onProgress?: (message: string) => void; } + +/** + * Extended options for runtime import (includes source code fields). + */ +export interface RuntimeImportOptions extends ImportResourceOptions { + code?: string; + entrypoint?: string; +} diff --git a/src/cli/tui/screens/import/ArnInputScreen.tsx b/src/cli/tui/screens/import/ArnInputScreen.tsx index db86c7d97..7da6dc82f 100644 --- a/src/cli/tui/screens/import/ArnInputScreen.tsx +++ b/src/cli/tui/screens/import/ArnInputScreen.tsx @@ -3,23 +3,29 @@ import { Screen } from '../../components/Screen'; import { TextInput } from '../../components/TextInput'; import { HELP_TEXT } from '../../constants'; -const ARN_PATTERN = /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory)\/.+$/; +const ARN_PATTERN = /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator)\/.+$/; function validateArn(value: string): true | string { if (!ARN_PATTERN.test(value)) { - return 'Invalid ARN format. Expected: arn:aws:bedrock-agentcore:::/'; + return 'Invalid ARN format. Expected: arn:aws:bedrock-agentcore:::/'; } return true; } interface ArnInputScreenProps { - resourceType: 'runtime' | 'memory'; + resourceType: 'runtime' | 'memory' | 'evaluator'; onSubmit: (arn: string) => void; onExit: () => void; } +const RESOURCE_TYPE_LABELS: Record = { + runtime: 'Import Runtime', + memory: 'Import Memory', + evaluator: 'Import Evaluator', +}; + export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScreenProps) { - const title = resourceType === 'runtime' ? 'Import Runtime' : 'Import Memory'; + const title = RESOURCE_TYPE_LABELS[resourceType] ?? `Import ${resourceType}`; const placeholder = `arn:aws:bedrock-agentcore:::${resourceType}/`; return ( diff --git a/src/cli/tui/screens/import/ImportFlow.tsx b/src/cli/tui/screens/import/ImportFlow.tsx index a592a0f72..48f23f41f 100644 --- a/src/cli/tui/screens/import/ImportFlow.tsx +++ b/src/cli/tui/screens/import/ImportFlow.tsx @@ -14,7 +14,7 @@ import React, { useState } from 'react'; type ImportFlowState = | { name: 'select-type' } - | { name: 'arn-input'; resourceType: 'runtime' | 'memory' } + | { name: 'arn-input'; resourceType: 'runtime' | 'memory' | 'evaluator' } | { name: 'code-path'; resourceType: 'runtime'; arn: string } | { name: 'yaml-path' } | { @@ -48,7 +48,7 @@ export function ImportFlow({ onBack, onNavigate }: ImportFlowProps) { return ( { - if (type === 'runtime' || type === 'memory') { + if (type === 'runtime' || type === 'memory' || type === 'evaluator') { setFlow({ name: 'arn-input', resourceType: type }); } else { setFlow({ name: 'yaml-path' }); diff --git a/src/cli/tui/screens/import/ImportProgressScreen.tsx b/src/cli/tui/screens/import/ImportProgressScreen.tsx index af19fafba..2c5e39e10 100644 --- a/src/cli/tui/screens/import/ImportProgressScreen.tsx +++ b/src/cli/tui/screens/import/ImportProgressScreen.tsx @@ -40,11 +40,13 @@ export function ImportProgressScreen({ started.current = true; const run = async () => { - if (importType === 'runtime' || importType === 'memory') { + if (importType === 'runtime' || importType === 'memory' || importType === 'evaluator') { const handler = importType === 'runtime' ? (await import('../../../commands/import/import-runtime')).handleImportRuntime - : (await import('../../../commands/import/import-memory')).handleImportMemory; + : importType === 'memory' + ? (await import('../../../commands/import/import-memory')).handleImportMemory + : (await import('../../../commands/import/import-evaluator')).handleImportEvaluator; const result = await handler({ arn, code, onProgress }); if (result.success) { diff --git a/src/cli/tui/screens/import/ImportSelectScreen.tsx b/src/cli/tui/screens/import/ImportSelectScreen.tsx index ded69eb25..6e22987b4 100644 --- a/src/cli/tui/screens/import/ImportSelectScreen.tsx +++ b/src/cli/tui/screens/import/ImportSelectScreen.tsx @@ -2,7 +2,7 @@ import type { SelectableItem } from '../../components/SelectList'; import { SelectScreen } from '../../components/SelectScreen'; import { Text } from 'ink'; -export type ImportType = 'runtime' | 'memory' | 'starter-toolkit'; +export type ImportType = 'runtime' | 'memory' | 'evaluator' | 'starter-toolkit'; interface ImportSelectItem extends SelectableItem { id: ImportType; @@ -19,6 +19,11 @@ const IMPORT_OPTIONS: ImportSelectItem[] = [ title: 'Memory', description: 'Import an existing AgentCore Memory from your AWS account', }, + { + id: 'evaluator', + title: 'Evaluator', + description: 'Import an existing AgentCore Evaluator from your AWS account', + }, { id: 'starter-toolkit', title: 'From Starter Toolkit', From 6f676d9b0b566c41b22a9465d3c07296566141b3 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Fri, 3 Apr 2026 17:22:31 +0000 Subject: [PATCH 02/18] fix(import): use correct importType for evaluator in TUI flow The TUI import wizard hardcoded importType as 'memory' for all non-runtime resources, causing evaluator imports to fail with "ARN resource type evaluator does not match expected type memory". Use flow.resourceType instead so the correct handler is dispatched. --- src/cli/tui/screens/import/ImportFlow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/tui/screens/import/ImportFlow.tsx b/src/cli/tui/screens/import/ImportFlow.tsx index 48f23f41f..ecc41d095 100644 --- a/src/cli/tui/screens/import/ImportFlow.tsx +++ b/src/cli/tui/screens/import/ImportFlow.tsx @@ -69,7 +69,7 @@ export function ImportFlow({ onBack, onNavigate }: ImportFlowProps) { } else { setFlow({ name: 'importing', - importType: 'memory', + importType: flow.resourceType, arn, }); } From 6ca40307c392adfa9b10851c2e4e9f2632778240 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Fri, 3 Apr 2026 20:08:36 +0000 Subject: [PATCH 03/18] feat(import): add online eval config import subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `agentcore import online-eval` to import existing online evaluation configs from AWS into CLI-managed projects. Follows the same pattern as runtime, memory, and evaluator imports. The command extracts the agent reference from the config's service names (pattern: {agentName}.DEFAULT), maps evaluator IDs to local names or ARN fallbacks, and runs the full CDK import pipeline. Also removes incorrect project-prefix stripping from evaluator and runtime imports — imported resources come from outside the project and won't have the project prefix. Constraint: Agent must exist in project runtimes[] before import (schema enforces cross-reference) Constraint: Evaluators not in project fall back to ARN format to bypass schema validation Rejected: Loose agent validation | schema writeProjectSpec() enforces runtimes[] cross-reference Confidence: high Scope-risk: moderate --- src/cli/aws/agentcore-control.ts | 85 +++- .../__tests__/import-online-eval.test.ts | 368 +++++++++++++++++ src/cli/commands/import/command.ts | 2 + src/cli/commands/import/constants.ts | 1 + src/cli/commands/import/import-evaluator.ts | 6 +- src/cli/commands/import/import-online-eval.ts | 376 ++++++++++++++++++ src/cli/commands/import/import-runtime.ts | 6 +- src/cli/commands/import/import-utils.ts | 18 +- src/cli/commands/import/types.ts | 2 +- 9 files changed, 849 insertions(+), 15 deletions(-) create mode 100644 src/cli/commands/import/__tests__/import-online-eval.test.ts create mode 100644 src/cli/commands/import/import-online-eval.ts diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index 3e8642e5e..04c80a094 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -8,6 +8,7 @@ import { ListAgentRuntimesCommand, ListEvaluatorsCommand, ListMemoriesCommand, + ListOnlineEvaluationConfigsCommand, ListTagsForResourceCommand, UpdateOnlineEvaluationConfigCommand, } from '@aws-sdk/client-bedrock-agentcore-control'; @@ -595,7 +596,72 @@ export async function listAllEvaluators(options: { region: string }): Promise { + const client = createControlClient(options.region); + + const command = new ListOnlineEvaluationConfigsCommand({ + maxResults: options.maxResults, + nextToken: options.nextToken, + }); + + const response = await client.send(command); + + return { + configs: (response.onlineEvaluationConfigs ?? []).map(c => ({ + onlineEvaluationConfigId: c.onlineEvaluationConfigId ?? '', + onlineEvaluationConfigArn: c.onlineEvaluationConfigArn ?? '', + onlineEvaluationConfigName: c.onlineEvaluationConfigName ?? '', + description: c.description, + status: c.status ?? 'UNKNOWN', + executionStatus: c.executionStatus ?? 'UNKNOWN', + })), + nextToken: response.nextToken, + }; +} + +/** + * List all online evaluation configs in the given region, paginating through all pages. + */ +export async function listAllOnlineEvaluationConfigs(options: { region: string }): Promise { + const configs: OnlineEvalConfigSummary[] = []; + let nextToken: string | undefined; + + do { + const result = await listOnlineEvaluationConfigs({ region: options.region, maxResults: 100, nextToken }); + configs.push(...result.configs); + nextToken = result.nextToken; + } while (nextToken); + + return configs; +} + +// ============================================================================ +// Online Eval Config — Update / Get // ============================================================================ export type OnlineEvalExecutionStatus = 'ENABLED' | 'DISABLED'; @@ -661,6 +727,12 @@ export interface GetOnlineEvalConfigResult { description?: string; failureReason?: string; outputLogGroupName?: string; + /** Sampling percentage from the rule config */ + samplingPercentage?: number; + /** Service names from CloudWatch data source config (e.g. "projectName_agentName.DEFAULT") */ + serviceNames?: string[]; + /** Evaluator IDs referenced by this config */ + evaluatorIds?: string[]; } export async function getOnlineEvaluationConfig( @@ -679,6 +751,14 @@ export async function getOnlineEvaluationConfig( } const logGroupName = response.outputConfig?.cloudWatchConfig?.logGroupName; + const samplingPercentage = response.rule?.samplingConfig?.samplingPercentage; + const serviceNames = + response.dataSourceConfig && 'cloudWatchLogs' in response.dataSourceConfig + ? response.dataSourceConfig.cloudWatchLogs?.serviceNames + : undefined; + const evaluatorIds = (response.evaluators ?? []) + .map(e => ('evaluatorId' in e ? e.evaluatorId : undefined)) + .filter((id): id is string => !!id); return { configId: response.onlineEvaluationConfigId, @@ -689,5 +769,8 @@ export async function getOnlineEvaluationConfig( description: response.description, failureReason: response.failureReason, outputLogGroupName: logGroupName, + samplingPercentage, + serviceNames, + evaluatorIds, }; } diff --git a/src/cli/commands/import/__tests__/import-online-eval.test.ts b/src/cli/commands/import/__tests__/import-online-eval.test.ts new file mode 100644 index 000000000..57f0137fc --- /dev/null +++ b/src/cli/commands/import/__tests__/import-online-eval.test.ts @@ -0,0 +1,368 @@ +/** + * Import Online Eval Config Unit Tests + * + * Covers: + * - extractAgentName: service name parsing + * - toOnlineEvalConfigSpec conversion: happy path, missing sampling, enableOnCreate + * - Template logical ID lookup for online eval configs + * - Phase 2 import resource list construction for online eval configs + */ +import type { GetOnlineEvalConfigResult } from '../../../aws/agentcore-control'; +import { extractAgentName, toOnlineEvalConfigSpec } from '../import-online-eval'; +import { buildImportTemplate, findLogicalIdByProperty, findLogicalIdsByType } from '../template-utils'; +import type { CfnTemplate } from '../template-utils'; +import type { ResourceToImport } from '../types'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// extractAgentName Tests +// ============================================================================ + +describe('extractAgentName', () => { + it('extracts agent name from service name with .DEFAULT suffix', () => { + expect(extractAgentName(['my_agent.DEFAULT'])).toBe('my_agent'); + }); + + it('extracts agent name with project prefix pattern', () => { + expect(extractAgentName(['testproject_my_agent.DEFAULT'])).toBe('testproject_my_agent'); + }); + + it('returns full string when no dot suffix', () => { + expect(extractAgentName(['my_agent'])).toBe('my_agent'); + }); + + it('returns undefined for empty array', () => { + expect(extractAgentName([])).toBeUndefined(); + }); + + it('uses first service name when multiple provided', () => { + expect(extractAgentName(['agent_one.DEFAULT', 'agent_two.DEFAULT'])).toBe('agent_one'); + }); + + it('handles service name with multiple dots', () => { + expect(extractAgentName(['my.agent.DEFAULT'])).toBe('my.agent'); + }); +}); + +// ============================================================================ +// toOnlineEvalConfigSpec Conversion Tests +// ============================================================================ + +describe('toOnlineEvalConfigSpec', () => { + it('maps online eval config with all fields', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-123', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-123', + configName: 'QualityMonitor', + status: 'ACTIVE', + executionStatus: 'ENABLED', + description: 'Monitor agent quality', + samplingPercentage: 50, + serviceNames: ['my_agent.DEFAULT'], + evaluatorIds: ['eval-456'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'QualityMonitor', 'my_agent', ['my_evaluator']); + + expect(result.name).toBe('QualityMonitor'); + expect(result.agent).toBe('my_agent'); + expect(result.evaluators).toEqual(['my_evaluator']); + expect(result.samplingRate).toBe(50); + expect(result.description).toBe('Monitor agent quality'); + expect(result.enableOnCreate).toBe(true); + }); + + it('omits enableOnCreate when execution status is DISABLED', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-456', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-456', + configName: 'DisabledConfig', + status: 'ACTIVE', + executionStatus: 'DISABLED', + samplingPercentage: 10, + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'DisabledConfig', 'agent', ['eval_one']); + + expect(result.enableOnCreate).toBeUndefined(); + }); + + it('omits description when not present', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-789', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-789', + configName: 'NoDesc', + status: 'ACTIVE', + executionStatus: 'ENABLED', + samplingPercentage: 25, + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'NoDesc', 'agent', ['eval_one']); + + expect(result.description).toBeUndefined(); + }); + + it('throws when sampling percentage is missing', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-no-sampling', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-no-sampling', + configName: 'NoSampling', + status: 'ACTIVE', + executionStatus: 'ENABLED', + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1'], + }; + + expect(() => toOnlineEvalConfigSpec(detail, 'NoSampling', 'agent', ['eval_one'])).toThrow( + 'has no sampling configuration' + ); + }); + + it('supports multiple evaluator references', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-multi', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-multi', + configName: 'MultiEval', + status: 'ACTIVE', + executionStatus: 'ENABLED', + samplingPercentage: 75, + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1', 'eval-2'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'MultiEval', 'agent', [ + 'local_eval', + 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-2', + ]); + + expect(result.evaluators).toHaveLength(2); + expect(result.evaluators[0]).toBe('local_eval'); + expect(result.evaluators[1]).toMatch(/^arn:/); + }); +}); + +// ============================================================================ +// Template Logical ID Lookup Tests for Online Eval Configs +// ============================================================================ + +describe('Template Logical ID Lookup for Online Eval Configs', () => { + const synthTemplate: CfnTemplate = { + Resources: { + MyOnlineEvalConfig: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'QualityMonitor', + }, + }, + PrefixedOnlineEvalConfig: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'TestProject_PrefixedConfig', + }, + }, + MyAgentRuntime: { + Type: 'AWS::BedrockAgentCore::Runtime', + Properties: { + AgentRuntimeName: 'TestProject_my_agent', + }, + }, + MyIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { + RoleName: 'MyRole', + }, + }, + }, + }; + + it('finds online eval config logical ID by OnlineEvaluationConfigName property', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'QualityMonitor' + ); + expect(logicalId).toBe('MyOnlineEvalConfig'); + }); + + it('finds prefixed online eval config by full name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'TestProject_PrefixedConfig' + ); + expect(logicalId).toBe('PrefixedOnlineEvalConfig'); + }); + + it('finds all online eval config logical IDs by type', () => { + const logicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::OnlineEvaluationConfig'); + expect(logicalIds).toHaveLength(2); + expect(logicalIds).toContain('MyOnlineEvalConfig'); + expect(logicalIds).toContain('PrefixedOnlineEvalConfig'); + }); + + it('returns undefined for non-existent config name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'nonexistent_config' + ); + expect(logicalId).toBeUndefined(); + }); + + it('falls back to single online eval config logical ID when name does not match', () => { + const singleConfigTemplate: CfnTemplate = { + Resources: { + OnlyConfig: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'some_config', + }, + }, + }, + }; + + let logicalId = findLogicalIdByProperty( + singleConfigTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'different_name' + ); + + // Primary lookup fails + expect(logicalId).toBeUndefined(); + + // Fallback: if there's only one config resource, use it + if (!logicalId) { + const configLogicalIds = findLogicalIdsByType( + singleConfigTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig' + ); + if (configLogicalIds.length === 1) { + logicalId = configLogicalIds[0]; + } + } + expect(logicalId).toBe('OnlyConfig'); + }); +}); + +// ============================================================================ +// Phase 2 Resource Import List Construction for Online Eval Configs +// ============================================================================ + +describe('Phase 2: ResourceToImport List Construction for Online Eval Configs', () => { + const synthTemplate: CfnTemplate = { + Resources: { + OnlineEvalLogicalId: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'QualityMonitor', + }, + }, + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + it('builds ResourceToImport list for online eval config', () => { + const configName = 'QualityMonitor'; + const configId = 'oec-123'; + + const resourcesToImport: ResourceToImport[] = []; + + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + configName + ); + + if (logicalId) { + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + logicalResourceId: logicalId, + resourceIdentifier: { OnlineEvaluationConfigId: configId }, + }); + } + + expect(resourcesToImport).toHaveLength(1); + expect(resourcesToImport[0]!.resourceType).toBe('AWS::BedrockAgentCore::OnlineEvaluationConfig'); + expect(resourcesToImport[0]!.logicalResourceId).toBe('OnlineEvalLogicalId'); + expect(resourcesToImport[0]!.resourceIdentifier).toEqual({ OnlineEvaluationConfigId: 'oec-123' }); + }); + + it('returns empty list when online eval config not found in template', () => { + const emptyTemplate: CfnTemplate = { + Resources: { + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + const logicalId = findLogicalIdByProperty( + emptyTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'QualityMonitor' + ); + + expect(logicalId).toBeUndefined(); + }); +}); + +// ============================================================================ +// buildImportTemplate Tests for Online Eval Config Resources +// ============================================================================ + +describe('buildImportTemplate with Online Eval Config', () => { + it('adds online eval config resource to deployed template with Retain deletion policy', () => { + const deployedTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + }, + }; + + const synthTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + OnlineEvalLogicalId: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'QualityMonitor', + }, + DependsOn: 'ExistingIAMRole', + }, + }, + }; + + const importTemplate = buildImportTemplate(deployedTemplate, synthTemplate, ['OnlineEvalLogicalId']); + + // Verify online eval config resource was added + expect(importTemplate.Resources.OnlineEvalLogicalId).toBeDefined(); + expect(importTemplate.Resources.OnlineEvalLogicalId!.Type).toBe('AWS::BedrockAgentCore::OnlineEvaluationConfig'); + expect(importTemplate.Resources.OnlineEvalLogicalId!.DeletionPolicy).toBe('Retain'); + expect(importTemplate.Resources.OnlineEvalLogicalId!.UpdateReplacePolicy).toBe('Retain'); + + // DependsOn should be removed for import + expect(importTemplate.Resources.OnlineEvalLogicalId!.DependsOn).toBeUndefined(); + + // Original resource should still be there + expect(importTemplate.Resources.ExistingIAMRole).toBeDefined(); + }); +}); diff --git a/src/cli/commands/import/command.ts b/src/cli/commands/import/command.ts index 9a98871d4..b5a1a360a 100644 --- a/src/cli/commands/import/command.ts +++ b/src/cli/commands/import/command.ts @@ -1,6 +1,7 @@ import { handleImport } from './actions'; import { registerImportEvaluator } from './import-evaluator'; import { registerImportMemory } from './import-memory'; +import { registerImportOnlineEval } from './import-online-eval'; import { registerImportRuntime } from './import-runtime'; import type { Command } from '@commander-js/extra-typings'; import * as fs from 'node:fs'; @@ -153,4 +154,5 @@ export const registerImport = (program: Command) => { registerImportRuntime(importCmd); registerImportMemory(importCmd); registerImportEvaluator(importCmd); + registerImportOnlineEval(importCmd); }; diff --git a/src/cli/commands/import/constants.ts b/src/cli/commands/import/constants.ts index e2e8160fd..339af4191 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -6,6 +6,7 @@ export const CFN_RESOURCE_IDENTIFIERS: Record = { 'AWS::BedrockAgentCore::Memory': ['MemoryId'], 'AWS::BedrockAgentCore::Gateway': ['GatewayIdentifier'], 'AWS::BedrockAgentCore::Evaluator': ['EvaluatorId'], + 'AWS::BedrockAgentCore::OnlineEvaluationConfig': ['OnlineEvaluationConfigId'], }; /** diff --git a/src/cli/commands/import/import-evaluator.ts b/src/cli/commands/import/import-evaluator.ts index 9a542c6e9..923ac6147 100644 --- a/src/cli/commands/import/import-evaluator.ts +++ b/src/cli/commands/import/import-evaluator.ts @@ -127,11 +127,7 @@ export async function handleImportEvaluator(options: ImportResourceOptions): Pro } // Derive local name - let localName = options.name ?? evaluatorDetail.evaluatorName; - const prefix = `${ctx.projectName}_`; - if (localName.startsWith(prefix)) { - localName = localName.slice(prefix.length); - } + const localName = options.name ?? evaluatorDetail.evaluatorName; const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; if (!NAME_REGEX.test(localName)) { return failResult( diff --git a/src/cli/commands/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts new file mode 100644 index 000000000..9f8f3e899 --- /dev/null +++ b/src/cli/commands/import/import-online-eval.ts @@ -0,0 +1,376 @@ +import type { AgentCoreProjectSpec, OnlineEvalConfig } from '../../../schema'; +import type { GetOnlineEvalConfigResult } from '../../aws/agentcore-control'; +import { getOnlineEvaluationConfig, listAllOnlineEvaluationConfigs } from '../../aws/agentcore-control'; +import { executeCdkImportPipeline } from './import-pipeline'; +import { failResult, findResourceInDeployedState, resolveImportContext, toStackName } from './import-utils'; +import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; +import type { ImportResourceOptions, ImportResourceResult } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +const green = '\x1b[32m'; +const dim = '\x1b[2m'; +const reset = '\x1b[0m'; + +const ARN_PREFIX = 'arn:'; + +/** + * Derive the agent name from the online eval config's service names. + * Service names follow the pattern: "{agentName}.DEFAULT" + */ +export function extractAgentName(serviceNames: string[]): string | undefined { + if (serviceNames.length === 0) return undefined; + const serviceName = serviceNames[0]!; + const dotIndex = serviceName.lastIndexOf('.'); + if (dotIndex === -1) return serviceName; + return serviceName.slice(0, dotIndex); +} + +/** + * Map an AWS GetOnlineEvaluationConfig response to the CLI OnlineEvalConfig spec format. + * + * @param detail - The AWS online eval config details + * @param localName - The local name for this config in agentcore.json + * @param agentName - The resolved local agent name + * @param evaluatorNames - Mapping from evaluator ID to local evaluator name (or ARN fallback) + */ +export function toOnlineEvalConfigSpec( + detail: GetOnlineEvalConfigResult, + localName: string, + agentName: string, + evaluatorNames: string[] +): OnlineEvalConfig { + if (!detail.samplingPercentage) { + throw new Error(`Online eval config "${detail.configName}" has no sampling configuration. Cannot import.`); + } + + return { + name: localName, + agent: agentName, + evaluators: evaluatorNames, + samplingRate: detail.samplingPercentage, + ...(detail.description && { description: detail.description }), + ...(detail.executionStatus === 'ENABLED' && { enableOnCreate: true }), + }; +} + +/** + * Resolve evaluator IDs to local names or ARNs. + * If an evaluator ID matches a local evaluator (by checking deployed state), use the local name. + * Otherwise, construct an ARN so the schema validation passes. + */ +function resolveEvaluatorReferences( + evaluatorIds: string[], + projectSpec: AgentCoreProjectSpec, + region: string, + account: string +): string[] { + const localEvaluators = projectSpec.evaluators ?? []; + + return evaluatorIds.map(id => { + // Check if this evaluator ID matches a local evaluator name + // The CDK creates evaluators with name: {projectName}_{evaluatorName} + for (const localEval of localEvaluators) { + if (id.includes(localEval.name)) { + return localEval.name; + } + } + // Fall back to ARN format (bypasses schema cross-reference validation) + return `${ARN_PREFIX}aws:bedrock-agentcore:${region}:${account}:evaluator/${id}`; + }); +} + +/** + * Handle `agentcore import online-eval`. + */ +export async function handleImportOnlineEval(options: ImportResourceOptions): Promise { + // Rollback state + let configSnapshot: AgentCoreProjectSpec | undefined; + let configWritten = false; + + let importCtx: Awaited> | undefined; + + const rollback = async () => { + if (configWritten && configSnapshot && importCtx) { + try { + await importCtx.ctx.configIO.writeProjectSpec(configSnapshot); + } catch (err) { + console.warn(`Warning: Could not restore agentcore.json: ${err instanceof Error ? err.message : String(err)}`); + } + } + }; + + try { + // 1-2. Validate project context and resolve target + importCtx = await resolveImportContext(options, 'import-online-eval'); + const { ctx, target, logger, onProgress } = importCtx; + + // 3. Get online eval config details from AWS + logger.startStep('Fetch online eval config from AWS'); + let configId: string; + + if (options.arn) { + // Parse config ID from ARN (last segment after /) + const arnMatch = /\/([^/]+)$/.exec(options.arn); + if (!arnMatch) { + return failResult(logger, `Could not parse config ID from ARN: ${options.arn}`, 'online-eval', ''); + } + configId = arnMatch[1]!; + } else { + onProgress('Listing online eval configs in your account...'); + const configs = await listAllOnlineEvaluationConfigs({ region: target.region }); + + if (configs.length === 0) { + return failResult(logger, 'No online evaluation configs found in your account.', 'online-eval', ''); + } + + if (configs.length === 1) { + configId = configs[0]!.onlineEvaluationConfigId; + onProgress(`Found 1 config: ${configs[0]!.onlineEvaluationConfigName} (${configId}). Auto-selecting.`); + } else { + console.log(`\nFound ${configs.length} online eval config(s):\n`); + for (let i = 0; i < configs.length; i++) { + const c = configs[i]!; + console.log( + ` ${dim}[${i + 1}]${reset} ${c.onlineEvaluationConfigName} — ${c.status} (${c.executionStatus})` + ); + console.log(` ${dim}${c.onlineEvaluationConfigArn}${reset}`); + } + console.log(''); + + return failResult( + logger, + 'Multiple online eval configs found. Use --arn to specify which config to import.', + 'online-eval', + '' + ); + } + } + + onProgress(`Fetching online eval config details for ${configId}...`); + const configDetail = await getOnlineEvaluationConfig({ region: target.region, configId }); + + if (configDetail.status !== 'ACTIVE') { + onProgress(`Warning: Online eval config status is ${configDetail.status}, not ACTIVE`); + } + + // Derive local name + const localName = options.name ?? configDetail.configName; + const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; + if (!NAME_REGEX.test(localName)) { + return failResult( + logger, + `Invalid name "${localName}". Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).`, + 'online-eval', + localName + ); + } + onProgress(`Online eval config: ${configDetail.configName} → local name: ${localName}`); + logger.endStep('success'); + + // 4. Check for duplicates + logger.startStep('Check for duplicates'); + const projectSpec = await ctx.configIO.readProjectSpec(); + const existingNames = new Set((projectSpec.onlineEvalConfigs ?? []).map(c => c.name)); + if (existingNames.has(localName)) { + return failResult( + logger, + `Online eval config "${localName}" already exists in the project. Use --name to specify a different local name.`, + 'online-eval', + localName + ); + } + const targetName = target.name ?? 'default'; + const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'online-eval', configId); + if (existingResource) { + return failResult( + logger, + `Online eval config "${configId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, + 'online-eval', + localName + ); + } + logger.endStep('success'); + + // 5. Resolve agent and evaluator references + logger.startStep('Resolve references'); + + // Extract agent name from service names + const agentName = extractAgentName(configDetail.serviceNames ?? []); + if (!agentName) { + return failResult( + logger, + 'Could not determine agent name from online eval config. The config has no data source service names.', + 'online-eval', + localName + ); + } + + // Validate agent exists in project + const agentNames = new Set((projectSpec.runtimes ?? []).map(r => r.name)); + if (!agentNames.has(agentName)) { + return failResult( + logger, + `Online eval config references agent "${agentName}" which is not in this project. ` + + `Import or add the agent first with \`agentcore import runtime\` or \`agentcore add agent\`.`, + 'online-eval', + localName + ); + } + + // Resolve evaluator IDs to local names or ARNs + const evaluatorIds = configDetail.evaluatorIds ?? []; + if (evaluatorIds.length === 0) { + return failResult( + logger, + 'Online eval config has no evaluators configured. Cannot import.', + 'online-eval', + localName + ); + } + const evaluatorNames = resolveEvaluatorReferences(evaluatorIds, projectSpec, target.region, target.account); + onProgress(`Agent: ${agentName}, Evaluators: ${evaluatorNames.join(', ')}`); + logger.endStep('success'); + + // 6. Add to project config + logger.startStep('Update project config'); + configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; + const onlineEvalSpec = toOnlineEvalConfigSpec(configDetail, localName, agentName, evaluatorNames); + (projectSpec.onlineEvalConfigs ??= []).push(onlineEvalSpec); + await ctx.configIO.writeProjectSpec(projectSpec); + configWritten = true; + onProgress(`Added online eval config "${localName}" to agentcore.json`); + logger.endStep('success'); + + // 7-10. CDK build → synth → bootstrap → phase 1 → phase 2 → update state + logger.startStep('Build and synth CDK'); + const stackName = toStackName(ctx.projectName, targetName); + + const pipelineResult = await executeCdkImportPipeline({ + projectRoot: ctx.projectRoot, + stackName, + target, + configIO: ctx.configIO, + targetName, + onProgress, + buildResourcesToImport: synthTemplate => { + // Try matching by OnlineEvaluationConfigName property + let logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + localName + ); + + if (!logicalId) { + const prefixedName = `${ctx.projectName}_${localName}`; + logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + prefixedName + ); + } + + // Fall back to single online eval config by type + if (!logicalId) { + const configLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::OnlineEvaluationConfig'); + if (configLogicalIds.length === 1) { + logicalId = configLogicalIds[0]; + } + } + + if (!logicalId) { + return []; + } + + return [ + { + resourceType: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + logicalResourceId: logicalId, + resourceIdentifier: { OnlineEvaluationConfigId: configId }, + }, + ]; + }, + deployedStateEntries: [ + { + type: 'online-eval', + name: localName, + id: configId, + arn: configDetail.configArn, + }, + ], + }); + + if (pipelineResult.noResources) { + const error = `Could not find logical ID for online eval config "${localName}" in CloudFormation template`; + await rollback(); + return failResult(logger, error, 'online-eval', localName); + } + + if (!pipelineResult.success) { + await rollback(); + logger.endStep('error', pipelineResult.error); + logger.finalize(false); + return { + success: false, + error: pipelineResult.error, + resourceType: 'online-eval', + resourceName: localName, + logPath: logger.getRelativeLogPath(), + }; + } + logger.endStep('success'); + + logger.finalize(true); + return { + success: true, + resourceType: 'online-eval', + resourceName: localName, + resourceId: configId, + logPath: logger.getRelativeLogPath(), + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + await rollback(); + if (importCtx) { + importCtx.logger.log(message, 'error'); + importCtx.logger.finalize(false); + } + return { + success: false, + error: message, + resourceType: 'online-eval', + resourceName: options.name ?? '', + logPath: importCtx?.logger.getRelativeLogPath(), + }; + } +} + +/** + * Register the `import online-eval` subcommand. + */ +export function registerImportOnlineEval(importCmd: Command): void { + importCmd + .command('online-eval') + .description('Import an existing AgentCore Online Evaluation Config from your AWS account') + .option('--arn ', 'Online evaluation config ARN to import') + .option('--name ', 'Local name for the imported online eval config') + .option('-y, --yes', 'Auto-confirm prompts') + .action(async (cliOptions: ImportResourceOptions) => { + const result = await handleImportOnlineEval(cliOptions); + + if (result.success) { + console.log(''); + console.log(`${green}Online eval config imported successfully!${reset}`); + console.log(` Name: ${result.resourceName}`); + console.log(` ID: ${result.resourceId}`); + console.log(''); + } else { + console.error(`\n\x1b[31m[error]${reset} ${result.error}`); + if (result.logPath) { + console.error(`Log: ${result.logPath}`); + } + process.exit(1); + } + }); +} diff --git a/src/cli/commands/import/import-runtime.ts b/src/cli/commands/import/import-runtime.ts index eefd57569..a363cdb2a 100644 --- a/src/cli/commands/import/import-runtime.ts +++ b/src/cli/commands/import/import-runtime.ts @@ -170,11 +170,7 @@ export async function handleImportRuntime(options: RuntimeImportOptions): Promis } // Derive local name - let localName = options.name ?? runtimeDetail.agentRuntimeName; - const prefix = `${ctx.projectName}_`; - if (localName.startsWith(prefix)) { - localName = localName.slice(prefix.length); - } + const localName = options.name ?? runtimeDetail.agentRuntimeName; const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; if (!NAME_REGEX.test(localName)) { return failResult( diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index 3e9bc369d..c4383fd48 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -128,9 +128,12 @@ export async function resolveImportTarget(options: ResolveTargetOptions): Promis const { configIO, targetName, arn, onProgress } = options; // Validate ARN format early if provided - if (arn && !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator)\/(.+)$/.test(arn)) { + if ( + arn && + !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/.test(arn) + ) { throw new Error( - `Not a valid ARN: "${arn}".\nExpected format: arn:aws:bedrock-agentcore:::/` + `Not a valid ARN: "${arn}".\nExpected format: arn:aws:bedrock-agentcore:::/` ); } @@ -206,7 +209,8 @@ export interface ParsedArn { resourceId: string; } -const ARN_PATTERN = /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator)\/(.+)$/; +const ARN_PATTERN = + /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/; /** * Parse and validate a BedrockAgentCore ARN. @@ -280,11 +284,13 @@ export async function findResourceInDeployedState( runtime: 'runtimes', memory: 'memories', evaluator: 'evaluators', + 'online-eval': 'onlineEvalConfigs', }; const idFieldMap: Record = { runtime: 'runtimeId', memory: 'memoryId', evaluator: 'evaluatorId', + 'online-eval': 'onlineEvaluationConfigId', }; const collection = targetState.resources[collectionKeyMap[resourceType]]; @@ -341,6 +347,12 @@ export async function updateDeployedState( evaluatorId: resource.id, evaluatorArn: resource.arn, }; + } else if (resource.type === 'online-eval') { + targetState.resources.onlineEvalConfigs ??= {}; + targetState.resources.onlineEvalConfigs[resource.name] = { + onlineEvaluationConfigId: resource.id, + onlineEvaluationConfigArn: resource.arn, + }; } } diff --git a/src/cli/commands/import/types.ts b/src/cli/commands/import/types.ts index d9da95b5e..e1aca038f 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -66,7 +66,7 @@ export interface ParsedStarterToolkitConfig { /** * Resource types supported by the import subcommands. */ -export type ImportableResourceType = 'runtime' | 'memory' | 'evaluator'; +export type ImportableResourceType = 'runtime' | 'memory' | 'evaluator' | 'online-eval'; /** * Resource to be imported via CloudFormation IMPORT change set. From e21718f86566af4152a545bfca74b87ba0fd1ab2 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Fri, 3 Apr 2026 20:34:05 +0000 Subject: [PATCH 04/18] feat(import): add online eval config to TUI import wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'Online Eval Config' option to the interactive import flow so users can import online evaluation configs via the TUI, not just the CLI. Follows the same ARN-only pattern as evaluator and memory imports: select type → enter ARN → import progress → success/error. --- src/cli/tui/screens/import/ArnInputScreen.tsx | 10 ++++++---- src/cli/tui/screens/import/ImportFlow.tsx | 4 ++-- src/cli/tui/screens/import/ImportProgressScreen.tsx | 11 +++++++++-- src/cli/tui/screens/import/ImportSelectScreen.tsx | 7 ++++++- 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/cli/tui/screens/import/ArnInputScreen.tsx b/src/cli/tui/screens/import/ArnInputScreen.tsx index 7da6dc82f..85928dc69 100644 --- a/src/cli/tui/screens/import/ArnInputScreen.tsx +++ b/src/cli/tui/screens/import/ArnInputScreen.tsx @@ -3,17 +3,17 @@ import { Screen } from '../../components/Screen'; import { TextInput } from '../../components/TextInput'; import { HELP_TEXT } from '../../constants'; -const ARN_PATTERN = /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator)\/.+$/; +const ARN_PATTERN = /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator|online-evaluation-config)\/.+$/; function validateArn(value: string): true | string { if (!ARN_PATTERN.test(value)) { - return 'Invalid ARN format. Expected: arn:aws:bedrock-agentcore:::/'; + return 'Invalid ARN format. Expected: arn:aws:bedrock-agentcore:::/'; } return true; } interface ArnInputScreenProps { - resourceType: 'runtime' | 'memory' | 'evaluator'; + resourceType: 'runtime' | 'memory' | 'evaluator' | 'online-eval'; onSubmit: (arn: string) => void; onExit: () => void; } @@ -22,11 +22,13 @@ const RESOURCE_TYPE_LABELS: Record = { runtime: 'Import Runtime', memory: 'Import Memory', evaluator: 'Import Evaluator', + 'online-eval': 'Import Online Eval Config', }; export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScreenProps) { const title = RESOURCE_TYPE_LABELS[resourceType] ?? `Import ${resourceType}`; - const placeholder = `arn:aws:bedrock-agentcore:::${resourceType}/`; + const arnResourceType = resourceType === 'online-eval' ? 'online-evaluation-config' : resourceType; + const placeholder = `arn:aws:bedrock-agentcore:::${arnResourceType}/`; return ( diff --git a/src/cli/tui/screens/import/ImportFlow.tsx b/src/cli/tui/screens/import/ImportFlow.tsx index ecc41d095..88b8fe71a 100644 --- a/src/cli/tui/screens/import/ImportFlow.tsx +++ b/src/cli/tui/screens/import/ImportFlow.tsx @@ -14,7 +14,7 @@ import React, { useState } from 'react'; type ImportFlowState = | { name: 'select-type' } - | { name: 'arn-input'; resourceType: 'runtime' | 'memory' | 'evaluator' } + | { name: 'arn-input'; resourceType: 'runtime' | 'memory' | 'evaluator' | 'online-eval' } | { name: 'code-path'; resourceType: 'runtime'; arn: string } | { name: 'yaml-path' } | { @@ -48,7 +48,7 @@ export function ImportFlow({ onBack, onNavigate }: ImportFlowProps) { return ( { - if (type === 'runtime' || type === 'memory' || type === 'evaluator') { + if (type === 'runtime' || type === 'memory' || type === 'evaluator' || type === 'online-eval') { setFlow({ name: 'arn-input', resourceType: type }); } else { setFlow({ name: 'yaml-path' }); diff --git a/src/cli/tui/screens/import/ImportProgressScreen.tsx b/src/cli/tui/screens/import/ImportProgressScreen.tsx index 2c5e39e10..d5cb6dd7a 100644 --- a/src/cli/tui/screens/import/ImportProgressScreen.tsx +++ b/src/cli/tui/screens/import/ImportProgressScreen.tsx @@ -40,13 +40,20 @@ export function ImportProgressScreen({ started.current = true; const run = async () => { - if (importType === 'runtime' || importType === 'memory' || importType === 'evaluator') { + if ( + importType === 'runtime' || + importType === 'memory' || + importType === 'evaluator' || + importType === 'online-eval' + ) { const handler = importType === 'runtime' ? (await import('../../../commands/import/import-runtime')).handleImportRuntime : importType === 'memory' ? (await import('../../../commands/import/import-memory')).handleImportMemory - : (await import('../../../commands/import/import-evaluator')).handleImportEvaluator; + : importType === 'evaluator' + ? (await import('../../../commands/import/import-evaluator')).handleImportEvaluator + : (await import('../../../commands/import/import-online-eval')).handleImportOnlineEval; const result = await handler({ arn, code, onProgress }); if (result.success) { diff --git a/src/cli/tui/screens/import/ImportSelectScreen.tsx b/src/cli/tui/screens/import/ImportSelectScreen.tsx index 6e22987b4..21ab114f8 100644 --- a/src/cli/tui/screens/import/ImportSelectScreen.tsx +++ b/src/cli/tui/screens/import/ImportSelectScreen.tsx @@ -2,7 +2,7 @@ import type { SelectableItem } from '../../components/SelectList'; import { SelectScreen } from '../../components/SelectScreen'; import { Text } from 'ink'; -export type ImportType = 'runtime' | 'memory' | 'evaluator' | 'starter-toolkit'; +export type ImportType = 'runtime' | 'memory' | 'evaluator' | 'online-eval' | 'starter-toolkit'; interface ImportSelectItem extends SelectableItem { id: ImportType; @@ -24,6 +24,11 @@ const IMPORT_OPTIONS: ImportSelectItem[] = [ title: 'Evaluator', description: 'Import an existing AgentCore Evaluator from your AWS account', }, + { + id: 'online-eval', + title: 'Online Eval Config', + description: 'Import an existing AgentCore Online Evaluation Config from your AWS account', + }, { id: 'starter-toolkit', title: 'From Starter Toolkit', From cb4c6757e66ffefe05c974d44e34754cff216196 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Fri, 3 Apr 2026 20:51:22 +0000 Subject: [PATCH 05/18] docs: add TUI import wizard screenshots for online eval Screenshots captured from the TUI import flow showing: - Import type selection menu with Online Eval Config option - ARN input screen for online eval config - ARN input with a real config ARN filled in --- .../import-online-eval-arn-filled.svg | 24 ++++++++++++++ docs/screenshots/import-online-eval-arn.svg | 25 ++++++++++++++ docs/screenshots/import-select.svg | 33 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 docs/screenshots/import-online-eval-arn-filled.svg create mode 100644 docs/screenshots/import-online-eval-arn.svg create mode 100644 docs/screenshots/import-select.svg diff --git a/docs/screenshots/import-online-eval-arn-filled.svg b/docs/screenshots/import-online-eval-arn-filled.svg new file mode 100644 index 000000000..afa4e8868 --- /dev/null +++ b/docs/screenshots/import-online-eval-arn-filled.svg @@ -0,0 +1,24 @@ + + + + + + + + + Import Online Eval Config + ╭──────────────────────────────────────────────────────────╮ + │ Enter the resource ARN: │ +> aws:bedrock-agentcore:us-west-2:325335451438:online-… │ + ╰──────────────────────────────────────────────────────────╯ + Enter submit · Esc cancel + + + \ No newline at end of file diff --git a/docs/screenshots/import-online-eval-arn.svg b/docs/screenshots/import-online-eval-arn.svg new file mode 100644 index 000000000..800cde05d --- /dev/null +++ b/docs/screenshots/import-online-eval-arn.svg @@ -0,0 +1,25 @@ + + + + + + + + + Import Online Eval Config + ╭──────────────────────────────────────────────────────────╮ + │ Enter the resource ARN: │ + +> rn:aws:bedrock-agentcore:<region>:<account>:online-e… + ╰──────────────────────────────────────────────────────────╯ + Enter submit · Esc cancel + + + \ No newline at end of file diff --git a/docs/screenshots/import-select.svg b/docs/screenshots/import-select.svg new file mode 100644 index 000000000..0e5c38e97 --- /dev/null +++ b/docs/screenshots/import-select.svg @@ -0,0 +1,33 @@ + + + + + + + + + Import + Experimental: this feature imports resources that are already deployed, use with caution + ╭──────────────────────────────────────────────────────────╮ +❯ Runtime - Import an existing AgentCore Runtime from +your AWS account + │ Memory - Import an existing AgentCore Memory from your + AWS account + │ Evaluator - Import an existing AgentCore Evaluator +from your AWS account + │ Online Eval Config - Import an existing AgentCore +Online Evaluation Config from your AWS account + │ From Starter Toolkit - Import from a +.bedrock_agentcore.yaml configuration file + ╰──────────────────────────────────────────────────────────╯ + ↑↓ navigate · Enter select · Esc back · Ctrl+C quit + + + \ No newline at end of file From 915b6c2ce2d32eb66d9a6ee067f033452339a039 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Fri, 3 Apr 2026 21:03:05 +0000 Subject: [PATCH 06/18] Revert "docs: add TUI import wizard screenshots for online eval" This reverts commit cb4c6757e66ffefe05c974d44e34754cff216196. --- .../import-online-eval-arn-filled.svg | 24 -------------- docs/screenshots/import-online-eval-arn.svg | 25 -------------- docs/screenshots/import-select.svg | 33 ------------------- 3 files changed, 82 deletions(-) delete mode 100644 docs/screenshots/import-online-eval-arn-filled.svg delete mode 100644 docs/screenshots/import-online-eval-arn.svg delete mode 100644 docs/screenshots/import-select.svg diff --git a/docs/screenshots/import-online-eval-arn-filled.svg b/docs/screenshots/import-online-eval-arn-filled.svg deleted file mode 100644 index afa4e8868..000000000 --- a/docs/screenshots/import-online-eval-arn-filled.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - Import Online Eval Config - ╭──────────────────────────────────────────────────────────╮ - │ Enter the resource ARN: │ -> aws:bedrock-agentcore:us-west-2:325335451438:online-… │ - ╰──────────────────────────────────────────────────────────╯ - Enter submit · Esc cancel - - - \ No newline at end of file diff --git a/docs/screenshots/import-online-eval-arn.svg b/docs/screenshots/import-online-eval-arn.svg deleted file mode 100644 index 800cde05d..000000000 --- a/docs/screenshots/import-online-eval-arn.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - Import Online Eval Config - ╭──────────────────────────────────────────────────────────╮ - │ Enter the resource ARN: │ - -> rn:aws:bedrock-agentcore:<region>:<account>:online-e… - ╰──────────────────────────────────────────────────────────╯ - Enter submit · Esc cancel - - - \ No newline at end of file diff --git a/docs/screenshots/import-select.svg b/docs/screenshots/import-select.svg deleted file mode 100644 index 0e5c38e97..000000000 --- a/docs/screenshots/import-select.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - Import - Experimental: this feature imports resources that are already deployed, use with caution - ╭──────────────────────────────────────────────────────────╮ -❯ Runtime - Import an existing AgentCore Runtime from -your AWS account - │ Memory - Import an existing AgentCore Memory from your - AWS account - │ Evaluator - Import an existing AgentCore Evaluator -from your AWS account - │ Online Eval Config - Import an existing AgentCore -Online Evaluation Config from your AWS account - │ From Starter Toolkit - Import from a -.bedrock_agentcore.yaml configuration file - ╰──────────────────────────────────────────────────────────╯ - ↑↓ navigate · Enter select · Esc back · Ctrl+C quit - - - \ No newline at end of file From 385f41604b0f7676ec2623f136b259fdf9c2fda4 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Mon, 6 Apr 2026 14:29:31 +0000 Subject: [PATCH 07/18] refactor(import): extract generic import orchestrator with descriptor pattern Reduce ~1,400 lines of duplicated orchestration across four import handlers (runtime, memory, evaluator, online-eval) to ~600 lines by extracting shared logic into executeResourceImport(). Each resource type now provides a thin descriptor declaring its specific behavior. Constraint: Public handleImport* function signatures unchanged (TUI depends on them) Constraint: Factory functions needed for runtime/online-eval to share mutable state between hooks Rejected: Strategy class hierarchy | descriptor objects are simpler and more composable Confidence: high Scope-risk: moderate --- src/cli/commands/import/command.ts | 7 +- src/cli/commands/import/constants.ts | 12 + src/cli/commands/import/import-evaluator.ts | 267 ++---------- src/cli/commands/import/import-memory.ts | 255 ++---------- src/cli/commands/import/import-online-eval.ts | 346 ++++----------- src/cli/commands/import/import-runtime.ts | 393 +++++------------- src/cli/commands/import/import-utils.ts | 4 +- src/cli/commands/import/resource-import.ts | 247 +++++++++++ src/cli/commands/import/types.ts | 115 ++++- 9 files changed, 648 insertions(+), 998 deletions(-) create mode 100644 src/cli/commands/import/resource-import.ts diff --git a/src/cli/commands/import/command.ts b/src/cli/commands/import/command.ts index b5a1a360a..3fd4f745d 100644 --- a/src/cli/commands/import/command.ts +++ b/src/cli/commands/import/command.ts @@ -1,4 +1,5 @@ import { handleImport } from './actions'; +import { ANSI } from './constants'; import { registerImportEvaluator } from './import-evaluator'; import { registerImportMemory } from './import-memory'; import { registerImportOnlineEval } from './import-online-eval'; @@ -6,11 +7,7 @@ import { registerImportRuntime } from './import-runtime'; import type { Command } from '@commander-js/extra-typings'; import * as fs from 'node:fs'; -const green = '\x1b[32m'; -const yellow = '\x1b[33m'; -const cyan = '\x1b[36m'; -const dim = '\x1b[2m'; -const reset = '\x1b[0m'; +const { green, yellow, cyan, dim, reset } = ANSI; export const registerImport = (program: Command) => { const importCmd = program diff --git a/src/cli/commands/import/constants.ts b/src/cli/commands/import/constants.ts index 339af4191..c4dd3ab7f 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -1,3 +1,15 @@ +/** Name validation regex used by all import handlers. */ +export const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; + +/** ANSI escape codes for console output. */ +export const ANSI = { + green: '\x1b[32m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + dim: '\x1b[2m', + reset: '\x1b[0m', +} as const; + /** * CloudFormation resource type to identifier key mapping for IMPORT. */ diff --git a/src/cli/commands/import/import-evaluator.ts b/src/cli/commands/import/import-evaluator.ts index 923ac6147..14162aacf 100644 --- a/src/cli/commands/import/import-evaluator.ts +++ b/src/cli/commands/import/import-evaluator.ts @@ -1,22 +1,12 @@ -import type { AgentCoreProjectSpec, Evaluator } from '../../../schema'; -import type { GetEvaluatorResult } from '../../aws/agentcore-control'; +import type { Evaluator } from '../../../schema'; +import type { EvaluatorSummary, GetEvaluatorResult } from '../../aws/agentcore-control'; import { getEvaluator, listAllEvaluators } from '../../aws/agentcore-control'; -import { executeCdkImportPipeline } from './import-pipeline'; -import { - failResult, - findResourceInDeployedState, - parseAndValidateArn, - resolveImportContext, - toStackName, -} from './import-utils'; -import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; -import type { ImportResourceOptions, ImportResourceResult } from './types'; +import { ANSI } from './constants'; +import { parseAndValidateArn } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; import type { Command } from '@commander-js/extra-typings'; -const green = '\x1b[32m'; -const dim = '\x1b[2m'; -const reset = '\x1b[0m'; - /** * Map an AWS GetEvaluator response to the CLI Evaluator spec format. */ @@ -58,225 +48,42 @@ export function toEvaluatorSpec(detail: GetEvaluatorResult, localName: string): }; } -/** - * Handle `agentcore import evaluator`. - */ -export async function handleImportEvaluator(options: ImportResourceOptions): Promise { - // Rollback state - let configSnapshot: AgentCoreProjectSpec | undefined; - let configWritten = false; - - let importCtx: Awaited> | undefined; - - const rollback = async () => { - if (configWritten && configSnapshot && importCtx) { - try { - await importCtx.ctx.configIO.writeProjectSpec(configSnapshot); - } catch (err) { - console.warn(`Warning: Could not restore agentcore.json: ${err instanceof Error ? err.message : String(err)}`); - } - } - }; - - try { - // 1-2. Validate project context and resolve target - importCtx = await resolveImportContext(options, 'import-evaluator'); - const { ctx, target, logger, onProgress } = importCtx; - - // 3. Get evaluator details from AWS - logger.startStep('Fetch evaluator from AWS'); - let evaluatorId: string; - - if (options.arn) { - const parsed = parseAndValidateArn(options.arn, 'evaluator', target); - evaluatorId = parsed.resourceId; - } else { - onProgress('Listing evaluators in your account...'); - const evaluators = await listAllEvaluators({ region: target.region }); - - if (evaluators.length === 0) { - return failResult(logger, 'No custom evaluators found in your account.', 'evaluator', ''); - } - - if (evaluators.length === 1) { - evaluatorId = evaluators[0]!.evaluatorId; - onProgress(`Found 1 evaluator: ${evaluators[0]!.evaluatorName} (${evaluatorId}). Auto-selecting.`); - } else { - console.log(`\nFound ${evaluators.length} evaluator(s):\n`); - for (let i = 0; i < evaluators.length; i++) { - const e = evaluators[i]!; - console.log(` ${dim}[${i + 1}]${reset} ${e.evaluatorName} — ${e.status}`); - console.log(` ${dim}${e.evaluatorArn}${reset}`); - } - console.log(''); - - return failResult( - logger, - 'Multiple evaluators found. Use --arn to specify which evaluator to import.', - 'evaluator', - '' - ); - } - } - - onProgress(`Fetching evaluator details for ${evaluatorId}...`); - const evaluatorDetail = await getEvaluator({ region: target.region, evaluatorId }); - - if (evaluatorDetail.status !== 'ACTIVE') { - onProgress(`Warning: Evaluator status is ${evaluatorDetail.status}, not ACTIVE`); - } - - // Derive local name - const localName = options.name ?? evaluatorDetail.evaluatorName; - const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; - if (!NAME_REGEX.test(localName)) { - return failResult( - logger, - `Invalid name "${localName}". Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).`, - 'evaluator', - localName - ); - } - onProgress(`Evaluator: ${evaluatorDetail.evaluatorName} → local name: ${localName}`); - logger.endStep('success'); - - // 4. Check for duplicates - logger.startStep('Check for duplicates'); - const projectSpec = await ctx.configIO.readProjectSpec(); - const existingNames = new Set((projectSpec.evaluators ?? []).map(e => e.name)); - if (existingNames.has(localName)) { - return failResult( - logger, - `Evaluator "${localName}" already exists in the project. Use --name to specify a different local name.`, - 'evaluator', - localName - ); - } - const targetName = target.name ?? 'default'; - const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'evaluator', evaluatorId); - if (existingResource) { - return failResult( - logger, - `Evaluator "${evaluatorId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, - 'evaluator', - localName - ); - } - logger.endStep('success'); - - // 5. Add to project config - logger.startStep('Update project config'); - configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; - const evaluatorSpec = toEvaluatorSpec(evaluatorDetail, localName); - (projectSpec.evaluators ??= []).push(evaluatorSpec); - await ctx.configIO.writeProjectSpec(projectSpec); - configWritten = true; - onProgress(`Added evaluator "${localName}" to agentcore.json`); - logger.endStep('success'); +const evaluatorDescriptor: ResourceImportDescriptor = { + resourceType: 'evaluator', + displayName: 'evaluator', + logCommand: 'import-evaluator', - // 6-10. CDK build → synth → bootstrap → phase 1 → phase 2 → update state - logger.startStep('Build and synth CDK'); - const stackName = toStackName(ctx.projectName, targetName); + listResources: region => listAllEvaluators({ region }), + getDetail: (region, id) => getEvaluator({ region, evaluatorId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'evaluator', target).resourceId, - const pipelineResult = await executeCdkImportPipeline({ - projectRoot: ctx.projectRoot, - stackName, - target, - configIO: ctx.configIO, - targetName, - onProgress, - buildResourcesToImport: synthTemplate => { - // Try matching by EvaluatorName property (plain name first, then prefixed) - let logicalId = findLogicalIdByProperty( - synthTemplate, - 'AWS::BedrockAgentCore::Evaluator', - 'EvaluatorName', - localName - ); + extractSummaryId: s => s.evaluatorId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.evaluatorName} — ${s.status}\n ${ANSI.dim}${s.evaluatorArn}${ANSI.reset}`, + formatAutoSelectMessage: s => `Found 1 evaluator: ${s.evaluatorName} (${s.evaluatorId}). Auto-selecting.`, - if (!logicalId) { - const prefixedName = `${ctx.projectName}_${localName}`; - logicalId = findLogicalIdByProperty( - synthTemplate, - 'AWS::BedrockAgentCore::Evaluator', - 'EvaluatorName', - prefixedName - ); - } - - // Fall back to single evaluator by type - if (!logicalId) { - const evaluatorLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Evaluator'); - if (evaluatorLogicalIds.length === 1) { - logicalId = evaluatorLogicalIds[0]; - } - } - - if (!logicalId) { - return []; - } + extractDetailName: d => d.evaluatorName, + extractDetailArn: d => d.evaluatorArn, + readyStatus: 'ACTIVE', + extractDetailStatus: d => d.status, - return [ - { - resourceType: 'AWS::BedrockAgentCore::Evaluator', - logicalResourceId: logicalId, - resourceIdentifier: { EvaluatorId: evaluatorId }, - }, - ]; - }, - deployedStateEntries: [ - { - type: 'evaluator', - name: localName, - id: evaluatorId, - arn: evaluatorDetail.evaluatorArn, - }, - ], - }); + getExistingNames: spec => (spec.evaluators ?? []).map(e => e.name), + addToProjectSpec: (detail, localName, spec) => { + (spec.evaluators ??= []).push(toEvaluatorSpec(detail, localName)); + }, - if (pipelineResult.noResources) { - const error = `Could not find logical ID for evaluator "${localName}" in CloudFormation template`; - await rollback(); - return failResult(logger, error, 'evaluator', localName); - } + cfnResourceType: 'AWS::BedrockAgentCore::Evaluator', + cfnNameProperty: 'EvaluatorName', + cfnIdentifierKey: 'EvaluatorId', - if (!pipelineResult.success) { - await rollback(); - logger.endStep('error', pipelineResult.error); - logger.finalize(false); - return { - success: false, - error: pipelineResult.error, - resourceType: 'evaluator', - resourceName: localName, - logPath: logger.getRelativeLogPath(), - }; - } - logger.endStep('success'); + buildDeployedStateEntry: (name, id, d) => ({ type: 'evaluator', name, id, arn: d.evaluatorArn }), +}; - logger.finalize(true); - return { - success: true, - resourceType: 'evaluator', - resourceName: localName, - resourceId: evaluatorId, - logPath: logger.getRelativeLogPath(), - }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - await rollback(); - if (importCtx) { - importCtx.logger.log(message, 'error'); - importCtx.logger.finalize(false); - } - return { - success: false, - error: message, - resourceType: 'evaluator', - resourceName: options.name ?? '', - logPath: importCtx?.logger.getRelativeLogPath(), - }; - } +/** + * Handle `agentcore import evaluator`. + */ +export async function handleImportEvaluator(options: ImportResourceOptions): Promise { + return executeResourceImport(evaluatorDescriptor, options); } /** @@ -294,12 +101,12 @@ export function registerImportEvaluator(importCmd: Command): void { if (result.success) { console.log(''); - console.log(`${green}Evaluator imported successfully!${reset}`); + console.log(`${ANSI.green}Evaluator imported successfully!${ANSI.reset}`); console.log(` Name: ${result.resourceName}`); console.log(` ID: ${result.resourceId}`); console.log(''); } else { - console.error(`\n\x1b[31m[error]${reset} ${result.error}`); + console.error(`\n\x1b[31m[error]${ANSI.reset} ${result.error}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-memory.ts b/src/cli/commands/import/import-memory.ts index 3ad557129..1e5a23db9 100644 --- a/src/cli/commands/import/import-memory.ts +++ b/src/cli/commands/import/import-memory.ts @@ -1,22 +1,12 @@ -import type { AgentCoreProjectSpec, Memory } from '../../../schema'; -import type { MemoryDetail } from '../../aws/agentcore-control'; +import type { Memory } from '../../../schema'; +import type { MemoryDetail, MemorySummary } from '../../aws/agentcore-control'; import { getMemoryDetail, listAllMemories } from '../../aws/agentcore-control'; -import { executeCdkImportPipeline } from './import-pipeline'; -import { - failResult, - findResourceInDeployedState, - parseAndValidateArn, - resolveImportContext, - toStackName, -} from './import-utils'; -import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; -import type { ImportResourceOptions, ImportResourceResult } from './types'; +import { ANSI } from './constants'; +import { parseAndValidateArn } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; import type { Command } from '@commander-js/extra-typings'; -const green = '\x1b[32m'; -const dim = '\x1b[2m'; -const reset = '\x1b[0m'; - /** * Map strategy type from AWS API format to CLI schema format. * The API returns types like "SEMANTIC_OVERRIDE", "SUMMARY_OVERRIDE", etc. @@ -73,213 +63,42 @@ function toMemorySpec(memory: MemoryDetail, localName: string): Memory { }; } -/** - * Handle `agentcore import memory`. - */ -export async function handleImportMemory(options: ImportResourceOptions): Promise { - // Rollback state - let configSnapshot: AgentCoreProjectSpec | undefined; - let configWritten = false; - - let importCtx: Awaited> | undefined; - - const rollback = async () => { - if (configWritten && configSnapshot && importCtx) { - try { - await importCtx.ctx.configIO.writeProjectSpec(configSnapshot); - } catch (err) { - console.warn(`Warning: Could not restore agentcore.json: ${err instanceof Error ? err.message : String(err)}`); - } - } - }; - - try { - // 1-2. Validate project context and resolve target - importCtx = await resolveImportContext(options, 'import-memory'); - const { ctx, target, logger, onProgress } = importCtx; - - // 3. Get memory details from AWS - logger.startStep('Fetch memory from AWS'); - let memoryId: string; - - if (options.arn) { - const parsed = parseAndValidateArn(options.arn, 'memory', target); - memoryId = parsed.resourceId; - } else { - onProgress('Listing memories in your account...'); - const memories = await listAllMemories({ region: target.region }); - - if (memories.length === 0) { - return failResult(logger, 'No memories found in your account.', 'memory', ''); - } - - if (memories.length === 1) { - memoryId = memories[0]!.memoryId; - onProgress(`Found 1 memory: ${memoryId}. Auto-selecting.`); - } else { - console.log(`\nFound ${memories.length} ${memories.length === 1 ? 'memory' : 'memories'}:\n`); - for (let i = 0; i < memories.length; i++) { - const m = memories[i]!; - console.log(` ${dim}[${i + 1}]${reset} ${m.memoryId} — ${m.status}`); - console.log(` ${dim}${m.memoryArn}${reset}`); - } - console.log(''); - - return failResult( - logger, - 'Multiple memories found. Use --arn to specify which memory to import.', - 'memory', - '' - ); - } - } +const memoryDescriptor: ResourceImportDescriptor = { + resourceType: 'memory', + displayName: 'memory', + logCommand: 'import-memory', - onProgress(`Fetching memory details for ${memoryId}...`); - const memoryDetail = await getMemoryDetail({ region: target.region, memoryId }); + listResources: region => listAllMemories({ region }), + getDetail: (region, id) => getMemoryDetail({ region, memoryId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'memory', target).resourceId, - if (memoryDetail.status !== 'ACTIVE') { - onProgress(`Warning: Memory status is ${memoryDetail.status}, not ACTIVE`); - } + extractSummaryId: s => s.memoryId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.memoryId} — ${s.status}\n ${ANSI.dim}${s.memoryArn}${ANSI.reset}`, + formatAutoSelectMessage: s => `Found 1 memory: ${s.memoryId}. Auto-selecting.`, - const localName = options.name ?? memoryDetail.name; - const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; - if (!NAME_REGEX.test(localName)) { - return failResult( - logger, - `Invalid name "${localName}". Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).`, - 'memory', - localName - ); - } - onProgress(`Memory: ${memoryDetail.name} → local name: ${localName}`); - logger.endStep('success'); + extractDetailName: d => d.name, + extractDetailArn: d => d.memoryArn, + readyStatus: 'ACTIVE', + extractDetailStatus: d => d.status, - // 4. Check for duplicates - logger.startStep('Check for duplicates'); - const projectSpec = await ctx.configIO.readProjectSpec(); - const existingNames = new Set((projectSpec.memories ?? []).map(m => m.name)); - if (existingNames.has(localName)) { - return failResult( - logger, - `Memory "${localName}" already exists in the project. Use --name to specify a different local name.`, - 'memory', - localName - ); - } - const targetName = target.name ?? 'default'; - const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'memory', memoryId); - if (existingResource) { - return failResult( - logger, - `Memory "${memoryId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, - 'memory', - localName - ); - } - logger.endStep('success'); + getExistingNames: spec => (spec.memories ?? []).map(m => m.name), + addToProjectSpec: (detail, localName, spec) => { + (spec.memories ??= []).push(toMemorySpec(detail, localName)); + }, - // 5. Add to project config - logger.startStep('Update project config'); - configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; - const memorySpec = toMemorySpec(memoryDetail, localName); - (projectSpec.memories ??= []).push(memorySpec); - await ctx.configIO.writeProjectSpec(projectSpec); - configWritten = true; - onProgress(`Added memory "${localName}" to agentcore.json`); - logger.endStep('success'); + cfnResourceType: 'AWS::BedrockAgentCore::Memory', + cfnNameProperty: 'Name', + cfnIdentifierKey: 'MemoryId', - // 6-10. CDK build → synth → bootstrap → phase 1 → phase 2 → update state - logger.startStep('Build and synth CDK'); - const stackName = toStackName(ctx.projectName, targetName); + buildDeployedStateEntry: (name, id, d) => ({ type: 'memory', name, id, arn: d.memoryArn }), +}; - const pipelineResult = await executeCdkImportPipeline({ - projectRoot: ctx.projectRoot, - stackName, - target, - configIO: ctx.configIO, - targetName, - onProgress, - buildResourcesToImport: synthTemplate => { - let logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', localName); - - // CDK prefixes memory names with the project name - if (!logicalId) { - const prefixedName = `${ctx.projectName}_${localName}`; - logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', prefixedName); - } - - if (!logicalId) { - const memoryLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Memory'); - if (memoryLogicalIds.length === 1) { - logicalId = memoryLogicalIds[0]; - } - } - - if (!logicalId) { - return []; - } - - return [ - { - resourceType: 'AWS::BedrockAgentCore::Memory', - logicalResourceId: logicalId, - resourceIdentifier: { MemoryId: memoryId }, - }, - ]; - }, - deployedStateEntries: [ - { - type: 'memory', - name: localName, - id: memoryId, - arn: memoryDetail.memoryArn, - }, - ], - }); - - if (pipelineResult.noResources) { - const error = `Could not find logical ID for memory "${localName}" in CloudFormation template`; - await rollback(); - return failResult(logger, error, 'memory', localName); - } - - if (!pipelineResult.success) { - await rollback(); - logger.endStep('error', pipelineResult.error); - logger.finalize(false); - return { - success: false, - error: pipelineResult.error, - resourceType: 'memory', - resourceName: localName, - logPath: logger.getRelativeLogPath(), - }; - } - logger.endStep('success'); - - logger.finalize(true); - return { - success: true, - resourceType: 'memory', - resourceName: localName, - resourceId: memoryId, - logPath: logger.getRelativeLogPath(), - }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - await rollback(); - if (importCtx) { - importCtx.logger.log(message, 'error'); - importCtx.logger.finalize(false); - } - return { - success: false, - error: message, - resourceType: 'memory', - resourceName: options.name ?? '', - logPath: importCtx?.logger.getRelativeLogPath(), - }; - } +/** + * Handle `agentcore import memory`. + */ +export async function handleImportMemory(options: ImportResourceOptions): Promise { + return executeResourceImport(memoryDescriptor, options); } /** @@ -297,12 +116,12 @@ export function registerImportMemory(importCmd: Command): void { if (result.success) { console.log(''); - console.log(`${green}Memory imported successfully!${reset}`); + console.log(`${ANSI.green}Memory imported successfully!${ANSI.reset}`); console.log(` Name: ${result.resourceName}`); console.log(` ID: ${result.resourceId}`); console.log(''); } else { - console.error(`\n\x1b[31m[error]${reset} ${result.error}`); + console.error(`\n\x1b[31m[error]${ANSI.reset} ${result.error}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts index 9f8f3e899..1306dcdc6 100644 --- a/src/cli/commands/import/import-online-eval.ts +++ b/src/cli/commands/import/import-online-eval.ts @@ -1,16 +1,12 @@ import type { AgentCoreProjectSpec, OnlineEvalConfig } from '../../../schema'; -import type { GetOnlineEvalConfigResult } from '../../aws/agentcore-control'; +import type { GetOnlineEvalConfigResult, OnlineEvalConfigSummary } from '../../aws/agentcore-control'; import { getOnlineEvaluationConfig, listAllOnlineEvaluationConfigs } from '../../aws/agentcore-control'; -import { executeCdkImportPipeline } from './import-pipeline'; -import { failResult, findResourceInDeployedState, resolveImportContext, toStackName } from './import-utils'; -import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; -import type { ImportResourceOptions, ImportResourceResult } from './types'; +import { ANSI } from './constants'; +import { failResult } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; import type { Command } from '@commander-js/extra-typings'; -const green = '\x1b[32m'; -const dim = '\x1b[2m'; -const reset = '\x1b[0m'; - const ARN_PREFIX = 'arn:'; /** @@ -27,11 +23,6 @@ export function extractAgentName(serviceNames: string[]): string | undefined { /** * Map an AWS GetOnlineEvaluationConfig response to the CLI OnlineEvalConfig spec format. - * - * @param detail - The AWS online eval config details - * @param localName - The local name for this config in agentcore.json - * @param agentName - The resolved local agent name - * @param evaluatorNames - Mapping from evaluator ID to local evaluator name (or ARN fallback) */ export function toOnlineEvalConfigSpec( detail: GetOnlineEvalConfigResult, @@ -80,270 +71,101 @@ function resolveEvaluatorReferences( } /** - * Handle `agentcore import online-eval`. + * Create an online-eval descriptor with closed-over state for reference resolution. */ -export async function handleImportOnlineEval(options: ImportResourceOptions): Promise { - // Rollback state - let configSnapshot: AgentCoreProjectSpec | undefined; - let configWritten = false; +function createOnlineEvalDescriptor(): ResourceImportDescriptor { + let resolvedAgentName = ''; + let resolvedEvaluatorNames: string[] = []; - let importCtx: Awaited> | undefined; - - const rollback = async () => { - if (configWritten && configSnapshot && importCtx) { - try { - await importCtx.ctx.configIO.writeProjectSpec(configSnapshot); - } catch (err) { - console.warn(`Warning: Could not restore agentcore.json: ${err instanceof Error ? err.message : String(err)}`); + return { + resourceType: 'online-eval', + displayName: 'online eval config', + logCommand: 'import-online-eval', + + listResources: region => listAllOnlineEvaluationConfigs({ region }), + getDetail: (region, id) => getOnlineEvaluationConfig({ region, configId: id }), + parseResourceId: arn => { + const match = /\/([^/]+)$/.exec(arn); + if (!match) { + throw new Error(`Could not parse config ID from ARN: ${arn}`); } - } - }; + return match[1]!; + }, + + extractSummaryId: s => s.onlineEvaluationConfigId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.onlineEvaluationConfigName} — ${s.status} (${s.executionStatus})\n ${ANSI.dim}${s.onlineEvaluationConfigArn}${ANSI.reset}`, + formatAutoSelectMessage: s => + `Found 1 config: ${s.onlineEvaluationConfigName} (${s.onlineEvaluationConfigId}). Auto-selecting.`, + + extractDetailName: d => d.configName, + extractDetailArn: d => d.configArn, + readyStatus: 'ACTIVE', + extractDetailStatus: d => d.status, + + getExistingNames: spec => (spec.onlineEvalConfigs ?? []).map(c => c.name), + addToProjectSpec: (detail, localName, spec) => { + (spec.onlineEvalConfigs ??= []).push( + toOnlineEvalConfigSpec(detail, localName, resolvedAgentName, resolvedEvaluatorNames) + ); + }, - try { - // 1-2. Validate project context and resolve target - importCtx = await resolveImportContext(options, 'import-online-eval'); - const { ctx, target, logger, onProgress } = importCtx; + cfnResourceType: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + cfnNameProperty: 'OnlineEvaluationConfigName', + cfnIdentifierKey: 'OnlineEvaluationConfigId', - // 3. Get online eval config details from AWS - logger.startStep('Fetch online eval config from AWS'); - let configId: string; + buildDeployedStateEntry: (name, id, d) => ({ type: 'online-eval', name, id, arn: d.configArn }), - if (options.arn) { - // Parse config ID from ARN (last segment after /) - const arnMatch = /\/([^/]+)$/.exec(options.arn); - if (!arnMatch) { - return failResult(logger, `Could not parse config ID from ARN: ${options.arn}`, 'online-eval', ''); - } - configId = arnMatch[1]!; - } else { - onProgress('Listing online eval configs in your account...'); - const configs = await listAllOnlineEvaluationConfigs({ region: target.region }); + // eslint-disable-next-line @typescript-eslint/require-await + beforeConfigWrite: async ({ detail, localName, projectSpec, target, onProgress, logger }) => { + logger.startStep('Resolve references'); - if (configs.length === 0) { - return failResult(logger, 'No online evaluation configs found in your account.', 'online-eval', ''); + // Extract agent name from service names + const agentName = extractAgentName(detail.serviceNames ?? []); + if (!agentName) { + return failResult( + logger, + 'Could not determine agent name from online eval config. The config has no data source service names.', + 'online-eval', + localName + ); } - if (configs.length === 1) { - configId = configs[0]!.onlineEvaluationConfigId; - onProgress(`Found 1 config: ${configs[0]!.onlineEvaluationConfigName} (${configId}). Auto-selecting.`); - } else { - console.log(`\nFound ${configs.length} online eval config(s):\n`); - for (let i = 0; i < configs.length; i++) { - const c = configs[i]!; - console.log( - ` ${dim}[${i + 1}]${reset} ${c.onlineEvaluationConfigName} — ${c.status} (${c.executionStatus})` - ); - console.log(` ${dim}${c.onlineEvaluationConfigArn}${reset}`); - } - console.log(''); - + // Validate agent exists in project + const agentNames = new Set((projectSpec.runtimes ?? []).map(r => r.name)); + if (!agentNames.has(agentName)) { return failResult( logger, - 'Multiple online eval configs found. Use --arn to specify which config to import.', + `Online eval config references agent "${agentName}" which is not in this project. ` + + `Import or add the agent first with \`agentcore import runtime\` or \`agentcore add agent\`.`, 'online-eval', - '' + localName ); } - } - - onProgress(`Fetching online eval config details for ${configId}...`); - const configDetail = await getOnlineEvaluationConfig({ region: target.region, configId }); - - if (configDetail.status !== 'ACTIVE') { - onProgress(`Warning: Online eval config status is ${configDetail.status}, not ACTIVE`); - } - - // Derive local name - const localName = options.name ?? configDetail.configName; - const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; - if (!NAME_REGEX.test(localName)) { - return failResult( - logger, - `Invalid name "${localName}". Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).`, - 'online-eval', - localName - ); - } - onProgress(`Online eval config: ${configDetail.configName} → local name: ${localName}`); - logger.endStep('success'); - // 4. Check for duplicates - logger.startStep('Check for duplicates'); - const projectSpec = await ctx.configIO.readProjectSpec(); - const existingNames = new Set((projectSpec.onlineEvalConfigs ?? []).map(c => c.name)); - if (existingNames.has(localName)) { - return failResult( - logger, - `Online eval config "${localName}" already exists in the project. Use --name to specify a different local name.`, - 'online-eval', - localName - ); - } - const targetName = target.name ?? 'default'; - const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'online-eval', configId); - if (existingResource) { - return failResult( - logger, - `Online eval config "${configId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, - 'online-eval', - localName - ); - } - logger.endStep('success'); - - // 5. Resolve agent and evaluator references - logger.startStep('Resolve references'); - - // Extract agent name from service names - const agentName = extractAgentName(configDetail.serviceNames ?? []); - if (!agentName) { - return failResult( - logger, - 'Could not determine agent name from online eval config. The config has no data source service names.', - 'online-eval', - localName - ); - } - - // Validate agent exists in project - const agentNames = new Set((projectSpec.runtimes ?? []).map(r => r.name)); - if (!agentNames.has(agentName)) { - return failResult( - logger, - `Online eval config references agent "${agentName}" which is not in this project. ` + - `Import or add the agent first with \`agentcore import runtime\` or \`agentcore add agent\`.`, - 'online-eval', - localName - ); - } - - // Resolve evaluator IDs to local names or ARNs - const evaluatorIds = configDetail.evaluatorIds ?? []; - if (evaluatorIds.length === 0) { - return failResult( - logger, - 'Online eval config has no evaluators configured. Cannot import.', - 'online-eval', - localName - ); - } - const evaluatorNames = resolveEvaluatorReferences(evaluatorIds, projectSpec, target.region, target.account); - onProgress(`Agent: ${agentName}, Evaluators: ${evaluatorNames.join(', ')}`); - logger.endStep('success'); - - // 6. Add to project config - logger.startStep('Update project config'); - configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; - const onlineEvalSpec = toOnlineEvalConfigSpec(configDetail, localName, agentName, evaluatorNames); - (projectSpec.onlineEvalConfigs ??= []).push(onlineEvalSpec); - await ctx.configIO.writeProjectSpec(projectSpec); - configWritten = true; - onProgress(`Added online eval config "${localName}" to agentcore.json`); - logger.endStep('success'); - - // 7-10. CDK build → synth → bootstrap → phase 1 → phase 2 → update state - logger.startStep('Build and synth CDK'); - const stackName = toStackName(ctx.projectName, targetName); - - const pipelineResult = await executeCdkImportPipeline({ - projectRoot: ctx.projectRoot, - stackName, - target, - configIO: ctx.configIO, - targetName, - onProgress, - buildResourcesToImport: synthTemplate => { - // Try matching by OnlineEvaluationConfigName property - let logicalId = findLogicalIdByProperty( - synthTemplate, - 'AWS::BedrockAgentCore::OnlineEvaluationConfig', - 'OnlineEvaluationConfigName', + // Resolve evaluator IDs to local names or ARNs + const evaluatorIds = detail.evaluatorIds ?? []; + if (evaluatorIds.length === 0) { + return failResult( + logger, + 'Online eval config has no evaluators configured. Cannot import.', + 'online-eval', localName ); + } + resolvedEvaluatorNames = resolveEvaluatorReferences(evaluatorIds, projectSpec, target.region, target.account); + resolvedAgentName = agentName; + onProgress(`Agent: ${agentName}, Evaluators: ${resolvedEvaluatorNames.join(', ')}`); + logger.endStep('success'); + }, + }; +} - if (!logicalId) { - const prefixedName = `${ctx.projectName}_${localName}`; - logicalId = findLogicalIdByProperty( - synthTemplate, - 'AWS::BedrockAgentCore::OnlineEvaluationConfig', - 'OnlineEvaluationConfigName', - prefixedName - ); - } - - // Fall back to single online eval config by type - if (!logicalId) { - const configLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::OnlineEvaluationConfig'); - if (configLogicalIds.length === 1) { - logicalId = configLogicalIds[0]; - } - } - - if (!logicalId) { - return []; - } - - return [ - { - resourceType: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', - logicalResourceId: logicalId, - resourceIdentifier: { OnlineEvaluationConfigId: configId }, - }, - ]; - }, - deployedStateEntries: [ - { - type: 'online-eval', - name: localName, - id: configId, - arn: configDetail.configArn, - }, - ], - }); - - if (pipelineResult.noResources) { - const error = `Could not find logical ID for online eval config "${localName}" in CloudFormation template`; - await rollback(); - return failResult(logger, error, 'online-eval', localName); - } - - if (!pipelineResult.success) { - await rollback(); - logger.endStep('error', pipelineResult.error); - logger.finalize(false); - return { - success: false, - error: pipelineResult.error, - resourceType: 'online-eval', - resourceName: localName, - logPath: logger.getRelativeLogPath(), - }; - } - logger.endStep('success'); - - logger.finalize(true); - return { - success: true, - resourceType: 'online-eval', - resourceName: localName, - resourceId: configId, - logPath: logger.getRelativeLogPath(), - }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - await rollback(); - if (importCtx) { - importCtx.logger.log(message, 'error'); - importCtx.logger.finalize(false); - } - return { - success: false, - error: message, - resourceType: 'online-eval', - resourceName: options.name ?? '', - logPath: importCtx?.logger.getRelativeLogPath(), - }; - } +/** + * Handle `agentcore import online-eval`. + */ +export async function handleImportOnlineEval(options: ImportResourceOptions): Promise { + return executeResourceImport(createOnlineEvalDescriptor(), options); } /** @@ -361,12 +183,12 @@ export function registerImportOnlineEval(importCmd: Command): void { if (result.success) { console.log(''); - console.log(`${green}Online eval config imported successfully!${reset}`); + console.log(`${ANSI.green}Online eval config imported successfully!${ANSI.reset}`); console.log(` Name: ${result.resourceName}`); console.log(` ID: ${result.resourceId}`); console.log(''); } else { - console.error(`\n\x1b[31m[error]${reset} ${result.error}`); + console.error(`\n\x1b[31m[error]${ANSI.reset} ${result.error}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-runtime.ts b/src/cli/commands/import/import-runtime.ts index a363cdb2a..afb50d6f8 100644 --- a/src/cli/commands/import/import-runtime.ts +++ b/src/cli/commands/import/import-runtime.ts @@ -1,25 +1,14 @@ -import type { AgentCoreProjectSpec, AgentEnvSpec } from '../../../schema'; -import type { AgentRuntimeDetail } from '../../aws/agentcore-control'; +import type { AgentEnvSpec } from '../../../schema'; +import type { AgentRuntimeDetail, AgentRuntimeSummary } from '../../aws/agentcore-control'; import { getAgentRuntimeDetail, listAllAgentRuntimes } from '../../aws/agentcore-control'; -import { executeCdkImportPipeline } from './import-pipeline'; -import { - copyAgentSource, - failResult, - findResourceInDeployedState, - parseAndValidateArn, - resolveImportContext, - toStackName, -} from './import-utils'; -import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; -import type { ImportResourceResult, RuntimeImportOptions } from './types'; +import { ANSI } from './constants'; +import { copyAgentSource, failResult, parseAndValidateArn } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceResult, ResourceImportDescriptor, RuntimeImportOptions } from './types'; import type { Command } from '@commander-js/extra-typings'; import * as fs from 'node:fs'; import * as path from 'node:path'; -const green = '\x1b[32m'; -const dim = '\x1b[2m'; -const reset = '\x1b[0m'; - /** * Extract the actual entrypoint file from the runtime's entryPoint array. * The array may contain wrapper commands like "opentelemetry-instrument" @@ -91,278 +80,122 @@ function toAgentEnvSpec( } /** - * Handle `agentcore import runtime`. + * Create a runtime descriptor with closed-over state for entrypoint, code location, and rollback. */ -export async function handleImportRuntime(options: RuntimeImportOptions): Promise { - // Rollback state - let configSnapshot: AgentCoreProjectSpec | undefined; - let configWritten = false; +function createRuntimeDescriptor( + options: RuntimeImportOptions +): ResourceImportDescriptor { + let resolvedEntrypoint = ''; + let resolvedCodeLocation = ''; let copiedAppDir: string | undefined; - let importCtx: Awaited> | undefined; - - const rollback = async () => { - if (configWritten && configSnapshot && importCtx) { - try { - await importCtx.ctx.configIO.writeProjectSpec(configSnapshot); - } catch (err) { - console.warn(`Warning: Could not restore agentcore.json: ${err instanceof Error ? err.message : String(err)}`); - } - } - if (copiedAppDir && fs.existsSync(copiedAppDir)) { - try { - fs.rmSync(copiedAppDir, { recursive: true, force: true }); - } catch (err) { - console.warn( - `Warning: Could not clean up ${copiedAppDir}: ${err instanceof Error ? err.message : String(err)}` + return { + resourceType: 'runtime', + displayName: 'runtime', + logCommand: 'import-runtime', + + listResources: region => listAllAgentRuntimes({ region }), + getDetail: (region, id) => getAgentRuntimeDetail({ region, runtimeId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'runtime', target).resourceId, + + extractSummaryId: s => s.agentRuntimeId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.agentRuntimeName} — ${s.status}\n ${ANSI.dim}${s.agentRuntimeArn}${ANSI.reset}`, + formatAutoSelectMessage: s => `Found 1 runtime: ${s.agentRuntimeName} (${s.agentRuntimeId}). Auto-selecting.`, + + extractDetailName: d => d.agentRuntimeName, + extractDetailArn: d => d.agentRuntimeArn, + readyStatus: 'READY', + extractDetailStatus: d => d.status, + + getExistingNames: spec => spec.runtimes.map(r => r.name), + addToProjectSpec: (detail, localName, spec) => { + spec.runtimes.push(toAgentEnvSpec(detail, localName, resolvedCodeLocation, resolvedEntrypoint)); + }, + + cfnResourceType: 'AWS::BedrockAgentCore::Runtime', + cfnNameProperty: 'AgentRuntimeName', + cfnIdentifierKey: 'AgentRuntimeId', + + buildDeployedStateEntry: (name, id, d) => ({ type: 'runtime', name, id, arn: d.agentRuntimeArn }), + + beforeConfigWrite: async ({ detail, localName, ctx, onProgress, logger }) => { + // Resolve entrypoint + logger.startStep('Resolve entrypoint'); + const entrypoint = options.entrypoint ?? extractEntrypoint(detail.entryPoint); + if (!entrypoint) { + return failResult( + logger, + 'Could not determine entrypoint from runtime configuration.\n Please re-run with --entrypoint to specify it manually.', + 'runtime', + localName ); } - } - }; - - try { - // 1-2. Validate project context and resolve target - importCtx = await resolveImportContext(options, 'import-runtime'); - const { ctx, target, logger, onProgress } = importCtx; - - // 3. Get runtime details from AWS - logger.startStep('Fetch runtime from AWS'); - let runtimeId: string; - - if (options.arn) { - const parsed = parseAndValidateArn(options.arn, 'runtime', target); - runtimeId = parsed.resourceId; - } else { - // List runtimes and let user pick - onProgress('Listing runtimes in your account...'); - const runtimes = await listAllAgentRuntimes({ region: target.region }); - - if (runtimes.length === 0) { - return failResult(logger, 'No runtimes found in your account. Deploy a runtime first.', 'runtime', ''); - } - - if (runtimes.length === 1) { - runtimeId = runtimes[0]!.agentRuntimeId; - onProgress(`Found 1 runtime: ${runtimes[0]!.agentRuntimeName} (${runtimeId}). Auto-selecting.`); - } else { - console.log(`\nFound ${runtimes.length} runtime(s):\n`); - for (let i = 0; i < runtimes.length; i++) { - const r = runtimes[i]!; - console.log(` ${dim}[${i + 1}]${reset} ${r.agentRuntimeName} — ${r.status}`); - console.log(` ${dim}${r.agentRuntimeArn}${reset}`); - } - console.log(''); + onProgress(`Entrypoint: ${entrypoint}`); + logger.endStep('success'); + // Validate source path + logger.startStep('Validate source path'); + if (!options.code) { return failResult( logger, - 'Multiple runtimes found. Use --arn to specify which runtime to import.', + 'Source path is required for runtime import. Use --code to specify the agent source code directory.', 'runtime', - '' + localName ); } - } - - onProgress(`Fetching runtime details for ${runtimeId}...`); - const runtimeDetail = await getAgentRuntimeDetail({ region: target.region, runtimeId }); - - if (runtimeDetail.status !== 'READY') { - onProgress(`Warning: Runtime status is ${runtimeDetail.status}, not READY`); - } - - // Derive local name - const localName = options.name ?? runtimeDetail.agentRuntimeName; - const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; - if (!NAME_REGEX.test(localName)) { - return failResult( - logger, - `Invalid name "${localName}". Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).`, - 'runtime', - localName - ); - } - onProgress(`Runtime: ${runtimeDetail.agentRuntimeName} → local name: ${localName}`); - logger.endStep('success'); - - // 4. Resolve entrypoint - logger.startStep('Resolve entrypoint'); - const entrypoint = options.entrypoint ?? extractEntrypoint(runtimeDetail.entryPoint); - if (!entrypoint) { - return failResult( - logger, - 'Could not determine entrypoint from runtime configuration.\n Please re-run with --entrypoint to specify it manually.', - 'runtime', - localName - ); - } - onProgress(`Entrypoint: ${entrypoint}`); - logger.endStep('success'); - - // 5. Validate source path - logger.startStep('Validate source path'); - if (!options.code) { - return failResult( - logger, - 'Source path is required for runtime import. Use --code to specify the agent source code directory.', - 'runtime', - localName - ); - } - - const sourcePath = path.resolve(options.code); - if (!fs.existsSync(sourcePath)) { - return failResult(logger, `Source path does not exist: ${sourcePath}`, 'runtime', localName); - } - const entrypointPath = path.join(sourcePath, entrypoint); - if (!fs.existsSync(entrypointPath)) { - return failResult( - logger, - `Entrypoint file '${entrypoint}' not found in ${sourcePath}. Ensure --code points to the directory containing your entrypoint file.`, - 'runtime', - localName - ); - } - logger.endStep('success'); - - // 6. Check for duplicates - logger.startStep('Check for duplicates'); - const projectSpec = await ctx.configIO.readProjectSpec(); - const existingNames = new Set(projectSpec.runtimes.map(r => r.name)); - if (existingNames.has(localName)) { - return failResult( - logger, - `Runtime "${localName}" already exists in the project. Use --name to specify a different local name.`, - 'runtime', - localName - ); - } - const targetName = target.name ?? 'default'; - const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'runtime', runtimeId); - if (existingResource) { - return failResult( - logger, - `Runtime "${runtimeId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, - 'runtime', - localName - ); - } - logger.endStep('success'); - // 7. Copy source code - logger.startStep('Copy agent source'); - const codeLocation = `app/${localName}/`; - copiedAppDir = path.join(ctx.projectRoot, 'app', localName); - await copyAgentSource({ - sourcePath, - agentName: localName, - projectRoot: ctx.projectRoot, - build: runtimeDetail.build, - entrypoint, - onProgress, - }); - logger.endStep('success'); - - // 8. Add to project config - logger.startStep('Update project config'); - configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; - const agentSpec = toAgentEnvSpec(runtimeDetail, localName, codeLocation, entrypoint); - projectSpec.runtimes.push(agentSpec); - await ctx.configIO.writeProjectSpec(projectSpec); - configWritten = true; - onProgress(`Added runtime "${localName}" to agentcore.json`); - logger.endStep('success'); - - // 9-13. CDK build → synth → bootstrap → phase 1 → phase 2 → update state - logger.startStep('Build and synth CDK'); - const stackName = toStackName(ctx.projectName, targetName); - - const pipelineResult = await executeCdkImportPipeline({ - projectRoot: ctx.projectRoot, - stackName, - target, - configIO: ctx.configIO, - targetName, - onProgress, - buildResourcesToImport: synthTemplate => { - const expectedRuntimeName = `${ctx.projectName}_${localName}`; - let logicalId = findLogicalIdByProperty( - synthTemplate, - 'AWS::BedrockAgentCore::Runtime', - 'AgentRuntimeName', - expectedRuntimeName + const sourcePath = path.resolve(options.code); + if (!fs.existsSync(sourcePath)) { + return failResult(logger, `Source path does not exist: ${sourcePath}`, 'runtime', localName); + } + const entrypointPath = path.join(sourcePath, entrypoint); + if (!fs.existsSync(entrypointPath)) { + return failResult( + logger, + `Entrypoint file '${entrypoint}' not found in ${sourcePath}. Ensure --code points to the directory containing your entrypoint file.`, + 'runtime', + localName ); - - if (!logicalId) { - const runtimeLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Runtime'); - if (runtimeLogicalIds.length === 1) { - logicalId = runtimeLogicalIds[0]; - } - } - - if (!logicalId) { - return []; + } + logger.endStep('success'); + + // Copy agent source + logger.startStep('Copy agent source'); + resolvedCodeLocation = `app/${localName}/`; + resolvedEntrypoint = entrypoint; + copiedAppDir = path.join(ctx.projectRoot, 'app', localName); + await copyAgentSource({ + sourcePath, + agentName: localName, + projectRoot: ctx.projectRoot, + build: detail.build, + entrypoint, + onProgress, + }); + logger.endStep('success'); + }, + + // eslint-disable-next-line @typescript-eslint/require-await + rollbackExtra: async () => { + if (copiedAppDir && fs.existsSync(copiedAppDir)) { + try { + fs.rmSync(copiedAppDir, { recursive: true, force: true }); + } catch (err) { + console.warn( + `Warning: Could not clean up ${copiedAppDir}: ${err instanceof Error ? err.message : String(err)}` + ); } + } + }, + }; +} - return [ - { - resourceType: 'AWS::BedrockAgentCore::Runtime', - logicalResourceId: logicalId, - resourceIdentifier: { AgentRuntimeId: runtimeId }, - }, - ]; - }, - deployedStateEntries: [ - { - type: 'runtime', - name: localName, - id: runtimeId, - arn: runtimeDetail.agentRuntimeArn, - }, - ], - }); - - if (pipelineResult.noResources) { - const error = `Could not find logical ID for runtime "${localName}" in CloudFormation template`; - await rollback(); - return failResult(logger, error, 'runtime', localName); - } - - if (!pipelineResult.success) { - await rollback(); - logger.endStep('error', pipelineResult.error); - logger.finalize(false); - return { - success: false, - error: pipelineResult.error, - resourceType: 'runtime', - resourceName: localName, - logPath: logger.getRelativeLogPath(), - }; - } - logger.endStep('success'); - - logger.finalize(true); - return { - success: true, - resourceType: 'runtime', - resourceName: localName, - resourceId: runtimeId, - logPath: logger.getRelativeLogPath(), - }; - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - await rollback(); - if (importCtx) { - importCtx.logger.log(message, 'error'); - importCtx.logger.finalize(false); - } - return { - success: false, - error: message, - resourceType: 'runtime', - resourceName: options.name ?? '', - logPath: importCtx?.logger.getRelativeLogPath(), - }; - } +/** + * Handle `agentcore import runtime`. + */ +export async function handleImportRuntime(options: RuntimeImportOptions): Promise { + return executeResourceImport(createRuntimeDescriptor(options), options); } /** @@ -382,17 +215,17 @@ export function registerImportRuntime(importCmd: Command): void { if (result.success) { console.log(''); - console.log(`${green}Runtime imported successfully!${reset}`); + console.log(`${ANSI.green}Runtime imported successfully!${ANSI.reset}`); console.log(` Name: ${result.resourceName}`); console.log(` ID: ${result.resourceId}`); console.log(''); - console.log(`${dim}Next steps:${reset}`); - console.log(` agentcore deploy ${dim}Deploy the imported stack${reset}`); - console.log(` agentcore status ${dim}Verify resource status${reset}`); - console.log(` agentcore invoke ${dim}Test your agent${reset}`); + console.log(`${ANSI.dim}Next steps:${ANSI.reset}`); + console.log(` agentcore deploy ${ANSI.dim}Deploy the imported stack${ANSI.reset}`); + console.log(` agentcore status ${ANSI.dim}Verify resource status${ANSI.reset}`); + console.log(` agentcore invoke ${ANSI.dim}Test your agent${ANSI.reset}`); console.log(''); } else { - console.error(`\n\x1b[31m[error]${reset} ${result.error}`); + console.error(`\n\x1b[31m[error]${ANSI.reset} ${result.error}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index c4383fd48..d3b20df68 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -4,6 +4,7 @@ import { detectAccount, validateAwsCredentials } from '../../aws/account'; import { ExecLogger } from '../../logging'; import { setupPythonProject } from '../../operations/python/setup'; import { getTemplatePath } from '../../templates/templateRoot'; +import { ANSI } from './constants'; import type { ImportResourceOptions, ImportResourceResult, ImportableResourceType } from './types'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -12,8 +13,7 @@ import * as path from 'node:path'; // Import Context (shared setup for import-runtime / import-memory) // ============================================================================ -const green = '\x1b[32m'; -const reset = '\x1b[0m'; +const { green, reset } = ANSI; export interface ImportContext { ctx: ProjectContext; diff --git a/src/cli/commands/import/resource-import.ts b/src/cli/commands/import/resource-import.ts new file mode 100644 index 000000000..6418e3676 --- /dev/null +++ b/src/cli/commands/import/resource-import.ts @@ -0,0 +1,247 @@ +import type { AgentCoreProjectSpec } from '../../../schema'; +import { NAME_REGEX } from './constants'; +import { executeCdkImportPipeline } from './import-pipeline'; +import { failResult, findResourceInDeployedState, resolveImportContext, toStackName } from './import-utils'; +import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; +import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; + +/** + * Generic import orchestrator. Owns the full 10-step sequence shared by all + * single-resource import commands (runtime, memory, evaluator, online-eval). + * + * Each resource type provides a descriptor declaring its specific behavior. + */ +export async function executeResourceImport( + descriptor: ResourceImportDescriptor, + options: ImportResourceOptions +): Promise { + let configSnapshot: AgentCoreProjectSpec | undefined; + let configWritten = false; + let importCtx: Awaited> | undefined; + + const rollback = async () => { + if (configWritten && configSnapshot && importCtx) { + try { + await importCtx.ctx.configIO.writeProjectSpec(configSnapshot); + } catch (err) { + console.warn(`Warning: Could not restore agentcore.json: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (descriptor.rollbackExtra) { + await descriptor.rollbackExtra(); + } + }; + + try { + // 1-2. Validate project context and resolve target + importCtx = await resolveImportContext(options, descriptor.logCommand); + const { ctx, target, logger, onProgress } = importCtx; + + // 3. Fetch resource from AWS + logger.startStep(`Fetch ${descriptor.displayName} from AWS`); + let resourceId: string; + + if (options.arn) { + resourceId = descriptor.parseResourceId(options.arn, target); + } else { + onProgress(`Listing ${descriptor.displayName}s in your account...`); + const summaries = await descriptor.listResources(target.region); + + if (summaries.length === 0) { + return failResult(logger, `No ${descriptor.displayName}s found in your account.`, descriptor.resourceType, ''); + } + + if (summaries.length === 1) { + resourceId = descriptor.extractSummaryId(summaries[0]!); + onProgress(descriptor.formatAutoSelectMessage(summaries[0]!)); + } else { + console.log(`\nFound ${summaries.length} ${descriptor.displayName}(s):\n`); + for (let i = 0; i < summaries.length; i++) { + console.log(descriptor.formatListItem(summaries[i]!, i)); + } + console.log(''); + + return failResult( + logger, + `Multiple ${descriptor.displayName}s found. Use --arn to specify which ${descriptor.displayName} to import.`, + descriptor.resourceType, + '' + ); + } + } + + onProgress(`Fetching ${descriptor.displayName} details for ${resourceId}...`); + const detail = await descriptor.getDetail(target.region, resourceId); + + if (descriptor.extractDetailStatus(detail) !== descriptor.readyStatus) { + onProgress( + `Warning: ${descriptor.displayName} status is ${descriptor.extractDetailStatus(detail)}, not ${descriptor.readyStatus}` + ); + } + + // 4. Validate name + const localName = options.name ?? descriptor.extractDetailName(detail); + if (!NAME_REGEX.test(localName)) { + return failResult( + logger, + `Invalid name "${localName}". Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).`, + descriptor.resourceType, + localName + ); + } + onProgress(`${descriptor.displayName}: ${descriptor.extractDetailName(detail)} → local name: ${localName}`); + logger.endStep('success'); + + // 5. Check for duplicates + logger.startStep('Check for duplicates'); + const projectSpec = await ctx.configIO.readProjectSpec(); + const existingNames = new Set(descriptor.getExistingNames(projectSpec)); + if (existingNames.has(localName)) { + return failResult( + logger, + `${descriptor.displayName} "${localName}" already exists in the project. Use --name to specify a different local name.`, + descriptor.resourceType, + localName + ); + } + const targetName = target.name ?? 'default'; + const existingResource = await findResourceInDeployedState( + ctx.configIO, + targetName, + descriptor.resourceType, + resourceId + ); + if (existingResource) { + return failResult( + logger, + `${descriptor.displayName} "${resourceId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, + descriptor.resourceType, + localName + ); + } + logger.endStep('success'); + + // 6. Optional pre-write hook + if (descriptor.beforeConfigWrite) { + const hookResult = await descriptor.beforeConfigWrite({ + detail, + localName, + projectSpec, + ctx, + target, + options, + onProgress, + logger, + }); + if (hookResult) { + return hookResult; + } + } + + // 7. Update project config + logger.startStep('Update project config'); + configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; + descriptor.addToProjectSpec(detail, localName, projectSpec); + await ctx.configIO.writeProjectSpec(projectSpec); + configWritten = true; + onProgress(`Added ${descriptor.displayName} "${localName}" to agentcore.json`); + logger.endStep('success'); + + // 8. CDK build → synth → bootstrap → phase 1 → phase 2 → update state + logger.startStep('Build and synth CDK'); + const stackName = toStackName(ctx.projectName, targetName); + + const pipelineResult = await executeCdkImportPipeline({ + projectRoot: ctx.projectRoot, + stackName, + target, + configIO: ctx.configIO, + targetName, + onProgress, + buildResourcesToImport: synthTemplate => { + // Try matching by name property (plain name first, then prefixed) + let logicalId = findLogicalIdByProperty( + synthTemplate, + descriptor.cfnResourceType, + descriptor.cfnNameProperty, + localName + ); + + if (!logicalId) { + const prefixedName = `${ctx.projectName}_${localName}`; + logicalId = findLogicalIdByProperty( + synthTemplate, + descriptor.cfnResourceType, + descriptor.cfnNameProperty, + prefixedName + ); + } + + // Fall back to single resource by type + if (!logicalId) { + const allLogicalIds = findLogicalIdsByType(synthTemplate, descriptor.cfnResourceType); + if (allLogicalIds.length === 1) { + logicalId = allLogicalIds[0]; + } + } + + if (!logicalId) { + return []; + } + + return [ + { + resourceType: descriptor.cfnResourceType, + logicalResourceId: logicalId, + resourceIdentifier: { [descriptor.cfnIdentifierKey]: resourceId }, + }, + ]; + }, + deployedStateEntries: [descriptor.buildDeployedStateEntry(localName, resourceId, detail)], + }); + + if (pipelineResult.noResources) { + const error = `Could not find logical ID for ${descriptor.displayName} "${localName}" in CloudFormation template`; + await rollback(); + return failResult(logger, error, descriptor.resourceType, localName); + } + + if (!pipelineResult.success) { + await rollback(); + logger.endStep('error', pipelineResult.error); + logger.finalize(false); + return { + success: false, + error: pipelineResult.error, + resourceType: descriptor.resourceType, + resourceName: localName, + logPath: logger.getRelativeLogPath(), + }; + } + logger.endStep('success'); + + // 9. Return success + logger.finalize(true); + return { + success: true, + resourceType: descriptor.resourceType, + resourceName: localName, + resourceId, + logPath: logger.getRelativeLogPath(), + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + await rollback(); + if (importCtx) { + importCtx.logger.log(message, 'error'); + importCtx.logger.finalize(false); + } + return { + success: false, + error: message, + resourceType: descriptor.resourceType, + resourceName: options.name ?? '', + logPath: importCtx?.logger.getRelativeLogPath(), + }; + } +} diff --git a/src/cli/commands/import/types.ts b/src/cli/commands/import/types.ts index e1aca038f..3161a0e86 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -1,4 +1,11 @@ -import type { AgentCoreProjectSpec, AuthorizerConfig, RuntimeAuthorizerType } from '../../../schema'; +import type { + AgentCoreProjectSpec, + AuthorizerConfig, + AwsDeploymentTarget, + RuntimeAuthorizerType, +} from '../../../schema'; +import type { ExecLogger } from '../../logging'; +import type { ImportedResource, ProjectContext } from './import-utils'; /** * Parsed representation of a starter toolkit agent from .bedrock_agentcore.yaml. @@ -120,3 +127,109 @@ export interface RuntimeImportOptions extends ImportResourceOptions { code?: string; entrypoint?: string; } + +// ============================================================================ +// Generic Resource Import Descriptor +// ============================================================================ + +/** + * Context passed to the beforeConfigWrite hook. + */ +export interface BeforeWriteContext { + detail: TDetail; + localName: string; + projectSpec: AgentCoreProjectSpec; + ctx: ProjectContext; + target: AwsDeploymentTarget; + options: ImportResourceOptions; + onProgress: (msg: string) => void; + logger: ExecLogger; +} + +/** + * Descriptor that defines resource-type-specific behavior for the generic import orchestrator. + * + * TDetail: The AWS "get" API response type (e.g., GetEvaluatorResult) + * TSummary: The AWS "list" API response item type (e.g., EvaluatorSummary) + */ +export interface ResourceImportDescriptor { + /** The importable resource type identifier. */ + resourceType: ImportableResourceType; + + /** Human-readable resource type name for log messages (e.g., "evaluator"). */ + displayName: string; + + /** Logger command name (e.g., 'import-evaluator'). */ + logCommand: string; + + // ---- AWS API ---- + + /** List all resources of this type in the region. */ + listResources: (region: string) => Promise; + + /** Get full details for a single resource by ID. */ + getDetail: (region: string, resourceId: string) => Promise; + + /** Extract the resource ID from an ARN. */ + parseResourceId: (arn: string, target: { region: string; account: string }) => string; + + // ---- List display ---- + + /** Extract ID from a summary item. */ + extractSummaryId: (summary: TSummary) => string; + + /** Format a summary item for console display in multi-result listing. */ + formatListItem: (summary: TSummary, index: number) => string; + + /** Format the auto-select message when exactly 1 result is found. */ + formatAutoSelectMessage: (summary: TSummary) => string; + + // ---- Detail inspection ---- + + /** Extract the canonical name from the detail response. */ + extractDetailName: (detail: TDetail) => string; + + /** Extract the ARN from the detail response. */ + extractDetailArn: (detail: TDetail) => string; + + /** The expected "ready" status value (e.g., 'READY' for runtime, 'ACTIVE' for others). */ + readyStatus: string; + + /** Extract the current status from the detail response. */ + extractDetailStatus: (detail: TDetail) => string; + + // ---- Config ---- + + /** Get the array of existing resource names from the project spec. */ + getExistingNames: (projectSpec: AgentCoreProjectSpec) => string[]; + + /** Convert the AWS detail to local spec and add it to the project spec. */ + addToProjectSpec: (detail: TDetail, localName: string, projectSpec: AgentCoreProjectSpec) => void; + + // ---- CFN template matching ---- + + /** CloudFormation resource type string. */ + cfnResourceType: string; + + /** CFN property name used for name-based lookup. */ + cfnNameProperty: string; + + /** CFN resource identifier key for the import. */ + cfnIdentifierKey: string; + + // ---- Deployed state ---- + + /** Build the deployed-state entry for this resource. */ + buildDeployedStateEntry: (localName: string, resourceId: string, detail: TDetail) => ImportedResource; + + // ---- Optional hooks ---- + + /** + * Called after detail fetch + name validation but before config write. + * Return an ImportResourceResult to abort, or void to continue. + */ + beforeConfigWrite?: (ctx: BeforeWriteContext) => Promise; + + /** Cleanup on rollback (e.g., runtime deletes copied app directory). */ + rollbackExtra?: () => Promise; +} From 1f1b62cb190754d744568c7d8ba36307f7544448 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Mon, 6 Apr 2026 14:41:53 +0000 Subject: [PATCH 08/18] refactor(aws): extract paginateAll and fetchTags helpers in agentcore-control Deduplicates identical pagination loops across 4 listAll* functions and identical tag-fetching try/catch blocks across 3 getDetail functions. Also adds optional client param to listEvaluators and listOnlineEvaluationConfigs for connection reuse during pagination. Addresses deferred review feedback from PR #763. Constraint: evaluator listAll still filters out Builtin.* entries Confidence: high Scope-risk: narrow --- src/cli/aws/agentcore-control.ts | 161 +++++++++++++++---------------- 1 file changed, 79 insertions(+), 82 deletions(-) diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index 04c80a094..15ae73054 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -25,6 +25,53 @@ export function createControlClient(region: string): BedrockAgentCoreControlClie }); } +/** + * Paginate through all pages of a list API and collect every item. + * Reuses a single client for connection pooling across pages. + */ +async function paginateAll( + region: string, + fetchPage: ( + options: { region: string; maxResults: number; nextToken?: string }, + client: BedrockAgentCoreControlClient + ) => Promise<{ items: T[]; nextToken?: string }> +): Promise { + const client = createControlClient(region); + const items: T[] = []; + let nextToken: string | undefined; + + do { + const result = await fetchPage({ region, maxResults: 100, nextToken }, client); + items.push(...result.items); + nextToken = result.nextToken; + } while (nextToken); + + return items; +} + +/** + * Fetch tags for a resource by ARN. Returns undefined when the ARN is missing, + * the resource has no tags, or the ListTagsForResource call fails. + */ +async function fetchTags( + client: BedrockAgentCoreControlClient, + resourceArn: string | undefined, + resourceLabel: string +): Promise | undefined> { + if (!resourceArn) return undefined; + try { + const response = await client.send(new ListTagsForResourceCommand({ resourceArn })); + if (response.tags && Object.keys(response.tags).length > 0) { + return response.tags; + } + } catch (err) { + console.warn( + `Warning: Failed to fetch tags for ${resourceLabel}: ${err instanceof Error ? err.message : String(err)}` + ); + } + return undefined; +} + export interface GetAgentRuntimeStatusOptions { region: string; runtimeId: string; @@ -114,17 +161,10 @@ export async function listAgentRuntimes( * List all AgentCore Runtimes in the given region, paginating through all pages. */ export async function listAllAgentRuntimes(options: { region: string }): Promise { - const client = createControlClient(options.region); - const runtimes: AgentRuntimeSummary[] = []; - let nextToken: string | undefined; - - do { - const result = await listAgentRuntimes({ region: options.region, maxResults: 100, nextToken }, client); - runtimes.push(...result.runtimes); - nextToken = result.nextToken; - } while (nextToken); - - return runtimes; + return paginateAll(options.region, async (opts, client) => { + const result = await listAgentRuntimes(opts, client); + return { items: result.runtimes, nextToken: result.nextToken }; + }); } export interface GetAgentRuntimeOptions { @@ -222,18 +262,7 @@ export async function getAgentRuntimeDetail(options: GetAgentRuntimeOptions): Pr } } - // Fetch tags via separate API call (same pattern as getMemoryDetail) - let tags: Record | undefined; - if (response.agentRuntimeArn) { - try { - const tagsResponse = await client.send(new ListTagsForResourceCommand({ resourceArn: response.agentRuntimeArn })); - if (tagsResponse.tags && Object.keys(tagsResponse.tags).length > 0) { - tags = tagsResponse.tags; - } - } catch (err) { - console.warn(`Warning: Failed to fetch tags for runtime: ${err instanceof Error ? err.message : String(err)}`); - } - } + const tags = await fetchTags(client, response.agentRuntimeArn, 'runtime'); return { agentRuntimeId: response.agentRuntimeId ?? '', @@ -312,17 +341,10 @@ export async function listMemories( * List all AgentCore Memories in the given region, paginating through all pages. */ export async function listAllMemories(options: { region: string }): Promise { - const client = createControlClient(options.region); - const memories: MemorySummary[] = []; - let nextToken: string | undefined; - - do { - const result = await listMemories({ region: options.region, maxResults: 100, nextToken }, client); - memories.push(...result.memories); - nextToken = result.nextToken; - } while (nextToken); - - return memories; + return paginateAll(options.region, async (opts, client) => { + const result = await listMemories(opts, client); + return { items: result.memories, nextToken: result.nextToken }; + }); } export interface GetMemoryOptions { @@ -379,16 +401,7 @@ export async function getMemoryDetail(options: GetMemoryOptions): Promise | undefined; - try { - const tagsResponse = await client.send(new ListTagsForResourceCommand({ resourceArn: memory.arn })); - if (tagsResponse.tags && Object.keys(tagsResponse.tags).length > 0) { - tags = tagsResponse.tags; - } - } catch (err) { - console.warn(`Warning: Failed to fetch tags for memory: ${err instanceof Error ? err.message : String(err)}`); - } + const tags = await fetchTags(client, memory.arn, 'memory'); return { memoryId: memory.id, @@ -508,18 +521,7 @@ export async function getEvaluator(options: GetEvaluatorOptions): Promise | undefined; - if (response.evaluatorArn) { - try { - const tagsResponse = await client.send(new ListTagsForResourceCommand({ resourceArn: response.evaluatorArn })); - if (tagsResponse.tags && Object.keys(tagsResponse.tags).length > 0) { - tags = tagsResponse.tags; - } - } catch { - // Tags are optional — continue without them - } - } + const tags = await fetchTags(client, response.evaluatorArn, 'evaluator'); return { evaluatorId: response.evaluatorId, @@ -554,15 +556,18 @@ export interface ListEvaluatorsResult { nextToken?: string; } -export async function listEvaluators(options: ListEvaluatorsOptions): Promise { - const client = createControlClient(options.region); +export async function listEvaluators( + options: ListEvaluatorsOptions, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); const command = new ListEvaluatorsCommand({ maxResults: options.maxResults, nextToken: options.nextToken, }); - const response = await client.send(command); + const response = await resolvedClient.send(command); return { evaluators: (response.evaluators ?? []).map(e => ({ @@ -583,16 +588,13 @@ export async function listEvaluators(options: ListEvaluatorsOptions): Promise
  • { - const evaluators: EvaluatorSummary[] = []; - let nextToken: string | undefined; - - do { - const result = await listEvaluators({ region: options.region, maxResults: 100, nextToken }); - evaluators.push(...result.evaluators.filter(e => !e.evaluatorName.startsWith('Builtin.'))); - nextToken = result.nextToken; - } while (nextToken); - - return evaluators; + return paginateAll(options.region, async (opts, client) => { + const result = await listEvaluators(opts, client); + return { + items: result.evaluators.filter(e => !e.evaluatorName.startsWith('Builtin.')), + nextToken: result.nextToken, + }; + }); } // ============================================================================ @@ -620,16 +622,17 @@ export interface ListOnlineEvalConfigsResult { } export async function listOnlineEvaluationConfigs( - options: ListOnlineEvalConfigsOptions + options: ListOnlineEvalConfigsOptions, + client?: BedrockAgentCoreControlClient ): Promise { - const client = createControlClient(options.region); + const resolvedClient = client ?? createControlClient(options.region); const command = new ListOnlineEvaluationConfigsCommand({ maxResults: options.maxResults, nextToken: options.nextToken, }); - const response = await client.send(command); + const response = await resolvedClient.send(command); return { configs: (response.onlineEvaluationConfigs ?? []).map(c => ({ @@ -648,16 +651,10 @@ export async function listOnlineEvaluationConfigs( * List all online evaluation configs in the given region, paginating through all pages. */ export async function listAllOnlineEvaluationConfigs(options: { region: string }): Promise { - const configs: OnlineEvalConfigSummary[] = []; - let nextToken: string | undefined; - - do { - const result = await listOnlineEvaluationConfigs({ region: options.region, maxResults: 100, nextToken }); - configs.push(...result.configs); - nextToken = result.nextToken; - } while (nextToken); - - return configs; + return paginateAll(options.region, async (opts, client) => { + const result = await listOnlineEvaluationConfigs(opts, client); + return { items: result.configs, nextToken: result.nextToken }; + }); } // ============================================================================ From b2b3630b8932f2e40f17000c1af93e909204d7ac Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Mon, 6 Apr 2026 15:01:30 +0000 Subject: [PATCH 09/18] fix(import): resolve evaluator references via deployed state for imported evaluators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveEvaluatorReferences used string-contains matching (evaluatorId.includes(localName)) which only works when the evaluator was deployed by the same project. Imported evaluators with renamed local names never matched, falling back to raw ARNs in the config. Now reads deployed-state.json to build an evaluatorId → localName reverse map and checks it first, before the string-contains heuristic. Constraint: Deployed state may not exist yet (first import) — .catch() handles gracefully Rejected: Passing deployed state through descriptor interface | only online-eval needs this Confidence: high Scope-risk: narrow --- src/cli/commands/import/import-online-eval.ts | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts index 1306dcdc6..374f71d34 100644 --- a/src/cli/commands/import/import-online-eval.ts +++ b/src/cli/commands/import/import-online-eval.ts @@ -1,4 +1,4 @@ -import type { AgentCoreProjectSpec, OnlineEvalConfig } from '../../../schema'; +import type { AgentCoreProjectSpec, DeployedState, OnlineEvalConfig } from '../../../schema'; import type { GetOnlineEvalConfigResult, OnlineEvalConfigSummary } from '../../aws/agentcore-control'; import { getOnlineEvaluationConfig, listAllOnlineEvaluationConfigs } from '../../aws/agentcore-control'; import { ANSI } from './constants'; @@ -52,14 +52,20 @@ export function toOnlineEvalConfigSpec( function resolveEvaluatorReferences( evaluatorIds: string[], projectSpec: AgentCoreProjectSpec, + deployedEvaluators: Record, region: string, account: string ): string[] { const localEvaluators = projectSpec.evaluators ?? []; return evaluatorIds.map(id => { - // Check if this evaluator ID matches a local evaluator name - // The CDK creates evaluators with name: {projectName}_{evaluatorName} + // First check deployed state for an exact physical ID → local name match + // This handles imported evaluators where the local name differs from the AWS name + if (deployedEvaluators[id]) { + return deployedEvaluators[id]; + } + // Then check if the evaluator ID contains a local evaluator name + // This handles evaluators deployed by the same project (ID pattern: {projectName}_{evaluatorName}-{suffix}) for (const localEval of localEvaluators) { if (id.includes(localEval.name)) { return localEval.name; @@ -116,8 +122,7 @@ function createOnlineEvalDescriptor(): ResourceImportDescriptor ({ type: 'online-eval', name, id, arn: d.configArn }), - // eslint-disable-next-line @typescript-eslint/require-await - beforeConfigWrite: async ({ detail, localName, projectSpec, target, onProgress, logger }) => { + beforeConfigWrite: async ({ detail, localName, projectSpec, ctx, target, onProgress, logger }) => { logger.startStep('Resolve references'); // Extract agent name from service names @@ -153,7 +158,27 @@ function createOnlineEvalDescriptor(): ResourceImportDescriptor = {}; + const deployedState: DeployedState = await ctx.configIO + .readDeployedState() + .catch((): DeployedState => ({ targets: {} })); + const targetName = target.name ?? 'default'; + const evalEntries = deployedState.targets[targetName]?.resources?.evaluators; + if (evalEntries) { + for (const [localEvalName, entry] of Object.entries(evalEntries)) { + deployedEvaluators[entry.evaluatorId] = localEvalName; + } + } + + resolvedEvaluatorNames = resolveEvaluatorReferences( + evaluatorIds, + projectSpec, + deployedEvaluators, + target.region, + target.account + ); resolvedAgentName = agentName; onProgress(`Agent: ${agentName}, Evaluators: ${resolvedEvaluatorNames.join(', ')}`); logger.endStep('success'); From 583939153e336a72c6e5cd425dd02a834d73b9d0 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Mon, 6 Apr 2026 16:09:54 +0000 Subject: [PATCH 10/18] fix(import): auto-disable online eval configs to unlock evaluators during import Evaluators referenced by ENABLED online eval configs are locked by the service (lockedForModification=true), causing CFN import to fail when it tries to apply stack-level tags. Now the evaluator import detects the lock, temporarily disables referencing online eval configs, performs the import, then re-enables them. Constraint: Re-enable runs in finally block so configs are restored on both success and failure Constraint: Only disables configs that actually reference this specific evaluator Rejected: Refuse import with manual guidance | user can't pause configs not yet in project Confidence: high Scope-risk: moderate --- src/cli/aws/agentcore-control.ts | 2 + src/cli/commands/import/import-evaluator.ts | 152 ++++++++++++++++---- 2 files changed, 128 insertions(+), 26 deletions(-) diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index 15ae73054..33443427d 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -458,6 +458,7 @@ export interface GetEvaluatorResult { level: string; status: string; description?: string; + lockedForModification?: boolean; evaluatorConfig?: { llmAsAJudge?: GetEvaluatorLlmConfig; codeBased?: GetEvaluatorCodeBasedConfig; @@ -530,6 +531,7 @@ export async function getEvaluator(options: GetEvaluatorOptions): Promise = { - resourceType: 'evaluator', - displayName: 'evaluator', - logCommand: 'import-evaluator', +/** + * Create an evaluator descriptor with closed-over state for tracking + * online eval configs that were temporarily disabled to unlock the evaluator. + */ +function createEvaluatorDescriptor(): { + descriptor: ResourceImportDescriptor; + getDisabledConfigs: () => { configId: string; configName: string }[]; + getRegion: () => string | undefined; +} { + const disabledConfigs: { configId: string; configName: string }[] = []; + let resolvedRegion: string | undefined; + + const descriptor: ResourceImportDescriptor = { + resourceType: 'evaluator', + displayName: 'evaluator', + logCommand: 'import-evaluator', + + listResources: region => listAllEvaluators({ region }), + getDetail: (region, id) => getEvaluator({ region, evaluatorId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'evaluator', target).resourceId, + + extractSummaryId: s => s.evaluatorId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.evaluatorName} — ${s.status}\n ${ANSI.dim}${s.evaluatorArn}${ANSI.reset}`, + formatAutoSelectMessage: s => `Found 1 evaluator: ${s.evaluatorName} (${s.evaluatorId}). Auto-selecting.`, + + extractDetailName: d => d.evaluatorName, + extractDetailArn: d => d.evaluatorArn, + readyStatus: 'ACTIVE', + extractDetailStatus: d => d.status, - listResources: region => listAllEvaluators({ region }), - getDetail: (region, id) => getEvaluator({ region, evaluatorId: id }), - parseResourceId: (arn, target) => parseAndValidateArn(arn, 'evaluator', target).resourceId, + getExistingNames: spec => (spec.evaluators ?? []).map(e => e.name), + addToProjectSpec: (detail, localName, spec) => { + (spec.evaluators ??= []).push(toEvaluatorSpec(detail, localName)); + }, - extractSummaryId: s => s.evaluatorId, - formatListItem: (s, i) => - ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.evaluatorName} — ${s.status}\n ${ANSI.dim}${s.evaluatorArn}${ANSI.reset}`, - formatAutoSelectMessage: s => `Found 1 evaluator: ${s.evaluatorName} (${s.evaluatorId}). Auto-selecting.`, + cfnResourceType: 'AWS::BedrockAgentCore::Evaluator', + cfnNameProperty: 'EvaluatorName', + cfnIdentifierKey: 'EvaluatorId', - extractDetailName: d => d.evaluatorName, - extractDetailArn: d => d.evaluatorArn, - readyStatus: 'ACTIVE', - extractDetailStatus: d => d.status, + buildDeployedStateEntry: (name, id, d) => ({ type: 'evaluator', name, id, arn: d.evaluatorArn }), + + beforeConfigWrite: async ({ detail, target, onProgress, logger }) => { + resolvedRegion = target.region; + if (!detail.lockedForModification) return; + + logger.startStep('Unlock evaluator'); + onProgress('Evaluator is locked. Finding referencing online eval configs...'); + + const allConfigs = await listAllOnlineEvaluationConfigs({ region: target.region }); + const enabledConfigs = allConfigs.filter(c => c.executionStatus === 'ENABLED'); + + for (const config of enabledConfigs) { + const configDetail = await getOnlineEvaluationConfig({ + region: target.region, + configId: config.onlineEvaluationConfigId, + }); + if (configDetail.evaluatorIds?.includes(detail.evaluatorId)) { + onProgress(`Disabling online eval config: ${config.onlineEvaluationConfigName}`); + await updateOnlineEvalExecutionStatus({ + region: target.region, + onlineEvaluationConfigId: config.onlineEvaluationConfigId, + executionStatus: 'DISABLED', + }); + disabledConfigs.push({ + configId: config.onlineEvaluationConfigId, + configName: config.onlineEvaluationConfigName, + }); + } + } - getExistingNames: spec => (spec.evaluators ?? []).map(e => e.name), - addToProjectSpec: (detail, localName, spec) => { - (spec.evaluators ??= []).push(toEvaluatorSpec(detail, localName)); - }, + if (disabledConfigs.length > 0) { + onProgress(`Disabled ${disabledConfigs.length} online eval config(s) to unlock evaluator`); + } else { + onProgress('Evaluator is locked but no enabled online eval configs reference it'); + } + logger.endStep('success'); + }, + }; - cfnResourceType: 'AWS::BedrockAgentCore::Evaluator', - cfnNameProperty: 'EvaluatorName', - cfnIdentifierKey: 'EvaluatorId', + return { + descriptor, + getDisabledConfigs: () => [...disabledConfigs], + getRegion: () => resolvedRegion, + }; +} - buildDeployedStateEntry: (name, id, d) => ({ type: 'evaluator', name, id, arn: d.evaluatorArn }), -}; +/** + * Re-enable online eval configs that were temporarily disabled during import. + */ +async function reEnableConfigs( + configs: { configId: string; configName: string }[], + region: string, + onWarn: (msg: string) => void +): Promise { + for (const config of configs) { + try { + await updateOnlineEvalExecutionStatus({ + region, + onlineEvaluationConfigId: config.configId, + executionStatus: 'ENABLED', + }); + } catch (err) { + onWarn( + `Warning: Could not re-enable online eval config "${config.configName}" (${config.configId}): ${err instanceof Error ? err.message : String(err)}` + ); + } + } +} /** * Handle `agentcore import evaluator`. */ export async function handleImportEvaluator(options: ImportResourceOptions): Promise { - return executeResourceImport(evaluatorDescriptor, options); + const { descriptor, getDisabledConfigs, getRegion } = createEvaluatorDescriptor(); + + try { + const result = await executeResourceImport(descriptor, options); + return result; + } finally { + const disabled = getDisabledConfigs(); + const region = getRegion(); + if (disabled.length > 0 && region) { + await reEnableConfigs(disabled, region, msg => console.warn(msg)); + const names = disabled.map(c => c.configName).join(', '); + console.warn( + `\n${ANSI.yellow}Warning:${ANSI.reset} ${disabled.length} online eval config(s) were temporarily disabled to unlock this evaluator and have been re-enabled: ${names}` + ); + } + } } /** From 1cadfc7b9f9313b336eb0bbe20c175f037c9f5a8 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Mon, 6 Apr 2026 17:08:03 +0000 Subject: [PATCH 11/18] Revert "fix(import): auto-disable online eval configs to unlock evaluators during import" This reverts commit 583939153e336a72c6e5cd425dd02a834d73b9d0. --- src/cli/aws/agentcore-control.ts | 2 - src/cli/commands/import/import-evaluator.ts | 152 ++++---------------- 2 files changed, 26 insertions(+), 128 deletions(-) diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index 33443427d..15ae73054 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -458,7 +458,6 @@ export interface GetEvaluatorResult { level: string; status: string; description?: string; - lockedForModification?: boolean; evaluatorConfig?: { llmAsAJudge?: GetEvaluatorLlmConfig; codeBased?: GetEvaluatorCodeBasedConfig; @@ -531,7 +530,6 @@ export async function getEvaluator(options: GetEvaluatorOptions): Promise; - getDisabledConfigs: () => { configId: string; configName: string }[]; - getRegion: () => string | undefined; -} { - const disabledConfigs: { configId: string; configName: string }[] = []; - let resolvedRegion: string | undefined; - - const descriptor: ResourceImportDescriptor = { - resourceType: 'evaluator', - displayName: 'evaluator', - logCommand: 'import-evaluator', - - listResources: region => listAllEvaluators({ region }), - getDetail: (region, id) => getEvaluator({ region, evaluatorId: id }), - parseResourceId: (arn, target) => parseAndValidateArn(arn, 'evaluator', target).resourceId, - - extractSummaryId: s => s.evaluatorId, - formatListItem: (s, i) => - ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.evaluatorName} — ${s.status}\n ${ANSI.dim}${s.evaluatorArn}${ANSI.reset}`, - formatAutoSelectMessage: s => `Found 1 evaluator: ${s.evaluatorName} (${s.evaluatorId}). Auto-selecting.`, - - extractDetailName: d => d.evaluatorName, - extractDetailArn: d => d.evaluatorArn, - readyStatus: 'ACTIVE', - extractDetailStatus: d => d.status, +const evaluatorDescriptor: ResourceImportDescriptor = { + resourceType: 'evaluator', + displayName: 'evaluator', + logCommand: 'import-evaluator', - getExistingNames: spec => (spec.evaluators ?? []).map(e => e.name), - addToProjectSpec: (detail, localName, spec) => { - (spec.evaluators ??= []).push(toEvaluatorSpec(detail, localName)); - }, + listResources: region => listAllEvaluators({ region }), + getDetail: (region, id) => getEvaluator({ region, evaluatorId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'evaluator', target).resourceId, - cfnResourceType: 'AWS::BedrockAgentCore::Evaluator', - cfnNameProperty: 'EvaluatorName', - cfnIdentifierKey: 'EvaluatorId', + extractSummaryId: s => s.evaluatorId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.evaluatorName} — ${s.status}\n ${ANSI.dim}${s.evaluatorArn}${ANSI.reset}`, + formatAutoSelectMessage: s => `Found 1 evaluator: ${s.evaluatorName} (${s.evaluatorId}). Auto-selecting.`, - buildDeployedStateEntry: (name, id, d) => ({ type: 'evaluator', name, id, arn: d.evaluatorArn }), - - beforeConfigWrite: async ({ detail, target, onProgress, logger }) => { - resolvedRegion = target.region; - if (!detail.lockedForModification) return; - - logger.startStep('Unlock evaluator'); - onProgress('Evaluator is locked. Finding referencing online eval configs...'); - - const allConfigs = await listAllOnlineEvaluationConfigs({ region: target.region }); - const enabledConfigs = allConfigs.filter(c => c.executionStatus === 'ENABLED'); - - for (const config of enabledConfigs) { - const configDetail = await getOnlineEvaluationConfig({ - region: target.region, - configId: config.onlineEvaluationConfigId, - }); - if (configDetail.evaluatorIds?.includes(detail.evaluatorId)) { - onProgress(`Disabling online eval config: ${config.onlineEvaluationConfigName}`); - await updateOnlineEvalExecutionStatus({ - region: target.region, - onlineEvaluationConfigId: config.onlineEvaluationConfigId, - executionStatus: 'DISABLED', - }); - disabledConfigs.push({ - configId: config.onlineEvaluationConfigId, - configName: config.onlineEvaluationConfigName, - }); - } - } + extractDetailName: d => d.evaluatorName, + extractDetailArn: d => d.evaluatorArn, + readyStatus: 'ACTIVE', + extractDetailStatus: d => d.status, - if (disabledConfigs.length > 0) { - onProgress(`Disabled ${disabledConfigs.length} online eval config(s) to unlock evaluator`); - } else { - onProgress('Evaluator is locked but no enabled online eval configs reference it'); - } - logger.endStep('success'); - }, - }; + getExistingNames: spec => (spec.evaluators ?? []).map(e => e.name), + addToProjectSpec: (detail, localName, spec) => { + (spec.evaluators ??= []).push(toEvaluatorSpec(detail, localName)); + }, - return { - descriptor, - getDisabledConfigs: () => [...disabledConfigs], - getRegion: () => resolvedRegion, - }; -} + cfnResourceType: 'AWS::BedrockAgentCore::Evaluator', + cfnNameProperty: 'EvaluatorName', + cfnIdentifierKey: 'EvaluatorId', -/** - * Re-enable online eval configs that were temporarily disabled during import. - */ -async function reEnableConfigs( - configs: { configId: string; configName: string }[], - region: string, - onWarn: (msg: string) => void -): Promise { - for (const config of configs) { - try { - await updateOnlineEvalExecutionStatus({ - region, - onlineEvaluationConfigId: config.configId, - executionStatus: 'ENABLED', - }); - } catch (err) { - onWarn( - `Warning: Could not re-enable online eval config "${config.configName}" (${config.configId}): ${err instanceof Error ? err.message : String(err)}` - ); - } - } -} + buildDeployedStateEntry: (name, id, d) => ({ type: 'evaluator', name, id, arn: d.evaluatorArn }), +}; /** * Handle `agentcore import evaluator`. */ export async function handleImportEvaluator(options: ImportResourceOptions): Promise { - const { descriptor, getDisabledConfigs, getRegion } = createEvaluatorDescriptor(); - - try { - const result = await executeResourceImport(descriptor, options); - return result; - } finally { - const disabled = getDisabledConfigs(); - const region = getRegion(); - if (disabled.length > 0 && region) { - await reEnableConfigs(disabled, region, msg => console.warn(msg)); - const names = disabled.map(c => c.configName).join(', '); - console.warn( - `\n${ANSI.yellow}Warning:${ANSI.reset} ${disabled.length} online eval config(s) were temporarily disabled to unlock this evaluator and have been re-enabled: ${names}` - ); - } - } + return executeResourceImport(evaluatorDescriptor, options); } /** From 5bed8edf77156c43227e8758163a9c5c01ffb9a1 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Mon, 6 Apr 2026 18:49:38 +0000 Subject: [PATCH 12/18] fix(import): block evaluator import when referenced by online eval, use ARN-only references Evaluators locked by an online eval config cannot be CFN-imported because CloudFormation triggers a post-import TagResource call that the resource handler rejects. Instead of stripping tags from the import template, block the import with a clear error and suggestion to use import online-eval. Online eval config import now always references evaluators by ARN rather than resolving to local names, since the evaluators cannot be imported into the project alongside the config. Constraint: CFN IMPORT triggers TagResource which fails on locked evaluators Rejected: Strip Tags from import template | still fails on some resource types Confidence: high Scope-risk: narrow --- src/cli/commands/import/import-evaluator.ts | 41 ++++++++++- src/cli/commands/import/import-online-eval.ts | 73 ++++--------------- 2 files changed, 54 insertions(+), 60 deletions(-) diff --git a/src/cli/commands/import/import-evaluator.ts b/src/cli/commands/import/import-evaluator.ts index 14162aacf..2161b0124 100644 --- a/src/cli/commands/import/import-evaluator.ts +++ b/src/cli/commands/import/import-evaluator.ts @@ -1,8 +1,13 @@ import type { Evaluator } from '../../../schema'; import type { EvaluatorSummary, GetEvaluatorResult } from '../../aws/agentcore-control'; -import { getEvaluator, listAllEvaluators } from '../../aws/agentcore-control'; +import { + getEvaluator, + getOnlineEvaluationConfig, + listAllEvaluators, + listAllOnlineEvaluationConfigs, +} from '../../aws/agentcore-control'; import { ANSI } from './constants'; -import { parseAndValidateArn } from './import-utils'; +import { failResult, parseAndValidateArn } from './import-utils'; import { executeResourceImport } from './resource-import'; import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; import type { Command } from '@commander-js/extra-typings'; @@ -77,6 +82,38 @@ const evaluatorDescriptor: ResourceImportDescriptor ({ type: 'evaluator', name, id, arn: d.evaluatorArn }), + + beforeConfigWrite: async ({ detail, localName, target, onProgress, logger }) => { + // Check if any online eval config references this evaluator. + // CFN IMPORT of locked evaluators always fails because CFN triggers a + // post-import TagResource call that the resource handler rejects. + logger.startStep('Check for online eval config references'); + onProgress('Checking if evaluator is referenced by an online eval config...'); + + const oecSummaries = await listAllOnlineEvaluationConfigs({ region: target.region }); + if (oecSummaries.length > 0) { + const oecDetails = await Promise.all( + oecSummaries.map(s => + getOnlineEvaluationConfig({ region: target.region, configId: s.onlineEvaluationConfigId }) + ) + ); + + const referencingOec = oecDetails.find(oec => oec.evaluatorIds?.includes(detail.evaluatorId)); + + if (referencingOec) { + return failResult( + logger, + `Evaluator "${localName}" is referenced by online eval config "${referencingOec.configName}" and cannot be imported directly (locked by CloudFormation).\n` + + `To import this evaluator along with its online eval config, run:\n` + + ` agentcore import online-eval --arn ${referencingOec.configArn}`, + 'evaluator', + localName + ); + } + } + + logger.endStep('success'); + }, }; /** diff --git a/src/cli/commands/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts index 374f71d34..a07a9579e 100644 --- a/src/cli/commands/import/import-online-eval.ts +++ b/src/cli/commands/import/import-online-eval.ts @@ -1,4 +1,4 @@ -import type { AgentCoreProjectSpec, DeployedState, OnlineEvalConfig } from '../../../schema'; +import type { OnlineEvalConfig } from '../../../schema'; import type { GetOnlineEvalConfigResult, OnlineEvalConfigSummary } from '../../aws/agentcore-control'; import { getOnlineEvaluationConfig, listAllOnlineEvaluationConfigs } from '../../aws/agentcore-control'; import { ANSI } from './constants'; @@ -7,8 +7,6 @@ import { executeResourceImport } from './resource-import'; import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; import type { Command } from '@commander-js/extra-typings'; -const ARN_PREFIX = 'arn:'; - /** * Derive the agent name from the online eval config's service names. * Service names follow the pattern: "{agentName}.DEFAULT" @@ -28,7 +26,7 @@ export function toOnlineEvalConfigSpec( detail: GetOnlineEvalConfigResult, localName: string, agentName: string, - evaluatorNames: string[] + evaluatorArns: string[] ): OnlineEvalConfig { if (!detail.samplingPercentage) { throw new Error(`Online eval config "${detail.configName}" has no sampling configuration. Cannot import.`); @@ -37,7 +35,7 @@ export function toOnlineEvalConfigSpec( return { name: localName, agent: agentName, - evaluators: evaluatorNames, + evaluators: evaluatorArns, samplingRate: detail.samplingPercentage, ...(detail.description && { description: detail.description }), ...(detail.executionStatus === 'ENABLED' && { enableOnCreate: true }), @@ -45,35 +43,12 @@ export function toOnlineEvalConfigSpec( } /** - * Resolve evaluator IDs to local names or ARNs. - * If an evaluator ID matches a local evaluator (by checking deployed state), use the local name. - * Otherwise, construct an ARN so the schema validation passes. + * Build evaluator ARNs from evaluator IDs. + * Online eval configs reference evaluators by ARN rather than importing them, + * since evaluators locked by an online eval config cannot be CFN-imported. */ -function resolveEvaluatorReferences( - evaluatorIds: string[], - projectSpec: AgentCoreProjectSpec, - deployedEvaluators: Record, - region: string, - account: string -): string[] { - const localEvaluators = projectSpec.evaluators ?? []; - - return evaluatorIds.map(id => { - // First check deployed state for an exact physical ID → local name match - // This handles imported evaluators where the local name differs from the AWS name - if (deployedEvaluators[id]) { - return deployedEvaluators[id]; - } - // Then check if the evaluator ID contains a local evaluator name - // This handles evaluators deployed by the same project (ID pattern: {projectName}_{evaluatorName}-{suffix}) - for (const localEval of localEvaluators) { - if (id.includes(localEval.name)) { - return localEval.name; - } - } - // Fall back to ARN format (bypasses schema cross-reference validation) - return `${ARN_PREFIX}aws:bedrock-agentcore:${region}:${account}:evaluator/${id}`; - }); +function buildEvaluatorArns(evaluatorIds: string[], region: string, account: string): string[] { + return evaluatorIds.map(id => `arn:aws:bedrock-agentcore:${region}:${account}:evaluator/${id}`); } /** @@ -81,7 +56,7 @@ function resolveEvaluatorReferences( */ function createOnlineEvalDescriptor(): ResourceImportDescriptor { let resolvedAgentName = ''; - let resolvedEvaluatorNames: string[] = []; + let resolvedEvaluatorArns: string[] = []; return { resourceType: 'online-eval', @@ -112,7 +87,7 @@ function createOnlineEvalDescriptor(): ResourceImportDescriptor (spec.onlineEvalConfigs ?? []).map(c => c.name), addToProjectSpec: (detail, localName, spec) => { (spec.onlineEvalConfigs ??= []).push( - toOnlineEvalConfigSpec(detail, localName, resolvedAgentName, resolvedEvaluatorNames) + toOnlineEvalConfigSpec(detail, localName, resolvedAgentName, resolvedEvaluatorArns) ); }, @@ -122,7 +97,8 @@ function createOnlineEvalDescriptor(): ResourceImportDescriptor ({ type: 'online-eval', name, id, arn: d.configArn }), - beforeConfigWrite: async ({ detail, localName, projectSpec, ctx, target, onProgress, logger }) => { + // eslint-disable-next-line @typescript-eslint/require-await -- interface requires Promise return type + beforeConfigWrite: async ({ detail, localName, projectSpec, target, onProgress, logger }) => { logger.startStep('Resolve references'); // Extract agent name from service names @@ -148,7 +124,7 @@ function createOnlineEvalDescriptor(): ResourceImportDescriptor = {}; - const deployedState: DeployedState = await ctx.configIO - .readDeployedState() - .catch((): DeployedState => ({ targets: {} })); - const targetName = target.name ?? 'default'; - const evalEntries = deployedState.targets[targetName]?.resources?.evaluators; - if (evalEntries) { - for (const [localEvalName, entry] of Object.entries(evalEntries)) { - deployedEvaluators[entry.evaluatorId] = localEvalName; - } - } - - resolvedEvaluatorNames = resolveEvaluatorReferences( - evaluatorIds, - projectSpec, - deployedEvaluators, - target.region, - target.account - ); + resolvedEvaluatorArns = buildEvaluatorArns(evaluatorIds, target.region, target.account); resolvedAgentName = agentName; - onProgress(`Agent: ${agentName}, Evaluators: ${resolvedEvaluatorNames.join(', ')}`); + onProgress(`Agent: ${agentName}, Evaluators: ${resolvedEvaluatorArns.join(', ')}`); logger.endStep('success'); }, }; From 7bf2aee7578b574be7cf2b5f0b67bbc1977e48a0 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Mon, 6 Apr 2026 19:22:19 +0000 Subject: [PATCH 13/18] fix(import): resolve OEC agent reference via deployed state when runtime has custom name extractAgentName() derives the AWS runtime name from the OEC service name pattern, but this fails to match when the runtime was imported with --name since the project spec stores the local name. Now falls back to listing runtimes to find the runtime ID, then looks up the local name in deployed-state.json. --- src/cli/commands/import/import-online-eval.ts | 49 +++++++++++++++---- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/src/cli/commands/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts index a07a9579e..08a096e78 100644 --- a/src/cli/commands/import/import-online-eval.ts +++ b/src/cli/commands/import/import-online-eval.ts @@ -1,8 +1,12 @@ import type { OnlineEvalConfig } from '../../../schema'; import type { GetOnlineEvalConfigResult, OnlineEvalConfigSummary } from '../../aws/agentcore-control'; -import { getOnlineEvaluationConfig, listAllOnlineEvaluationConfigs } from '../../aws/agentcore-control'; +import { + getOnlineEvaluationConfig, + listAllAgentRuntimes, + listAllOnlineEvaluationConfigs, +} from '../../aws/agentcore-control'; import { ANSI } from './constants'; -import { failResult } from './import-utils'; +import { failResult, findResourceInDeployedState } from './import-utils'; import { executeResourceImport } from './resource-import'; import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; import type { Command } from '@commander-js/extra-typings'; @@ -97,13 +101,12 @@ function createOnlineEvalDescriptor(): ResourceImportDescriptor ({ type: 'online-eval', name, id, arn: d.configArn }), - // eslint-disable-next-line @typescript-eslint/require-await -- interface requires Promise return type - beforeConfigWrite: async ({ detail, localName, projectSpec, target, onProgress, logger }) => { + beforeConfigWrite: async ({ detail, localName, projectSpec, ctx, target, onProgress, logger }) => { logger.startStep('Resolve references'); // Extract agent name from service names - const agentName = extractAgentName(detail.serviceNames ?? []); - if (!agentName) { + const awsAgentName = extractAgentName(detail.serviceNames ?? []); + if (!awsAgentName) { return failResult( logger, 'Could not determine agent name from online eval config. The config has no data source service names.', @@ -112,12 +115,40 @@ function createOnlineEvalDescriptor(): ResourceImportDescriptor r.name)); - if (!agentNames.has(agentName)) { + let agentName: string | undefined; + + if (agentNames.has(awsAgentName)) { + // Direct match — local name equals AWS name + agentName = awsAgentName; + } else { + // Look up the AWS runtime ID for the AWS name, then find the local name + // that maps to it in deployed state. + onProgress(`Agent "${awsAgentName}" not found by name, checking deployed state...`); + const runtimes = await listAllAgentRuntimes({ region: target.region }); + const matchingRuntime = runtimes.find(r => r.agentRuntimeName === awsAgentName); + + if (matchingRuntime) { + const targetName = target.name ?? 'default'; + const localMatch = await findResourceInDeployedState( + ctx.configIO, + targetName, + 'runtime', + matchingRuntime.agentRuntimeId + ); + if (localMatch && agentNames.has(localMatch)) { + agentName = localMatch; + onProgress(`Resolved AWS runtime "${awsAgentName}" to local name "${agentName}"`); + } + } + } + + if (!agentName) { return failResult( logger, - `Online eval config references agent "${agentName}" which is not in this project. ` + + `Online eval config references agent "${awsAgentName}" which is not in this project. ` + `Import or add the agent first with \`agentcore import runtime\` or \`agentcore add agent\`.`, 'online-eval', localName From 1506e14b7d51427b865b40ceb321821d5c6b700b Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Mon, 6 Apr 2026 19:26:23 +0000 Subject: [PATCH 14/18] fix(import): strip CDK project prefix from OEC service name when resolving agent CDK constructs set the OEC service name as "{projectName}_{agentName}.DEFAULT". extractAgentName() strips ".DEFAULT" but not the project prefix, so the lookup fails against local runtime names. Now strips the prefix as a fast path before falling back to the deployed-state API lookup. --- src/cli/commands/import/import-online-eval.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts index 08a096e78..cd8900d1d 100644 --- a/src/cli/commands/import/import-online-eval.ts +++ b/src/cli/commands/import/import-online-eval.ts @@ -116,7 +116,8 @@ function createOnlineEvalDescriptor(): ResourceImportDescriptor r.name)); let agentName: string | undefined; @@ -124,6 +125,17 @@ function createOnlineEvalDescriptor(): ResourceImportDescriptor Date: Mon, 6 Apr 2026 19:27:51 +0000 Subject: [PATCH 15/18] fix(import): show friendly error for non-existent evaluator ID getEvaluator() now catches ResourceNotFoundException and ValidationException from the SDK and rethrows a clear message instead of exposing the raw regex validation error. --- src/cli/aws/agentcore-control.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index 15ae73054..3362872ea 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -472,7 +472,16 @@ export async function getEvaluator(options: GetEvaluatorOptions): Promise Date: Mon, 6 Apr 2026 19:30:29 +0000 Subject: [PATCH 16/18] fix(import): validate ARN resource type for online-eval import import online-eval used a naive regex to extract the config ID from the ARN, skipping resource type, region, and account validation. Now uses parseAndValidateArn like all other import commands. Added an ARN resource type mapping to handle the online-eval vs online-evaluation-config mismatch between ImportableResourceType and the ARN format. --- src/cli/commands/import/import-online-eval.ts | 10 ++-------- src/cli/commands/import/import-utils.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/cli/commands/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts index cd8900d1d..c35ddd7b5 100644 --- a/src/cli/commands/import/import-online-eval.ts +++ b/src/cli/commands/import/import-online-eval.ts @@ -6,7 +6,7 @@ import { listAllOnlineEvaluationConfigs, } from '../../aws/agentcore-control'; import { ANSI } from './constants'; -import { failResult, findResourceInDeployedState } from './import-utils'; +import { failResult, findResourceInDeployedState, parseAndValidateArn } from './import-utils'; import { executeResourceImport } from './resource-import'; import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; import type { Command } from '@commander-js/extra-typings'; @@ -69,13 +69,7 @@ function createOnlineEvalDescriptor(): ResourceImportDescriptor listAllOnlineEvaluationConfigs({ region }), getDetail: (region, id) => getOnlineEvaluationConfig({ region, configId: id }), - parseResourceId: arn => { - const match = /\/([^/]+)$/.exec(arn); - if (!match) { - throw new Error(`Could not parse config ID from ARN: ${arn}`); - } - return match[1]!; - }, + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'online-eval', target).resourceId, extractSummaryId: s => s.onlineEvaluationConfigId, formatListItem: (s, i) => diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index d3b20df68..ca8075d4d 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -212,6 +212,14 @@ export interface ParsedArn { const ARN_PATTERN = /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/; +/** Map from ImportableResourceType to the resource type string used in ARNs. */ +const ARN_RESOURCE_TYPE_MAP: Record = { + runtime: 'runtime', + memory: 'memory', + evaluator: 'evaluator', + 'online-eval': 'online-evaluation-config', +}; + /** * Parse and validate a BedrockAgentCore ARN. * Validates format, region, and account against the deployment target. @@ -222,16 +230,17 @@ export function parseAndValidateArn( target: { region: string; account: string } ): ParsedArn { const match = ARN_PATTERN.exec(arn); + const expectedArnType = ARN_RESOURCE_TYPE_MAP[expectedResourceType]; if (!match) { throw new Error( - `Invalid ARN format: "${arn}". Expected format: arn:aws:bedrock-agentcore:::${expectedResourceType}/` + `Invalid ARN format: "${arn}". Expected format: arn:aws:bedrock-agentcore:::${expectedArnType}/` ); } const [, region, account, resourceType, resourceId] = match; - if (resourceType !== expectedResourceType) { - throw new Error(`ARN resource type "${resourceType}" does not match expected type "${expectedResourceType}".`); + if (resourceType !== expectedArnType) { + throw new Error(`ARN resource type "${resourceType}" does not match expected type "${expectedArnType}".`); } if (region !== target.region) { From 612c7acefcdfe356f6e3355dc0ea2798709d07bd Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Tue, 7 Apr 2026 14:39:45 +0000 Subject: [PATCH 17/18] refactor(import): address PR review feedback - Add `red` to ANSI constants, replace inline escape codes - Type GetEvaluatorResult.level as EvaluationLevel at boundary - Combine ARN_RESOURCE_TYPE_MAP, collectionKeyMap, idFieldMap into single RESOURCE_TYPE_CONFIG to prevent drift - Export IMPORTABLE_RESOURCES as const array, derive type from it, replace || chains with .includes() - Fix samplingPercentage === 0 false positive (use == null) - Document closure state sequencing contract on descriptor hooks --- src/cli/aws/agentcore-control.ts | 5 ++- .../import/__tests__/import-evaluator.test.ts | 13 +++--- src/cli/commands/import/constants.ts | 1 + src/cli/commands/import/import-evaluator.ts | 4 +- src/cli/commands/import/import-memory.ts | 2 +- src/cli/commands/import/import-online-eval.ts | 5 ++- src/cli/commands/import/import-runtime.ts | 2 +- src/cli/commands/import/import-utils.ts | 42 +++++++++---------- src/cli/commands/import/types.ts | 11 ++++- src/cli/tui/screens/import/ArnInputScreen.tsx | 3 +- src/cli/tui/screens/import/ImportFlow.tsx | 13 ++++-- .../screens/import/ImportProgressScreen.tsx | 8 +--- 12 files changed, 60 insertions(+), 49 deletions(-) diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index 3362872ea..d44c6473f 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -1,3 +1,4 @@ +import type { EvaluationLevel } from '../../schema/schemas/primitives/evaluator'; import { getCredentialProvider } from './account'; import { BedrockAgentCoreControlClient, @@ -455,7 +456,7 @@ export interface GetEvaluatorResult { evaluatorId: string; evaluatorArn: string; evaluatorName: string; - level: string; + level: EvaluationLevel; status: string; description?: string; evaluatorConfig?: { @@ -536,7 +537,7 @@ export async function getEvaluator(options: GetEvaluatorOptions): Promise { expect(result.tags).toBeUndefined(); }); - it('defaults level to SESSION when not provided', () => { - const detail: GetEvaluatorResult = { + it('defaults level to SESSION when empty string reaches toEvaluatorSpec', () => { + // In practice, getEvaluator defaults empty level to 'SESSION' via the as EvaluationLevel cast. + // This tests the fallback in toEvaluatorSpec for defensive coverage. + const detail = { evaluatorId: 'eval-no-level', evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-no-level', evaluatorName: 'no_level_eval', - level: '', + level: '' as GetEvaluatorResult['level'], status: 'ACTIVE', evaluatorConfig: { llmAsAJudge: { @@ -225,11 +227,10 @@ describe('toEvaluatorSpec', () => { ratingScale: { numerical: [{ value: 1, label: 'Low', definition: 'Low' }] }, }, }, - }; + } as GetEvaluatorResult; - // level defaults to 'SESSION' via the ?? in getEvaluator, but toEvaluatorSpec takes what it gets const result = toEvaluatorSpec(detail, 'no_level_eval'); - expect(result.level).toBe(''); + expect(result.level).toBe('SESSION'); }); }); diff --git a/src/cli/commands/import/constants.ts b/src/cli/commands/import/constants.ts index c4dd3ab7f..93c25f902 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -3,6 +3,7 @@ export const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; /** ANSI escape codes for console output. */ export const ANSI = { + red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', diff --git a/src/cli/commands/import/import-evaluator.ts b/src/cli/commands/import/import-evaluator.ts index 2161b0124..be85829f3 100644 --- a/src/cli/commands/import/import-evaluator.ts +++ b/src/cli/commands/import/import-evaluator.ts @@ -16,7 +16,7 @@ import type { Command } from '@commander-js/extra-typings'; * Map an AWS GetEvaluator response to the CLI Evaluator spec format. */ export function toEvaluatorSpec(detail: GetEvaluatorResult, localName: string): Evaluator { - const level = (detail.level ?? 'SESSION') as Evaluator['level']; + const level = detail.level || 'SESSION'; let config: Evaluator['config']; @@ -143,7 +143,7 @@ export function registerImportEvaluator(importCmd: Command): void { console.log(` ID: ${result.resourceId}`); console.log(''); } else { - console.error(`\n\x1b[31m[error]${ANSI.reset} ${result.error}`); + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-memory.ts b/src/cli/commands/import/import-memory.ts index 1e5a23db9..81740b726 100644 --- a/src/cli/commands/import/import-memory.ts +++ b/src/cli/commands/import/import-memory.ts @@ -121,7 +121,7 @@ export function registerImportMemory(importCmd: Command): void { console.log(` ID: ${result.resourceId}`); console.log(''); } else { - console.error(`\n\x1b[31m[error]${ANSI.reset} ${result.error}`); + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts index c35ddd7b5..298ea45fd 100644 --- a/src/cli/commands/import/import-online-eval.ts +++ b/src/cli/commands/import/import-online-eval.ts @@ -32,7 +32,7 @@ export function toOnlineEvalConfigSpec( agentName: string, evaluatorArns: string[] ): OnlineEvalConfig { - if (!detail.samplingPercentage) { + if (detail.samplingPercentage == null) { throw new Error(`Online eval config "${detail.configName}" has no sampling configuration. Cannot import.`); } @@ -59,6 +59,7 @@ function buildEvaluatorArns(evaluatorIds: string[], region: string, account: str * Create an online-eval descriptor with closed-over state for reference resolution. */ function createOnlineEvalDescriptor(): ResourceImportDescriptor { + // Set by beforeConfigWrite, read by addToProjectSpec. Ordering guaranteed by executeResourceImport. let resolvedAgentName = ''; let resolvedEvaluatorArns: string[] = []; @@ -207,7 +208,7 @@ export function registerImportOnlineEval(importCmd: Command): void { console.log(` ID: ${result.resourceId}`); console.log(''); } else { - console.error(`\n\x1b[31m[error]${ANSI.reset} ${result.error}`); + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-runtime.ts b/src/cli/commands/import/import-runtime.ts index afb50d6f8..b1921d546 100644 --- a/src/cli/commands/import/import-runtime.ts +++ b/src/cli/commands/import/import-runtime.ts @@ -225,7 +225,7 @@ export function registerImportRuntime(importCmd: Command): void { console.log(` agentcore invoke ${ANSI.dim}Test your agent${ANSI.reset}`); console.log(''); } else { - console.error(`\n\x1b[31m[error]${ANSI.reset} ${result.error}`); + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index ca8075d4d..d224870ec 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -212,12 +212,23 @@ export interface ParsedArn { const ARN_PATTERN = /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/; -/** Map from ImportableResourceType to the resource type string used in ARNs. */ -const ARN_RESOURCE_TYPE_MAP: Record = { - runtime: 'runtime', - memory: 'memory', - evaluator: 'evaluator', - 'online-eval': 'online-evaluation-config', +/** Unified config for each importable resource type — ARN mapping, deployed state keys. */ +const RESOURCE_TYPE_CONFIG: Record< + ImportableResourceType, + { + arnType: string; + collectionKey: string; + idField: string; + } +> = { + runtime: { arnType: 'runtime', collectionKey: 'runtimes', idField: 'runtimeId' }, + memory: { arnType: 'memory', collectionKey: 'memories', idField: 'memoryId' }, + evaluator: { arnType: 'evaluator', collectionKey: 'evaluators', idField: 'evaluatorId' }, + 'online-eval': { + arnType: 'online-evaluation-config', + collectionKey: 'onlineEvalConfigs', + idField: 'onlineEvaluationConfigId', + }, }; /** @@ -230,7 +241,7 @@ export function parseAndValidateArn( target: { region: string; account: string } ): ParsedArn { const match = ARN_PATTERN.exec(arn); - const expectedArnType = ARN_RESOURCE_TYPE_MAP[expectedResourceType]; + const expectedArnType = RESOURCE_TYPE_CONFIG[expectedResourceType].arnType; if (!match) { throw new Error( `Invalid ARN format: "${arn}". Expected format: arn:aws:bedrock-agentcore:::${expectedArnType}/` @@ -289,23 +300,10 @@ export async function findResourceInDeployedState( const targetState = state.targets?.[targetName]; if (!targetState?.resources) return undefined; - const collectionKeyMap: Record = { - runtime: 'runtimes', - memory: 'memories', - evaluator: 'evaluators', - 'online-eval': 'onlineEvalConfigs', - }; - const idFieldMap: Record = { - runtime: 'runtimeId', - memory: 'memoryId', - evaluator: 'evaluatorId', - 'online-eval': 'onlineEvaluationConfigId', - }; + const { collectionKey, idField } = RESOURCE_TYPE_CONFIG[resourceType]; - const collection = targetState.resources[collectionKeyMap[resourceType]]; + const collection = targetState.resources[collectionKey]; if (!collection) return undefined; - - const idField = idFieldMap[resourceType]; for (const [name, entry] of Object.entries(collection)) { if ((entry as any)[idField] === resourceId) return name; } diff --git a/src/cli/commands/import/types.ts b/src/cli/commands/import/types.ts index 3161a0e86..eab11c0a8 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -72,8 +72,10 @@ export interface ParsedStarterToolkitConfig { /** * Resource types supported by the import subcommands. + * Use the array for runtime checks (e.g., IMPORTABLE_RESOURCES.includes(x)). */ -export type ImportableResourceType = 'runtime' | 'memory' | 'evaluator' | 'online-eval'; +export const IMPORTABLE_RESOURCES = ['runtime', 'memory', 'evaluator', 'online-eval'] as const; +export type ImportableResourceType = (typeof IMPORTABLE_RESOURCES)[number]; /** * Resource to be imported via CloudFormation IMPORT change set. @@ -203,7 +205,10 @@ export interface ResourceImportDescriptor { /** Get the array of existing resource names from the project spec. */ getExistingNames: (projectSpec: AgentCoreProjectSpec) => string[]; - /** Convert the AWS detail to local spec and add it to the project spec. */ + /** + * Convert the AWS detail to local spec and add it to the project spec. + * Called after beforeConfigWrite — descriptor factories may rely on state set during that hook. + */ addToProjectSpec: (detail: TDetail, localName: string, projectSpec: AgentCoreProjectSpec) => void; // ---- CFN template matching ---- @@ -226,6 +231,8 @@ export interface ResourceImportDescriptor { /** * Called after detail fetch + name validation but before config write. + * Always runs before addToProjectSpec — descriptor factories can use this + * to set closed-over state that addToProjectSpec later reads. * Return an ImportResourceResult to abort, or void to continue. */ beforeConfigWrite?: (ctx: BeforeWriteContext) => Promise; diff --git a/src/cli/tui/screens/import/ArnInputScreen.tsx b/src/cli/tui/screens/import/ArnInputScreen.tsx index 85928dc69..188f9a694 100644 --- a/src/cli/tui/screens/import/ArnInputScreen.tsx +++ b/src/cli/tui/screens/import/ArnInputScreen.tsx @@ -1,3 +1,4 @@ +import type { ImportableResourceType } from '../../../commands/import/types'; import { Panel } from '../../components/Panel'; import { Screen } from '../../components/Screen'; import { TextInput } from '../../components/TextInput'; @@ -13,7 +14,7 @@ function validateArn(value: string): true | string { } interface ArnInputScreenProps { - resourceType: 'runtime' | 'memory' | 'evaluator' | 'online-eval'; + resourceType: ImportableResourceType; onSubmit: (arn: string) => void; onExit: () => void; } diff --git a/src/cli/tui/screens/import/ImportFlow.tsx b/src/cli/tui/screens/import/ImportFlow.tsx index 88b8fe71a..ed82962d3 100644 --- a/src/cli/tui/screens/import/ImportFlow.tsx +++ b/src/cli/tui/screens/import/ImportFlow.tsx @@ -1,4 +1,9 @@ -import type { ImportResourceResult, ImportResult } from '../../../commands/import/types'; +import { + IMPORTABLE_RESOURCES, + type ImportResourceResult, + type ImportResult, + type ImportableResourceType, +} from '../../../commands/import/types'; import { type NextStep, NextSteps } from '../../components/NextSteps'; import { Panel } from '../../components/Panel'; import { ErrorPrompt } from '../../components/PromptScreen'; @@ -14,7 +19,7 @@ import React, { useState } from 'react'; type ImportFlowState = | { name: 'select-type' } - | { name: 'arn-input'; resourceType: 'runtime' | 'memory' | 'evaluator' | 'online-eval' } + | { name: 'arn-input'; resourceType: ImportableResourceType } | { name: 'code-path'; resourceType: 'runtime'; arn: string } | { name: 'yaml-path' } | { @@ -48,8 +53,8 @@ export function ImportFlow({ onBack, onNavigate }: ImportFlowProps) { return ( { - if (type === 'runtime' || type === 'memory' || type === 'evaluator' || type === 'online-eval') { - setFlow({ name: 'arn-input', resourceType: type }); + if ((IMPORTABLE_RESOURCES as readonly string[]).includes(type)) { + setFlow({ name: 'arn-input', resourceType: type as ImportableResourceType }); } else { setFlow({ name: 'yaml-path' }); } diff --git a/src/cli/tui/screens/import/ImportProgressScreen.tsx b/src/cli/tui/screens/import/ImportProgressScreen.tsx index d5cb6dd7a..bd7096d5f 100644 --- a/src/cli/tui/screens/import/ImportProgressScreen.tsx +++ b/src/cli/tui/screens/import/ImportProgressScreen.tsx @@ -1,4 +1,5 @@ import type { ImportResourceResult, ImportResult } from '../../../commands/import/types'; +import { IMPORTABLE_RESOURCES } from '../../../commands/import/types'; import { Panel } from '../../components/Panel'; import { Screen } from '../../components/Screen'; import { type Step, StepProgress } from '../../components/StepProgress'; @@ -40,12 +41,7 @@ export function ImportProgressScreen({ started.current = true; const run = async () => { - if ( - importType === 'runtime' || - importType === 'memory' || - importType === 'evaluator' || - importType === 'online-eval' - ) { + if ((IMPORTABLE_RESOURCES as readonly string[]).includes(importType)) { const handler = importType === 'runtime' ? (await import('../../../commands/import/import-runtime')).handleImportRuntime From 426d782055523c76eb8ca0880c7e439555ab259b Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Tue, 7 Apr 2026 15:53:45 +0000 Subject: [PATCH 18/18] test(import): remove unreachable empty-level evaluator test The test exercised a defensive fallback in toEvaluatorSpec for an empty level string, but now that GetEvaluatorResult.level is typed as EvaluationLevel, the boundary cast in getEvaluator prevents this case from ever reaching toEvaluatorSpec. --- .../import/__tests__/import-evaluator.test.ts | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/src/cli/commands/import/__tests__/import-evaluator.test.ts b/src/cli/commands/import/__tests__/import-evaluator.test.ts index 126f3fade..5e6fb5e96 100644 --- a/src/cli/commands/import/__tests__/import-evaluator.test.ts +++ b/src/cli/commands/import/__tests__/import-evaluator.test.ts @@ -210,28 +210,6 @@ describe('toEvaluatorSpec', () => { expect(result.tags).toBeUndefined(); }); - - it('defaults level to SESSION when empty string reaches toEvaluatorSpec', () => { - // In practice, getEvaluator defaults empty level to 'SESSION' via the as EvaluationLevel cast. - // This tests the fallback in toEvaluatorSpec for defensive coverage. - const detail = { - evaluatorId: 'eval-no-level', - evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-no-level', - evaluatorName: 'no_level_eval', - level: '' as GetEvaluatorResult['level'], - status: 'ACTIVE', - evaluatorConfig: { - llmAsAJudge: { - model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', - instructions: 'Evaluate', - ratingScale: { numerical: [{ value: 1, label: 'Low', definition: 'Low' }] }, - }, - }, - } as GetEvaluatorResult; - - const result = toEvaluatorSpec(detail, 'no_level_eval'); - expect(result.level).toBe('SESSION'); - }); }); // ============================================================================