From 264a01dd185c00c9996b2da2171de2132959c53b Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Mon, 4 May 2026 14:52:59 -0400 Subject: [PATCH 1/3] feat: add archive command for batch evaluations and recommendations --- e2e-tests/archive-lifecycle.test.ts | 333 +++++++++++++++ src/cli/cli.ts | 2 + .../archive/__tests__/command.test.ts | 396 ++++++++++++++++++ src/cli/commands/archive/command.tsx | 93 ++++ src/cli/commands/archive/index.ts | 1 + src/cli/commands/index.ts | 1 + src/cli/commands/pause/command.tsx | 13 +- .../shared/__tests__/region-utils.test.ts | 130 ++++++ src/cli/commands/shared/region-utils.ts | 13 + src/cli/commands/stop/command.tsx | 14 +- .../archive/__tests__/archive-storage.test.ts | 114 +++++ src/cli/operations/archive/archive-storage.ts | 35 ++ src/cli/operations/archive/index.ts | 1 + src/cli/operations/eval/batch-eval-storage.ts | 2 +- .../recommendation/recommendation-storage.ts | 2 +- src/cli/tui/copy.ts | 12 + src/cli/tui/utils/commands.ts | 2 +- 17 files changed, 1136 insertions(+), 28 deletions(-) create mode 100644 e2e-tests/archive-lifecycle.test.ts create mode 100644 src/cli/commands/archive/__tests__/command.test.ts create mode 100644 src/cli/commands/archive/command.tsx create mode 100644 src/cli/commands/archive/index.ts create mode 100644 src/cli/commands/shared/__tests__/region-utils.test.ts create mode 100644 src/cli/commands/shared/region-utils.ts create mode 100644 src/cli/operations/archive/__tests__/archive-storage.test.ts create mode 100644 src/cli/operations/archive/archive-storage.ts create mode 100644 src/cli/operations/archive/index.ts diff --git a/e2e-tests/archive-lifecycle.test.ts b/e2e-tests/archive-lifecycle.test.ts new file mode 100644 index 000000000..dbe0a053e --- /dev/null +++ b/e2e-tests/archive-lifecycle.test.ts @@ -0,0 +1,333 @@ +/** + * E2E tests for the archive command. + * + * Flow: create project → deploy → invoke → run batch-eval → run recommendation → + * archive batch-eval (verify service delete + local .cli cleared) → + * archive recommendation (verify service delete + local .cli cleared) + * + * Prerequisites: + * - AWS credentials + * - npm, git, uv installed + */ +import { parseJsonOutput, retry } from '../src/test-utils/index.js'; +import { + baseCanRun, + hasAws, + installCdkTarball, + runAgentCoreCLI, + teardownE2EProject, + writeAwsTargets, +} from './e2e-helper.js'; +import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const canRun = baseCanRun && hasAws; + +describe.sequential('e2e: archive command lifecycle', () => { + let testDir: string; + let projectPath: string; + const agentName = `E2eArch${String(Date.now()).slice(-8)}`; + + // IDs captured from run steps and used in archive steps + let batchEvaluationId: string; + let recommendationId: string; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-archive-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const result = await runAgentCoreCLI( + [ + 'create', + '--name', + agentName, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir + ); + expect(result.exitCode, `Create failed: ${result.stderr}`).toBe(0); + projectPath = (parseJsonOutput(result.stdout) as { projectPath: string }).projectPath; + + await writeAwsTargets(projectPath); + installCdkTarball(projectPath); + }, 300000); + + afterAll(async () => { + if (projectPath && hasAws) { + await teardownE2EProject(projectPath, agentName, 'Bedrock'); + } + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600000); + + const run = (args: string[]) => runAgentCoreCLI(args, projectPath); + + // ════════════════════════════════════════════════════════════════════════ + // Setup — deploy and generate traces + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'deploys the agent', + async () => { + const result = await run(['deploy', '--yes', '--json']); + if (result.exitCode !== 0) { + console.log('Deploy stdout:', result.stdout); + console.log('Deploy stderr:', result.stderr); + } + expect(result.exitCode, 'Deploy failed').toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'invokes the deployed agent to generate traces', + async () => { + await retry( + async () => { + const result = await run(['invoke', '--prompt', 'Say hello', '--runtime', agentName, '--json']); + expect(result.exitCode, `Invoke failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 3, + 15000 + ); + }, + 180000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Batch evaluation — run and capture ID + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'runs batch evaluation and captures the ID', + async () => { + await retry( + async () => { + const result = await run([ + 'run', + 'batch-evaluation', + '--runtime', + agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--lookback-days', + '1', + '--json', + ]); + expect(result.exitCode, `batch-evaluation failed (stdout: ${result.stdout}, stderr: ${result.stderr})`).toBe( + 0 + ); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json.batchEvaluationId).toBeTruthy(); + expect(json.status).not.toBe('FAILED'); + batchEvaluationId = json.batchEvaluationId as string; + }, + 6, + 15000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'local .cli/batch-eval-results contains the run record', + () => { + expect(batchEvaluationId, 'batchEvaluationId should have been captured').toBeTruthy(); + const filePath = join(projectPath, 'agentcore', '.cli', 'batch-eval-results', `${batchEvaluationId}.json`); + expect(existsSync(filePath), `Expected local record at ${filePath}`).toBe(true); + }, + 30000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Recommendation — run and capture ID + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'runs a recommendation and captures the ID', + async () => { + await retry( + async () => { + const result = await run([ + 'run', + 'recommendation', + '--runtime', + agentName, + '--evaluator', + 'Builtin.Faithfulness', + '--inline', + 'You are a helpful assistant for testing.', + '--lookback', + '1', + '--json', + ]); + expect(result.exitCode, `recommendation failed (stdout: ${result.stdout}, stderr: ${result.stderr})`).toBe(0); + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json.recommendationId).toBeTruthy(); + recommendationId = json.recommendationId as string; + }, + 6, + 30000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)( + 'local .cli/recommendations contains the run record', + () => { + expect(recommendationId, 'recommendationId should have been captured').toBeTruthy(); + const filePath = join(projectPath, 'agentcore', '.cli', 'recommendations', `${recommendationId}.json`); + expect(existsSync(filePath), `Expected local record at ${filePath}`).toBe(true); + }, + 30000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Archive batch evaluation + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'archive batch-evaluation fails without --id flag', + async () => { + const result = await run(['archive', 'batch-evaluation']); + expect(result.exitCode).not.toBe(0); + }, + 30000 + ); + + it.skipIf(!canRun)( + 'archives the batch evaluation with --json flag', + async () => { + expect(batchEvaluationId, 'batchEvaluationId must have been captured').toBeTruthy(); + + const result = await run(['archive', 'batch-evaluation', '--id', batchEvaluationId, '--json']); + expect(result.exitCode, `archive batch-evaluation failed: ${result.stderr}\n${result.stdout}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json.batchEvaluationId).toBe(batchEvaluationId); + expect(json).toHaveProperty('localCliHistoryDeleted', true); + expect(json.localDeleteWarning).toBeUndefined(); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'local .cli/batch-eval-results no longer contains the archived record', + () => { + const filePath = join(projectPath, 'agentcore', '.cli', 'batch-eval-results', `${batchEvaluationId}.json`); + expect(existsSync(filePath), `Local record should have been deleted from ${filePath}`).toBe(false); + }, + 30000 + ); + + it.skipIf(!canRun)( + 'evals history does not surface the archived batch evaluation ID', + async () => { + // evals history lists on-demand (run eval) records — batch evals are stored separately. + // Verify: the command succeeds and contains no entry matching our batch evaluation ID. + const result = await run(['evals', 'history', '--json']); + expect(result.exitCode, `evals history failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { runs?: { agent: string }[] }; + const output = JSON.stringify(json.runs ?? []); + expect(output).not.toContain(batchEvaluationId); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'archiving the same batch evaluation again returns success false (already deleted)', + async () => { + const result = await run(['archive', 'batch-evaluation', '--id', batchEvaluationId, '--json']); + // Service should return an error (resource not found / already deleted) + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toBeTruthy(); + }, + 120000 + ); + + // ════════════════════════════════════════════════════════════════════════ + // Archive recommendation + // ════════════════════════════════════════════════════════════════════════ + + it.skipIf(!canRun)( + 'archive recommendation fails without --id flag', + async () => { + const result = await run(['archive', 'recommendation']); + expect(result.exitCode).not.toBe(0); + }, + 30000 + ); + + it.skipIf(!canRun)( + 'archives the recommendation with --json flag', + async () => { + expect(recommendationId, 'recommendationId must have been captured').toBeTruthy(); + + const result = await run(['archive', 'recommendation', '--id', recommendationId, '--json']); + expect(result.exitCode, `archive recommendation failed: ${result.stderr}\n${result.stdout}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as Record; + expect(json).toHaveProperty('success', true); + expect(json.recommendationId).toBe(recommendationId); + expect(json).toHaveProperty('localCliHistoryDeleted', true); + expect(json.localDeleteWarning).toBeUndefined(); + }, + 120000 + ); + + it.skipIf(!canRun)( + 'local .cli/recommendations no longer contains the archived record', + () => { + const filePath = join(projectPath, 'agentcore', '.cli', 'recommendations', `${recommendationId}.json`); + expect(existsSync(filePath), `Local record should have been deleted from ${filePath}`).toBe(false); + }, + 30000 + ); + + it.skipIf(!canRun)( + 'recommendations history no longer includes the archived entry', + async () => { + const result = await run(['recommendations', 'history', '--json']); + expect(result.exitCode, `recommendations history failed: ${result.stderr}`).toBe(0); + const json = parseJsonOutput(result.stdout) as { recommendations: { recommendationId: string }[] }; + const ids = (json.recommendations ?? []).map(r => r.recommendationId); + expect(ids).not.toContain(recommendationId); + }, + 60000 + ); + + it.skipIf(!canRun)( + 'archiving the same recommendation again returns success false (already deleted)', + async () => { + const result = await run(['archive', 'recommendation', '--id', recommendationId, '--json']); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as Record; + expect(json.success).toBe(false); + expect(json.error).toBeTruthy(); + }, + 120000 + ); +}); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index e44ea6729..e40732f52 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -1,6 +1,7 @@ import { getOrCreateInstallationId } from '../lib/schemas/io/global-config'; import { registerABTestCommand } from './commands/abtest'; import { registerAdd } from './commands/add'; +import { registerArchive } from './commands/archive'; import { registerConfigBundle } from './commands/config-bundle'; import { registerCreate } from './commands/create'; import { registerDeploy } from './commands/deploy'; @@ -198,6 +199,7 @@ export function registerCommands(program: Command) { registerUpdate(program); registerValidate(program); registerConfigBundle(program); + registerArchive(program); // Register primitive subcommands (add agent, remove agent, add memory, etc.) for (const primitive of ALL_PRIMITIVES) { diff --git a/src/cli/commands/archive/__tests__/command.test.ts b/src/cli/commands/archive/__tests__/command.test.ts new file mode 100644 index 000000000..8d65c7099 --- /dev/null +++ b/src/cli/commands/archive/__tests__/command.test.ts @@ -0,0 +1,396 @@ +import { registerArchive } from '../command.js'; +import { Command } from '@commander-js/extra-typings'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockDeleteBatchEvaluation = vi.fn(); +const mockDeleteRecommendation = vi.fn(); +const mockDeleteLocalBatchEvalRun = vi.fn(); +const mockDeleteLocalRecommendationRun = vi.fn(); +const mockRequireProject = vi.fn(); +const mockRender = vi.fn(); +const mockResolveAWSDeploymentTargets = vi.fn(); + +vi.mock('../../../aws/agentcore-batch-evaluation', () => ({ + deleteBatchEvaluation: (...args: unknown[]) => mockDeleteBatchEvaluation(...args), +})); + +vi.mock('../../../aws/agentcore-recommendation', () => ({ + deleteRecommendation: (...args: unknown[]) => mockDeleteRecommendation(...args), +})); + +vi.mock('../../../operations/archive/archive-storage', () => ({ + deleteLocalBatchEvalRun: (...args: unknown[]) => mockDeleteLocalBatchEvalRun(...args), + deleteLocalRecommendationRun: (...args: unknown[]) => mockDeleteLocalRecommendationRun(...args), +})); + +vi.mock('../../../tui/guards', () => ({ + requireProject: (...args: unknown[]) => mockRequireProject(...args), +})); + +vi.mock('ink', () => ({ + render: (...args: unknown[]) => mockRender(...args), + Text: 'Text', +})); + +vi.mock('../../../../lib', () => ({ + ConfigIO: function () { + return { resolveAWSDeploymentTargets: () => mockResolveAWSDeploymentTargets() }; + }, +})); + +const batchEvalResult = { + batchEvaluationId: 'eval-abc-123', + batchEvaluationArn: 'arn:aws:bedrock:us-east-1:123456789:batch-evaluation/eval-abc-123', + status: 'DELETED', +}; + +const recommendationResult = { + recommendationId: 'rec-xyz-789', + status: 'DELETED', +}; + +describe('registerArchive', () => { + let program: Command; + let mockExit: ReturnType; + let mockLog: ReturnType; + + beforeEach(() => { + program = new Command(); + program.exitOverride(); + registerArchive(program); + + mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit'); + }); + mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + mockResolveAWSDeploymentTargets.mockResolvedValue([{ region: 'us-east-1' }]); + mockDeleteLocalBatchEvalRun.mockReturnValue(true); + mockDeleteLocalRecommendationRun.mockReturnValue(true); + }); + + afterEach(() => { + mockExit.mockRestore(); + mockLog.mockRestore(); + vi.clearAllMocks(); + }); + + describe('command registration', () => { + it('registers archive command', () => { + const archiveCmd = program.commands.find(c => c.name() === 'archive'); + expect(archiveCmd).toBeDefined(); + }); + + it('registers batch-evaluation subcommand', () => { + const archiveCmd = program.commands.find(c => c.name() === 'archive')!; + const batchCmd = archiveCmd.commands.find(c => c.name() === 'batch-evaluation'); + expect(batchCmd).toBeDefined(); + }); + + it('registers recommendation subcommand', () => { + const archiveCmd = program.commands.find(c => c.name() === 'archive')!; + const recCmd = archiveCmd.commands.find(c => c.name() === 'recommendation'); + expect(recCmd).toBeDefined(); + }); + }); + + describe('archive batch-evaluation', () => { + it('rejects when --id is missing', async () => { + await expect(program.parseAsync(['archive', 'batch-evaluation'], { from: 'user' })).rejects.toThrow(); + expect(mockDeleteBatchEvaluation).not.toHaveBeenCalled(); + }); + + it('calls deleteBatchEvaluation with the given id and auto-detected region', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + expect(mockDeleteBatchEvaluation).toHaveBeenCalledWith({ + region: 'us-east-1', + batchEvaluationId: 'eval-abc-123', + }); + }); + + it('uses --region when provided', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--region', 'eu-west-1'], { + from: 'user', + }); + + expect(mockDeleteBatchEvaluation).toHaveBeenCalledWith({ + region: 'eu-west-1', + batchEvaluationId: 'eval-abc-123', + }); + }); + + it('calls deleteLocalBatchEvalRun with the id', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + expect(mockDeleteLocalBatchEvalRun).toHaveBeenCalledWith('eval-abc-123'); + }); + + it('outputs JSON on success with --json flag', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }); + + expect(mockLog).toHaveBeenCalledTimes(1); + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(true); + expect(output.batchEvaluationId).toBe('eval-abc-123'); + expect(output.status).toBe('DELETED'); + expect(output.localCliHistoryDeleted).toBe(true); + }); + + it('includes localCliHistoryDeleted: false in JSON when local file was not found', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + mockDeleteLocalBatchEvalRun.mockReturnValue(false); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.localCliHistoryDeleted).toBe(false); + }); + + it('includes localDeleteWarning in JSON and exits 0 when local delete throws', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + mockDeleteLocalBatchEvalRun.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(true); + expect(output.localCliHistoryDeleted).toBe(false); + expect(output.localDeleteWarning).toBe('Permission denied'); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('prints warning and exits 0 when local delete throws without --json', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + mockDeleteLocalBatchEvalRun.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(allOutput).toContain('Warning: could not clear local history: Permission denied'); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('prints human-readable success output without --json', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(allOutput).toContain('eval-abc-123'); + expect(allOutput).toContain('DELETED'); + }); + + it('does not call process.exit on success', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('outputs JSON error when deleteBatchEvaluation throws and --json is set', async () => { + mockDeleteBatchEvaluation.mockRejectedValue(new Error('Service unavailable')); + + await expect( + program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123', '--json'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(false); + expect(output.error).toBe('Service unavailable'); + }); + + it('renders error via ink when deleteBatchEvaluation throws without --json', async () => { + mockDeleteBatchEvaluation.mockRejectedValue(new Error('Service unavailable')); + + await expect( + program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + expect(mockRender).toHaveBeenCalled(); + const renderArg = mockRender.mock.calls[0]![0]; + expect(JSON.stringify(renderArg)).toContain('Service unavailable'); + }); + + it('exits with code 1 on error', async () => { + mockDeleteBatchEvaluation.mockRejectedValue(new Error('fail')); + + await expect( + program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('calls requireProject', async () => { + mockDeleteBatchEvaluation.mockResolvedValue(batchEvalResult); + + await program.parseAsync(['archive', 'batch-evaluation', '--id', 'eval-abc-123'], { from: 'user' }); + + expect(mockRequireProject).toHaveBeenCalled(); + }); + }); + + describe('archive recommendation', () => { + it('rejects when --id is missing', async () => { + await expect(program.parseAsync(['archive', 'recommendation'], { from: 'user' })).rejects.toThrow(); + expect(mockDeleteRecommendation).not.toHaveBeenCalled(); + }); + + it('calls deleteRecommendation with the given id and auto-detected region', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + expect(mockDeleteRecommendation).toHaveBeenCalledWith({ + region: 'us-east-1', + recommendationId: 'rec-xyz-789', + }); + }); + + it('uses --region when provided', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--region', 'ap-southeast-1'], { + from: 'user', + }); + + expect(mockDeleteRecommendation).toHaveBeenCalledWith({ + region: 'ap-southeast-1', + recommendationId: 'rec-xyz-789', + }); + }); + + it('calls deleteLocalRecommendationRun with the id', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + expect(mockDeleteLocalRecommendationRun).toHaveBeenCalledWith('rec-xyz-789'); + }); + + it('outputs JSON on success with --json flag', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }); + + expect(mockLog).toHaveBeenCalledTimes(1); + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(true); + expect(output.recommendationId).toBe('rec-xyz-789'); + expect(output.status).toBe('DELETED'); + expect(output.localCliHistoryDeleted).toBe(true); + }); + + it('includes localCliHistoryDeleted: false in JSON when local file was not found', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + mockDeleteLocalRecommendationRun.mockReturnValue(false); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.localCliHistoryDeleted).toBe(false); + }); + + it('includes localDeleteWarning in JSON and exits 0 when local delete throws', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + mockDeleteLocalRecommendationRun.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(true); + expect(output.localCliHistoryDeleted).toBe(false); + expect(output.localDeleteWarning).toBe('Permission denied'); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('prints warning and exits 0 when local delete throws without --json', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + mockDeleteLocalRecommendationRun.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(allOutput).toContain('Warning: could not clear local history: Permission denied'); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('prints human-readable success output without --json', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + const allOutput = mockLog.mock.calls.map((c: unknown[]) => String(c[0])).join('\n'); + expect(allOutput).toContain('rec-xyz-789'); + expect(allOutput).toContain('DELETED'); + }); + + it('does not call process.exit on success', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('outputs JSON error when deleteRecommendation throws and --json is set', async () => { + mockDeleteRecommendation.mockRejectedValue(new Error('Not found')); + + await expect( + program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789', '--json'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + const output = JSON.parse(mockLog.mock.calls[0]![0]); + expect(output.success).toBe(false); + expect(output.error).toBe('Not found'); + }); + + it('renders error via ink when deleteRecommendation throws without --json', async () => { + mockDeleteRecommendation.mockRejectedValue(new Error('Not found')); + + await expect( + program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + expect(mockRender).toHaveBeenCalled(); + const renderArg = mockRender.mock.calls[0]![0]; + expect(JSON.stringify(renderArg)).toContain('Not found'); + }); + + it('exits with code 1 on error', async () => { + mockDeleteRecommendation.mockRejectedValue(new Error('fail')); + + await expect( + program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it('calls requireProject', async () => { + mockDeleteRecommendation.mockResolvedValue(recommendationResult); + + await program.parseAsync(['archive', 'recommendation', '--id', 'rec-xyz-789'], { from: 'user' }); + + expect(mockRequireProject).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/cli/commands/archive/command.tsx b/src/cli/commands/archive/command.tsx new file mode 100644 index 000000000..780883522 --- /dev/null +++ b/src/cli/commands/archive/command.tsx @@ -0,0 +1,93 @@ +import { deleteBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; +import { deleteRecommendation } from '../../aws/agentcore-recommendation'; +import { getErrorMessage } from '../../errors'; +import { deleteLocalBatchEvalRun, deleteLocalRecommendationRun } from '../../operations/archive/archive-storage'; +import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; +import { requireProject } from '../../tui/guards'; +import { getRegion } from '../shared/region-utils'; +import type { Command } from '@commander-js/extra-typings'; +import { Text, render } from 'ink'; +import React from 'react'; + +async function executeArchive( + cliOptions: { id: string; region?: string; json?: boolean }, + config: { + serviceDelete: (id: string, region: string) => Promise; + localDelete: (id: string) => boolean; + getId: (result: T) => string; + successMessage: string; + } +): Promise { + requireProject(); + try { + const region = await getRegion(cliOptions.region); + const result = await config.serviceDelete(cliOptions.id, region); + + let localCliHistoryDeleted = false; + let localDeleteWarning: string | undefined; + try { + localCliHistoryDeleted = config.localDelete(cliOptions.id); + } catch (err) { + localDeleteWarning = getErrorMessage(err); + } + + if (cliOptions.json) { + console.log( + JSON.stringify({ + success: true, + ...result, + localCliHistoryDeleted, + ...(localDeleteWarning && { localDeleteWarning }), + }) + ); + } else { + console.log(`\n${config.successMessage}`); + console.log(`ID: ${config.getId(result)}`); + console.log(`Status: ${result.status}`); + if (localCliHistoryDeleted) console.log(`Local history cleared.`); + if (localDeleteWarning) console.log(`Warning: could not clear local history: ${localDeleteWarning}`); + console.log(''); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + render(Error: {getErrorMessage(error)}); + } + process.exit(1); + } +} + +export const registerArchive = (program: Command) => { + const archiveCmd = program.command('archive').description(COMMAND_DESCRIPTIONS.archive); + + archiveCmd + .command('batch-evaluation') + .description('[preview] Archive (delete) a batch evaluation on the service and clear local history') + .requiredOption('-i, --id ', 'Batch evaluation ID to archive') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--json', 'Output as JSON') + .action((cliOptions: { id: string; region?: string; json?: boolean }) => + executeArchive(cliOptions, { + serviceDelete: (id, region) => deleteBatchEvaluation({ region, batchEvaluationId: id }), + localDelete: deleteLocalBatchEvalRun, + getId: result => result.batchEvaluationId, + successMessage: 'Batch evaluation archived successfully', + }) + ); + + archiveCmd + .command('recommendation') + .description('[preview] Archive (delete) a recommendation on the service and clear local history') + .requiredOption('-i, --id ', 'Recommendation ID to archive') + .option('--region ', 'AWS region (auto-detected if omitted)') + .option('--json', 'Output as JSON') + .action((cliOptions: { id: string; region?: string; json?: boolean }) => + executeArchive(cliOptions, { + serviceDelete: (id, region) => deleteRecommendation({ region, recommendationId: id }), + localDelete: deleteLocalRecommendationRun, + getId: result => result.recommendationId, + successMessage: 'Recommendation archived successfully', + }) + ); +}; diff --git a/src/cli/commands/archive/index.ts b/src/cli/commands/archive/index.ts new file mode 100644 index 000000000..ab2d7cd2d --- /dev/null +++ b/src/cli/commands/archive/index.ts @@ -0,0 +1 @@ +export { registerArchive } from './command'; diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index a7eda0787..be25fd685 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -1,4 +1,5 @@ // Command registrations +export { registerArchive } from './archive'; export { registerAdd } from './add'; export { registerDeploy } from './deploy'; export { registerDev } from './dev'; diff --git a/src/cli/commands/pause/command.tsx b/src/cli/commands/pause/command.tsx index 82a79bccf..4ad1cc0dc 100644 --- a/src/cli/commands/pause/command.tsx +++ b/src/cli/commands/pause/command.tsx @@ -6,6 +6,7 @@ import { handlePauseResume } from '../../operations/eval'; import type { OnlineEvalActionOptions } from '../../operations/eval'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; +import { getRegion } from '../shared/region-utils'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; import React from 'react'; @@ -70,18 +71,6 @@ function registerOnlineEvalSubcommand(parent: Command, action: 'pause' | 'resume }); } -async function getRegion(cliRegion?: string): Promise { - if (cliRegion) return cliRegion; - try { - const configIO = new ConfigIO(); - const targets = await configIO.resolveAWSDeploymentTargets(); - if (targets.length > 0) return targets[0]!.region; - } catch { - // Fall through to env vars - } - return process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; -} - async function resolveABTestId( testName: string, region: string diff --git a/src/cli/commands/shared/__tests__/region-utils.test.ts b/src/cli/commands/shared/__tests__/region-utils.test.ts new file mode 100644 index 000000000..4e44686d8 --- /dev/null +++ b/src/cli/commands/shared/__tests__/region-utils.test.ts @@ -0,0 +1,130 @@ +import { getRegion } from '../region-utils.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockResolveAWSDeploymentTargets = vi.fn(); + +vi.mock('../../../../lib', () => ({ + ConfigIO: function () { + return { resolveAWSDeploymentTargets: () => mockResolveAWSDeploymentTargets() }; + }, +})); + +describe('getRegion', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_REGION; + }); + + afterEach(() => { + process.env = originalEnv; + vi.clearAllMocks(); + }); + + describe('explicit cliRegion argument', () => { + it('returns cliRegion immediately without consulting ConfigIO or env vars', async () => { + process.env.AWS_DEFAULT_REGION = 'eu-central-1'; + mockResolveAWSDeploymentTargets.mockResolvedValue([{ region: 'ap-southeast-1' }]); + + const result = await getRegion('us-west-2'); + + expect(result).toBe('us-west-2'); + expect(mockResolveAWSDeploymentTargets).not.toHaveBeenCalled(); + }); + }); + + describe('project config fallback', () => { + it('returns first target region from project config when no cliRegion', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([{ region: 'ap-northeast-1' }, { region: 'us-east-1' }]); + + const result = await getRegion(); + + expect(result).toBe('ap-northeast-1'); + }); + + it('falls through to env vars when resolveAWSDeploymentTargets returns empty array', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([]); + process.env.AWS_DEFAULT_REGION = 'eu-west-1'; + + const result = await getRegion(); + + expect(result).toBe('eu-west-1'); + }); + + it('falls through to env vars when resolveAWSDeploymentTargets throws', async () => { + mockResolveAWSDeploymentTargets.mockRejectedValue(new Error('No agentcore project found')); + process.env.AWS_DEFAULT_REGION = 'eu-west-2'; + + const result = await getRegion(); + + expect(result).toBe('eu-west-2'); + }); + + it('does not throw when ConfigIO constructor throws', async () => { + mockResolveAWSDeploymentTargets.mockRejectedValue(new Error('fs error')); + process.env.AWS_REGION = 'us-west-1'; + + await expect(getRegion()).resolves.toBe('us-west-1'); + }); + }); + + describe('environment variable fallback', () => { + it('prefers AWS_DEFAULT_REGION over AWS_REGION', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([]); + process.env.AWS_DEFAULT_REGION = 'eu-central-1'; + process.env.AWS_REGION = 'us-west-2'; + + const result = await getRegion(); + + expect(result).toBe('eu-central-1'); + }); + + it('falls back to AWS_REGION when AWS_DEFAULT_REGION is unset', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([]); + process.env.AWS_REGION = 'ap-south-1'; + + const result = await getRegion(); + + expect(result).toBe('ap-south-1'); + }); + + it('returns us-east-1 when no region is configured anywhere', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([]); + + const result = await getRegion(); + + expect(result).toBe('us-east-1'); + }); + + it('returns us-east-1 when ConfigIO throws and no env vars are set', async () => { + mockResolveAWSDeploymentTargets.mockRejectedValue(new Error('project not found')); + + const result = await getRegion(); + + expect(result).toBe('us-east-1'); + }); + }); + + describe('fallback priority order', () => { + it('uses cliRegion > project config > AWS_DEFAULT_REGION > AWS_REGION > us-east-1', async () => { + mockResolveAWSDeploymentTargets.mockResolvedValue([{ region: 'project-region' }]); + process.env.AWS_DEFAULT_REGION = 'env-default-region'; + process.env.AWS_REGION = 'env-region'; + + expect(await getRegion('explicit-region')).toBe('explicit-region'); + + expect(await getRegion(undefined)).toBe('project-region'); + + mockResolveAWSDeploymentTargets.mockResolvedValue([]); + expect(await getRegion(undefined)).toBe('env-default-region'); + + delete process.env.AWS_DEFAULT_REGION; + expect(await getRegion(undefined)).toBe('env-region'); + + delete process.env.AWS_REGION; + expect(await getRegion(undefined)).toBe('us-east-1'); + }); + }); +}); diff --git a/src/cli/commands/shared/region-utils.ts b/src/cli/commands/shared/region-utils.ts new file mode 100644 index 000000000..6013ae823 --- /dev/null +++ b/src/cli/commands/shared/region-utils.ts @@ -0,0 +1,13 @@ +import { ConfigIO } from '../../../lib'; + +export async function getRegion(cliRegion?: string): Promise { + if (cliRegion) return cliRegion; + try { + const configIO = new ConfigIO(); + const targets = await configIO.resolveAWSDeploymentTargets(); + if (targets.length > 0) return targets[0]!.region; + } catch { + // Fall through to env vars + } + return process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; +} diff --git a/src/cli/commands/stop/command.tsx b/src/cli/commands/stop/command.tsx index 7a29dd8da..74d6665f2 100644 --- a/src/cli/commands/stop/command.tsx +++ b/src/cli/commands/stop/command.tsx @@ -1,23 +1,11 @@ -import { ConfigIO } from '../../../lib'; import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; +import { getRegion } from '../shared/region-utils'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; import React from 'react'; -async function getRegion(cliRegion?: string): Promise { - if (cliRegion) return cliRegion; - try { - const configIO = new ConfigIO(); - const targets = await configIO.resolveAWSDeploymentTargets(); - if (targets.length > 0) return targets[0]!.region; - } catch { - // Fall through to env vars - } - return process.env.AWS_DEFAULT_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; -} - export const registerStop = (program: Command) => { const stopCmd = program.command('stop').description(COMMAND_DESCRIPTIONS.stop); diff --git a/src/cli/operations/archive/__tests__/archive-storage.test.ts b/src/cli/operations/archive/__tests__/archive-storage.test.ts new file mode 100644 index 000000000..c449b2b68 --- /dev/null +++ b/src/cli/operations/archive/__tests__/archive-storage.test.ts @@ -0,0 +1,114 @@ +import { deleteLocalBatchEvalRun, deleteLocalRecommendationRun } from '../archive-storage.js'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockFindConfigRoot = vi.fn(); + +vi.mock('../../../../lib', () => ({ + findConfigRoot: () => mockFindConfigRoot(), +})); + +function makeTmpDir(): string { + const dir = join(tmpdir(), `archive-storage-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeJsonFile(path: string, data: unknown): void { + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, JSON.stringify(data)); +} + +describe('archive-storage', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + mockFindConfigRoot.mockReturnValue(tmpDir); + }); + + afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + vi.clearAllMocks(); + }); + + describe('deleteLocalBatchEvalRun', () => { + it('deletes the file and returns true when file exists', () => { + const filePath = join(tmpDir, '.cli', 'batch-eval-results', 'eval-123.json'); + writeJsonFile(filePath, { batchEvaluationId: 'eval-123' }); + + const result = deleteLocalBatchEvalRun('eval-123'); + + expect(result).toBe(true); + expect(existsSync(filePath)).toBe(false); + }); + + it('returns false when file does not exist', () => { + const result = deleteLocalBatchEvalRun('nonexistent-id'); + expect(result).toBe(false); + }); + + it('does not throw when the batch-eval-results directory does not exist', () => { + expect(() => deleteLocalBatchEvalRun('any-id')).not.toThrow(); + }); + + it('throws when findConfigRoot returns null', () => { + mockFindConfigRoot.mockReturnValue(null); + expect(() => deleteLocalBatchEvalRun('eval-123')).toThrow('No agentcore project found'); + }); + + it('leaves other files in the directory untouched', () => { + const keep = join(tmpDir, '.cli', 'batch-eval-results', 'keep-me.json'); + const del = join(tmpDir, '.cli', 'batch-eval-results', 'delete-me.json'); + writeJsonFile(keep, { batchEvaluationId: 'keep-me' }); + writeJsonFile(del, { batchEvaluationId: 'delete-me' }); + + deleteLocalBatchEvalRun('delete-me'); + + expect(existsSync(keep)).toBe(true); + expect(existsSync(del)).toBe(false); + }); + }); + + describe('deleteLocalRecommendationRun', () => { + it('deletes the file and returns true when file exists', () => { + const filePath = join(tmpDir, '.cli', 'recommendations', 'rec-456.json'); + writeJsonFile(filePath, { recommendationId: 'rec-456' }); + + const result = deleteLocalRecommendationRun('rec-456'); + + expect(result).toBe(true); + expect(existsSync(filePath)).toBe(false); + }); + + it('returns false when file does not exist', () => { + const result = deleteLocalRecommendationRun('nonexistent-id'); + expect(result).toBe(false); + }); + + it('does not throw when the recommendations directory does not exist', () => { + expect(() => deleteLocalRecommendationRun('any-id')).not.toThrow(); + }); + + it('throws when findConfigRoot returns null', () => { + mockFindConfigRoot.mockReturnValue(null); + expect(() => deleteLocalRecommendationRun('rec-456')).toThrow('No agentcore project found'); + }); + + it('leaves other files in the directory untouched', () => { + const keep = join(tmpDir, '.cli', 'recommendations', 'keep-me.json'); + const del = join(tmpDir, '.cli', 'recommendations', 'delete-me.json'); + writeJsonFile(keep, { recommendationId: 'keep-me' }); + writeJsonFile(del, { recommendationId: 'delete-me' }); + + deleteLocalRecommendationRun('delete-me'); + + expect(existsSync(keep)).toBe(true); + expect(existsSync(del)).toBe(false); + }); + }); +}); diff --git a/src/cli/operations/archive/archive-storage.ts b/src/cli/operations/archive/archive-storage.ts new file mode 100644 index 000000000..0945ec906 --- /dev/null +++ b/src/cli/operations/archive/archive-storage.ts @@ -0,0 +1,35 @@ +import { findConfigRoot } from '../../../lib'; +import { BATCH_EVAL_RESULTS_DIR } from '../eval/batch-eval-storage'; +import { RECOMMENDATIONS_DIR } from '../recommendation/recommendation-storage'; +import { existsSync, rmSync } from 'fs'; +import { join } from 'path'; + +function getCliDir(): string { + const configRoot = findConfigRoot(); + if (!configRoot) { + throw new Error('No agentcore project found. Run `agentcore create` first.'); + } + return join(configRoot, '.cli'); +} + +/** + * Delete the local batch eval run record for the given ID. + * Returns true if the file existed and was deleted, false if it was not found. + */ +export function deleteLocalBatchEvalRun(batchEvaluationId: string): boolean { + const filePath = join(getCliDir(), BATCH_EVAL_RESULTS_DIR, `${batchEvaluationId}.json`); + if (!existsSync(filePath)) return false; + rmSync(filePath); + return true; +} + +/** + * Delete the local recommendation run record for the given ID. + * Returns true if the file existed and was deleted, false if it was not found. + */ +export function deleteLocalRecommendationRun(recommendationId: string): boolean { + const filePath = join(getCliDir(), RECOMMENDATIONS_DIR, `${recommendationId}.json`); + if (!existsSync(filePath)) return false; + rmSync(filePath); + return true; +} diff --git a/src/cli/operations/archive/index.ts b/src/cli/operations/archive/index.ts new file mode 100644 index 000000000..0f5d523ba --- /dev/null +++ b/src/cli/operations/archive/index.ts @@ -0,0 +1 @@ +export { deleteLocalBatchEvalRun, deleteLocalRecommendationRun } from './archive-storage'; diff --git a/src/cli/operations/eval/batch-eval-storage.ts b/src/cli/operations/eval/batch-eval-storage.ts index 3145120ba..9b55e5240 100644 --- a/src/cli/operations/eval/batch-eval-storage.ts +++ b/src/cli/operations/eval/batch-eval-storage.ts @@ -4,7 +4,7 @@ import type { BatchEvaluationResult, RunBatchEvaluationCommandResult } from './r import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'; import { join } from 'path'; -const BATCH_EVAL_RESULTS_DIR = 'batch-eval-results'; +export const BATCH_EVAL_RESULTS_DIR = 'batch-eval-results'; export interface BatchEvalRunRecord { name: string; diff --git a/src/cli/operations/recommendation/recommendation-storage.ts b/src/cli/operations/recommendation/recommendation-storage.ts index e60846574..ad8aa7160 100644 --- a/src/cli/operations/recommendation/recommendation-storage.ts +++ b/src/cli/operations/recommendation/recommendation-storage.ts @@ -4,7 +4,7 @@ import type { RunRecommendationCommandResult } from './types'; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'; import { join } from 'path'; -const RECOMMENDATIONS_DIR = 'recommendations'; +export const RECOMMENDATIONS_DIR = 'recommendations'; export interface RecommendationRunRecord { recommendationId: string; diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 5ed1bff33..81185f394 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -54,6 +54,7 @@ export const COMMAND_DESCRIPTIONS = { update: 'Check for and install CLI updates', validate: 'Validate agentcore/ config files.', 'config-bundle': '[preview] Manage configuration bundle versions and diffs.', + archive: '[preview] Archive (delete) a batch evaluation or recommendation on the service and clear local history.', } as const; /** @@ -120,4 +121,15 @@ export const CLI_ONLY_EXAMPLES: Record', ], }, + archive: { + description: 'Archive (delete) a batch evaluation or recommendation on the service and clear local history.', + examples: [ + 'agentcore archive batch-evaluation -i ', + 'agentcore archive batch-evaluation -i --region us-west-2', + 'agentcore archive batch-evaluation -i --json', + 'agentcore archive recommendation -i ', + 'agentcore archive recommendation -i --region us-west-2', + 'agentcore archive recommendation -i --json', + ], + }, }; diff --git a/src/cli/tui/utils/commands.ts b/src/cli/tui/utils/commands.ts index 912c6a7f6..8014e5d98 100644 --- a/src/cli/tui/utils/commands.ts +++ b/src/cli/tui/utils/commands.ts @@ -17,7 +17,7 @@ const HIDDEN_FROM_TUI = ['help', 'telemetry', 'promote'] as const; /** * Commands that are CLI-only (shown but marked as requiring CLI invocation). */ -const CLI_ONLY_COMMANDS = ['logs', 'traces', 'pause', 'resume', 'stop'] as const; +const CLI_ONLY_COMMANDS = ['logs', 'traces', 'pause', 'resume', 'stop', 'archive'] as const; /** * Commands hidden from TUI when inside an existing project. From b294f835f1f0296e2e64d44b381a9008561d1c7a Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Mon, 4 May 2026 15:36:49 -0400 Subject: [PATCH 2/3] Update iam policy for delete batch evals/recommendation --- docs/policies/iam-policy-user.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/policies/iam-policy-user.json b/docs/policies/iam-policy-user.json index 03bcafee7..b7fa29118 100644 --- a/docs/policies/iam-policy-user.json +++ b/docs/policies/iam-policy-user.json @@ -292,9 +292,12 @@ "bedrock-agentcore:StartBatchEvaluation", "bedrock-agentcore:GetBatchEvaluation", "bedrock-agentcore:ListBatchEvaluations", + "bedrock-agentcore:StopBatchEvaluation", + "bedrock-agentcore:DeleteBatchEvaluation", "bedrock-agentcore:StartRecommendation", "bedrock-agentcore:GetRecommendation", - "bedrock-agentcore:ListRecommendations" + "bedrock-agentcore:ListRecommendations", + "bedrock-agentcore:DeleteRecommendation" ], "Resource": "*" } From ccb556cee105adc1a61d1749a184ff2632de7f56 Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Mon, 4 May 2026 16:04:04 -0400 Subject: [PATCH 3/3] guard archive against path traversal in --id argument --- .../archive/__tests__/archive-storage.test.ts | 16 ++++++++++++++++ src/cli/operations/archive/archive-storage.ts | 8 ++++++++ 2 files changed, 24 insertions(+) diff --git a/src/cli/operations/archive/__tests__/archive-storage.test.ts b/src/cli/operations/archive/__tests__/archive-storage.test.ts index c449b2b68..9ebb41fd5 100644 --- a/src/cli/operations/archive/__tests__/archive-storage.test.ts +++ b/src/cli/operations/archive/__tests__/archive-storage.test.ts @@ -61,6 +61,14 @@ describe('archive-storage', () => { expect(() => deleteLocalBatchEvalRun('eval-123')).toThrow('No agentcore project found'); }); + it('throws when id contains a forward slash', () => { + expect(() => deleteLocalBatchEvalRun('../evil')).toThrow('Invalid batch evaluation ID'); + }); + + it('throws when id contains a backslash', () => { + expect(() => deleteLocalBatchEvalRun('evil\\path')).toThrow('Invalid batch evaluation ID'); + }); + it('leaves other files in the directory untouched', () => { const keep = join(tmpDir, '.cli', 'batch-eval-results', 'keep-me.json'); const del = join(tmpDir, '.cli', 'batch-eval-results', 'delete-me.json'); @@ -99,6 +107,14 @@ describe('archive-storage', () => { expect(() => deleteLocalRecommendationRun('rec-456')).toThrow('No agentcore project found'); }); + it('throws when id contains a forward slash', () => { + expect(() => deleteLocalRecommendationRun('../evil')).toThrow('Invalid recommendation ID'); + }); + + it('throws when id contains a backslash', () => { + expect(() => deleteLocalRecommendationRun('evil\\path')).toThrow('Invalid recommendation ID'); + }); + it('leaves other files in the directory untouched', () => { const keep = join(tmpDir, '.cli', 'recommendations', 'keep-me.json'); const del = join(tmpDir, '.cli', 'recommendations', 'delete-me.json'); diff --git a/src/cli/operations/archive/archive-storage.ts b/src/cli/operations/archive/archive-storage.ts index 0945ec906..5b4481fda 100644 --- a/src/cli/operations/archive/archive-storage.ts +++ b/src/cli/operations/archive/archive-storage.ts @@ -12,11 +12,18 @@ function getCliDir(): string { return join(configRoot, '.cli'); } +function assertSafeId(id: string, label: string): void { + if (/[/\\]/.test(id)) { + throw new Error(`Invalid ${label}: must not contain path separators`); + } +} + /** * Delete the local batch eval run record for the given ID. * Returns true if the file existed and was deleted, false if it was not found. */ export function deleteLocalBatchEvalRun(batchEvaluationId: string): boolean { + assertSafeId(batchEvaluationId, 'batch evaluation ID'); const filePath = join(getCliDir(), BATCH_EVAL_RESULTS_DIR, `${batchEvaluationId}.json`); if (!existsSync(filePath)) return false; rmSync(filePath); @@ -28,6 +35,7 @@ export function deleteLocalBatchEvalRun(batchEvaluationId: string): boolean { * Returns true if the file existed and was deleted, false if it was not found. */ export function deleteLocalRecommendationRun(recommendationId: string): boolean { + assertSafeId(recommendationId, 'recommendation ID'); const filePath = join(getCliDir(), RECOMMENDATIONS_DIR, `${recommendationId}.json`); if (!existsSync(filePath)) return false; rmSync(filePath);