From eea25e4371721c0da85d54945049d55b39d2ff2a Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Tue, 5 May 2026 20:47:29 +0000 Subject: [PATCH] refactor: unify result types with discriminated Result union Introduce a shared Result type (inspired by Rust's Result) that replaces ad-hoc { success: boolean; error?: string } patterns across the codebase. Key changes: - Add src/lib/types.ts with Result discriminated union type - Add toError() helper in src/cli/errors.ts for catch blocks - Migrate all command, operation, and primitive result types to Result - Error field is now Error (not string) on the failure branch - Data fields only exist on the success branch (proper narrowing) - Update all consumers to narrow before accessing branch-specific fields - Update test assertions to match new Error objects and add narrowing --- .../aws/__tests__/transaction-search.test.ts | 21 ++-- src/cli/aws/index.ts | 2 +- src/cli/aws/transaction-search.ts | 38 ++++--- src/cli/commands/add/types.ts | 32 ------ src/cli/commands/create/action.ts | 20 ++-- src/cli/commands/create/command.tsx | 10 +- src/cli/commands/create/types.ts | 19 ++-- src/cli/commands/deploy/actions.ts | 33 +++--- src/cli/commands/deploy/command.tsx | 5 +- src/cli/commands/deploy/types.ts | 31 ++--- src/cli/commands/dev/browser-mode.ts | 16 ++- src/cli/commands/eval/command.tsx | 5 +- .../import/__tests__/idempotency.test.ts | 27 +++-- .../__tests__/import-gateway-flow.test.ts | 43 +++---- .../import/__tests__/import-no-deploy.test.ts | 10 +- .../__tests__/import-runtime-handler.test.ts | 37 +++--- .../__tests__/phase-failure-rollback.test.ts | 23 ++-- src/cli/commands/import/actions.ts | 23 ++-- src/cli/commands/import/import-evaluator.ts | 2 +- src/cli/commands/import/import-gateway.ts | 6 +- src/cli/commands/import/import-memory.ts | 2 +- src/cli/commands/import/import-online-eval.ts | 2 +- src/cli/commands/import/import-runtime.ts | 2 +- src/cli/commands/import/import-utils.ts | 2 +- src/cli/commands/import/resource-import.ts | 4 +- src/cli/commands/import/types.ts | 30 +++-- src/cli/commands/invoke/action.ts | 106 +++++++++++++----- src/cli/commands/invoke/command.tsx | 5 +- src/cli/commands/invoke/types.ts | 27 +++-- .../commands/logs/__tests__/action.test.ts | 12 +- src/cli/commands/logs/action.ts | 17 ++- src/cli/commands/logs/command.tsx | 4 +- src/cli/commands/pause/command.tsx | 6 +- src/cli/commands/remove/command.tsx | 8 +- src/cli/commands/remove/types.ts | 8 +- src/cli/commands/run/command.tsx | 22 ++-- .../commands/status/__tests__/action.test.ts | 57 ++++++---- src/cli/commands/status/action.ts | 51 +++++---- src/cli/commands/status/command.tsx | 15 ++- src/cli/commands/traces/action.ts | 31 +++-- src/cli/commands/traces/command.tsx | 4 +- .../validate/__tests__/action.test.ts | 41 +++---- src/cli/commands/validate/action.ts | 16 +-- src/cli/commands/validate/command.tsx | 2 +- src/cli/commands/validate/index.ts | 2 +- src/cli/errors.ts | 29 +++-- src/cli/operations/agent/import/index.ts | 8 +- src/cli/operations/deploy/index.ts | 3 +- .../deploy/post-deploy-observability.ts | 10 +- src/cli/operations/deploy/teardown.ts | 8 +- .../eval/__tests__/list-eval-runs.test.ts | 25 +++-- .../eval/__tests__/logs-eval.test.ts | 15 +-- .../eval/__tests__/pause-resume.test.ts | 37 +++--- .../eval/__tests__/run-eval.test.ts | 78 ++++++------- src/cli/operations/eval/index.ts | 1 - src/cli/operations/eval/list-eval-runs.ts | 11 +- src/cli/operations/eval/logs-eval.ts | 13 +-- src/cli/operations/eval/pause-resume.ts | 49 ++++---- .../operations/eval/run-batch-evaluation.ts | 22 ++-- src/cli/operations/eval/run-eval.ts | 51 +++++---- .../mcp/__tests__/create-mcp-utils.test.ts | 5 +- .../memory/__tests__/create-memory.test.ts | 7 +- .../operations/memory/list-memory-records.ts | 16 +-- .../memory/retrieve-memory-records.ts | 16 +-- .../__tests__/apply-to-bundle.test.ts | 15 +-- .../__tests__/recommendation-storage.test.ts | 4 +- .../__tests__/run-recommendation.test.ts | 49 ++++---- .../recommendation/apply-to-bundle.ts | 18 ++- .../recommendation/recommendation-storage.ts | 6 +- .../recommendation/run-recommendation.ts | 39 ++++--- src/cli/operations/recommendation/types.ts | 22 ++-- .../remove/__tests__/remove-agent-ops.test.ts | 5 +- .../__tests__/remove-gateway-ops.test.ts | 5 +- .../__tests__/remove-identity-ops.test.ts | 5 +- .../__tests__/remove-memory-ops.test.ts | 5 +- .../remove/remove-gateway-target.ts | 19 ++-- src/cli/operations/remove/types.ts | 5 - src/cli/operations/resolve-agent.ts | 26 +++-- .../traces/__tests__/get-trace.test.ts | 47 ++++---- .../traces/__tests__/list-traces.test.ts | 23 ++-- src/cli/operations/traces/get-trace.ts | 39 ++++--- src/cli/operations/traces/insights-query.ts | 20 ++-- src/cli/operations/traces/list-traces.ts | 2 +- src/cli/operations/traces/types.ts | 22 ++-- src/cli/primitives/ABTestPrimitive.ts | 34 +++--- src/cli/primitives/AgentPrimitive.tsx | 38 ++++--- src/cli/primitives/BasePrimitive.ts | 8 +- src/cli/primitives/ConfigBundlePrimitive.ts | 24 ++-- src/cli/primitives/CredentialPrimitive.tsx | 32 +++--- src/cli/primitives/EvaluatorPrimitive.ts | 28 ++--- src/cli/primitives/GatewayPrimitive.ts | 26 ++--- src/cli/primitives/GatewayTargetPrimitive.ts | 39 ++++--- src/cli/primitives/MemoryPrimitive.tsx | 24 ++-- .../primitives/OnlineEvalConfigPrimitive.ts | 28 ++--- src/cli/primitives/PolicyEnginePrimitive.ts | 28 ++--- src/cli/primitives/PolicyPrimitive.ts | 65 ++++++----- .../primitives/RuntimeEndpointPrimitive.ts | 40 ++++--- .../__tests__/ABTestPrimitive.test.ts | 15 ++- .../__tests__/BasePrimitive.test.ts | 6 +- .../__tests__/EvaluatorPrimitive.test.ts | 17 +-- .../OnlineEvalConfigPrimitive.test.ts | 13 ++- .../RuntimeEndpointPrimitive.test.ts | 29 ++++- src/cli/primitives/index.ts | 2 +- src/cli/primitives/types.ts | 13 +-- src/cli/telemetry/cli-command-run.ts | 12 +- src/cli/tui/hooks/useCreateABTest.ts | 4 +- src/cli/tui/hooks/useCreateConfigBundle.ts | 2 +- src/cli/tui/hooks/useCreateEvaluator.ts | 2 +- src/cli/tui/hooks/useCreateMcp.ts | 2 +- src/cli/tui/hooks/useCreateMemory.ts | 2 +- src/cli/tui/hooks/useCreateOnlineEval.ts | 2 +- src/cli/tui/hooks/useRemove.ts | 11 +- src/cli/tui/screens/agent/useAddAgent.ts | 15 +-- src/cli/tui/screens/create/useCreateFlow.ts | 2 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 4 +- src/cli/tui/screens/eval/EvalScreen.tsx | 2 +- .../tui/screens/identity/useCreateIdentity.ts | 2 +- src/cli/tui/screens/import/ImportFlow.tsx | 2 +- .../screens/import/ImportProgressScreen.tsx | 8 +- .../online-eval/OnlineEvalDashboard.tsx | 2 +- src/cli/tui/screens/policy/AddPolicyFlow.tsx | 4 +- .../recommendation/RecommendationFlow.tsx | 19 +++- .../recommendation/RecommendationScreen.tsx | 2 +- src/cli/tui/screens/remove/RemoveFlow.tsx | 48 ++++---- .../tui/screens/run-eval/RunBatchEvalFlow.tsx | 6 +- src/cli/tui/screens/run-eval/RunEvalFlow.tsx | 10 +- .../tui/screens/run-eval/RunEvalScreen.tsx | 2 +- .../AddRuntimeEndpointFlow.tsx | 2 +- src/cli/tui/screens/status/useStatusFlow.ts | 2 +- src/lib/__tests__/result.test.ts | 18 +++ src/lib/errors/index.ts | 1 + src/lib/errors/types.ts | 91 +++++++++++++++ src/lib/index.ts | 1 + src/lib/result.ts | 37 ++++++ src/lib/schemas/io/path-resolver.ts | 12 +- 135 files changed, 1415 insertions(+), 1082 deletions(-) create mode 100644 src/lib/__tests__/result.test.ts create mode 100644 src/lib/errors/types.ts create mode 100644 src/lib/result.ts diff --git a/src/cli/aws/__tests__/transaction-search.test.ts b/src/cli/aws/__tests__/transaction-search.test.ts index 67ccd0ac6..307063a61 100644 --- a/src/cli/aws/__tests__/transaction-search.test.ts +++ b/src/cli/aws/__tests__/transaction-search.test.ts @@ -1,4 +1,5 @@ import { enableTransactionSearch } from '../transaction-search.js'; +import assert from 'node:assert'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const { mockAppSignalsSend, mockLogsSend, mockXRaySend } = vi.hoisted(() => ({ @@ -162,8 +163,8 @@ describe('enableTransactionSearch', () => { const result = await enableTransactionSearch('us-east-1', '123456789012'); - expect(result.success).toBe(false); - expect(result.error).toContain('Insufficient permissions to enable Application Signals'); + assert(!result.success); + expect(result.error.message).toContain('Insufficient permissions to enable Application Signals'); }); it('returns error when Application Signals fails with generic error', async () => { @@ -171,8 +172,8 @@ describe('enableTransactionSearch', () => { const result = await enableTransactionSearch('us-east-1', '123456789012'); - expect(result.success).toBe(false); - expect(result.error).toContain('Failed to enable Application Signals'); + assert(!result.success); + expect(result.error.message).toContain('Failed to enable Application Signals'); }); it('returns error when CloudWatch Logs policy fails with AccessDenied', async () => { @@ -183,8 +184,8 @@ describe('enableTransactionSearch', () => { const result = await enableTransactionSearch('us-east-1', '123456789012'); - expect(result.success).toBe(false); - expect(result.error).toContain('Insufficient permissions to configure CloudWatch Logs policy'); + assert(!result.success); + expect(result.error.message).toContain('Insufficient permissions to configure CloudWatch Logs policy'); }); it('returns error when trace destination fails', async () => { @@ -194,8 +195,8 @@ describe('enableTransactionSearch', () => { const result = await enableTransactionSearch('us-east-1', '123456789012'); - expect(result.success).toBe(false); - expect(result.error).toContain('Failed to configure trace destination'); + assert(!result.success); + expect(result.error.message).toContain('Failed to configure trace destination'); }); it('returns error when indexing rule update fails', async () => { @@ -214,8 +215,8 @@ describe('enableTransactionSearch', () => { const result = await enableTransactionSearch('us-east-1', '123456789012'); - expect(result.success).toBe(false); - expect(result.error).toContain('Failed to configure indexing rules'); + assert(!result.success); + expect(result.error.message).toContain('Failed to configure indexing rules'); }); it('does not proceed to later steps when an earlier step fails', async () => { diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts index c44ff37f4..7c87fec34 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -17,7 +17,7 @@ export { type GetAgentRuntimeStatusOptions, } from './agentcore-control'; export { streamLogs, searchLogs, type LogEvent, type StreamLogsOptions, type SearchLogsOptions } from './cloudwatch'; -export { enableTransactionSearch, type TransactionSearchEnableResult } from './transaction-search'; +export { enableTransactionSearch } from './transaction-search'; export { startPolicyGeneration, getPolicyGeneration, diff --git a/src/cli/aws/transaction-search.ts b/src/cli/aws/transaction-search.ts index 3a3b53fa6..50820806a 100644 --- a/src/cli/aws/transaction-search.ts +++ b/src/cli/aws/transaction-search.ts @@ -1,4 +1,5 @@ -import { getErrorMessage, isAccessDeniedError } from '../errors'; +import type { Result } from '../../lib/result'; +import { AccessDeniedError, getErrorMessage, isAccessDeniedError } from '../errors'; import { getCredentialProvider } from './account'; import { arnPrefix } from './partition'; import { ApplicationSignalsClient, StartDiscoveryCommand } from '@aws-sdk/client-application-signals'; @@ -14,11 +15,6 @@ import { XRayClient, } from '@aws-sdk/client-xray'; -export interface TransactionSearchEnableResult { - success: boolean; - error?: string; -} - const RESOURCE_POLICY_NAME = 'TransactionSearchXRayAccess'; /** @@ -34,7 +30,7 @@ export async function enableTransactionSearch( region: string, accountId: string, indexPercentage = 100 -): Promise { +): Promise { const credentials = getCredentialProvider(); // Step 1: Enable Application Signals (creates service-linked role, idempotent) @@ -44,9 +40,12 @@ export async function enableTransactionSearch( } catch (err: unknown) { const message = getErrorMessage(err); if (isAccessDeniedError(err)) { - return { success: false, error: `Insufficient permissions to enable Application Signals: ${message}` }; + return { + success: false, + error: new AccessDeniedError(`Insufficient permissions to enable Application Signals: ${message}`), + }; } - return { success: false, error: `Failed to enable Application Signals: ${message}` }; + return { success: false, error: new Error(`Failed to enable Application Signals: ${message}`) }; } // Step 2: Create CloudWatch Logs resource policy for X-Ray (if needed) @@ -80,9 +79,12 @@ export async function enableTransactionSearch( } catch (err: unknown) { const message = getErrorMessage(err); if (isAccessDeniedError(err)) { - return { success: false, error: `Insufficient permissions to configure CloudWatch Logs policy: ${message}` }; + return { + success: false, + error: new AccessDeniedError(`Insufficient permissions to configure CloudWatch Logs policy: ${message}`), + }; } - return { success: false, error: `Failed to configure CloudWatch Logs policy: ${message}` }; + return { success: false, error: new Error(`Failed to configure CloudWatch Logs policy: ${message}`) }; } const xrayClient = new XRayClient({ region, credentials }); @@ -96,9 +98,12 @@ export async function enableTransactionSearch( } catch (err: unknown) { const message = getErrorMessage(err); if (isAccessDeniedError(err)) { - return { success: false, error: `Insufficient permissions to configure trace destination: ${message}` }; + return { + success: false, + error: new AccessDeniedError(`Insufficient permissions to configure trace destination: ${message}`), + }; } - return { success: false, error: `Failed to configure trace destination: ${message}` }; + return { success: false, error: new Error(`Failed to configure trace destination: ${message}`) }; } // Step 4: Set indexing to 100% on the built-in Default rule (always exists, idempotent) @@ -112,9 +117,12 @@ export async function enableTransactionSearch( } catch (err: unknown) { const message = getErrorMessage(err); if (isAccessDeniedError(err)) { - return { success: false, error: `Insufficient permissions to configure indexing rules: ${message}` }; + return { + success: false, + error: new AccessDeniedError(`Insufficient permissions to configure indexing rules: ${message}`), + }; } - return { success: false, error: `Failed to configure indexing rules: ${message}` }; + return { success: false, error: new Error(`Failed to configure indexing rules: ${message}`) }; } return { success: true }; diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index a066b1cc3..c1dd6641f 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -41,13 +41,6 @@ export interface AddAgentOptions extends VpcOptions { json?: boolean; } -export interface AddAgentResult { - success: boolean; - agentName?: string; - agentPath?: string; - error?: string; -} - // Gateway types export interface AddGatewayOptions { name?: string; @@ -68,12 +61,6 @@ export interface AddGatewayOptions { json?: boolean; } -export interface AddGatewayResult { - success: boolean; - gatewayName?: string; - error?: string; -} - // Gateway Target types export interface AddGatewayTargetOptions { name?: string; @@ -100,13 +87,6 @@ export interface AddGatewayTargetOptions { json?: boolean; } -export interface AddGatewayTargetResult { - success: boolean; - toolName?: string; - sourcePath?: string; - error?: string; -} - // Memory types (v2: no owner/user concept) export interface AddMemoryOptions { name?: string; @@ -119,12 +99,6 @@ export interface AddMemoryOptions { json?: boolean; } -export interface AddMemoryResult { - success: boolean; - memoryName?: string; - error?: string; -} - // Credential types (v2: credential, no owner/user concept) export interface AddCredentialOptions { name?: string; @@ -139,9 +113,3 @@ export interface AddCredentialOptions { /** @deprecated Use AddCredentialOptions */ export type AddIdentityOptions = AddCredentialOptions; - -export interface AddCredentialResult { - success: boolean; - credentialName?: string; - error?: string; -} diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index a00397f38..cc5e59696 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -8,7 +8,7 @@ import type { SDKFramework, TargetLanguage, } from '../../../schema'; -import { getErrorMessage } from '../../errors'; +import { DependencyCheckError, GitInitError, toError } from '../../errors'; import { checkCreateDependencies } from '../../external-requirements'; import { initGitRepo, setupPythonProject, writeEnvFile, writeGitignore } from '../../operations'; import { createConfigBundleForAgent } from '../../operations/agent/config-bundle-defaults'; @@ -57,7 +57,7 @@ export async function createProject(options: CreateProjectOptions): Promise 0 ? depWarnings : undefined, }; } catch (err) { - return { success: false, error: getErrorMessage(err), warnings: depWarnings }; + return { success: false, error: toError(err), warnings: depWarnings }; } } @@ -174,7 +178,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P // Fail on errors if (!depCheck.passed) { - return { success: false, error: depCheck.errors.join('\n'), warnings: depWarnings }; + return { success: false, error: new DependencyCheckError(depCheck.errors), warnings: depWarnings }; } // First create the base project (skip dependency check since we already did it) @@ -207,7 +211,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P }); if (!importResult.success) { onProgress?.('Import agent from Bedrock', 'error'); - return { success: false, error: importResult.error, warnings: depWarnings }; + return { ...importResult, warnings: depWarnings.length > 0 ? depWarnings : undefined }; } onProgress?.('Import agent from Bedrock', 'done'); return { @@ -217,7 +221,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P warnings: depWarnings.length > 0 ? depWarnings : undefined, }; } catch (err) { - return { success: false, error: getErrorMessage(err), warnings: depWarnings }; + return { success: false, error: toError(err), warnings: depWarnings }; } } @@ -310,7 +314,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P warnings: depWarnings.length > 0 ? depWarnings : undefined, }; } catch (err) { - return { success: false, error: getErrorMessage(err), warnings: depWarnings }; + return { success: false, error: toError(err), warnings: depWarnings }; } } diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 42c8c7403..103ce4833 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -1,4 +1,4 @@ -import { getWorkingDirectory } from '../../../lib'; +import { getWorkingDirectory, resultToJson } from '../../../lib'; import type { BuildType, ModelProvider, @@ -93,8 +93,8 @@ async function handleCreateCLI(options: CreateOptions): Promise { if (options.dryRun) { const result = getDryRunInfo({ name: name!, projectName, cwd, language: options.language }); if (options.json) { - console.log(JSON.stringify(result)); - } else { + console.log(resultToJson(result)); + } else if (result.success) { console.log('Dry run - would create:'); for (const path of result.wouldCreate ?? []) { console.log(` ${path}`); @@ -158,7 +158,7 @@ async function handleCreateCLI(options: CreateOptions): Promise { }); if (options.json) { - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); } else if (result.success) { printCreateSummary(projectName!, result.agentName, options.language, options.framework); if (options.skipInstall) { @@ -167,7 +167,7 @@ async function handleCreateCLI(options: CreateOptions): Promise { ); } } else { - console.error(result.error); + console.error(result.error.message); } process.exit(result.success ? 0 : 1); diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index f762a36eb..cb2991284 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -28,12 +28,13 @@ export interface CreateOptions extends VpcOptions { json?: boolean; } -export interface CreateResult { - success: boolean; - projectPath?: string; - agentName?: string; - error?: string; - dryRun?: boolean; - wouldCreate?: string[]; - warnings?: string[]; -} +export type CreateResult = + | { + success: true; + projectPath?: string; + agentName?: string; + dryRun?: boolean; + wouldCreate?: string[]; + warnings?: string[]; + } + | { success: false; error: Error; warnings?: string[] }; diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index acae6fb03..30d035e25 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -15,7 +15,7 @@ import { parsePolicyOutputs, parseRuntimeEndpointOutputs, } from '../../cloudformation'; -import { getErrorMessage } from '../../errors'; +import { ResourceNotFoundError, ValidationError, getErrorMessage, toError } from '../../errors'; import { ExecLogger } from '../../logging'; import { bootstrapEnvironment, @@ -85,7 +85,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { } if (options.json) { - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); } else if (result.success) { if (options.diff) { console.log(`\n✓ Diff complete for '${result.targetName}' (stack: ${result.stackName})`); @@ -125,7 +126,7 @@ async function handleDeployCLI(options: DeployOptions): Promise { console.log(`\nLog: ${result.logPath}`); } } else { - console.error(result.error); + console.error(result.error.message); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/deploy/types.ts b/src/cli/commands/deploy/types.ts index 44cdc7847..bd53b6c22 100644 --- a/src/cli/commands/deploy/types.ts +++ b/src/cli/commands/deploy/types.ts @@ -1,3 +1,5 @@ +import type { Result } from '../../../lib/result'; + export interface DeployOptions { target?: string; yes?: boolean; @@ -8,21 +10,20 @@ export interface DeployOptions { diff?: boolean; } -export interface DeployResult { - success: boolean; - targetName?: string; - stackName?: string; - outputs?: Record; - logPath?: string; - nextSteps?: string[]; - notes?: string[]; - postDeployWarnings?: string[]; - error?: string; -} +export type DeployResult = + | { + success: true; + targetName?: string; + stackName?: string; + outputs?: Record; + logPath?: string; + nextSteps?: string[]; + notes?: string[]; + postDeployWarnings?: string[]; + } + | { success: false; error: Error; logPath?: string }; -export interface PreflightResult { - success: boolean; +export type PreflightResult = Result<{ stackNames?: string[]; needsBootstrap?: boolean; - error?: string; -} +}>; diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index c35113e6d..e87fee187 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -63,24 +63,26 @@ async function resolveDeployedHandlers( result.onListMemoryRecords = async (memoryName, namespace, strategyId) => { const memory = memories.find(m => m.name === memoryName); if (!memory) return { success: false, error: `Memory "${memoryName}" not found in deployed state` }; - return listMemoryRecords({ + const res = await listMemoryRecords({ region: memory.region, memoryId: memory.memoryId, namespace, memoryStrategyId: strategyId, }); + return res.success ? res : { success: false as const, error: res.error.message }; }; result.onRetrieveMemoryRecords = async (memoryName, namespace, searchQuery, strategyId) => { const memory = memories.find(m => m.name === memoryName); if (!memory) return { success: false, error: `Memory "${memoryName}" not found in deployed state` }; - return retrieveMemoryRecords({ + const res = await retrieveMemoryRecords({ region: memory.region, memoryId: memory.memoryId, namespace, searchQuery, memoryStrategyId: strategyId, }); + return res.success ? res : { success: false as const, error: res.error.message }; }; } @@ -199,14 +201,15 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { const configIO = new ConfigIO({ baseDir }); const context = await loadDeployedProjectConfig(configIO); const resolved = resolveAgent(context, { runtime: agentName }); - if (!resolved.success) return { success: false, error: resolved.error }; - return listTraces({ + if (!resolved.success) return { success: false, error: resolved.error.message }; + const res = await listTraces({ region: resolved.agent.region, runtimeId: resolved.agent.runtimeId, agentName: resolved.agent.agentName, startTime, endTime, }); + return res.success ? res : { success: false as const, error: res.error.message }; } catch (err) { return { success: false, @@ -219,8 +222,8 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { const configIO = new ConfigIO({ baseDir }); const context = await loadDeployedProjectConfig(configIO); const resolved = resolveAgent(context, { runtime: agentName }); - if (!resolved.success) return { success: false, error: resolved.error }; - return fetchTraceRecords({ + if (!resolved.success) return { success: false, error: resolved.error.message }; + const res = await fetchTraceRecords({ region: resolved.agent.region, runtimeId: resolved.agent.runtimeId, traceId, @@ -228,6 +231,7 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { endTime, includeSpans: true, }); + return res.success ? res : { success: false as const, error: res.error.message }; } catch (err) { return { success: false, diff --git a/src/cli/commands/eval/command.tsx b/src/cli/commands/eval/command.tsx index 9dff195c4..79e3d4a69 100644 --- a/src/cli/commands/eval/command.tsx +++ b/src/cli/commands/eval/command.tsx @@ -1,3 +1,4 @@ +import { resultToJson } from '../../../lib'; import { getErrorMessage } from '../../errors'; import { handleListEvalRuns } from '../../operations/eval'; import { getResultsPath } from '../../operations/eval/storage'; @@ -27,13 +28,13 @@ export const registerEval = (program: Command) => { }); if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); process.exit(result.success ? 0 : 1); return; } if (!result.success) { - render({result.error}); + render({result.error.message}); process.exit(1); } diff --git a/src/cli/commands/import/__tests__/idempotency.test.ts b/src/cli/commands/import/__tests__/idempotency.test.ts index 4b1938e28..4dc0d2374 100644 --- a/src/cli/commands/import/__tests__/idempotency.test.ts +++ b/src/cli/commands/import/__tests__/idempotency.test.ts @@ -10,6 +10,7 @@ // ── Import the function under test AFTER mocks ──────────────────────────────── import { handleImport } from '../actions'; import type { ParsedStarterToolkitConfig } from '../types'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // ── Hoisted mock fns (available inside vi.mock factories) ───────────────────── @@ -73,11 +74,15 @@ const { // ── Module mocks ────────────────────────────────────────────────────────────── -vi.mock('../../../../lib', () => ({ - APP_DIR: 'app', - ConfigIO: MockConfigIOClass, - findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), -})); +vi.mock('../../../../lib', async importOriginal => { + const actual = await importOriginal(); + return { + APP_DIR: 'app', + ConfigIO: MockConfigIOClass, + NoProjectError: actual.NoProjectError, + findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), + }; +}); vi.mock('../../../aws/account', () => ({ validateAwsCredentials: (...args: unknown[]) => mockValidateAwsCredentials(...args), @@ -279,7 +284,7 @@ describe('Import Idempotency (Test Group 7)', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(true); + assert(result.success); expect(result.importedAgents).toContain('my-agent'); expect(result.importedMemories).toContain('my-memory'); @@ -393,7 +398,7 @@ describe('Import Idempotency (Test Group 7)', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(true); + assert(result.success); expect(result.importedAgents).toEqual([]); expect(result.importedMemories).toEqual([]); }); @@ -610,8 +615,8 @@ describe('Import Idempotency (Test Group 7)', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('No agents found'); + assert(!result.success); + expect(result.error.message).toContain('No agents found'); }); it('returns error when no project found', async () => { @@ -619,8 +624,8 @@ describe('Import Idempotency (Test Group 7)', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('No agentcore project found'); + assert(!result.success); + expect(result.error.message).toContain('No agentcore project found'); }); }); diff --git a/src/cli/commands/import/__tests__/import-gateway-flow.test.ts b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts index 183a6e63f..bba806ea5 100644 --- a/src/cli/commands/import/__tests__/import-gateway-flow.test.ts +++ b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts @@ -11,6 +11,7 @@ * - Non-READY gateway warning */ import { handleImportGateway } from '../import-gateway'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // ── Hoisted mock fns ──────────────────────────────────────────────────────── @@ -177,7 +178,7 @@ describe('handleImportGateway', () => { it('successfully imports a gateway with --arn', async () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(true); + assert(result.success); expect(result.resourceId).toBe(GATEWAY_ID); expect(result.resourceType).toBe('gateway'); expect(result.resourceName).toBe(GATEWAY_NAME); @@ -199,8 +200,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(false); - expect(result.error).toBe('Phase 2 failed'); + assert(!result.success); + expect(result.error.message).toBe('Phase 2 failed'); // First call = write merged config, second call = rollback expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); @@ -213,8 +214,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(false); - expect(result.error).toContain('Could not find logical ID'); + assert(!result.success); + expect(result.error.message).toContain('Could not find logical ID'); // First call = write merged config, second call = rollback expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); @@ -231,8 +232,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(false); - expect(result.error).toContain('already exists'); + assert(!result.success); + expect(result.error.message).toContain('already exists'); expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); }); @@ -254,7 +255,7 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(true); + assert(result.success); expect(result.resourceName).toBe('ExistingGateway'); expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1); expect(mockExecuteCdkImportPipeline).toHaveBeenCalled(); @@ -278,7 +279,7 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN, name: 'myCustomName' }); - expect(result.success).toBe(true); + assert(result.success); expect(result.resourceName).toBe('myCustomName'); expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1); expect(mockExecuteCdkImportPipeline).toHaveBeenCalled(); @@ -302,8 +303,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(false); - expect(result.error).toContain('already exists'); + assert(!result.success); + expect(result.error.message).toContain('already exists'); }); }); @@ -315,15 +316,15 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({ arn: GATEWAY_ARN }); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid name'); + assert(!result.success); + expect(result.error.message).toContain('Invalid name'); expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); }); it('uses --name override with original resourceName preserved', async () => { const result = await handleImportGateway({ arn: GATEWAY_ARN, name: 'myCustomName' }); - expect(result.success).toBe(true); + assert(result.success); expect(result.resourceName).toBe('myCustomName'); const writtenSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[0]![0]; @@ -343,7 +344,7 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({}); - expect(result.success).toBe(true); + assert(result.success); expect(result.resourceId).toBe(GATEWAY_ID); expect(mockGetGatewayDetail).toHaveBeenCalledWith({ region: REGION, gatewayId: GATEWAY_ID }); }); @@ -356,8 +357,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({}); - expect(result.success).toBe(false); - expect(result.error).toContain('Multiple gateways found'); + assert(!result.success); + expect(result.error.message).toContain('Multiple gateways found'); }); it('fails when no gateways exist and no --arn', async () => { @@ -365,8 +366,8 @@ describe('handleImportGateway', () => { const result = await handleImportGateway({}); - expect(result.success).toBe(false); - expect(result.error).toContain('No gateways found'); + assert(!result.success); + expect(result.error.message).toContain('No gateways found'); }); }); @@ -399,7 +400,7 @@ describe('handleImportGateway', () => { onProgress: (msg: string) => progressMessages.push(msg), }); - expect(result.success).toBe(true); + assert(result.success); // Verify warning about unmapped targets expect(progressMessages.some(m => m.includes('1 target(s) could not be mapped'))).toBe(true); @@ -414,7 +415,7 @@ describe('handleImportGateway', () => { onProgress: (msg: string) => progressMessages.push(msg), }); - expect(result.success).toBe(true); + assert(result.success); expect(progressMessages.some(m => m.includes('CREATING') && m.includes('not READY'))).toBe(true); }); }); diff --git a/src/cli/commands/import/__tests__/import-no-deploy.test.ts b/src/cli/commands/import/__tests__/import-no-deploy.test.ts index f0b84303f..438cdebf7 100644 --- a/src/cli/commands/import/__tests__/import-no-deploy.test.ts +++ b/src/cli/commands/import/__tests__/import-no-deploy.test.ts @@ -5,6 +5,7 @@ * that were created but never deployed (no agent_id/memory_id in YAML). */ import { parseStarterToolkitYaml } from '../yaml-parser.js'; +import assert from 'node:assert'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -488,7 +489,7 @@ agents: onProgress: (msg: string) => progressMessages.push(msg), }); - expect(result.success).toBe(true); + assert(result.success); expect(result.importedAgents).toEqual([]); expect(result.importedMemories).toEqual([]); expect(result.stackName).toBeDefined(); @@ -645,6 +646,7 @@ agents: const { handleImport } = await import('../actions.js'); const result = await handleImport({ source: yamlPath }); + assert(result.success); expect(result.stackName).toBe('AgentCore-myproject-default'); }); }); @@ -724,7 +726,7 @@ agents: // No physical IDs means target resolution is skipped entirely. // The import succeeds -- config merge + source copy still happen. - expect(result.success).toBe(true); + assert(result.success); expect(result.importedAgents).toEqual([]); expect(result.importedMemories).toEqual([]); }); @@ -768,7 +770,7 @@ agents: const { handleImport } = await import('../actions.js'); const result = await handleImport({ source: yamlPath }); - expect(result.success).toBe(true); + assert(result.success); expect(result.importedAgents).toEqual([]); expect(result.importedMemories).toEqual([]); }); @@ -812,7 +814,7 @@ agents: const { handleImport } = await import('../actions.js'); const result = await handleImport({ source: yamlPath }); - expect(result.success).toBe(true); + assert(result.success); // No physical IDs means target is not written to disk expect(mockWriteAWSDeploymentTargets).not.toHaveBeenCalled(); // But the stackName should still be computed using 'default' fallback diff --git a/src/cli/commands/import/__tests__/import-runtime-handler.test.ts b/src/cli/commands/import/__tests__/import-runtime-handler.test.ts index b4fb7de90..9f9290454 100644 --- a/src/cli/commands/import/__tests__/import-runtime-handler.test.ts +++ b/src/cli/commands/import/__tests__/import-runtime-handler.test.ts @@ -10,6 +10,7 @@ * - Fails when runtime name already exists in project */ import { handleImportRuntime } from '../import-runtime'; +import assert from 'node:assert'; import { afterEach, describe, expect, it, vi } from 'vitest'; // ── Mock dependencies ──────────────────────────────────────────────────────── @@ -25,7 +26,7 @@ const mockParseAndValidateArn = vi.fn(); const mockFindResourceInDeployedState = vi.fn(); const mockFailResult = vi.fn((...args: unknown[]) => ({ success: false, - error: args[1] as string, + error: new Error(args[1] as string), resourceType: args[2] as string, resourceName: args[3] as string, logPath: 'test.log', @@ -204,9 +205,9 @@ describe('handleImportRuntime', () => { name: 'myagent', }); - expect(result.success).toBe(false); - expect(result.error).toContain('Could not determine entrypoint'); - expect(result.error).toContain('--entrypoint'); + assert(!result.success); + expect(result.error.message).toContain('Could not determine entrypoint'); + expect(result.error.message).toContain('--entrypoint'); }); it('fails with clear error when entryPoint is undefined', async () => { @@ -230,8 +231,8 @@ describe('handleImportRuntime', () => { name: 'myagent', }); - expect(result.success).toBe(false); - expect(result.error).toContain('Could not determine entrypoint'); + assert(!result.success); + expect(result.error.message).toContain('Could not determine entrypoint'); }); it('fails with clear error when entryPoint is empty array', async () => { @@ -255,8 +256,8 @@ describe('handleImportRuntime', () => { name: 'myagent', }); - expect(result.success).toBe(false); - expect(result.error).toContain('Could not determine entrypoint'); + assert(!result.success); + expect(result.error.message).toContain('Could not determine entrypoint'); }); it('uses --entrypoint flag when provided, bypassing auto-detection', async () => { @@ -368,8 +369,8 @@ describe('handleImportRuntime', () => { name: 'myagent', }); - expect(result.success).toBe(false); - expect(result.error).toContain('Multiple runtimes found'); + assert(!result.success); + expect(result.error.message).toContain('Multiple runtimes found'); }); it('errors when no runtimes exist', async () => { @@ -381,8 +382,8 @@ describe('handleImportRuntime', () => { name: 'myagent', }); - expect(result.success).toBe(false); - expect(result.error).toContain('No runtimes found'); + assert(!result.success); + expect(result.error.message).toContain('No runtimes found'); }); }); @@ -545,8 +546,8 @@ describe('handleImportRuntime', () => { // no code option }); - expect(result.success).toBe(false); - expect(result.error).toContain('--code'); + assert(!result.success); + expect(result.error.message).toContain('--code'); }); it('fails when source path does not exist', async () => { @@ -573,8 +574,8 @@ describe('handleImportRuntime', () => { name: 'myagent', }); - expect(result.success).toBe(false); - expect(result.error).toContain('does not exist'); + assert(!result.success); + expect(result.error.message).toContain('does not exist'); }); it('fails when runtime name already exists in project', async () => { @@ -605,8 +606,8 @@ describe('handleImportRuntime', () => { name: 'myagent', }); - expect(result.success).toBe(false); - expect(result.error).toContain('already exists'); + assert(!result.success); + expect(result.error.message).toContain('already exists'); }); }); }); diff --git a/src/cli/commands/import/__tests__/phase-failure-rollback.test.ts b/src/cli/commands/import/__tests__/phase-failure-rollback.test.ts index 149d65faf..3e3909f00 100644 --- a/src/cli/commands/import/__tests__/phase-failure-rollback.test.ts +++ b/src/cli/commands/import/__tests__/phase-failure-rollback.test.ts @@ -7,6 +7,7 @@ */ import { handleImport } from '../actions'; import type { ParsedStarterToolkitConfig } from '../types'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // ── Hoisted mock fns ──────────────────────────────────────────────────────── @@ -249,8 +250,8 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('Phase 1 failed'); + assert(!result.success); + expect(result.error.message).toContain('Phase 1 failed'); // First call = merge write, second call = rollback with original (empty) runtimes expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); @@ -265,8 +266,8 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('Phase 2 failed'); + assert(!result.success); + expect(result.error.message).toContain('Phase 2 failed'); expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); const rollbackData = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; @@ -280,8 +281,8 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('CDK build failed'); + assert(!result.success); + expect(result.error.message).toContain('CDK build failed'); expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); const rollbackData = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; @@ -294,7 +295,7 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(true); + assert(result.success); // Only one write: the merge write expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1); }); @@ -311,8 +312,8 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('No agents found'); + assert(!result.success); + expect(result.error.message).toContain('No agents found'); // Config was never written, so no rollback expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); }); @@ -338,8 +339,8 @@ describe('Config Rollback on Import Failure', () => { const result = await handleImport({ source: '/tmp/config.yaml' }); - expect(result.success).toBe(false); - expect(result.error).toContain('Could not read deployed template'); + assert(!result.success); + expect(result.error.message).toContain('Could not read deployed template'); expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); const rollbackData = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; diff --git a/src/cli/commands/import/actions.ts b/src/cli/commands/import/actions.ts index 71eb70f83..d079839d4 100644 --- a/src/cli/commands/import/actions.ts +++ b/src/cli/commands/import/actions.ts @@ -1,4 +1,4 @@ -import { APP_DIR, ConfigIO, findConfigRoot } from '../../../lib'; +import { APP_DIR, ConfigIO, NoProjectError, findConfigRoot } from '../../../lib'; import type { AgentCoreProjectSpec, AgentCoreRegion, @@ -124,9 +124,10 @@ export async function handleImport(options: ImportOptions): Promise` first, then run import from inside the project.'; - logger.endStep('error', error); + const error = new NoProjectError( + 'No agentcore project found in the current directory.\nRun `agentcore create ` first, then run import from inside the project.' + ); + logger.endStep('error', error.message); logger.finalize(false); return { success: false, @@ -160,7 +161,7 @@ export async function handleImport(options: ImportOptions): Promise( logger.finalize(false); return { success: false, - error: pipelineResult.error, + error: new Error(pipelineResult.error ?? 'Pipeline failed'), resourceType: descriptor.resourceType, resourceName: localName, logPath: logger.getRelativeLogPath(), @@ -254,7 +254,7 @@ export async function executeResourceImport( } return { success: false, - error: message, + error: new 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 5d99c79c1..bb3393e93 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -89,27 +89,23 @@ export interface ResourceToImport { /** * Result of the import command. */ -export interface ImportResult { - success: boolean; - error?: string; - projectSpec?: AgentCoreProjectSpec; - importedAgents?: string[]; - importedMemories?: string[]; - stackName?: string; - logPath?: string; -} +export type ImportResult = + | { + success: true; + projectSpec?: AgentCoreProjectSpec; + importedAgents?: string[]; + importedMemories?: string[]; + stackName?: string; + logPath?: string; + } + | { success: false; error: Error; logPath?: string }; /** * Result for single-resource import (runtime, memory, evaluator, etc.). */ -export interface ImportResourceResult { - success: boolean; - error?: string; - resourceType: ImportableResourceType; - resourceName: string; - resourceId?: string; - logPath?: string; -} +export type ImportResourceResult = + | { success: true; resourceType: ImportableResourceType; resourceName: string; resourceId?: string; logPath?: string } + | { success: false; error: Error; resourceType: ImportableResourceType; resourceName: string; logPath?: string }; /** * Options shared across import subcommands. diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index eb7aaaac2..29376e49a 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -11,6 +11,7 @@ import { mcpInitSession, mcpListTools, } from '../../aws'; +import { ResourceNotFoundError, ValidationError, getErrorMessage, toError } from '../../errors'; import { InvokeLogger } from '../../logging'; import { formatMcpToolList } from '../../operations/dev/utils'; import { canFetchRuntimeToken, fetchRuntimeToken } from '../../operations/fetch-access'; @@ -43,41 +44,58 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption // Resolve target const targetNames = Object.keys(deployedState.targets); if (targetNames.length === 0) { - return { success: false, error: 'No deployed targets found. Run `agentcore deploy` first.' }; + return { + success: false, + error: new ResourceNotFoundError('No deployed targets found. Run `agentcore deploy` first.'), + }; } const selectedTargetName = options.targetName ?? targetNames[0]!; if (options.targetName && !targetNames.includes(options.targetName)) { - return { success: false, error: `Target '${options.targetName}' not found. Available: ${targetNames.join(', ')}` }; + return { + success: false, + error: new ResourceNotFoundError( + `Target '${options.targetName}' not found. Available: ${targetNames.join(', ')}` + ), + }; } const targetState = deployedState.targets[selectedTargetName]; const targetConfig = awsTargets.find(t => t.name === selectedTargetName); if (!targetConfig) { - return { success: false, error: `Target config '${selectedTargetName}' not found in aws-targets` }; + return { + success: false, + error: new ResourceNotFoundError(`Target config '${selectedTargetName}' not found in aws-targets`), + }; } if (project.runtimes.length === 0) { - return { success: false, error: 'No agents defined in configuration' }; + return { success: false, error: new ResourceNotFoundError('No agents defined in configuration') }; } // Resolve agent const agentNames = project.runtimes.map(a => a.name); if (!options.agentName && project.runtimes.length > 1) { - return { success: false, error: `Multiple runtimes found. Use --runtime to specify one: ${agentNames.join(', ')}` }; + return { + success: false, + error: new ValidationError(`Multiple runtimes found. Use --runtime to specify one: ${agentNames.join(', ')}`), + }; } const agentSpec = options.agentName ? project.runtimes.find(a => a.name === options.agentName) : project.runtimes[0]; if (options.agentName && !agentSpec) { - return { success: false, error: `Agent '${options.agentName}' not found. Available: ${agentNames.join(', ')}` }; + return { + success: false, + error: new ResourceNotFoundError(`Agent '${options.agentName}' not found. Available: ${agentNames.join(', ')}`), + }; } if (!agentSpec) { - return { success: false, error: 'No agents defined in configuration' }; + return { success: false, error: new ResourceNotFoundError('No agents defined in configuration') }; } // Warn about VPC mode endpoint requirements @@ -91,7 +109,10 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption const agentState = targetState?.resources?.runtimes?.[agentSpec.name]; if (!agentState) { - return { success: false, error: `Agent '${agentSpec.name}' is not deployed to target '${selectedTargetName}'` }; + return { + success: false, + error: new Error(`Agent '${agentSpec.name}' is not deployed to target '${selectedTargetName}'`), + }; } // Build config bundle baggage if a bundle is associated with this agent @@ -118,13 +139,17 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption } catch (err) { return { success: false, - error: `CUSTOM_JWT agent requires a bearer token. Auto-fetch failed: ${err instanceof Error ? err.message : String(err)}\nProvide one manually with --bearer-token.`, + error: new ValidationError( + `CUSTOM_JWT agent requires a bearer token. Auto-fetch failed: ${err instanceof Error ? err.message : String(err)}\nProvide one manually with --bearer-token.` + ), }; } } else { return { success: false, - error: `Agent '${agentSpec.name}' is configured for CUSTOM_JWT but no bearer token is available.\nEither provide --bearer-token or re-add the agent with --client-id and --client-secret to enable auto-fetch.`, + error: new ValidationError( + `Agent '${agentSpec.name}' is configured for CUSTOM_JWT but no bearer token is available.\nEither provide --bearer-token or re-add the agent with --client-id and --client-secret to enable auto-fetch.` + ), }; } } @@ -147,7 +172,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption }); const command = options.prompt; if (!command) { - return { success: false, error: '--exec requires a command (prompt)' }; + return { success: false, error: new ValidationError('--exec requires a command (prompt)') }; } logger.logPrompt(command, options.sessionId, options.userId); @@ -195,8 +220,18 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption logger.logResponse(stdout || stderr || `exit code: ${exitCode}`); if (options.json) { + if (exitCode === 0) { + return { + success: true, + agentName: agentSpec.name, + targetName: selectedTargetName, + response: JSON.stringify({ stdout, stderr, exitCode, status }), + logFilePath: logger.logFilePath, + }; + } return { - success: exitCode === 0, + success: false, + error: new Error(`Command exited with code ${exitCode}`), agentName: agentSpec.name, targetName: selectedTargetName, response: JSON.stringify({ stdout, stderr, exitCode, status }), @@ -207,9 +242,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (exitCode === undefined) { return { success: false, - agentName: agentSpec.name, - targetName: selectedTargetName, - error: 'Command stream ended without exit code', + error: new Error('Command stream ended without exit code'), logFilePath: logger.logFilePath, }; } @@ -217,9 +250,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (exitCode !== 0) { return { success: false, - agentName: agentSpec.name, - targetName: selectedTargetName, - error: `Command exited with code ${exitCode}${status === 'TIMED_OUT' ? ' (timed out)' : ''}`, + error: new Error(`Command exited with code ${exitCode}${status === 'TIMED_OUT' ? ' (timed out)' : ''}`), logFilePath: logger.logFilePath, }; } @@ -261,7 +292,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption } catch (err) { return { success: false, - error: `Failed to list MCP tools: ${err instanceof Error ? err.message : String(err)}`, + error: new Error(`Failed to list MCP tools: ${getErrorMessage(err)}`, { cause: toError(err) }), }; } } @@ -271,7 +302,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (!options.tool) { return { success: false, - error: 'MCP call-tool requires --tool . Use "list-tools" to see available tools.', + error: new ValidationError('MCP call-tool requires --tool . Use "list-tools" to see available tools.'), }; } let args: Record = {}; @@ -279,7 +310,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption try { args = JSON.parse(options.input) as Record; } catch { - return { success: false, error: `Invalid JSON for --input: ${options.input}` }; + return { success: false, error: new ValidationError(`Invalid JSON for --input: ${options.input}`) }; } } try { @@ -295,7 +326,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption } catch (err) { return { success: false, - error: `Failed to call MCP tool: ${err instanceof Error ? err.message : String(err)}`, + error: new Error(`Failed to call MCP tool: ${getErrorMessage(err)}`, { cause: toError(err) }), }; } } @@ -303,14 +334,15 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption if (!options.prompt) { return { success: false, - error: - 'MCP agents require a command. Usage:\n agentcore invoke list-tools\n agentcore invoke call-tool --tool --input \'{"arg": "value"}\'', + error: new ValidationError( + 'MCP agents require a command. Usage:\n agentcore invoke list-tools\n agentcore invoke call-tool --tool --input \'{"arg": "value"}\'' + ), }; } } if (!options.prompt) { - return { success: false, error: 'No prompt provided. Usage: agentcore invoke "your prompt"' }; + return { success: false, error: new ValidationError('No prompt provided. Usage: agentcore invoke "your prompt"') }; } // A2A protocol handling — send JSON-RPC message/send via InvokeAgentRuntime @@ -343,7 +375,10 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption response, }; } catch (err) { - return { success: false, error: `A2A invoke failed: ${err instanceof Error ? err.message : String(err)}` }; + return { + success: false, + error: new Error(`A2A invoke failed: ${getErrorMessage(err)}`, { cause: toError(err) }), + }; } } @@ -388,8 +423,20 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption logger.logResponse(response); + if (hasError) { + return { + success: false, + error: new Error(response), + agentName: agentSpec.name, + targetName: selectedTargetName, + response, + sessionId: aguiResult.sessionId, + logFilePath: logger.logFilePath, + }; + } + return { - success: !hasError, + success: true, agentName: agentSpec.name, targetName: selectedTargetName, response, @@ -398,7 +445,10 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption }; } catch (err) { logger.logError(err, 'AGUI invoke failed'); - return { success: false, error: `AGUI invoke failed: ${err instanceof Error ? err.message : String(err)}` }; + return { + success: false, + error: new Error(`AGUI invoke failed: ${getErrorMessage(err)}`, { cause: toError(err) }), + }; } } diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index cc0cd1e35..df468060f 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -1,3 +1,4 @@ +import { resultToJson } from '../../../lib'; import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; @@ -55,7 +56,7 @@ async function handleInvokeCLI(options: InvokeOptions): Promise { } if (options.json) { - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); } else if (options.stream) { // Streaming already wrote to stdout, just show session and log path if (result.sessionId) { @@ -70,7 +71,7 @@ async function handleInvokeCLI(options: InvokeOptions): Promise { if (result.success && result.response) { console.log(result.response); } else if (!result.success && result.error) { - console.error(result.error); + console.error(result.error.message); } if (result.sessionId) { console.error(`\nSession: ${result.sessionId}`); diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts index 61401c332..08ecafe22 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -22,12 +22,21 @@ export interface InvokeOptions { bearerToken?: string; } -export interface InvokeResult { - success: boolean; - agentName?: string; - targetName?: string; - response?: string; - sessionId?: string; - error?: string; - logFilePath?: string; -} +export type InvokeResult = + | { + success: true; + agentName?: string; + targetName?: string; + response?: string; + sessionId?: string; + logFilePath?: string; + } + | { + success: false; + error: Error; + agentName?: string; + targetName?: string; + response?: string; + sessionId?: string; + logFilePath?: string; + }; diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index 07d072320..1cd58c625 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -132,9 +132,9 @@ describe('resolveAgentContext', () => { const result = resolveAgentContext(context, {}); expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('Multiple runtimes found'); - expect(result.error).toContain('AgentA'); - expect(result.error).toContain('AgentB'); + expect(result.error.message).toContain('Multiple runtimes found'); + expect(result.error.message).toContain('AgentA'); + expect(result.error.message).toContain('AgentB'); } }); @@ -205,7 +205,7 @@ describe('resolveAgentContext', () => { const result = resolveAgentContext(makeContext(), { runtime: 'UnknownAgent' }); expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain("Runtime 'UnknownAgent' not found"); + expect(result.error.message).toContain("Runtime 'UnknownAgent' not found"); } }); @@ -230,7 +230,7 @@ describe('resolveAgentContext', () => { const result = resolveAgentContext(context, {}); expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('No runtimes defined'); + expect(result.error.message).toContain('No runtimes defined'); } }); @@ -249,7 +249,7 @@ describe('resolveAgentContext', () => { const result = resolveAgentContext(context, {}); expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('is not deployed'); + expect(result.error.message).toContain('is not deployed'); } }); }); diff --git a/src/cli/commands/logs/action.ts b/src/cli/commands/logs/action.ts index 72be2c864..13650c643 100644 --- a/src/cli/commands/logs/action.ts +++ b/src/cli/commands/logs/action.ts @@ -1,6 +1,8 @@ +import type { Result } from '../../../lib/result'; import { parseTimeString } from '../../../lib/utils'; import { searchLogs, streamLogs } from '../../aws/cloudwatch'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; +import { ValidationError, toError } from '../../errors'; import type { DeployedProjectConfig } from '../../operations/resolve-agent'; import { loadDeployedProjectConfig, resolveAgent } from '../../operations/resolve-agent'; import { VALID_LEVELS, buildFilterPattern } from './filter-pattern'; @@ -17,11 +19,6 @@ export interface AgentContext { logGroupName: string; } -export interface LogsResult { - success: boolean; - error?: string; -} - /** * Detect whether to stream or search based on options */ @@ -49,7 +46,7 @@ export function formatLogLine(event: { timestamp: number; message: string }, jso export function resolveAgentContext( context: DeployedProjectConfig, options: LogsOptions -): { success: true; agentContext: AgentContext } | { success: false; error: string } { +): Result<{ agentContext: AgentContext }> { const result = resolveAgent(context, options); if (!result.success) { return { success: false, error: result.error }; @@ -73,12 +70,12 @@ export function resolveAgentContext( /** * Main logs handler */ -export async function handleLogs(options: LogsOptions): Promise { +export async function handleLogs(options: LogsOptions): Promise { // Validate level early if (options.level && !VALID_LEVELS.includes(options.level.toLowerCase())) { return { success: false, - error: `Invalid log level: "${options.level}". Valid levels: ${VALID_LEVELS.join(', ')}`, + error: new ValidationError(`Invalid log level: "${options.level}". Valid levels: ${VALID_LEVELS.join(', ')}`), }; } @@ -96,7 +93,7 @@ export async function handleLogs(options: LogsOptions): Promise { try { filterPattern = buildFilterPattern({ level: options.level, query: options.query }); } catch (err) { - return { success: false, error: (err as Error).message }; + return { success: false, error: toError(err) }; } const mode = detectMode(options); @@ -143,7 +140,7 @@ export async function handleLogs(options: LogsOptions): Promise { if (errorName === 'ResourceNotFoundException') { return { success: false, - error: `No logs found for agent '${agentContext.agentName}'. Has the agent been invoked?`, + error: new Error(`No logs found for agent '${agentContext.agentName}'. Has the agent been invoked?`), }; } diff --git a/src/cli/commands/logs/command.tsx b/src/cli/commands/logs/command.tsx index 05649887d..ba6d1ef21 100644 --- a/src/cli/commands/logs/command.tsx +++ b/src/cli/commands/logs/command.tsx @@ -34,7 +34,7 @@ export const registerLogs = (program: Command) => { const result = await handleLogs(cliOptions); if (!result.success) { - render({result.error}); + render({result.error.message}); process.exit(1); } } catch (error) { @@ -59,7 +59,7 @@ export const registerLogs = (program: Command) => { const result = await handleLogsEval(cliOptions); if (!result.success) { - render({result.error}); + render({result.error.message}); process.exit(1); } } catch (error) { diff --git a/src/cli/commands/pause/command.tsx b/src/cli/commands/pause/command.tsx index 4ad1cc0dc..87406013d 100644 --- a/src/cli/commands/pause/command.tsx +++ b/src/cli/commands/pause/command.tsx @@ -1,4 +1,4 @@ -import { ConfigIO } from '../../../lib'; +import { ConfigIO, resultToJson } from '../../../lib'; import { listABTests, updateABTest } from '../../aws/agentcore-ab-tests'; import { stopBatchEvaluation } from '../../aws/agentcore-batch-evaluation'; import { getErrorMessage } from '../../errors'; @@ -51,12 +51,12 @@ function registerOnlineEvalSubcommand(parent: Command, action: 'pause' | 'resume const result = await handlePauseResume(options, action); if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); } else if (result.success) { const displayName = cliOptions.arn ? result.configId : name; console.log(`${pastTense} online eval config "${displayName}" (status: ${result.executionStatus})`); } else { - render({result.error}); + render({result.error.message}); } process.exit(result.success ? 0 : 1); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 05e532688..39daa2080 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -1,5 +1,5 @@ -import { ConfigIO } from '../../../lib'; -import { getErrorMessage } from '../../errors'; +import { ConfigIO, resultToJson } from '../../../lib'; +import { getErrorMessage, toError } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; import { RemoveAllScreen, RemoveFlow } from '../../tui/screens/remove'; @@ -48,14 +48,14 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise { validateRemoveAllOptions(options); const result = await handleRemoveAll(options); - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); process.exit(result.success ? 0 : 1); } diff --git a/src/cli/commands/remove/types.ts b/src/cli/commands/remove/types.ts index 9a30f5e24..b45c3ba4a 100644 --- a/src/cli/commands/remove/types.ts +++ b/src/cli/commands/remove/types.ts @@ -1,3 +1,5 @@ +import type { Result } from '../../../lib/result'; + export type ResourceType = | 'agent' | 'gateway' @@ -25,11 +27,9 @@ export interface RemoveAllOptions { json?: boolean; } -export interface RemoveResult { - success: boolean; +export type RemoveResult = Result<{ resourceType?: ResourceType; resourceName?: string; message?: string; note?: string; - error?: string; -} +}>; diff --git a/src/cli/commands/run/command.tsx b/src/cli/commands/run/command.tsx index e5ba8ca59..6f601e7e9 100644 --- a/src/cli/commands/run/command.tsx +++ b/src/cli/commands/run/command.tsx @@ -1,3 +1,4 @@ +import { resultToJson } from '../../../lib'; import type { RecommendationType } from '../../aws/agentcore-recommendation'; import { getErrorMessage } from '../../errors'; import { handleRunEval } from '../../operations/eval'; @@ -25,7 +26,7 @@ const RECOMMENDATION_TYPE_MAP: Record = { }; function formatRunOutput(result: Awaited>): void { - if (!result.run) return; + if (!result.success) return; const { run } = result; const date = new Date(run.timestamp).toLocaleString([], { @@ -56,7 +57,7 @@ function formatRunOutput(result: Awaited>): voi for (const r of run.results) { const score = r.aggregateScore.toFixed(2); - const errors = r.sessionScores.filter(s => s.errorMessage).length; + const errors = r.sessionScores.filter((s: { errorMessage?: string }) => s.errorMessage).length; const errorSuffix = errors > 0 ? ` (${errors} errors)` : ''; console.log(` ${r.evaluator}: ${score}${errorSuffix}`); } @@ -146,12 +147,11 @@ export const registerRun = (program: Command) => { const result = await handleRunEval(options); if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); } else if (result.success) { formatRunOutput(result); } else { - formatRunOutput(result); - render({result.error}); + render({result.error.message}); } process.exit(result.success ? 0 : 1); @@ -240,11 +240,11 @@ export const registerRun = (program: Command) => { } if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); } else if (result.success) { formatBatchEvalOutput(result); } else { - render({result.error}); + render({result.error.message}); if (result.logFilePath) { console.error(`\nLog: ${result.logFilePath}`); } @@ -401,9 +401,9 @@ export const registerRun = (program: Command) => { if (!result.success) { if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); } else { - render({result.error}); + render({result.error.message}); if (result.logFilePath) { console.error(`\nLog: ${result.logFilePath}`); } @@ -428,7 +428,7 @@ export const registerRun = (program: Command) => { } if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); } else { console.log(`\nRecommendation ID: ${result.recommendationId}`); @@ -467,7 +467,7 @@ export const registerRun = (program: Command) => { ); console.log(`Local config for "${cliOptions.bundleName}" has been updated to match.`); } else { - console.log(`\nCould not sync config bundle: ${applyResult.error}`); + console.log(`\nCould not sync config bundle: ${applyResult.error.message}`); } } catch { // Non-fatal — user can manually sync diff --git a/src/cli/commands/status/__tests__/action.test.ts b/src/cli/commands/status/__tests__/action.test.ts index 1c3a089dc..f8c94c6bc 100644 --- a/src/cli/commands/status/__tests__/action.test.ts +++ b/src/cli/commands/status/__tests__/action.test.ts @@ -1,7 +1,8 @@ import type { AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema/index.js'; import { computeResourceStatuses, handleProjectStatus } from '../action.js'; -import type { StatusContext } from '../action.js'; +import type { ResourceStatusEntry, StatusContext } from '../action.js'; import { buildRuntimeInvocationUrl } from '../constants.js'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockGetAgentRuntimeStatus = vi.fn(); @@ -508,9 +509,11 @@ describe('handleProjectStatus — live enrichment', () => { const result = await handleProjectStatus(makeContext()); - expect(result.success).toBe(true); + assert(result.success); - const evalEntry = result.resources.find(r => r.resourceType === 'evaluator' && r.name === 'MyEval'); + const evalEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'evaluator' && r.name === 'MyEval' + ); expect(evalEntry).toBeDefined(); expect(evalEntry!.detail).toContain('ACTIVE'); @@ -536,9 +539,11 @@ describe('handleProjectStatus — live enrichment', () => { const result = await handleProjectStatus(makeContext()); - expect(result.success).toBe(true); + assert(result.success); - const configEntry = result.resources.find(r => r.resourceType === 'online-eval' && r.name === 'MyConfig'); + const configEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'online-eval' && r.name === 'MyConfig' + ); expect(configEntry).toBeDefined(); expect(configEntry!.detail).toContain('ACTIVE'); expect(configEntry!.detail).toContain('ENABLED'); @@ -560,9 +565,11 @@ describe('handleProjectStatus — live enrichment', () => { const result = await handleProjectStatus(makeContext()); - expect(result.success).toBe(true); + assert(result.success); - const evalEntry = result.resources.find(r => r.resourceType === 'evaluator' && r.name === 'MyEval'); + const evalEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'evaluator' && r.name === 'MyEval' + ); expect(evalEntry).toBeDefined(); expect(evalEntry!.error).toBe('AccessDenied'); }); @@ -578,9 +585,11 @@ describe('handleProjectStatus — live enrichment', () => { const result = await handleProjectStatus(makeContext()); - expect(result.success).toBe(true); + assert(result.success); - const configEntry = result.resources.find(r => r.resourceType === 'online-eval' && r.name === 'MyConfig'); + const configEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'online-eval' && r.name === 'MyConfig' + ); expect(configEntry).toBeDefined(); expect(configEntry!.error).toBe('ResourceNotFound'); }); @@ -624,9 +633,11 @@ describe('handleProjectStatus — live enrichment', () => { const result = await handleProjectStatus(ctx); - expect(result.success).toBe(true); + assert(result.success); - const evalEntry = result.resources.find(r => r.resourceType === 'evaluator' && r.name === 'MyEval'); + const evalEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'evaluator' && r.name === 'MyEval' + ); expect(evalEntry).toBeDefined(); expect(evalEntry!.deploymentState).toBe('local-only'); expect(mockGetEvaluator).not.toHaveBeenCalled(); @@ -697,8 +708,10 @@ describe('handleProjectStatus — invocation URL enrichment', () => { const result = await handleProjectStatus(ctx); - expect(result.success).toBe(true); - const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'MyAgent'); + assert(result.success); + const agentEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'agent' && r.name === 'MyAgent' + ); expect(agentEntry).toBeDefined(); expect(agentEntry!.invocationUrl).toBe( `https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/${encodeURIComponent(runtimeArn)}/invocations` @@ -723,8 +736,10 @@ describe('handleProjectStatus — invocation URL enrichment', () => { const result = await handleProjectStatus(ctx); - expect(result.success).toBe(true); - const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'LocalAgent'); + assert(result.success); + const agentEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'agent' && r.name === 'LocalAgent' + ); expect(agentEntry).toBeDefined(); expect(agentEntry!.invocationUrl).toBeUndefined(); }); @@ -758,8 +773,10 @@ describe('handleProjectStatus — invocation URL enrichment', () => { const result = await handleProjectStatus(ctx); - expect(result.success).toBe(true); - const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'FailAgent'); + assert(result.success); + const agentEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'agent' && r.name === 'FailAgent' + ); expect(agentEntry).toBeDefined(); expect(agentEntry!.error).toBe('Timeout'); expect(agentEntry!.invocationUrl).toBe( @@ -798,8 +815,10 @@ describe('handleProjectStatus — invocation URL enrichment', () => { const result = await handleProjectStatus(ctx); - expect(result.success).toBe(true); - const agentEntry = result.resources.find(r => r.resourceType === 'agent' && r.name === 'OldAgent'); + assert(result.success); + const agentEntry = result.resources.find( + (r: ResourceStatusEntry) => r.resourceType === 'agent' && r.name === 'OldAgent' + ); expect(agentEntry).toBeDefined(); expect(agentEntry!.deploymentState).toBe('pending-removal'); expect(agentEntry!.invocationUrl).toBeUndefined(); diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 271b05bc9..15f5c2797 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -3,7 +3,7 @@ import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedResourceState, import { getAgentRuntimeStatus } from '../../aws'; import { getEvaluator, getOnlineEvaluationConfig } from '../../aws/agentcore-control'; import { dnsSuffix } from '../../aws/partition'; -import { getErrorMessage } from '../../errors'; +import { getErrorMessage, toError } from '../../errors'; import { ExecLogger } from '../../logging'; import type { ResourceDeploymentState } from './constants'; import { buildRuntimeInvocationUrl } from './constants'; @@ -32,15 +32,16 @@ export interface ResourceStatusEntry { invocationUrl?: string; } -export interface ProjectStatusResult { - success: boolean; - projectName: string; - targetName: string; - targetRegion?: string; - resources: ResourceStatusEntry[]; - error?: string; - logPath?: string; -} +export type ProjectStatusResult = + | { + success: true; + projectName: string; + targetName: string; + targetRegion?: string; + resources: ResourceStatusEntry[]; + logPath?: string; + } + | { success: false; error: Error; logPath?: string }; export interface StatusContext { project: AgentCoreProjectSpec; @@ -48,14 +49,15 @@ export interface StatusContext { awsTargets: AwsDeploymentTargets; } -export interface RuntimeLookupResult { - success: boolean; - targetName?: string; - runtimeId?: string; - runtimeStatus?: string; - error?: string; - logPath?: string; -} +export type RuntimeLookupResult = + | { + success: true; + targetName?: string; + runtimeId?: string; + runtimeStatus?: string; + logPath?: string; + } + | { success: false; error: Error; logPath?: string }; /** * Loads configuration required for status check. @@ -333,10 +335,7 @@ export async function handleProjectStatus( logger.finalize(false); return { success: false, - projectName: project.name, - targetName: options.targetName, - resources: [], - error, + error: new Error(error), logPath: logger.getRelativeLogPath(), }; } @@ -504,7 +503,7 @@ export async function handleRuntimeLookup( const error = 'No deployment targets found. Run `agentcore create` first.'; logger.endStep('error', error); logger.finalize(false); - return { success: false, error, logPath: logger.getRelativeLogPath() }; + return { success: false, error: new Error(error), logPath: logger.getRelativeLogPath() }; } const selectedTargetName = options.targetName ?? targetNames[0]!; @@ -513,7 +512,7 @@ export async function handleRuntimeLookup( const error = `Target '${options.targetName}' not found. Available: ${targetNames.join(', ')}`; logger.endStep('error', error); logger.finalize(false); - return { success: false, error, logPath: logger.getRelativeLogPath() }; + return { success: false, error: new Error(error), logPath: logger.getRelativeLogPath() }; } const targetConfig = awsTargets.find(target => target.name === selectedTargetName); @@ -522,7 +521,7 @@ export async function handleRuntimeLookup( const error = `Target config '${selectedTargetName}' not found in aws-targets`; logger.endStep('error', error); logger.finalize(false); - return { success: false, error, logPath: logger.getRelativeLogPath() }; + return { success: false, error: new Error(error), logPath: logger.getRelativeLogPath() }; } logger.log(`Target: ${selectedTargetName} (${targetConfig.region})`); @@ -550,6 +549,6 @@ export async function handleRuntimeLookup( const errorMsg = getErrorMessage(error); logger.endStep('error', errorMsg); logger.finalize(false); - return { success: false, error: errorMsg, logPath: logger.getRelativeLogPath() }; + return { success: false, error: toError(error), logPath: logger.getRelativeLogPath() }; } } diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index 506ad10ec..5e051831d 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -1,3 +1,4 @@ +import { resultToJson } from '../../../lib'; import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; @@ -100,12 +101,12 @@ export const registerStatus = (program: Command) => { }); if (cliOptions.json) { - console.log(JSON.stringify(result, null, 2)); + console.log(resultToJson(result)); return; } if (!result.success) { - render({result.error}); + render({result.error.message}); return; } @@ -126,13 +127,17 @@ export const registerStatus = (program: Command) => { }); if (cliOptions.json) { - const filtered = filterResources(result.resources, cliOptions); - console.log(JSON.stringify({ ...result, resources: filtered }, null, 2)); + if (result.success) { + const filtered = filterResources(result.resources, cliOptions); + console.log(resultToJson({ ...result, resources: filtered })); + } else { + console.log(resultToJson(result)); + } return; } if (!result.success) { - render({result.error}); + render({result.error.message}); return; } diff --git a/src/cli/commands/traces/action.ts b/src/cli/commands/traces/action.ts index d761cd4b7..09ede50bc 100644 --- a/src/cli/commands/traces/action.ts +++ b/src/cli/commands/traces/action.ts @@ -1,17 +1,19 @@ import { parseTimeString } from '../../../lib/utils'; +import { ValidationError } from '../../errors'; import type { DeployedProjectConfig } from '../../operations/resolve-agent'; import { resolveAgent } from '../../operations/resolve-agent'; import { buildTraceConsoleUrl, getTrace, listTraces } from '../../operations/traces'; import type { TracesGetOptions, TracesListOptions } from './types'; -export interface TracesListResult { - success: boolean; - agentName?: string; - targetName?: string; - consoleUrl?: string; - traces?: { traceId: string; timestamp: string; sessionId?: string }[]; - error?: string; -} +export type TracesListResult = + | { + success: true; + agentName?: string; + targetName?: string; + consoleUrl?: string; + traces: { traceId: string; timestamp: string; sessionId?: string }[]; + } + | { success: false; error: Error; consoleUrl?: string }; export async function handleTracesList( context: DeployedProjectConfig, @@ -33,7 +35,7 @@ export async function handleTracesList( const limit = options.limit ? parseInt(options.limit, 10) : 20; if (isNaN(limit)) { - return { success: false, error: '--limit must be a number' }; + return { success: false, error: new ValidationError('--limit must be a number') }; } // Parse time options @@ -68,14 +70,9 @@ export async function handleTracesList( }; } -export interface TracesGetResult { - success: boolean; - agentName?: string; - targetName?: string; - consoleUrl?: string; - filePath?: string; - error?: string; -} +export type TracesGetResult = + | { success: true; agentName?: string; targetName?: string; consoleUrl?: string; filePath?: string } + | { success: false; error: Error; consoleUrl?: string }; export async function handleTracesGet( context: DeployedProjectConfig, diff --git a/src/cli/commands/traces/command.tsx b/src/cli/commands/traces/command.tsx index 0222ce419..576a686b3 100644 --- a/src/cli/commands/traces/command.tsx +++ b/src/cli/commands/traces/command.tsx @@ -37,7 +37,7 @@ export const registerTraces = (program: Command) => { if (!result.success) { render( - Error: {result.error} + Error: {result.error.message} {result.consoleUrl && Console: {result.consoleUrl}} ); @@ -109,7 +109,7 @@ export const registerTraces = (program: Command) => { if (!result.success) { render( - Error: {result.error} + Error: {result.error.message} {result.consoleUrl && Console: {result.consoleUrl}} ); diff --git a/src/cli/commands/validate/__tests__/action.test.ts b/src/cli/commands/validate/__tests__/action.test.ts index d6774f252..1027299c6 100644 --- a/src/cli/commands/validate/__tests__/action.test.ts +++ b/src/cli/commands/validate/__tests__/action.test.ts @@ -1,4 +1,5 @@ import { handleValidate } from '../action.js'; +import assert from 'node:assert'; import { afterEach, describe, expect, it, vi } from 'vitest'; const { @@ -73,8 +74,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); - expect(result.success).toBe(false); - expect(result.error).toContain('No agentcore project found'); + assert(!result.success); + expect(result.error.message).toContain('No agentcore project found'); }); it('returns success when all configs are valid', async () => { @@ -94,8 +95,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); - expect(result.success).toBe(false); - expect(result.error).toContain('invalid project'); + assert(!result.success); + expect(result.error.message).toContain('invalid project'); }); it('returns error when AWS targets fails', async () => { @@ -105,8 +106,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); - expect(result.success).toBe(false); - expect(result.error).toContain('bad targets'); + assert(!result.success); + expect(result.error.message).toContain('bad targets'); }); it('validates state file when it exists', async () => { @@ -131,8 +132,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); - expect(result.success).toBe(false); - expect(result.error).toContain('bad state'); + assert(!result.success); + expect(result.error.message).toContain('bad state'); }); it('uses custom directory when provided', async () => { @@ -154,8 +155,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); - expect(result.success).toBe(false); - expect(result.error).toBe('field "name" is required'); + assert(!result.success); + expect(result.error.message).toBe('field "name" is required'); }); it('formats ConfigParseError with cause', async () => { @@ -165,9 +166,9 @@ describe('handleValidate', () => { const result = await handleValidate({}); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid JSON in agentcore.json'); - expect(result.error).toContain('Unexpected token'); + assert(!result.success); + expect(result.error.message).toContain('Invalid JSON in agentcore.json'); + expect(result.error.message).toContain('Unexpected token'); }); it('formats ConfigReadError with cause', async () => { @@ -179,9 +180,9 @@ describe('handleValidate', () => { const result = await handleValidate({}); - expect(result.success).toBe(false); - expect(result.error).toContain('Failed to read agentcore.json'); - expect(result.error).toContain('EACCES'); + assert(!result.success); + expect(result.error.message).toContain('Failed to read agentcore.json'); + expect(result.error.message).toContain('EACCES'); }); it('formats ConfigNotFoundError with file name', async () => { @@ -191,8 +192,8 @@ describe('handleValidate', () => { const result = await handleValidate({}); - expect(result.success).toBe(false); - expect(result.error).toBe('Required file not found: agentcore.json'); + assert(!result.success); + expect(result.error.message).toBe('Required file not found: agentcore.json'); }); it('formats non-Error values as strings', async () => { @@ -201,7 +202,7 @@ describe('handleValidate', () => { const result = await handleValidate({}); - expect(result.success).toBe(false); - expect(result.error).toBe('string error'); + assert(!result.success); + expect(result.error.message).toBe('string error'); }); }); diff --git a/src/cli/commands/validate/action.ts b/src/cli/commands/validate/action.ts index 572f5cf7d..c9d7c878a 100644 --- a/src/cli/commands/validate/action.ts +++ b/src/cli/commands/validate/action.ts @@ -7,21 +7,17 @@ import { NoProjectError, findConfigRoot, } from '../../../lib'; +import type { Result } from '../../../lib/result'; export interface ValidateOptions { directory?: string; } -export interface ValidateResult { - success: boolean; - error?: string; -} - /** * Validates all AgentCore schema files in the project. * Returns a binary success/fail result with an error message if validation fails. */ -export async function handleValidate(options: ValidateOptions): Promise { +export async function handleValidate(options: ValidateOptions): Promise { const baseDir = options.directory ?? process.cwd(); // Check if project exists @@ -29,7 +25,7 @@ export async function handleValidate(options: ValidateOptions): Promise { render(Valid); process.exit(0); } else { - render({result.error}); + render({result.error.message}); process.exit(1); } }); diff --git a/src/cli/commands/validate/index.ts b/src/cli/commands/validate/index.ts index cecbb46ac..067c35e56 100644 --- a/src/cli/commands/validate/index.ts +++ b/src/cli/commands/validate/index.ts @@ -1,2 +1,2 @@ export { registerValidate } from './command'; -export { handleValidate, type ValidateOptions, type ValidateResult } from './action'; +export { handleValidate, type ValidateOptions } from './action'; diff --git a/src/cli/errors.ts b/src/cli/errors.ts index 9e7e9397d..947172dea 100644 --- a/src/cli/errors.ts +++ b/src/cli/errors.ts @@ -1,12 +1,13 @@ -/** - * Error thrown when an agent with the same name already exists. - */ -export class AgentAlreadyExistsError extends Error { - constructor(agentName: string) { - super(`An agent named "${agentName}" already exists in the schema.`); - this.name = 'AgentAlreadyExistsError'; - } -} +export { + AccessDeniedError, + AgentAlreadyExistsError, + ConflictError, + DependencyCheckError, + GitInitError, + ResourceNotFoundError, + OperationTimeoutError, + ValidationError, +} from '../lib/errors/types'; /** * Converts an unknown error to a string message. @@ -16,6 +17,14 @@ export function getErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +/** + * Converts an unknown thrown value to an Error instance. + * Use in catch blocks to ensure the error field is always an Error object. + */ +export function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + /** * Checks if an error is an AWS access denied error. * Returns true for AccessDeniedException or AccessDenied error codes. @@ -141,8 +150,6 @@ export function isStackInProgressError(err: unknown): boolean { /** * Checks if an error indicates a CloudFormation changeset operation is in progress. - * This typically occurs when multiple deploys race and one tries to create/delete - * a changeset while another operation is already using it. */ export function isChangesetInProgressError(err: unknown): boolean { const message = getErrorMessage(err).toLowerCase(); diff --git a/src/cli/operations/agent/import/index.ts b/src/cli/operations/agent/import/index.ts index e49d9e3c0..3e46d577f 100644 --- a/src/cli/operations/agent/import/index.ts +++ b/src/cli/operations/agent/import/index.ts @@ -6,10 +6,10 @@ import { APP_DIR, ConfigIO } from '../../../../lib'; import type { RuntimeAuthorizerType, SDKFramework } from '../../../../schema'; import { getBedrockAgentConfig } from '../../../aws/bedrock-import'; -import { getErrorMessage } from '../../../errors'; +import { toError } from '../../../errors'; import type { JwtConfigOptions } from '../../../primitives/auth-utils'; import { createManagedOAuthCredential } from '../../../primitives/auth-utils'; -import type { AddResult } from '../../../primitives/types'; +import type { Result } from '../../../primitives/types'; import type { MemoryOption } from '../../../tui/screens/generate/types'; import { setupPythonProject } from '../../python'; import { writeAgentToProject } from '../generate/write-agent-to-project'; @@ -36,7 +36,7 @@ export interface ExecuteImportAgentParams { export async function executeImportAgent( params: ExecuteImportAgentParams -): Promise> { +): Promise> { const { name, framework, @@ -120,6 +120,6 @@ export async function executeImportAgent( return { success: true, agentName: name, agentPath }; } catch (err) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: toError(err) }; } } diff --git a/src/cli/operations/deploy/index.ts b/src/cli/operations/deploy/index.ts index 332f0ca2d..93cdb6353 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -39,11 +39,10 @@ export { type DeployedTarget, type DiscoverDeployedResult, type DestroyTargetOptions, - type StackTeardownResult, } from './teardown'; // Post-deploy observability setup -export { setupTransactionSearch, type TransactionSearchSetupResult } from './post-deploy-observability'; +export { setupTransactionSearch } from './post-deploy-observability'; // Post-deploy HTTP gateways export { diff --git a/src/cli/operations/deploy/post-deploy-observability.ts b/src/cli/operations/deploy/post-deploy-observability.ts index 0616a65dc..4ee613c42 100644 --- a/src/cli/operations/deploy/post-deploy-observability.ts +++ b/src/cli/operations/deploy/post-deploy-observability.ts @@ -1,3 +1,4 @@ +import type { Result } from '../../../lib/result'; import { readGlobalConfigSync } from '../../../lib/schemas/io/global-config'; import { enableTransactionSearch } from '../../aws/transaction-search'; @@ -8,11 +9,6 @@ export interface TransactionSearchSetupOptions { hasGateways?: boolean; } -export interface TransactionSearchSetupResult { - success: boolean; - error?: string; -} - /** * Post-deploy step: enable CloudWatch Transaction Search (Application Signals + * resource policy + CloudWatchLogs destination + 100% indexing). @@ -22,9 +18,7 @@ export interface TransactionSearchSetupResult { * * This is a non-blocking best-effort operation — failures do not fail the deploy. */ -export async function setupTransactionSearch( - options: TransactionSearchSetupOptions -): Promise { +export async function setupTransactionSearch(options: TransactionSearchSetupOptions): Promise { const { region, accountId, agentNames } = options; if (agentNames.length === 0 && !options.hasGateways) { diff --git a/src/cli/operations/deploy/teardown.ts b/src/cli/operations/deploy/teardown.ts index 2e38f2576..b7c39c49b 100644 --- a/src/cli/operations/deploy/teardown.ts +++ b/src/cli/operations/deploy/teardown.ts @@ -1,4 +1,5 @@ import { CONFIG_DIR, ConfigIO } from '../../../lib'; +import type { Result } from '../../../lib/result'; import type { AwsDeploymentTarget } from '../../../schema'; import { withTargetRegion } from '../../aws'; import { deleteConfigurationBundle } from '../../aws/agentcore-config-bundles'; @@ -99,16 +100,11 @@ export function getCdkProjectDir(cwd?: string): string { return join(baseDir, CONFIG_DIR, 'cdk'); } -export interface StackTeardownResult { - success: boolean; - error?: string; -} - /** * Perform full stack teardown for a target: destroy CloudFormation stack, * remove deployed-state entry, and remove the target from aws-targets.json. */ -export async function performStackTeardown(targetName: string): Promise { +export async function performStackTeardown(targetName: string): Promise { const cdkProjectDir = getCdkProjectDir(); const configIO = new ConfigIO(); diff --git a/src/cli/operations/eval/__tests__/list-eval-runs.test.ts b/src/cli/operations/eval/__tests__/list-eval-runs.test.ts index c9a71a8cf..1c1c48f71 100644 --- a/src/cli/operations/eval/__tests__/list-eval-runs.test.ts +++ b/src/cli/operations/eval/__tests__/list-eval-runs.test.ts @@ -1,5 +1,6 @@ import { handleListEvalRuns } from '../list-eval-runs.js'; import type { EvalRunResult } from '../types.js'; +import assert from 'node:assert'; import { afterEach, describe, expect, it, vi } from 'vitest'; const mockListEvalRuns = vi.fn(); @@ -28,7 +29,7 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({}); - expect(result.success).toBe(true); + assert(result.success); expect(result.runs).toHaveLength(2); }); @@ -42,9 +43,9 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({ agent: 'agent-a' }); - expect(result.success).toBe(true); + assert(result.success); expect(result.runs).toHaveLength(2); - expect(result.runs!.every(r => r.agent === 'agent-a')).toBe(true); + expect(result.runs.every((r: EvalRunResult) => r.agent === 'agent-a')).toBe(true); }); it('limits the number of results', () => { @@ -57,7 +58,7 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({ limit: 2 }); - expect(result.success).toBe(true); + assert(result.success); expect(result.runs).toHaveLength(2); }); @@ -72,9 +73,10 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({ agent: 'a', limit: 2 }); + assert(result.success); expect(result.runs).toHaveLength(2); - expect(result.runs![0]!.timestamp).toBe('2025-01-15T10:00:00.000Z'); - expect(result.runs![1]!.timestamp).toBe('2025-01-15T12:00:00.000Z'); + expect(result.runs[0]!.timestamp).toBe('2025-01-15T10:00:00.000Z'); + expect(result.runs[1]!.timestamp).toBe('2025-01-15T12:00:00.000Z'); }); it('returns empty array when no runs exist', () => { @@ -82,7 +84,7 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({}); - expect(result.success).toBe(true); + assert(result.success); expect(result.runs).toEqual([]); }); @@ -93,9 +95,8 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({}); - expect(result.success).toBe(false); - expect(result.error).toBe('disk error'); - expect(result.runs).toBeUndefined(); + assert(!result.success); + expect(result.error.message).toBe('disk error'); }); it('handles non-Error thrown values', () => { @@ -105,7 +106,7 @@ describe('handleListEvalRuns', () => { const result = handleListEvalRuns({}); - expect(result.success).toBe(false); - expect(result.error).toBe('42'); + assert(!result.success); + expect(result.error.message).toBe('42'); }); }); diff --git a/src/cli/operations/eval/__tests__/logs-eval.test.ts b/src/cli/operations/eval/__tests__/logs-eval.test.ts index 5411d842e..0341e0408 100644 --- a/src/cli/operations/eval/__tests__/logs-eval.test.ts +++ b/src/cli/operations/eval/__tests__/logs-eval.test.ts @@ -1,4 +1,5 @@ import { handleLogsEval } from '../logs-eval.js'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockLoadDeployedProjectConfig = vi.fn(); @@ -96,12 +97,12 @@ describe('handleLogsEval', () => { it('returns error when agent resolution fails', async () => { mockLoadDeployedProjectConfig.mockResolvedValue({}); - mockResolveAgent.mockReturnValue({ success: false, error: 'No agents defined' }); + mockResolveAgent.mockReturnValue({ success: false, error: new Error('No agents defined') }); const result = await handleLogsEval({}); - expect(result.success).toBe(false); - expect(result.error).toBe('No agents defined'); + assert(!result.success); + expect(result.error.message).toBe('No agents defined'); }); it('returns error when no online eval configs exist for the agent', async () => { @@ -111,8 +112,8 @@ describe('handleLogsEval', () => { const result = await handleLogsEval({}); - expect(result.success).toBe(false); - expect(result.error).toContain('No deployed online eval configs found'); + assert(!result.success); + expect(result.error.message).toContain('No deployed online eval configs found'); }); it('returns error when online eval configs exist but none are deployed', async () => { @@ -122,8 +123,8 @@ describe('handleLogsEval', () => { const result = await handleLogsEval({}); - expect(result.success).toBe(false); - expect(result.error).toContain('No deployed online eval configs found'); + assert(!result.success); + expect(result.error.message).toContain('No deployed online eval configs found'); }); it('searches logs with time range when --since is specified', async () => { diff --git a/src/cli/operations/eval/__tests__/pause-resume.test.ts b/src/cli/operations/eval/__tests__/pause-resume.test.ts index 834525675..9a6291a3d 100644 --- a/src/cli/operations/eval/__tests__/pause-resume.test.ts +++ b/src/cli/operations/eval/__tests__/pause-resume.test.ts @@ -1,4 +1,5 @@ import { handlePauseResume } from '../pause-resume.js'; +import assert from 'node:assert'; import { afterEach, describe, expect, it, vi } from 'vitest'; const mockLoadDeployedProjectConfig = vi.fn(); @@ -46,7 +47,7 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'my-config' }, 'pause'); - expect(result.success).toBe(true); + assert(result.success); expect(result.executionStatus).toBe('DISABLED'); expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalledWith({ region: 'us-east-1', @@ -65,7 +66,7 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'my-config' }, 'resume'); - expect(result.success).toBe(true); + assert(result.success); expect(result.executionStatus).toBe('ENABLED'); expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalledWith({ region: 'us-east-1', @@ -83,8 +84,8 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'my-config' }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toContain('No deployed targets found'); + assert(!result.success); + expect(result.error.message).toContain('No deployed targets found'); }); it('returns error when config name is not found in deployed state', async () => { @@ -92,9 +93,9 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'missing-config' }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toContain('missing-config'); - expect(result.error).toContain('not found'); + assert(!result.success); + expect(result.error.message).toContain('missing-config'); + expect(result.error.message).toContain('not found'); }); it('returns error when target config is missing from aws-targets', async () => { @@ -105,9 +106,9 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'my-config' }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toContain('Target config'); - expect(result.error).toContain('not found'); + assert(!result.success); + expect(result.error.message).toContain('Target config'); + expect(result.error.message).toContain('not found'); }); it('returns error when the SDK call fails', async () => { @@ -116,8 +117,8 @@ describe('handlePauseResume', () => { const result = await handlePauseResume({ name: 'my-config' }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toBe('Service unavailable'); + assert(!result.success); + expect(result.error.message).toBe('Service unavailable'); }); describe('ARN mode', () => { @@ -131,7 +132,7 @@ describe('handlePauseResume', () => { const arn = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/my-cfg-id'; const result = await handlePauseResume({ name: '', arn }, 'pause'); - expect(result.success).toBe(true); + assert(result.success); expect(result.executionStatus).toBe('DISABLED'); expect(mockLoadDeployedProjectConfig).not.toHaveBeenCalled(); expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalledWith({ @@ -151,7 +152,7 @@ describe('handlePauseResume', () => { const arn = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/my-cfg-id'; const result = await handlePauseResume({ name: '', arn, region: 'eu-west-1' }, 'resume'); - expect(result.success).toBe(true); + assert(result.success); expect(result.executionStatus).toBe('ENABLED'); expect(mockUpdateOnlineEvalExecutionStatus).toHaveBeenCalledWith({ region: 'eu-west-1', @@ -163,16 +164,16 @@ describe('handlePauseResume', () => { it('returns error for invalid ARN', async () => { const result = await handlePauseResume({ name: '', arn: 'not-an-arn' }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid online eval config ARN'); + assert(!result.success); + expect(result.error.message).toContain('Invalid online eval config ARN'); }); it('returns error when config ID cannot be extracted from ARN', async () => { const arn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:some-other-resource/foo'; const result = await handlePauseResume({ name: '', arn }, 'pause'); - expect(result.success).toBe(false); - expect(result.error).toContain('Could not extract config ID'); + assert(!result.success); + expect(result.error.message).toContain('Could not extract config ID'); }); }); }); diff --git a/src/cli/operations/eval/__tests__/run-eval.test.ts b/src/cli/operations/eval/__tests__/run-eval.test.ts index 39314af69..bbf5ac82e 100644 --- a/src/cli/operations/eval/__tests__/run-eval.test.ts +++ b/src/cli/operations/eval/__tests__/run-eval.test.ts @@ -1,4 +1,5 @@ import { handleRunEval } from '../run-eval.js'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // ─── Mocks ──────────────────────────────────────────────────────────────────── @@ -146,12 +147,12 @@ describe('handleRunEval', () => { it('returns error when agent resolution fails', async () => { mockLoadDeployedProjectConfig.mockResolvedValue({}); - mockResolveAgent.mockReturnValue({ success: false, error: 'No agents defined' }); + mockResolveAgent.mockReturnValue({ success: false, error: new Error('No agents defined') }); const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); - expect(result.success).toBe(false); - expect(result.error).toBe('No agents defined'); + assert(!result.success); + expect(result.error.message).toBe('No agents defined'); }); it('returns error when a custom evaluator is not found in deployed state', async () => { @@ -170,9 +171,9 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['MissingEval'], days: 7 }); - expect(result.success).toBe(false); - expect(result.error).toContain('MissingEval'); - expect(result.error).toContain('not found in deployed state'); + assert(!result.success); + expect(result.error.message).toContain('MissingEval'); + expect(result.error.message).toContain('not found in deployed state'); }); it('resolves builtin evaluators without deployed state lookup', async () => { @@ -195,7 +196,8 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); // Fails because no spans, but NOT because evaluator wasn't found - expect(result.error).toContain('No session spans found'); + assert(!result.success); + expect(result.error.message).toContain('No session spans found'); }); it('resolves custom evaluator name to deployed evaluator ID', async () => { @@ -278,9 +280,9 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); - expect(result.success).toBe(false); - expect(result.error).toContain('No session spans found'); - expect(result.error).toContain('my-agent'); + assert(!result.success); + expect(result.error.message).toContain('No session spans found'); + expect(result.error.message).toContain('my-agent'); }); // ─── Successful evaluation ──────────────────────────────────────────────── @@ -324,12 +326,12 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); - expect(result.success).toBe(true); + assert(result.success); expect(result.run).toBeDefined(); - expect(result.run!.sessionCount).toBe(2); - expect(result.run!.results).toHaveLength(1); + expect(result.run.sessionCount).toBe(2); + expect(result.run.results).toHaveLength(1); - const evalResult = result.run!.results[0]!; + const evalResult = result.run.results[0]!; expect(evalResult.aggregateScore).toBe(3.0); // (4 + 2) / 2 expect(evalResult.sessionScores).toHaveLength(2); expect(evalResult.tokenUsage).toEqual({ inputTokens: 180, outputTokens: 90, totalTokens: 270 }); @@ -361,8 +363,8 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); - expect(result.success).toBe(true); - const evalResult = result.run!.results[0]!; + assert(result.success); + const evalResult = result.run.results[0]!; // Only the non-errored session (value 5.0) should be in the aggregate expect(evalResult.aggregateScore).toBe(5.0); expect(evalResult.sessionScores).toHaveLength(2); @@ -391,7 +393,7 @@ describe('handleRunEval', () => { const result = await handleRunEval({ evaluator: ['Builtin.GoalSuccessRate'], days: 7 }); - expect(result.success).toBe(true); + assert(result.success); expect(mockSaveEvalRun).toHaveBeenCalled(); expect(mockWriteFileSync).not.toHaveBeenCalled(); expect(result.filePath).toBe('/tmp/eval-results/eval_2025-01-15_10-00-00.json'); @@ -422,7 +424,7 @@ describe('handleRunEval', () => { output: '/tmp/my-output.json', }); - expect(result.success).toBe(true); + assert(result.success); expect(mockWriteFileSync).toHaveBeenCalledWith('/tmp/my-output.json', expect.any(String)); expect(mockSaveEvalRun).not.toHaveBeenCalled(); expect(result.filePath).toBe('/tmp/my-output.json'); @@ -461,12 +463,12 @@ describe('handleRunEval', () => { days: 7, }); - expect(result.success).toBe(true); - expect(result.run!.results).toHaveLength(2); - expect(result.run!.results[0]!.evaluator).toBe('Builtin.GoalSuccessRate'); - expect(result.run!.results[0]!.aggregateScore).toBe(0.9); - expect(result.run!.results[1]!.evaluator).toBe('CustomEval'); - expect(result.run!.results[1]!.aggregateScore).toBe(4.5); + assert(result.success); + expect(result.run.results).toHaveLength(2); + expect(result.run.results[0]!.evaluator).toBe('Builtin.GoalSuccessRate'); + expect(result.run.results[0]!.aggregateScore).toBe(0.9); + expect(result.run.results[1]!.evaluator).toBe('CustomEval'); + expect(result.run.results[1]!.aggregateScore).toBe(4.5); }); // ─── ARN mode ───────────────────────────────────────────────────────────── @@ -484,8 +486,8 @@ describe('handleRunEval', () => { days: 3, }); - expect(result.success).toBe(true); - expect(result.run!.agent).toBe('rt-arn-test'); + assert(result.success); + expect(result.run.agent).toBe('rt-arn-test'); expect(mockLoadDeployedProjectConfig).not.toHaveBeenCalled(); expect(mockResolveAgent).not.toHaveBeenCalled(); }); @@ -532,8 +534,8 @@ describe('handleRunEval', () => { days: 7, }); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid agent runtime ARN'); + assert(!result.success); + expect(result.error.message).toContain('Invalid agent runtime ARN'); }); it('rejects custom evaluator names in ARN mode', async () => { @@ -543,8 +545,8 @@ describe('handleRunEval', () => { days: 7, }); - expect(result.success).toBe(false); - expect(result.error).toContain('cannot be resolved in ARN mode'); + assert(!result.success); + expect(result.error.message).toContain('cannot be resolved in ARN mode'); }); it('saves to cwd in ARN mode when no --output is specified', async () => { @@ -559,7 +561,7 @@ describe('handleRunEval', () => { days: 7, }); - expect(result.success).toBe(true); + assert(result.success); // Should write to cwd, not call saveEvalRun (which requires a project) expect(mockSaveEvalRun).not.toHaveBeenCalled(); expect(mockWriteFileSync).toHaveBeenCalledWith( @@ -582,7 +584,7 @@ describe('handleRunEval', () => { output: '/tmp/custom-eval.json', }); - expect(result.success).toBe(true); + assert(result.success); expect(mockWriteFileSync).toHaveBeenCalledWith('/tmp/custom-eval.json', expect.any(String)); expect(result.filePath).toBe('/tmp/custom-eval.json'); }); @@ -594,8 +596,8 @@ describe('handleRunEval', () => { days: 7, }); - expect(result.success).toBe(false); - expect(result.error).toContain('No evaluators specified'); + assert(!result.success); + expect(result.error.message).toContain('No evaluators specified'); }); // ─── Endpoint selection ────────────────────────────────────────────────── @@ -1199,8 +1201,8 @@ describe('handleRunEval', () => { expectedTrajectory: ['tool_1', 'tool_2'], }); - expect(result.success).toBe(true); - expect(result.run!.referenceInputs).toEqual({ + assert(result.success); + expect(result.run.referenceInputs).toEqual({ expectedTrajectory: ['tool_1', 'tool_2'], }); }); @@ -1215,7 +1217,7 @@ describe('handleRunEval', () => { assertions: ['Agent should greet user'], }); - expect(result.success).toBe(false); - expect(result.error).toContain('require exactly one session'); + assert(!result.success); + expect(result.error.message).toContain('require exactly one session'); }); }); diff --git a/src/cli/operations/eval/index.ts b/src/cli/operations/eval/index.ts index de5999569..66efbbb21 100644 --- a/src/cli/operations/eval/index.ts +++ b/src/cli/operations/eval/index.ts @@ -5,6 +5,5 @@ export type { ListEvalRunsResult } from './list-eval-runs'; export { handlePauseResume } from './pause-resume'; export type { PauseResumeResult } from './pause-resume'; export { handleLogsEval } from './logs-eval'; -export type { LogsEvalResult } from './logs-eval'; export type { EvalRunResult, RunEvalOptions, ListEvalRunsOptions, OnlineEvalActionOptions, SessionInfo } from './types'; export type { LogsEvalOptions } from './logs-eval'; diff --git a/src/cli/operations/eval/list-eval-runs.ts b/src/cli/operations/eval/list-eval-runs.ts index 66b0ed528..ab41e4510 100644 --- a/src/cli/operations/eval/list-eval-runs.ts +++ b/src/cli/operations/eval/list-eval-runs.ts @@ -1,12 +1,9 @@ -import { getErrorMessage } from '../../errors'; +import type { Result } from '../../../lib/result'; +import { toError } from '../../errors'; import { listEvalRuns } from './storage'; import type { EvalRunResult, ListEvalRunsOptions } from './types'; -export interface ListEvalRunsResult { - success: boolean; - error?: string; - runs?: EvalRunResult[]; -} +export type ListEvalRunsResult = Result<{ runs: EvalRunResult[] }>; export function handleListEvalRuns(options: ListEvalRunsOptions): ListEvalRunsResult { try { @@ -22,6 +19,6 @@ export function handleListEvalRuns(options: ListEvalRunsOptions): ListEvalRunsRe return { success: true, runs }; } catch (err) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: toError(err) }; } } diff --git a/src/cli/operations/eval/logs-eval.ts b/src/cli/operations/eval/logs-eval.ts index 430103a44..25c7a73e7 100644 --- a/src/cli/operations/eval/logs-eval.ts +++ b/src/cli/operations/eval/logs-eval.ts @@ -1,6 +1,8 @@ +import type { Result } from '../../../lib/result'; import { parseTimeString } from '../../../lib/utils'; import { getOnlineEvaluationConfig } from '../../aws/agentcore-control'; import { searchLogs, streamLogs } from '../../aws/cloudwatch'; +import { ValidationError } from '../../errors'; import type { DeployedProjectConfig } from '../resolve-agent'; import { loadDeployedProjectConfig, resolveAgent } from '../resolve-agent'; @@ -13,11 +15,6 @@ export interface LogsEvalOptions { follow?: boolean; } -export interface LogsEvalResult { - success: boolean; - error?: string; -} - function formatLogLine(event: { timestamp: number; message: string }, json: boolean): string { if (json) { return JSON.stringify({ timestamp: new Date(event.timestamp).toISOString(), message: event.message }); @@ -70,7 +67,7 @@ async function resolveEvalLogGroups( return results; } -export async function handleLogsEval(options: LogsEvalOptions): Promise { +export async function handleLogsEval(options: LogsEvalOptions): Promise { const context = await loadDeployedProjectConfig(); const agentResult = resolveAgent(context, { runtime: options.agent }); @@ -85,7 +82,9 @@ export async function handleLogsEval(options: LogsEvalOptions): Promise; -async function resolveOnlineEvalConfig( - configName: string -): Promise<{ success: true; configId: string; region: string } | { success: false; error: string }> { +async function resolveOnlineEvalConfig(configName: string): Promise> { const context = await loadDeployedProjectConfig(); const targetNames = Object.keys(context.deployedState.targets); if (targetNames.length === 0) { - return { success: false, error: 'No deployed targets found. Run `agentcore deploy` first.' }; + return { + success: false, + error: new ResourceNotFoundError('No deployed targets found. Run `agentcore deploy` first.'), + }; } const targetName = targetNames[0]!; @@ -27,13 +25,18 @@ async function resolveOnlineEvalConfig( if (!deployedConfig) { return { success: false, - error: `Online eval config "${configName}" not found in deployed state. Has it been deployed?`, + error: new ResourceNotFoundError( + `Online eval config "${configName}" not found in deployed state. Has it been deployed?` + ), }; } const targetConfig = context.awsTargets.find(t => t.name === targetName); if (!targetConfig) { - return { success: false, error: `Target config "${targetName}" not found in aws-targets.` }; + return { + success: false, + error: new ResourceNotFoundError(`Target config "${targetName}" not found in aws-targets.`), + }; } return { @@ -47,24 +50,24 @@ async function resolveOnlineEvalConfig( * Parse an online eval config ARN to extract the config ID and region. * ARN format: arn:aws:bedrock-agentcore:::online-evaluation-config/ */ -function parseOnlineEvalConfigArn( - arn: string, - regionOverride?: string -): { success: true; configId: string; region: string } | { success: false; error: string } { +function parseOnlineEvalConfigArn(arn: string, regionOverride?: string): Result<{ configId: string; region: string }> { const parts = arn.split(':'); if (parts.length < 6 || !arn.startsWith('arn:')) { - return { success: false, error: `Invalid online eval config ARN: ${arn}` }; + return { success: false, error: new ValidationError(`Invalid online eval config ARN: ${arn}`) }; } const region = regionOverride ?? parts[3]; if (!region) { - return { success: false, error: 'Could not determine region from ARN. Use --region to specify.' }; + return { + success: false, + error: new ValidationError('Could not determine region from ARN. Use --region to specify.'), + }; } const resource = parts.slice(5).join(':'); const match = /online-evaluation-config\/(.+)$/.exec(resource); if (!match) { - return { success: false, error: `Could not extract config ID from ARN: ${arn}` }; + return { success: false, error: new ValidationError(`Could not extract config ID from ARN: ${arn}`) }; } return { success: true, configId: match[1]!, region }; @@ -73,9 +76,7 @@ function parseOnlineEvalConfigArn( /** * Resolve config ID and region from either a project config name or an ARN. */ -async function resolveConfig( - options: OnlineEvalActionOptions -): Promise<{ success: true; configId: string; region: string } | { success: false; error: string }> { +async function resolveConfig(options: OnlineEvalActionOptions): Promise> { if (options.arn) { return parseOnlineEvalConfigArn(options.arn, options.region); } @@ -88,7 +89,7 @@ export async function handlePauseResume( ): Promise { const resolution = await resolveConfig(options); if (!resolution.success) { - return resolution; + return { success: false, error: resolution.error }; } const executionStatus: OnlineEvalExecutionStatus = action === 'pause' ? 'DISABLED' : 'ENABLED'; @@ -106,6 +107,6 @@ export async function handlePauseResume( executionStatus: result.executionStatus, }; } catch (err) { - return { success: false, error: (err as Error).message }; + return { success: false, error: toError(err) }; } } diff --git a/src/cli/operations/eval/run-batch-evaluation.ts b/src/cli/operations/eval/run-batch-evaluation.ts index 0962f4e0a..cbb66675e 100644 --- a/src/cli/operations/eval/run-batch-evaluation.ts +++ b/src/cli/operations/eval/run-batch-evaluation.ts @@ -16,6 +16,7 @@ import type { SessionMetadataEntry, } from '../../aws/agentcore-batch-evaluation'; import { detectRegion } from '../../aws/region'; +import { ValidationError, getErrorMessage, toError } from '../../errors'; import { ExecLogger } from '../../logging/exec-logger'; import { CloudWatchLogsClient, GetLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs'; @@ -54,9 +55,7 @@ export interface BatchEvaluationResult { error?: string; } -export interface RunBatchEvaluationCommandResult { - success: boolean; - error?: string; +interface BatchEvalResultBase { batchEvaluationId?: string; name?: string; status?: string; @@ -67,6 +66,10 @@ export interface RunBatchEvaluationCommandResult { logFilePath?: string; } +export type RunBatchEvaluationCommandResult = + | (BatchEvalResultBase & { success: true }) + | (BatchEvalResultBase & { success: false; error: Error }); + // ============================================================================ // Constants // ============================================================================ @@ -116,7 +119,7 @@ export async function runBatchEvaluationCommand( logger?.log(error, 'error'); logger?.endStep('error', error); logger?.finalize(false); - return { success: false, error, results: [], logFilePath: logger?.logFilePath }; + return { success: false, error: new Error(error), results: [], logFilePath: logger?.logFilePath }; } const runtimeId = agentState.runtimeId; @@ -152,7 +155,9 @@ export async function runBatchEvaluationCommand( if (!/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/.test(options.name)) { return { success: false, - error: `Batch evaluation name must start with a letter and contain only letters, digits, and underscores (max 48 chars). Got: "${options.name}"`, + error: new ValidationError( + `Batch evaluation name must start with a letter and contain only letters, digits, and underscores (max 48 chars). Got: "${options.name}"` + ), results: [], logFilePath: logger?.logFilePath, }; @@ -242,7 +247,7 @@ export async function runBatchEvaluationCommand( logger?.finalize(false); return { success: false, - error, + error: new Error(error), batchEvaluationId: startResult.batchEvaluationId, name: evalName, status: current.status, @@ -284,10 +289,9 @@ export async function runBatchEvaluationCommand( logFilePath: logger?.logFilePath, }; } catch (err) { - const error = err instanceof Error ? err.message : String(err); - logger?.log(error, 'error'); + logger?.log(getErrorMessage(err), 'error'); logger?.finalize(false); - return { success: false, error, results: [], logFilePath: logger?.logFilePath }; + return { success: false, error: toError(err), results: [], logFilePath: logger?.logFilePath }; } } diff --git a/src/cli/operations/eval/run-eval.ts b/src/cli/operations/eval/run-eval.ts index 90cd519c7..892722914 100644 --- a/src/cli/operations/eval/run-eval.ts +++ b/src/cli/operations/eval/run-eval.ts @@ -1,8 +1,10 @@ +import type { Result } from '../../../lib/result'; import { getCredentialProvider } from '../../aws'; import { evaluate } from '../../aws/agentcore'; import type { EvaluationReferenceInput } from '../../aws/agentcore'; import { getEvaluator } from '../../aws/agentcore-control'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; +import { ResourceNotFoundError, ValidationError } from '../../errors'; import type { DeployedProjectConfig } from '../resolve-agent'; import { loadDeployedProjectConfig, resolveAgent } from '../resolve-agent'; import { generateFilename, saveEvalRun } from './storage'; @@ -30,7 +32,7 @@ interface ResolvedEvalContext { evaluatorLabels: string[]; } -type ResolveResult = { success: true; ctx: ResolvedEvalContext } | { success: false; error: string }; +type ResolveResult = { success: true; ctx: ResolvedEvalContext } | { success: false; error: Error }; /** * Resolve evaluator IDs from ARN strings or raw IDs. @@ -53,18 +55,21 @@ function resolveFromArn(options: RunEvalOptions): ResolveResult { // Parse ARN: arn:aws:bedrock-agentcore:::runtime/ const arnParts = arn.split(':'); if (arnParts.length < 6) { - return { success: false, error: `Invalid agent runtime ARN: ${arn}` }; + return { success: false, error: new ValidationError(`Invalid agent runtime ARN: ${arn}`) }; } const region = options.region ?? arnParts[3]; if (!region) { - return { success: false, error: 'Could not determine region from ARN. Use --region to specify.' }; + return { + success: false, + error: new ValidationError('Could not determine region from ARN. Use --region to specify.'), + }; } const resourcePart = arnParts.slice(5).join(':'); const runtimeMatch = /runtime\/(.+)$/.exec(resourcePart); if (!runtimeMatch) { - return { success: false, error: `Could not extract runtime ID from ARN: ${arn}` }; + return { success: false, error: new ValidationError(`Could not extract runtime ID from ARN: ${arn}`) }; } const runtimeId = runtimeMatch[1]!; @@ -79,7 +84,9 @@ function resolveFromArn(options: RunEvalOptions): ResolveResult { } else { return { success: false, - error: `Custom evaluator "${evalName}" cannot be resolved in ARN mode. Use --evaluator-arn with an evaluator ARN or ID, or use Builtin.* evaluators.`, + error: new ValidationError( + `Custom evaluator "${evalName}" cannot be resolved in ARN mode. Use --evaluator-arn with an evaluator ARN or ID, or use Builtin.* evaluators.` + ), }; } } @@ -91,7 +98,10 @@ function resolveFromArn(options: RunEvalOptions): ResolveResult { } if (evaluatorIds.length === 0) { - return { success: false, error: 'No evaluators specified. Use -e/--evaluator with Builtin.* or --evaluator-arn.' }; + return { + success: false, + error: new ValidationError('No evaluators specified. Use -e/--evaluator with Builtin.* or --evaluator-arn.'), + }; } const endpointName = options.endpoint ?? process.env.AGENTCORE_RUNTIME_ENDPOINT ?? DEFAULT_ENDPOINT_NAME; @@ -116,7 +126,7 @@ function resolveFromArn(options: RunEvalOptions): ResolveResult { function resolveFromProject(context: DeployedProjectConfig, options: RunEvalOptions): ResolveResult { const agentResult = resolveAgent(context, { runtime: options.agent }); if (!agentResult.success) { - return agentResult; + return { success: false, error: agentResult.error }; } const { agent } = agentResult; @@ -139,7 +149,7 @@ function resolveFromProject(context: DeployedProjectConfig, options: RunEvalOpti if (!deployedEval) { return { success: false, - error: `Evaluator "${evalName}" not found in deployed state. Has it been deployed?`, + error: new ResourceNotFoundError(`Evaluator "${evalName}" not found in deployed state. Has it been deployed?`), }; } evaluatorIds.push(deployedEval.evaluatorId); @@ -154,7 +164,10 @@ function resolveFromProject(context: DeployedProjectConfig, options: RunEvalOpti } if (evaluatorIds.length === 0) { - return { success: false, error: 'No evaluators specified. Use -e/--evaluator or --evaluator-arn.' }; + return { + success: false, + error: new ValidationError('No evaluators specified. Use -e/--evaluator or --evaluator-arn.'), + }; } return { @@ -551,12 +564,7 @@ async function fetchSessionSpans(opts: FetchSpansOptions): Promise; export async function handleRunEval(options: RunEvalOptions): Promise { let resolution: ResolveResult; @@ -593,7 +601,9 @@ export async function handleRunEval(options: RunEvalOptions): Promise { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('Gateway "dup-gw" already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('Gateway "dup-gw" already exists') }), + }) ); }); diff --git a/src/cli/operations/memory/__tests__/create-memory.test.ts b/src/cli/operations/memory/__tests__/create-memory.test.ts index a0b8077c4..09c827ac0 100644 --- a/src/cli/operations/memory/__tests__/create-memory.test.ts +++ b/src/cli/operations/memory/__tests__/create-memory.test.ts @@ -86,7 +86,7 @@ describe('add', () => { expiry: 30, }); - expect(result).toEqual(expect.objectContaining({ success: false, error: expect.any(String) })); + expect(result).toEqual(expect.objectContaining({ success: false, error: expect.any(Error) })); expect(mockWriteProjectSpec).not.toHaveBeenCalled(); }); @@ -96,7 +96,10 @@ describe('add', () => { const result = await primitive.add({ name: 'Existing', strategies: '', expiry: 30 }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('Memory "Existing" already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('Memory "Existing" already exists') }), + }) ); }); }); diff --git a/src/cli/operations/memory/list-memory-records.ts b/src/cli/operations/memory/list-memory-records.ts index 8bbf34628..be39b6b97 100644 --- a/src/cli/operations/memory/list-memory-records.ts +++ b/src/cli/operations/memory/list-memory-records.ts @@ -1,4 +1,6 @@ +import type { Result } from '../../../lib/result'; import { getCredentialProvider } from '../../aws'; +import { ResourceNotFoundError, toError } from '../../errors'; import { BedrockAgentCoreClient, ListMemoryRecordsCommand } from '@aws-sdk/client-bedrock-agentcore'; export interface MemoryRecordEntry { @@ -20,12 +22,7 @@ export interface ListMemoryRecordsOptions { nextToken?: string; } -export interface ListMemoryRecordsResult { - success: boolean; - records?: MemoryRecordEntry[]; - nextToken?: string; - error?: string; -} +export type ListMemoryRecordsResult = Result<{ records: MemoryRecordEntry[]; nextToken?: string }>; /** * Lists memory records for a deployed memory resource via the AWS SDK. @@ -63,8 +60,11 @@ export async function listMemoryRecords(options: ListMemoryRecordsOptions): Prom } catch (error: unknown) { const err = error as Error; if (err.name === 'ResourceNotFoundException') { - return { success: false, error: `Memory '${memoryId}' not found. It may not have been deployed yet.` }; + return { + success: false, + error: new ResourceNotFoundError(`Memory '${memoryId}' not found. It may not have been deployed yet.`), + }; } - return { success: false, error: err.message ?? String(error) }; + return { success: false, error: toError(error) }; } } diff --git a/src/cli/operations/memory/retrieve-memory-records.ts b/src/cli/operations/memory/retrieve-memory-records.ts index e8d2a65ed..8d168eba2 100644 --- a/src/cli/operations/memory/retrieve-memory-records.ts +++ b/src/cli/operations/memory/retrieve-memory-records.ts @@ -1,4 +1,6 @@ +import type { Result } from '../../../lib/result'; import { getCredentialProvider } from '../../aws'; +import { ResourceNotFoundError, toError } from '../../errors'; import type { MemoryRecordEntry } from './list-memory-records'; import { BedrockAgentCoreClient, RetrieveMemoryRecordsCommand } from '@aws-sdk/client-bedrock-agentcore'; @@ -13,12 +15,7 @@ export interface RetrieveMemoryRecordsOptions { nextToken?: string; } -export interface RetrieveMemoryRecordsResult { - success: boolean; - records?: MemoryRecordEntry[]; - nextToken?: string; - error?: string; -} +export type RetrieveMemoryRecordsResult = Result<{ records: MemoryRecordEntry[]; nextToken?: string }>; /** * Searches memory records using semantic retrieval via the AWS SDK. @@ -62,8 +59,11 @@ export async function retrieveMemoryRecords( } catch (error: unknown) { const err = error as Error; if (err.name === 'ResourceNotFoundException') { - return { success: false, error: `Memory '${memoryId}' not found. It may not have been deployed yet.` }; + return { + success: false, + error: new ResourceNotFoundError(`Memory '${memoryId}' not found. It may not have been deployed yet.`), + }; } - return { success: false, error: err.message ?? String(error) }; + return { success: false, error: toError(error) }; } } diff --git a/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts b/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts index 5e0fb668a..981f00a75 100644 --- a/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts +++ b/src/cli/operations/recommendation/__tests__/apply-to-bundle.test.ts @@ -1,6 +1,7 @@ import type { ConfigIO } from '../../../../lib'; import type { RecommendationResult } from '../../../aws/agentcore-recommendation'; import { applyRecommendationToBundle } from '../apply-to-bundle'; +import assert from 'node:assert'; import { describe, expect, it, vi } from 'vitest'; const { RUNTIME_ARN, BUNDLE_ARN, NEW_VERSION_ID } = vi.hoisted(() => ({ @@ -98,7 +99,7 @@ describe('applyRecommendationToBundle', () => { configIO ); - expect(applyResult.success).toBe(true); + assert(applyResult.success); expect(applyResult.newVersionId).toBe(NEW_VERSION_ID); // Verify spec was written with server components @@ -132,7 +133,7 @@ describe('applyRecommendationToBundle', () => { configIO ); - expect(applyResult.success).toBe(true); + assert(applyResult.success); expect(applyResult.newVersionId).toBe(NEW_VERSION_ID); }); @@ -152,7 +153,7 @@ describe('applyRecommendationToBundle', () => { configIO ); - expect(applyResult.success).toBe(true); + assert(applyResult.success); expect(applyResult.newVersionId).toBe(NEW_VERSION_ID); }); @@ -171,8 +172,8 @@ describe('applyRecommendationToBundle', () => { configIO ); - expect(applyResult.success).toBe(false); - expect(applyResult.error).toContain('does not contain a new config bundle version'); + assert(!applyResult.success); + expect(applyResult.error.message).toContain('does not contain a new config bundle version'); expect(writeSpecSpy).not.toHaveBeenCalled(); }); @@ -192,8 +193,8 @@ describe('applyRecommendationToBundle', () => { configIO ); - expect(applyResult.success).toBe(false); - expect(applyResult.error).toContain('NonExistent'); + assert(!applyResult.success); + expect(applyResult.error.message).toContain('NonExistent'); expect(writeSpecSpy).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts b/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts index f6a60b6e8..55c5b6eae 100644 --- a/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts +++ b/src/cli/operations/recommendation/__tests__/recommendation-storage.test.ts @@ -17,7 +17,9 @@ function makeTmpDir(): string { return dir; } -function makeResult(overrides: Partial = {}): RunRecommendationCommandResult { +function makeResult( + overrides: Partial> = {} +): RunRecommendationCommandResult { return { success: true, recommendationId: 'rec-123', diff --git a/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts index b26a59b32..a1bc8827e 100644 --- a/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts +++ b/src/cli/operations/recommendation/__tests__/run-recommendation.test.ts @@ -1,4 +1,5 @@ import { runRecommendationCommand } from '../run-recommendation'; +import assert from 'node:assert'; import { beforeEach, describe, expect, it, vi } from 'vitest'; // Mock dependencies — paths are relative to the file under test (run-recommendation.ts) @@ -71,9 +72,9 @@ describe('runRecommendationCommand', () => { traceSource: 'cloudwatch', }); - expect(result.success).toBe(false); - expect(result.error).toContain('NonExistentAgent'); - expect(result.error).toContain('not deployed'); + assert(!result.success); + expect(result.error.message).toContain('NonExistentAgent'); + expect(result.error.message).toContain('not deployed'); }); it('returns error when evaluator cannot be resolved', async () => { @@ -86,9 +87,9 @@ describe('runRecommendationCommand', () => { traceSource: 'cloudwatch', }); - expect(result.success).toBe(false); - expect(result.error).toContain('UnknownEvaluator'); - expect(result.error).toContain('not found'); + assert(!result.success); + expect(result.error.message).toContain('UnknownEvaluator'); + expect(result.error.message).toContain('not found'); }); it('returns result on COMPLETED status', async () => { @@ -123,7 +124,7 @@ describe('runRecommendationCommand', () => { pollIntervalMs: 0, }); - expect(result.success).toBe(true); + assert(result.success); expect(result.recommendationId).toBe('rec-001'); expect(result.status).toBe('COMPLETED'); expect(result.result?.systemPromptRecommendationResult?.recommendedSystemPrompt).toBe('Optimized prompt'); @@ -153,8 +154,8 @@ describe('runRecommendationCommand', () => { pollIntervalMs: 0, }); - expect(result.success).toBe(false); - expect(result.error).toContain('FAILED'); + assert(!result.success); + expect(result.error.message).toContain('FAILED'); expect(result.recommendationId).toBe('rec-002'); }); @@ -287,8 +288,8 @@ describe('runRecommendationCommand', () => { traceSource: 'cloudwatch', }); - expect(result.success).toBe(false); - expect(result.error).toContain('API timeout'); + assert(!result.success); + expect(result.error.message).toContain('API timeout'); }); it('retries transient poll failures and succeeds', async () => { @@ -345,10 +346,10 @@ describe('runRecommendationCommand', () => { pollIntervalMs: 0, }); - expect(result.success).toBe(false); - expect(result.error).toContain('consecutive errors'); - expect(result.error).toContain('fetch failed'); - expect(result.error).toContain('rec-retry-fail'); + assert(!result.success); + expect(result.error.message).toContain('consecutive errors'); + expect(result.error.message).toContain('fetch failed'); + expect(result.error.message).toContain('rec-retry-fail'); expect(mockGetRecommendation).toHaveBeenCalledTimes(3); }); @@ -377,9 +378,9 @@ describe('runRecommendationCommand', () => { maxPollDurationMs: 0, // Immediately timeout }); - expect(result.success).toBe(false); - expect(result.error).toContain('Polling timed out'); - expect(result.error).toContain('rec-timeout'); + assert(!result.success); + expect(result.error.message).toContain('Polling timed out'); + expect(result.error.message).toContain('rec-timeout'); }); it('reads system prompt from file when inputSource is file', async () => { @@ -538,8 +539,8 @@ describe('runRecommendationCommand', () => { pollIntervalMs: 0, }); - expect(result.success).toBe(false); - expect(result.error).toContain('No spans found'); + assert(!result.success); + expect(result.error.message).toContain('No spans found'); }); it('derives service name from runtimeId by stripping hash suffix', async () => { @@ -664,10 +665,10 @@ describe('runRecommendationCommand', () => { pollIntervalMs: 0, }); - expect(result.success).toBe(false); - expect(result.error).toContain('Insufficient trace data'); - expect(result.error).toContain('INSUFFICIENT_DATA'); - expect(result.error).toContain('Not enough traces'); + assert(!result.success); + expect(result.error.message).toContain('Insufficient trace data'); + expect(result.error.message).toContain('INSUFFICIENT_DATA'); + expect(result.error.message).toContain('Not enough traces'); // Request IDs are logged to file only, not included in the error message }); diff --git a/src/cli/operations/recommendation/apply-to-bundle.ts b/src/cli/operations/recommendation/apply-to-bundle.ts index bf9060d10..02b496a68 100644 --- a/src/cli/operations/recommendation/apply-to-bundle.ts +++ b/src/cli/operations/recommendation/apply-to-bundle.ts @@ -10,8 +10,10 @@ * updates the local agentcore.json components to match the server state. */ import { ConfigIO } from '../../../lib'; +import type { Result } from '../../../lib/result'; import { getConfigurationBundleVersion } from '../../aws/agentcore-config-bundles'; import type { RecommendationResult } from '../../aws/agentcore-recommendation'; +import { ResourceNotFoundError, ValidationError } from '../../errors'; export interface ApplyRecommendationOptions { /** Config bundle name in agentcore.json (used by CLI) */ @@ -24,12 +26,7 @@ export interface ApplyRecommendationOptions { region: string; } -export interface ApplyRecommendationResult { - success: boolean; - error?: string; - /** New version ID that was synced from the server */ - newVersionId?: string; -} +export type ApplyRecommendationResult = Result<{ newVersionId?: string }>; /** * Extract the bundleId from a bundle ARN. @@ -58,8 +55,9 @@ export async function applyRecommendationToBundle( if (!resultBundle) { return { success: false, - error: - 'Recommendation result does not contain a new config bundle version. The server may not have applied the recommendation to the bundle.', + error: new ValidationError( + 'Recommendation result does not contain a new config bundle version. The server may not have applied the recommendation to the bundle.' + ), }; } @@ -67,7 +65,7 @@ export async function applyRecommendationToBundle( if (!bundleId) { return { success: false, - error: `Could not extract bundle ID from ARN: ${resultBundle.bundleArn}`, + error: new ValidationError(`Could not extract bundle ID from ARN: ${resultBundle.bundleArn}`), }; } @@ -107,7 +105,7 @@ export async function applyRecommendationToBundle( if (!bundle) { return { success: false, - error: `Config bundle "${identifier}" not found in agentcore.json.`, + error: new ResourceNotFoundError(`Config bundle "${identifier}" not found in agentcore.json.`), }; } diff --git a/src/cli/operations/recommendation/recommendation-storage.ts b/src/cli/operations/recommendation/recommendation-storage.ts index ad8aa7160..2049535e3 100644 --- a/src/cli/operations/recommendation/recommendation-storage.ts +++ b/src/cli/operations/recommendation/recommendation-storage.ts @@ -43,9 +43,9 @@ export function saveRecommendationRun( agent, evaluators, status: result.status ?? 'unknown', - startedAt: result.startedAt, - completedAt: result.completedAt, - result: result.result, + startedAt: result.success ? result.startedAt : undefined, + completedAt: result.success ? result.completedAt : undefined, + result: result.success ? result.result : undefined, }; writeFileSync(filePath, JSON.stringify(record, null, 2)); diff --git a/src/cli/operations/recommendation/run-recommendation.ts b/src/cli/operations/recommendation/run-recommendation.ts index 0423cfe32..5d430f735 100644 --- a/src/cli/operations/recommendation/run-recommendation.ts +++ b/src/cli/operations/recommendation/run-recommendation.ts @@ -17,6 +17,7 @@ import type { import { getRecommendation, startRecommendation } from '../../aws/agentcore-recommendation'; import { arnPrefix } from '../../aws/partition'; import { detectRegion } from '../../aws/region'; +import { OperationTimeoutError, ResourceNotFoundError, ValidationError, getErrorMessage, toError } from '../../errors'; import { ExecLogger } from '../../logging/exec-logger'; import { DEFAULT_POLL_INTERVAL_MS, MAX_POLL_DURATION_MS, MAX_POLL_RETRIES, TERMINAL_STATUSES } from './constants'; import { fetchSessionSpans } from './fetch-session-spans'; @@ -60,7 +61,7 @@ export async function runRecommendationCommand( logger?.finalize(false); return { success: false, - error: `Agent "${options.agent}" not deployed. Run \`agentcore deploy\` first.`, + error: new ResourceNotFoundError(`Agent "${options.agent}" not deployed. Run \`agentcore deploy\` first.`), logFilePath: logger?.logFilePath, }; } @@ -73,7 +74,9 @@ export async function runRecommendationCommand( if (!evaluatorId) { return { success: false, - error: `Evaluator "${evaluator}" not found in deployed state. Use a Builtin.* name, a full ARN, or deploy a custom evaluator first.`, + error: new ValidationError( + `Evaluator "${evaluator}" not found in deployed state. Use a Builtin.* name, a full ARN, or deploy a custom evaluator first.` + ), logFilePath: logger?.logFilePath, }; } @@ -82,7 +85,7 @@ export async function runRecommendationCommand( if (options.type === 'SYSTEM_PROMPT_RECOMMENDATION' && evaluatorIds.length !== 1) { return { success: false, - error: 'System prompt recommendations require exactly one evaluator.', + error: new ValidationError('System prompt recommendations require exactly one evaluator.'), logFilePath: logger?.logFilePath, }; } @@ -105,7 +108,9 @@ export async function runRecommendationCommand( ) { return { success: false, - error: 'System prompt content is required. Provide via --inline, --prompt-file, or --bundle-name.', + error: new ValidationError( + 'System prompt content is required. Provide via --inline, --prompt-file, or --bundle-name.' + ), logFilePath: logger?.logFilePath, }; } @@ -132,7 +137,9 @@ export async function runRecommendationCommand( if (!bundleArn) { return { success: false, - error: `Config bundle "${options.bundleName}" not found in deployed state. Run \`agentcore deploy\` first.`, + error: new ResourceNotFoundError( + `Config bundle "${options.bundleName}" not found in deployed state. Run \`agentcore deploy\` first.` + ), logFilePath: logger?.logFilePath, }; } @@ -230,7 +237,9 @@ export async function runRecommendationCommand( logger?.finalize(false); return { success: false, - error: `Polling timed out after ${Math.round(maxDurationMs / 60000)} minutes. The recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}`, + error: new OperationTimeoutError( + `Polling timed out after ${Math.round(maxDurationMs / 60000)} minutes. The recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}` + ), recommendationId: startResult.recommendationId, status: currentStatus, logFilePath: logger?.logFilePath, @@ -255,7 +264,9 @@ export async function runRecommendationCommand( logger?.finalize(false); return { success: false, - error: `Polling failed after ${MAX_POLL_RETRIES} consecutive errors: ${pollErrMsg}\nThe recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}`, + error: new OperationTimeoutError( + `Polling failed after ${MAX_POLL_RETRIES} consecutive errors: ${pollErrMsg}\nThe recommendation may still be running server-side.\nRecommendation ID: ${startResult.recommendationId}` + ), recommendationId: startResult.recommendationId, status: currentStatus, logFilePath: logger?.logFilePath, @@ -303,9 +314,11 @@ export async function runRecommendationCommand( return { success: false, - error: failureDetails - ? `Recommendation failed: ${failureDetails}` - : `Recommendation finished with status: ${currentStatus}`, + error: new Error( + failureDetails + ? `Recommendation failed: ${failureDetails}` + : `Recommendation finished with status: ${currentStatus}` + ), recommendationId: startResult.recommendationId, status: currentStatus, logFilePath: logger?.logFilePath, @@ -319,19 +332,19 @@ export async function runRecommendationCommand( logger?.finalize(false); return { success: false, - error: `Recommendation ended with unexpected status: ${currentStatus}`, + error: new Error(`Recommendation ended with unexpected status: ${currentStatus}`), recommendationId: startResult.recommendationId, status: currentStatus, logFilePath: logger?.logFilePath, }; } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err); + const errorMsg = getErrorMessage(err); logger?.log(`Error: ${errorMsg}`, 'error'); logger?.endStep('error', errorMsg); logger?.finalize(false); return { success: false, - error: errorMsg, + error: toError(err), logFilePath: logger?.logFilePath, }; } diff --git a/src/cli/operations/recommendation/types.ts b/src/cli/operations/recommendation/types.ts index 426ba84a8..0392b8d56 100644 --- a/src/cli/operations/recommendation/types.ts +++ b/src/cli/operations/recommendation/types.ts @@ -56,17 +56,21 @@ export interface RunRecommendationCommandOptions { onStarted?: (info: { recommendationId: string; region: string }) => void; } -export interface RunRecommendationCommandResult { - success: boolean; - error?: string; +interface RecommendationResultBase { recommendationId?: string; status?: string; - /** The recommendation result from the API (populated on COMPLETED) */ - result?: RecommendationResult; - /** Resolved AWS region used for the recommendation */ - region?: string; - startedAt?: string; - completedAt?: string; /** Path to the execution log file */ logFilePath?: string; } + +export type RunRecommendationCommandResult = + | (RecommendationResultBase & { + success: true; + /** The recommendation result from the API (populated on COMPLETED) */ + result?: RecommendationResult; + /** Resolved AWS region used for the recommendation */ + region?: string; + startedAt?: string; + completedAt?: string; + }) + | (RecommendationResultBase & { success: false; error: Error }); diff --git a/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts b/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts index fb2b9fedc..54e4e5478 100644 --- a/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts +++ b/src/cli/operations/remove/__tests__/remove-agent-ops.test.ts @@ -1,3 +1,4 @@ +import { ResourceNotFoundError } from '../../../errors'; import { AgentPrimitive } from '../../../primitives/AgentPrimitive.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -87,7 +88,7 @@ describe('remove', () => { const result = await primitive.remove('Missing'); - expect(result).toEqual({ success: false, error: 'Agent "Missing" not found.' }); + expect(result).toEqual({ success: false, error: new ResourceNotFoundError('Agent "Missing" not found.') }); }); it('returns error on exception', async () => { @@ -95,6 +96,6 @@ describe('remove', () => { const result = await primitive.remove('Agent1'); - expect(result).toEqual({ success: false, error: 'read fail' }); + expect(result).toEqual({ success: false, error: new Error('read fail') }); }); }); diff --git a/src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts b/src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts index 54293df5a..3d2ee29fa 100644 --- a/src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts +++ b/src/cli/operations/remove/__tests__/remove-gateway-ops.test.ts @@ -1,3 +1,4 @@ +import { ResourceNotFoundError } from '../../../errors'; import { GatewayPrimitive } from '../../../primitives/GatewayPrimitive.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -101,7 +102,7 @@ describe('remove', () => { const result = await primitive.remove('missing'); - expect(result).toEqual({ success: false, error: 'Gateway "missing" not found.' }); + expect(result).toEqual({ success: false, error: new ResourceNotFoundError('Gateway "missing" not found.') }); }); it('returns error on exception', async () => { @@ -109,6 +110,6 @@ describe('remove', () => { const result = await primitive.remove('gw1'); - expect(result).toEqual({ success: false, error: 'read fail' }); + expect(result).toEqual({ success: false, error: new Error('read fail') }); }); }); diff --git a/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts b/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts index 1b1bbb8e7..07a95c18a 100644 --- a/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts +++ b/src/cli/operations/remove/__tests__/remove-identity-ops.test.ts @@ -1,3 +1,4 @@ +import { ResourceNotFoundError } from '../../../errors'; import { CredentialPrimitive } from '../../../primitives/CredentialPrimitive.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -93,7 +94,7 @@ describe('remove', () => { const result = await primitive.remove('Missing'); - expect(result).toEqual({ success: false, error: 'Credential "Missing" not found.' }); + expect(result).toEqual({ success: false, error: new ResourceNotFoundError('Credential "Missing" not found.') }); }); it('returns error on exception', async () => { @@ -101,6 +102,6 @@ describe('remove', () => { const result = await primitive.remove('Cred1'); - expect(result).toEqual({ success: false, error: 'read fail' }); + expect(result).toEqual({ success: false, error: new Error('read fail') }); }); }); diff --git a/src/cli/operations/remove/__tests__/remove-memory-ops.test.ts b/src/cli/operations/remove/__tests__/remove-memory-ops.test.ts index d42bedb94..9ab9f87c4 100644 --- a/src/cli/operations/remove/__tests__/remove-memory-ops.test.ts +++ b/src/cli/operations/remove/__tests__/remove-memory-ops.test.ts @@ -1,3 +1,4 @@ +import { ResourceNotFoundError } from '../../../errors'; import { MemoryPrimitive } from '../../../primitives/MemoryPrimitive.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -84,7 +85,7 @@ describe('remove', () => { const result = await primitive.remove('Missing'); - expect(result).toEqual({ success: false, error: 'Memory "Missing" not found.' }); + expect(result).toEqual({ success: false, error: new ResourceNotFoundError('Memory "Missing" not found.') }); }); it('returns error on exception', async () => { @@ -92,6 +93,6 @@ describe('remove', () => { const result = await primitive.remove('Mem1'); - expect(result).toEqual({ success: false, error: 'read fail' }); + expect(result).toEqual({ success: false, error: new Error('read fail') }); }); }); diff --git a/src/cli/operations/remove/remove-gateway-target.ts b/src/cli/operations/remove/remove-gateway-target.ts index 0ceebf7eb..c24da6ce7 100644 --- a/src/cli/operations/remove/remove-gateway-target.ts +++ b/src/cli/operations/remove/remove-gateway-target.ts @@ -1,6 +1,8 @@ import { ConfigIO } from '../../../lib'; +import type { Result } from '../../../lib/result'; import type { AgentCoreCliMcpDefs, AgentCoreMcpSpec } from '../../../schema'; -import type { RemovalPreview, RemovalResult, SchemaChange } from './types'; +import { ResourceNotFoundError } from '../../errors'; +import type { RemovalPreview, SchemaChange } from './types'; import { existsSync } from 'fs'; import { rm } from 'fs/promises'; import { join } from 'path'; @@ -62,12 +64,12 @@ export async function previewRemoveGatewayTarget(tool: RemovableGatewayTarget): // Gateway target const gateway = mcpSpec.agentCoreGateways.find(g => g.name === tool.gatewayName); if (!gateway) { - throw new Error(`Gateway "${tool.gatewayName}" not found.`); + throw new ResourceNotFoundError(`Gateway "${tool.gatewayName}" not found.`); } const target = gateway.targets.find(t => t.name === tool.name); if (!target) { - throw new Error(`Target "${tool.name}" not found in gateway "${tool.gatewayName}".`); + throw new ResourceNotFoundError(`Target "${tool.name}" not found in gateway "${tool.gatewayName}".`); } summary.push(`Removing gateway target: ${tool.name} (from ${tool.gatewayName})`); @@ -155,7 +157,7 @@ function computeRemovedToolMcpDefs( /** * Remove a gateway target from the project. */ -export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise { +export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise { try { const configIO = new ConfigIO(); const project = await configIO.readProjectSpec(); @@ -172,11 +174,14 @@ export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise const gateway = mcpSpec.agentCoreGateways.find(g => g.name === tool.gatewayName); if (!gateway) { - return { success: false, error: `Gateway "${tool.gatewayName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Gateway "${tool.gatewayName}" not found.`) }; } const target = gateway.targets.find(t => t.name === tool.name); if (!target) { - return { success: false, error: `Target "${tool.name}" not found in gateway "${tool.gatewayName}".` }; + return { + success: false, + error: new ResourceNotFoundError(`Target "${tool.name}" not found in gateway "${tool.gatewayName}".`), + }; } if (target.compute?.implementation && 'path' in target.compute.implementation) { toolPath = target.compute.implementation.path; @@ -201,6 +206,6 @@ export async function removeGatewayTarget(tool: RemovableGatewayTarget): Promise return { success: true }; } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; - return { success: false, error: message }; + return { success: false, error: new Error(message) }; } } diff --git a/src/cli/operations/remove/types.ts b/src/cli/operations/remove/types.ts index 2194a66f7..66c2af228 100644 --- a/src/cli/operations/remove/types.ts +++ b/src/cli/operations/remove/types.ts @@ -21,11 +21,6 @@ export interface RemovalPreview { schemaChanges: SchemaChange[]; } -/** - * Result of a removal operation. - */ -export type RemovalResult = { success: true } | { success: false; error: string }; - /** * Snapshot of all schemas before removal (for diff computation). */ diff --git a/src/cli/operations/resolve-agent.ts b/src/cli/operations/resolve-agent.ts index 8f8ee6ba3..548e40dc7 100644 --- a/src/cli/operations/resolve-agent.ts +++ b/src/cli/operations/resolve-agent.ts @@ -1,5 +1,7 @@ import { ConfigIO } from '../../lib'; +import type { Result } from '../../lib/result'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../schema'; +import { ResourceNotFoundError, ValidationError } from '../errors'; export interface DeployedProjectConfig { project: AgentCoreProjectSpec; @@ -32,11 +34,11 @@ export async function loadDeployedProjectConfig(configIO: ConfigIO = new ConfigI export function resolveAgent( context: DeployedProjectConfig, options: { runtime?: string } -): { success: true; agent: ResolvedAgent } | { success: false; error: string } { +): Result<{ agent: ResolvedAgent }> { const { project, deployedState, awsTargets } = context; if (project.runtimes.length === 0) { - return { success: false, error: 'No runtimes defined in agentcore.json' }; + return { success: false, error: new ValidationError('No runtimes defined in agentcore.json') }; } // Resolve runtime @@ -45,7 +47,7 @@ export function resolveAgent( if (!options.runtime && project.runtimes.length > 1) { return { success: false, - error: `Multiple runtimes found. Use --runtime to specify one: ${runtimeNames.join(', ')}`, + error: new ValidationError(`Multiple runtimes found. Use --runtime to specify one: ${runtimeNames.join(', ')}`), }; } @@ -54,18 +56,21 @@ export function resolveAgent( if (options.runtime && !agentSpec) { return { success: false, - error: `Runtime '${options.runtime}' not found. Available: ${runtimeNames.join(', ')}`, + error: new ResourceNotFoundError(`Runtime '${options.runtime}' not found. Available: ${runtimeNames.join(', ')}`), }; } if (!agentSpec) { - return { success: false, error: 'No runtimes defined in agentcore.json' }; + return { success: false, error: new ValidationError('No runtimes defined in agentcore.json') }; } // Resolve target const targetNames = Object.keys(deployedState.targets); if (targetNames.length === 0) { - return { success: false, error: 'No deployed targets found. Run `agentcore deploy` first.' }; + return { + success: false, + error: new ResourceNotFoundError('No deployed targets found. Run `agentcore deploy` first.'), + }; } const selectedTargetName = targetNames[0]!; @@ -73,7 +78,10 @@ export function resolveAgent( const targetConfig = awsTargets.find(t => t.name === selectedTargetName); if (!targetConfig) { - return { success: false, error: `Target config '${selectedTargetName}' not found in aws-targets` }; + return { + success: false, + error: new ResourceNotFoundError(`Target config '${selectedTargetName}' not found in aws-targets`), + }; } // Get the deployed state for this specific agent @@ -82,7 +90,9 @@ export function resolveAgent( if (!agentState) { return { success: false, - error: `Runtime '${agentSpec.name}' is not deployed to target '${selectedTargetName}'. Run 'agentcore deploy' first.`, + error: new ResourceNotFoundError( + `Runtime '${agentSpec.name}' is not deployed to target '${selectedTargetName}'. Run 'agentcore deploy' first.` + ), }; } diff --git a/src/cli/operations/traces/__tests__/get-trace.test.ts b/src/cli/operations/traces/__tests__/get-trace.test.ts index c6fda22f4..4196a143a 100644 --- a/src/cli/operations/traces/__tests__/get-trace.test.ts +++ b/src/cli/operations/traces/__tests__/get-trace.test.ts @@ -1,5 +1,6 @@ import { fetchTraceRecords, getTrace } from '../get-trace'; import type { FetchTraceRecordsOptions } from '../types'; +import assert from 'node:assert'; import { afterEach, describe, expect, it, vi } from 'vitest'; const { mockSend } = vi.hoisted(() => ({ @@ -61,14 +62,14 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.records).toHaveLength(2); - expect(result.records![0]).toEqual({ + expect(result.records[0]).toEqual({ '@timestamp': '2024-01-01T00:00:00Z', '@message': { traceId: 'abc123', spanId: 'span1' }, '@ptr': 'ptr-value-1', }); - expect(result.records![1]).toEqual({ + expect(result.records[1]).toEqual({ '@timestamp': '2024-01-01T00:00:01Z', '@message': { traceId: 'abc123', spanId: 'span2' }, }); @@ -80,8 +81,8 @@ describe('fetchTraceRecords', () => { traceId: 'invalid!@#$', }); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid trace ID format'); + assert(!result.success); + expect(result.error.message).toContain('Invalid trace ID format'); expect(mockSend).not.toHaveBeenCalled(); }); @@ -93,8 +94,8 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(false); - expect(result.error).toContain('No trace data found'); + assert(!result.success); + expect(result.error.message).toContain('No trace data found'); }); it('returns error when query fails to start', async () => { @@ -102,8 +103,8 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(false); - expect(result.error).toContain('Failed to start CloudWatch Logs Insights query'); + assert(!result.success); + expect(result.error.message).toContain('Failed to start CloudWatch Logs Insights query'); }); it('returns error when query status is Failed', async () => { @@ -111,8 +112,8 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(false); - expect(result.error).toContain('failed'); + assert(!result.success); + expect(result.error.message).toContain('failed'); }); it('preserves @ptr when present in CloudWatch response', async () => { @@ -129,9 +130,9 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.records).toHaveLength(1); - expect(result.records![0]!['@ptr']).toBe('cw-ptr-123'); + expect(result.records[0]!['@ptr']).toBe('cw-ptr-123'); }); it('omits @ptr when not present in CloudWatch response', async () => { @@ -147,8 +148,8 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(true); - expect(result.records![0]).not.toHaveProperty('@ptr'); + assert(result.success); + expect(result.records[0]).not.toHaveProperty('@ptr'); }); it('handles non-JSON @message gracefully', async () => { @@ -164,9 +165,9 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.records).toHaveLength(1); - expect(result.records![0]!['@message']).toBe('plain text message'); + expect(result.records[0]!['@message']).toBe('plain text message'); }); it('handles ResourceNotFoundException', async () => { @@ -176,9 +177,9 @@ describe('fetchTraceRecords', () => { const result = await fetchTraceRecords(baseOptions); - expect(result.success).toBe(false); - expect(result.error).toContain('Log group'); - expect(result.error).toContain('not found'); + assert(!result.success); + expect(result.error.message).toContain('Log group'); + expect(result.error.message).toContain('not found'); }); }); @@ -208,7 +209,7 @@ describe('getTrace', () => { endTime: 2000000, }); - expect(result.success).toBe(true); + assert(result.success); expect(result.filePath).toContain('test-trace.json'); expect(fs.default.mkdirSync).toHaveBeenCalled(); expect(fs.default.writeFileSync).toHaveBeenCalledWith('/tmp/test-trace.json', expect.stringContaining('"traceId"')); @@ -226,8 +227,8 @@ describe('getTrace', () => { endTime: 2000000, }); - expect(result.success).toBe(false); - expect(result.error).toContain('Invalid trace ID format'); + assert(!result.success); + expect(result.error.message).toContain('Invalid trace ID format'); expect(fs.default.writeFileSync).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/operations/traces/__tests__/list-traces.test.ts b/src/cli/operations/traces/__tests__/list-traces.test.ts index 0bbe884de..b72ed9692 100644 --- a/src/cli/operations/traces/__tests__/list-traces.test.ts +++ b/src/cli/operations/traces/__tests__/list-traces.test.ts @@ -1,5 +1,6 @@ import { listTraces } from '../list-traces'; import type { ListTracesOptions } from '../types'; +import assert from 'node:assert'; import { afterEach, describe, expect, it, vi } from 'vitest'; const { mockRunInsightsQuery } = vi.hoisted(() => ({ @@ -38,15 +39,15 @@ describe('listTraces', () => { const result = await listTraces(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.traces).toHaveLength(2); - expect(result.traces![0]).toEqual({ + expect(result.traces[0]).toEqual({ traceId: 'trace-1', timestamp: '2024-01-01T00:05:00Z', sessionId: 'sess-1', spanCount: '12', }); - expect(result.traces![1]).toEqual({ + expect(result.traces[1]).toEqual({ traceId: 'trace-2', timestamp: '2024-01-01T00:03:00Z', sessionId: undefined, @@ -66,9 +67,9 @@ describe('listTraces', () => { const result = await listTraces(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.traces).toHaveLength(1); - expect(result.traces![0]!.traceId).toBe('trace-1'); + expect(result.traces[0]!.traceId).toBe('trace-1'); }); it('falls back to firstSeen when lastSeen is missing', async () => { @@ -79,8 +80,8 @@ describe('listTraces', () => { const result = await listTraces(baseOptions); - expect(result.success).toBe(true); - expect(result.traces![0]!.timestamp).toBe('2024-01-01T00:00:00Z'); + assert(result.success); + expect(result.traces[0]!.timestamp).toBe('2024-01-01T00:00:00Z'); }); it('returns empty traces for empty query results', async () => { @@ -91,20 +92,20 @@ describe('listTraces', () => { const result = await listTraces(baseOptions); - expect(result.success).toBe(true); + assert(result.success); expect(result.traces).toHaveLength(0); }); it('propagates errors from runInsightsQuery', async () => { mockRunInsightsQuery.mockResolvedValueOnce({ success: false, - error: 'Log group not found', + error: new Error('Log group not found'), }); const result = await listTraces(baseOptions); - expect(result.success).toBe(false); - expect(result.error).toBe('Log group not found'); + assert(!result.success); + expect(result.error.message).toBe('Log group not found'); }); it('passes correct log group name and default limit', async () => { diff --git a/src/cli/operations/traces/get-trace.ts b/src/cli/operations/traces/get-trace.ts index a87f10a65..49438ca79 100644 --- a/src/cli/operations/traces/get-trace.ts +++ b/src/cli/operations/traces/get-trace.ts @@ -1,4 +1,6 @@ +import type { Result } from '../../../lib/result'; import { DEFAULT_ENDPOINT_NAME } from '../../constants'; +import { ResourceNotFoundError, ValidationError } from '../../errors'; import { runInsightsQuery } from './insights-query'; import type { CloudWatchSpanRecord, @@ -23,9 +25,12 @@ async function fetchSpans( traceId: string, startTime?: number, endTime?: number -): Promise<{ success: boolean; spans?: CloudWatchSpanRecord[]; error?: string }> { +): Promise> { if (!TRACE_ID_PATTERN.test(traceId)) { - return { success: false, error: 'Invalid trace ID format. Expected a hex string (e.g., abc123def456).' }; + return { + success: false, + error: new ValidationError('Invalid trace ID format. Expected a hex string (e.g., abc123def456).'), + }; } const result = await runInsightsQuery({ @@ -50,7 +55,7 @@ async function fetchSpans( if (!result.success) return { success: false, error: result.error }; - const spans: CloudWatchSpanRecord[] = (result.rows ?? []) + const spans: CloudWatchSpanRecord[] = result.rows .filter(row => row.traceId && row.spanId) .map(row => ({ traceId: row.traceId!, @@ -82,7 +87,10 @@ export async function fetchTraceRecords(options: FetchTraceRecordsOptions): Prom const { region, runtimeId, traceId, includeSpans } = options; if (!TRACE_ID_PATTERN.test(traceId)) { - return { success: false, error: 'Invalid trace ID format. Expected a hex string (e.g., abc123def456).' }; + return { + success: false, + error: new ValidationError('Invalid trace ID format. Expected a hex string (e.g., abc123def456).'), + }; } const [recordsResult, spansResult] = await Promise.all([ @@ -103,10 +111,10 @@ export async function fetchTraceRecords(options: FetchTraceRecordsOptions): Prom return { success: false, error: recordsResult.error }; } - const traceData = recordsResult.rows ?? []; + const traceData = recordsResult.rows; - if (traceData.length === 0 && (!spansResult || (spansResult.spans ?? []).length === 0)) { - return { success: false, error: `No trace data found for trace ID: ${traceId}` }; + if (traceData.length === 0 && (!spansResult || !spansResult.success || spansResult.spans.length === 0)) { + return { success: false, error: new ResourceNotFoundError(`No trace data found for trace ID: ${traceId}`) }; } const records: CloudWatchTraceRecord[] = traceData.map(entry => { @@ -129,11 +137,9 @@ export async function fetchTraceRecords(options: FetchTraceRecordsOptions): Prom return record; }); - const result: FetchTraceRecordsResult = { success: true, records }; - - if (spansResult?.success && spansResult.spans) { - result.spans = spansResult.spans; - } + const result: FetchTraceRecordsResult = spansResult?.success + ? { success: true, records, spans: spansResult.spans } + : { success: true, records }; return result; } @@ -146,7 +152,10 @@ export async function getTrace(options: GetTraceOptions): Promise { diff --git a/src/cli/operations/traces/insights-query.ts b/src/cli/operations/traces/insights-query.ts index 5a4da2031..0afd37ce2 100644 --- a/src/cli/operations/traces/insights-query.ts +++ b/src/cli/operations/traces/insights-query.ts @@ -1,4 +1,6 @@ +import type { Result } from '../../../lib/result'; import { getCredentialProvider } from '../../aws'; +import { OperationTimeoutError, ResourceNotFoundError, toError } from '../../errors'; import { CloudWatchLogsClient, GetQueryResultsCommand, StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'; const DEFAULT_LOOKBACK_MS = 12 * 60 * 60 * 1000; @@ -11,11 +13,7 @@ export interface InsightsQueryOptions { endTime?: number; } -export interface InsightsQueryResult { - success: boolean; - rows?: Record[]; - error?: string; -} +export type InsightsQueryResult = Result<{ rows: Record[] }>; async function pollQueryResults(client: CloudWatchLogsClient, queryId: string): Promise { for (let i = 0; i < 60; i++) { @@ -26,7 +24,7 @@ async function pollQueryResults(client: CloudWatchLogsClient, queryId: string): if (status === 'Complete' || status === 'Failed' || status === 'Cancelled') { if (status !== 'Complete') { - return { success: false, error: `Query ${status.toLowerCase()}` }; + return { success: false, error: new Error(`Query ${status.toLowerCase()}`) }; } const rows = (queryResults.results ?? []).map(row => { @@ -42,7 +40,7 @@ async function pollQueryResults(client: CloudWatchLogsClient, queryId: string): } } - return { success: false, error: 'Query timed out after 60 seconds' }; + return { success: false, error: new OperationTimeoutError('Query timed out after 60 seconds') }; } export async function runInsightsQuery(options: InsightsQueryOptions): Promise { @@ -68,7 +66,7 @@ export async function runInsightsQuery(options: InsightsQueryOptions): Promise((acc, row) => { + const traces = result.rows.reduce((acc, row) => { if (row.traceId) { acc.push({ traceId: row.traceId, diff --git a/src/cli/operations/traces/types.ts b/src/cli/operations/traces/types.ts index fae88a83b..95afe1a1e 100644 --- a/src/cli/operations/traces/types.ts +++ b/src/cli/operations/traces/types.ts @@ -1,3 +1,5 @@ +import type { Result } from '../../../lib/result'; + export interface CloudWatchTraceRecord { '@timestamp': string; '@message': unknown; @@ -31,12 +33,10 @@ export interface FetchTraceRecordsOptions { includeSpans?: boolean; } -export interface FetchTraceRecordsResult { - success: boolean; - records?: CloudWatchTraceRecord[]; +export type FetchTraceRecordsResult = Result<{ + records: CloudWatchTraceRecord[]; spans?: CloudWatchSpanRecord[]; - error?: string; -} +}>; export interface GetTraceOptions { region: string; @@ -48,11 +48,7 @@ export interface GetTraceOptions { endTime?: number; } -export interface GetTraceResult { - success: boolean; - filePath?: string; - error?: string; -} +export type GetTraceResult = Result<{ filePath: string }>; export interface TraceEntry { traceId: string; @@ -70,8 +66,4 @@ export interface ListTracesOptions { endTime?: number; } -export interface ListTracesResult { - success: boolean; - traces?: TraceEntry[]; - error?: string; -} +export type ListTracesResult = Result<{ traces: TraceEntry[] }>; diff --git a/src/cli/primitives/ABTestPrimitive.ts b/src/cli/primitives/ABTestPrimitive.ts index 9dd973571..b354ea57f 100644 --- a/src/cli/primitives/ABTestPrimitive.ts +++ b/src/cli/primitives/ABTestPrimitive.ts @@ -1,11 +1,11 @@ -import { findConfigRoot } from '../../lib'; +import { findConfigRoot, resultToJson } from '../../lib'; import type { ABTest } from '../../schema/schemas/primitives/ab-test'; import { ABTestSchema } from '../../schema/schemas/primitives/ab-test'; -import { getErrorMessage } from '../errors'; -import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { ResourceNotFoundError, getErrorMessage, toError } from '../errors'; +import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; -import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { AddScreenComponent, RemovableResource, Result } from './types'; import type { Command } from '@commander-js/extra-typings'; export type GatewayChoice = { type: 'create-new' } | { type: 'existing-http'; name: string }; @@ -60,22 +60,22 @@ export class ABTestPrimitive extends BasePrimitive> { + async add(options: AddABTestOptions): Promise> { try { const abTest = await this.createABTest(options); return { success: true, abTestName: abTest.name }; } catch (err) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: toError(err) }; } } - async remove(testName: string, options?: { deleteGateway?: boolean }): Promise { + async remove(testName: string, options?: { deleteGateway?: boolean }): Promise { try { const project = await this.readProjectSpec(); const index = (project.abTests ?? []).findIndex(t => t.name === testName); if (index === -1) { - return { success: false, error: `AB test "${testName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`AB test "${testName}" not found.`) }; } const removedTest = project.abTests[index]!; @@ -123,7 +123,7 @@ export class ABTestPrimitive extends BasePrimitive t.name === testName); if (!abTest) { - throw new Error(`AB test "${testName}" not found.`); + throw new ResourceNotFoundError(`AB test "${testName}" not found.`); } const summary: string[] = [`Removing AB test: ${testName}`]; @@ -364,11 +364,11 @@ Target-Based Mode (--mode target-based) }); if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); } else if (result.success) { console.log(`Added target-based AB test '${result.abTestName}'`); } else { - console.error(result.error); + console.error(result.error.message); } process.exit(result.success ? 0 : 1); return; @@ -411,11 +411,11 @@ Target-Based Mode (--mode target-based) }); if (cliOptions.json) { - console.log(JSON.stringify(result)); + console.log(resultToJson(result)); } else if (result.success) { console.log(`Added AB test '${result.abTestName}'`); } else { - console.error(result.error); + console.error(result.error.message); } process.exit(result.success ? 0 : 1); } else { @@ -475,7 +475,7 @@ Target-Based Mode (--mode target-based) resourceType: this.kind, resourceName: cliOptions.name, message: result.success ? `Removed ${this.label.toLowerCase()} '${cliOptions.name}'` : undefined, - error: !result.success ? result.error : undefined, + error: !result.success ? result.error.message : undefined, }) ); process.exit(result.success ? 0 : 1); @@ -598,12 +598,12 @@ Target-Based Mode (--mode target-based) return abTest; } - async addTargetBased(options: AddTargetBasedABTestOptions): Promise> { + async addTargetBased(options: AddTargetBasedABTestOptions): Promise> { try { const abTest = await this.createTargetBasedABTest(options); return { success: true, abTestName: abTest.name }; } catch (err) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: toError(err) }; } } diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index b9873990b..65992ebe1 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -1,4 +1,4 @@ -import { APP_DIR, ConfigIO, NoProjectError, findConfigRoot, setEnvVar } from '../../lib'; +import { APP_DIR, ConfigIO, NoProjectError, findConfigRoot, resultToJson, setEnvVar } from '../../lib'; import type { AgentEnvSpec, BuildType, @@ -24,7 +24,7 @@ import { validateAddAgentOptions } from '../commands/add/validate'; import { parseAndNormalizeHeaders } from '../commands/shared/header-utils'; import type { VpcOptions } from '../commands/shared/vpc-utils'; import { VPC_ENDPOINT_WARNING, parseCommaSeparatedList } from '../commands/shared/vpc-utils'; -import { getErrorMessage } from '../errors'; +import { ConflictError, ResourceNotFoundError, getErrorMessage, toError } from '../errors'; import { createConfigBundleForAgent } from '../operations/agent/config-bundle-defaults'; import { mapGenerateConfigToRenderConfig, @@ -34,7 +34,7 @@ import { } from '../operations/agent/generate'; import { executeImportAgent } from '../operations/agent/import'; import { setupPythonProject } from '../operations/python'; -import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { cliCommandRun } from '../telemetry/cli-command-run.js'; import { AgentType, @@ -55,7 +55,7 @@ import { BasePrimitive } from './BasePrimitive'; import { CredentialPrimitive } from './CredentialPrimitive'; import { buildAuthorizerConfigFromJwtConfig, createManagedOAuthCredential } from './auth-utils'; import { computeDefaultCredentialEnvVarName } from './credential-utils'; -import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { AddScreenComponent, RemovableResource, Result } from './types'; import type { Command } from '@commander-js/extra-typings'; import { mkdirSync } from 'fs'; import { dirname, join } from 'path'; @@ -115,17 +115,17 @@ export class AgentPrimitive extends BasePrimitive> { + async add(options: AddAgentOptions): Promise> { try { const configBaseDir = findConfigRoot(); if (!configBaseDir) { - return { success: false, error: new NoProjectError().message }; + return { success: false, error: new NoProjectError() }; } const configIO = new ConfigIO({ baseDir: configBaseDir }); if (!configIO.configExists('project')) { - return { success: false, error: new NoProjectError().message }; + return { success: false, error: new NoProjectError() }; } const project = await configIO.readProjectSpec(); @@ -133,7 +133,9 @@ export class AgentPrimitive extends BasePrimitive { + async remove(agentName: string): Promise { try { const project = await this.readProjectSpec(); const agentIndex = project.runtimes.findIndex(a => a.name === agentName); if (agentIndex === -1) { - return { success: false, error: `Agent "${agentName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Agent "${agentName}" not found.`) }; } // Remove agent (credentials preserved for potential reuse) @@ -165,7 +167,7 @@ export class AgentPrimitive extends BasePrimitive a.name === agentName); if (!agent) { - throw new Error(`Agent "${agentName}" not found.`); + throw new ResourceNotFoundError(`Agent "${agentName}" not found.`); } const summary: string[] = [`Removing agent: ${agentName}`]; @@ -333,11 +335,11 @@ export class AgentPrimitive extends BasePrimitive> { + ): Promise> { const projectRoot = dirname(configBaseDir); const configIO = new ConfigIO({ baseDir: configBaseDir }); const project = await configIO.readProjectSpec(); @@ -510,7 +512,7 @@ export class AgentPrimitive extends BasePrimitive> { + ): Promise> { return executeImportAgent({ name: options.name, framework: options.framework, @@ -532,7 +534,7 @@ export class AgentPrimitive extends BasePrimitive> { + ): Promise> { const codeLocation = options.codeLocation!.endsWith('/') ? options.codeLocation! : `${options.codeLocation!}/`; // Create the agent code directory so users know where to put their code diff --git a/src/cli/primitives/BasePrimitive.ts b/src/cli/primitives/BasePrimitive.ts index 149611c4f..f23d160dc 100644 --- a/src/cli/primitives/BasePrimitive.ts +++ b/src/cli/primitives/BasePrimitive.ts @@ -4,7 +4,7 @@ import type { ResourceType } from '../commands/remove/types'; import { getErrorMessage } from '../errors'; import { requireTTY } from '../tui/guards/tty'; import { SOURCE_CODE_NOTE } from './constants'; -import type { AddResult, AddScreenComponent, RemovableResource, RemovalPreview, RemovalResult } from './types'; +import type { AddScreenComponent, RemovableResource, RemovalPreview, Result } from './types'; import type { Command } from '@commander-js/extra-typings'; import type { z } from 'zod'; @@ -37,12 +37,12 @@ export abstract class BasePrimitive< * Add a new resource of this type. * Each primitive owns its implementation entirely. */ - abstract add(options: TAddOptions): Promise; + abstract add(options: TAddOptions): Promise>>; /** * Remove a resource by name. */ - abstract remove(name: string): Promise; + abstract remove(name: string): Promise; /** * Preview what will be removed. @@ -128,7 +128,7 @@ export abstract class BasePrimitive< resourceName: cliOptions.name, message: result.success ? `Removed ${this.label.toLowerCase()} '${cliOptions.name}'` : undefined, note: result.success ? SOURCE_CODE_NOTE : undefined, - error: !result.success ? result.error : undefined, + error: !result.success ? result.error.message : undefined, }) ); process.exit(result.success ? 0 : 1); diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index 77f26205b..37ae91860 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -1,10 +1,10 @@ -import { findConfigRoot } from '../../lib'; +import { findConfigRoot, resultToJson } from '../../lib'; import type { ConfigBundle } from '../../schema'; import { ConfigBundleSchema } from '../../schema'; -import { getErrorMessage } from '../errors'; -import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { ResourceNotFoundError, getErrorMessage, toError } from '../errors'; +import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { BasePrimitive } from './BasePrimitive'; -import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { AddScreenComponent, RemovableResource, Result } from './types'; import type { Command } from '@commander-js/extra-typings'; import { readFileSync } from 'fs'; @@ -32,22 +32,22 @@ export class ConfigBundlePrimitive extends BasePrimitive> { + async add(options: AddConfigBundleOptions): Promise> { try { const bundle = await this.createConfigBundle(options); return { success: true, bundleName: bundle.name }; } catch (err) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: toError(err) }; } } - async remove(bundleName: string): Promise { + async remove(bundleName: string): Promise { try { const project = await this.readProjectSpec(); const index = (project.configBundles ?? []).findIndex(b => b.name === bundleName); if (index === -1) { - return { success: false, error: `Configuration bundle "${bundleName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Configuration bundle "${bundleName}" not found.`) }; } project.configBundles.splice(index, 1); @@ -55,7 +55,7 @@ export class ConfigBundlePrimitive extends BasePrimitive b.name === bundleName); if (!bundle) { - throw new Error(`Configuration bundle "${bundleName}" not found.`); + throw new ResourceNotFoundError(`Configuration bundle "${bundleName}" not found.`); } const summary: string[] = [`Removing configuration bundle: ${bundleName}`]; @@ -170,11 +170,11 @@ export class ConfigBundlePrimitive extends BasePrimitive> { + async add(options: AddCredentialOptions): Promise> { try { const credential = await this.createCredential(options); return { success: true, credentialName: credential.name }; } catch (err) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: toError(err) }; } } - async remove(credentialName: string, options?: { force?: boolean }): Promise { + async remove(credentialName: string, options?: { force?: boolean }): Promise { try { const project = await this.readProjectSpec(); const credentialIndex = project.credentials.findIndex(c => c.name === credentialName); if (credentialIndex === -1) { - return { success: false, error: `Credential "${credentialName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Credential "${credentialName}" not found.`) }; } const credential = project.credentials[credentialIndex]!; @@ -96,7 +96,9 @@ export class CredentialPrimitive extends BasePrimitive t.name).join(', '); return { success: false, - error: `Credential "${credentialName}" is referenced by gateway target(s): ${targetList}. Use force to override.`, + error: new ConflictError( + `Credential "${credentialName}" is referenced by gateway target(s): ${targetList}. Use force to override.` + ), }; } @@ -116,7 +120,7 @@ export class CredentialPrimitive extends BasePrimitive c.name === credentialName); if (!credential) { - throw new Error(`Credential "${credentialName}" not found.`); + throw new ResourceNotFoundError(`Credential "${credentialName}" not found.`); } const summary: string[] = [ @@ -328,11 +332,11 @@ export class CredentialPrimitive extends BasePrimitive> { + async add(options: AddEvaluatorOptions): Promise> { try { const evaluator = await this.createEvaluator(options); @@ -57,17 +57,17 @@ export class EvaluatorPrimitive extends BasePrimitive { + async remove(evaluatorName: string): Promise { try { const project = await this.readProjectSpec(); const index = project.evaluators.findIndex(e => e.name === evaluatorName); if (index === -1) { - return { success: false, error: `Evaluator "${evaluatorName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Evaluator "${evaluatorName}" not found.`) }; } // Warn if referenced by online eval configs @@ -76,7 +76,9 @@ export class EvaluatorPrimitive extends BasePrimitive c.name).join(', '); return { success: false, - error: `Evaluator "${evaluatorName}" is referenced by online eval config(s): ${configNames}. Remove those references first.`, + error: new ConflictError( + `Evaluator "${evaluatorName}" is referenced by online eval config(s): ${configNames}. Remove those references first.` + ), }; } @@ -96,7 +98,7 @@ export class EvaluatorPrimitive extends BasePrimitive e.name === evaluatorName); if (!evaluator) { - throw new Error(`Evaluator "${evaluatorName}" not found.`); + throw new ResourceNotFoundError(`Evaluator "${evaluatorName}" not found.`); } const summary: string[] = [`Removing evaluator: ${evaluatorName}`]; @@ -296,11 +298,11 @@ export class EvaluatorPrimitive extends BasePrimitive> { + async add(options: AddGatewayOptions): Promise> { try { const config = this.buildGatewayConfig(options); const result = await this.createGatewayFromWizard(config); return { success: true, gatewayName: result.name }; } catch (err) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: toError(err) }; } } - async remove(gatewayName: string): Promise { + async remove(gatewayName: string): Promise { try { const project = await this.readProjectSpec(); const mcpSpec = extractMcpSpec(project); const gateway = mcpSpec.agentCoreGateways.find(g => g.name === gatewayName); if (!gateway) { - return { success: false, error: `Gateway "${gatewayName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Gateway "${gatewayName}" not found.`) }; } const newMcpSpec = this.computeRemovedGatewayMcpSpec(mcpSpec, gatewayName); @@ -87,7 +87,7 @@ export class GatewayPrimitive extends BasePrimitive g.name === gatewayName); if (!gateway) { - throw new Error(`Gateway "${gatewayName}" not found.`); + throw new ResourceNotFoundError(`Gateway "${gatewayName}" not found.`); } const summary: string[] = [`Removing gateway: ${gatewayName}`]; @@ -218,11 +218,11 @@ export class GatewayPrimitive extends BasePrimitive> { + async add(options: AddGatewayTargetOptions): Promise> { try { const config = this.buildGatewayTargetConfig(options); const result = await this.createToolFromWizard(config); return { success: true, toolName: result.toolName, sourcePath: result.projectPath }; } catch (err) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: toError(err) }; } } - async remove(name: string): Promise { + async remove(name: string): Promise { // Find the target by name to get its gateway info const tools = await this.getRemovable(); const tool = tools.find(t => t.name === name); if (!tool) { - return { success: false, error: `Gateway target "${name}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Gateway target "${name}" not found.`) }; } return this.removeGatewayTarget(tool); } @@ -94,7 +94,7 @@ export class GatewayTargetPrimitive extends BasePrimitive t.name === name); if (!tool) { - throw new Error(`Gateway target "${name}" not found.`); + throw new ResourceNotFoundError(`Gateway target "${name}" not found.`); } return this.previewRemoveGatewayTarget(tool); } @@ -136,12 +136,12 @@ export class GatewayTargetPrimitive extends BasePrimitive g.name === tool.gatewayName); if (!gateway) { - throw new Error(`Gateway "${tool.gatewayName}" not found.`); + throw new ResourceNotFoundError(`Gateway "${tool.gatewayName}" not found.`); } const target = gateway.targets.find(t => t.name === tool.name); if (!target) { - throw new Error(`Target "${tool.name}" not found in gateway "${tool.gatewayName}".`); + throw new ResourceNotFoundError(`Target "${tool.name}" not found in gateway "${tool.gatewayName}".`); } summary.push(`Removing gateway target: ${tool.name} (from ${tool.gatewayName})`); @@ -183,7 +183,7 @@ export class GatewayTargetPrimitive extends BasePrimitive { + async removeGatewayTarget(tool: RemovableGatewayTarget): Promise { try { const project = await this.readProjectSpec(); const mcpSpec = extractMcpSpec(project); @@ -195,11 +195,14 @@ export class GatewayTargetPrimitive extends BasePrimitive g.name === tool.gatewayName); if (!gateway) { - return { success: false, error: `Gateway "${tool.gatewayName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Gateway "${tool.gatewayName}" not found.`) }; } const target = gateway.targets.find(t => t.name === tool.name); if (!target) { - return { success: false, error: `Target "${tool.name}" not found in gateway "${tool.gatewayName}".` }; + return { + success: false, + error: new ResourceNotFoundError(`Target "${tool.name}" not found in gateway "${tool.gatewayName}".`), + }; } if (target.compute?.implementation && 'path' in target.compute.implementation) { toolPath = target.compute.implementation.path; @@ -224,7 +227,7 @@ export class GatewayTargetPrimitive extends BasePrimitive> { + async add(options: AddMemoryOptions): Promise> { try { const strategies = options.strategies ? options.strategies @@ -83,17 +83,17 @@ export class MemoryPrimitive extends BasePrimitive { + async remove(memoryName: string): Promise { try { const project = await this.readProjectSpec(); const memoryIndex = project.memories.findIndex(m => m.name === memoryName); if (memoryIndex === -1) { - return { success: false, error: `Memory "${memoryName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Memory "${memoryName}" not found.`) }; } project.memories.splice(memoryIndex, 1); @@ -102,7 +102,7 @@ export class MemoryPrimitive extends BasePrimitive m.name === memoryName); if (!memory) { - throw new Error(`Memory "${memoryName}" not found.`); + throw new ResourceNotFoundError(`Memory "${memoryName}" not found.`); } const summary: string[] = [`Removing memory: ${memoryName}`]; @@ -218,11 +218,11 @@ export class MemoryPrimitive extends BasePrimitive> { + async add(options: AddOnlineEvalConfigOptions): Promise> { try { const config = await this.createOnlineEvalConfig(options); return { success: true, configName: config.name }; } catch (err) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: toError(err) }; } } - async remove(configName: string): Promise { + async remove(configName: string): Promise { try { const project = await this.readProjectSpec(); const index = project.onlineEvalConfigs.findIndex(c => c.name === configName); if (index === -1) { - return { success: false, error: `Online eval config "${configName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Online eval config "${configName}" not found.`) }; } project.onlineEvalConfigs.splice(index, 1); @@ -52,7 +52,7 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive c.name === configName); if (!config) { - throw new Error(`Online eval config "${configName}" not found.`); + throw new ResourceNotFoundError(`Online eval config "${configName}" not found.`); } const summary: string[] = [ @@ -159,11 +159,11 @@ export class OnlineEvalConfigPrimitive extends BasePrimitive r.name === options.agent); if (!runtime) { - throw new Error(`Runtime "${options.agent}" not found in project.`); + throw new ResourceNotFoundError(`Runtime "${options.agent}" not found in project.`); } if (!runtime.endpoints?.[options.endpoint]) { - throw new Error( + throw new ResourceNotFoundError( `Endpoint "${options.endpoint}" not found on runtime "${options.agent}". Available endpoints: ${ runtime.endpoints ? Object.keys(runtime.endpoints).join(', ') : '(none)' }` diff --git a/src/cli/primitives/PolicyEnginePrimitive.ts b/src/cli/primitives/PolicyEnginePrimitive.ts index bb9b314d8..8f89114af 100644 --- a/src/cli/primitives/PolicyEnginePrimitive.ts +++ b/src/cli/primitives/PolicyEnginePrimitive.ts @@ -1,14 +1,14 @@ -import { findConfigRoot } from '../../lib'; +import { findConfigRoot, resultToJson } from '../../lib'; import type { AgentCoreProjectSpec, PolicyEngine } from '../../schema'; import { PolicyEngineModeSchema, PolicyEngineSchema } from '../../schema'; -import { getErrorMessage } from '../errors'; -import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { ResourceNotFoundError, getErrorMessage, toError } from '../errors'; +import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { cliCommandRun } from '../telemetry/cli-command-run.js'; import { AttachMode, standardize } from '../telemetry/schemas/common-shapes.js'; import { requireTTY } from '../tui/guards/tty'; import { BasePrimitive } from './BasePrimitive'; import { SOURCE_CODE_NOTE } from './constants'; -import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { AddScreenComponent, RemovableResource, Result } from './types'; import type { Command } from '@commander-js/extra-typings'; export interface AddPolicyEngineOptions { @@ -22,7 +22,7 @@ export class PolicyEnginePrimitive extends BasePrimitive> { + async add(options: AddPolicyEngineOptions): Promise> { try { const project = await this.readProjectSpec(); @@ -40,17 +40,17 @@ export class PolicyEnginePrimitive extends BasePrimitive { + async remove(engineName: string): Promise { try { const project = await this.readProjectSpec(); const index = project.policyEngines.findIndex(e => e.name === engineName); if (index === -1) { - return { success: false, error: `Policy engine "${engineName}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Policy engine "${engineName}" not found.`) }; } project.policyEngines.splice(index, 1); @@ -71,7 +71,7 @@ export class PolicyEnginePrimitive extends BasePrimitive e.name === engineName); if (!engine) { - throw new Error(`Policy engine "${engineName}" not found.`); + throw new ResourceNotFoundError(`Policy engine "${engineName}" not found.`); } const summary: string[] = [`Removing policy engine: ${engineName}`]; @@ -251,11 +251,11 @@ export class PolicyEnginePrimitive extends BasePrimitive> { + async add(options: AddPolicyOptions): Promise> { try { const sourceFlags = [options.statement, options.source, options.generate].filter(Boolean); if (sourceFlags.length > 1) { return { success: false, - error: 'Only one of --statement, --source, or --generate can be provided.', + error: new ValidationError('Only one of --statement, --source, or --generate can be provided.'), }; } @@ -48,7 +48,7 @@ export class PolicyPrimitive extends BasePrimitive e.name === options.engine); if (!engine) { - return { success: false, error: `Policy engine "${options.engine}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Policy engine "${options.engine}" not found.`) }; } this.checkDuplicate(engine.policies, options.name, 'Policy'); @@ -57,11 +57,11 @@ export class PolicyPrimitive extends BasePrimitive to specify one.', + error: new ResourceNotFoundError( + 'No deployed gateway found. Policy generation requires a deployed gateway. Use --gateway to specify one.' + ), }; } @@ -123,7 +129,10 @@ export class PolicyPrimitive extends BasePrimitive { + async remove(nameOrCompositeKey: string, engineName?: string): Promise { try { const project = await this.readProjectSpec(); @@ -167,7 +176,9 @@ export class PolicyPrimitive extends BasePrimitive 1) { return { success: false, - error: `Policy "${resolvedPolicy}" exists in multiple engines: ${matchingEngines.map(e => e.name).join(', ')}. Use --engine to specify which one.`, + error: new ValidationError( + `Policy "${resolvedPolicy}" exists in multiple engines: ${matchingEngines.map(e => e.name).join(', ')}. Use --engine to specify which one.` + ), }; } } @@ -185,10 +196,12 @@ export class PolicyPrimitive extends BasePrimitive e.policies.some(p => p.name === targetPolicy)); if (matchingEngines.length > 1) { - throw new Error( + throw new ValidationError( `Policy "${targetPolicy}" exists in multiple engines: ${matchingEngines.map(e => e.name).join(', ')}. Use --engine to specify which one.` ); } @@ -242,7 +255,9 @@ export class PolicyPrimitive extends BasePrimitive { @@ -328,11 +343,11 @@ export class PolicyPrimitive extends BasePrimitive { + async add( + options: AddRuntimeEndpointOptions + ): Promise> { try { const project = await this.readProjectSpec(); // Find the parent runtime const runtime = project.runtimes.find(a => a.name === options.runtime); if (!runtime) { - return { success: false, error: `Runtime "${options.runtime}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Runtime "${options.runtime}" not found.`) }; } // Initialize endpoints dictionary if needed @@ -56,14 +58,14 @@ export class RuntimeEndpointPrimitive extends BasePrimitive deployedRuntime.runtimeVersion) { return { success: false, - error: `Version ${version} exceeds latest deployed version ${deployedRuntime.runtimeVersion} for runtime "${options.runtime}".`, + error: new ConflictError( + `Version ${version} exceeds latest deployed version ${deployedRuntime.runtimeVersion} for runtime "${options.runtime}".` + ), }; } } @@ -104,11 +108,11 @@ export class RuntimeEndpointPrimitive extends BasePrimitive { + async remove(name: string): Promise { try { const project = await this.readProjectSpec(); @@ -119,7 +123,7 @@ export class RuntimeEndpointPrimitive extends BasePrimitive r.name === runtimeName); if (!runtime?.endpoints?.[endpointName]) { - return { success: false, error: `Runtime endpoint "${name}" not found.` }; + return { success: false, error: new ResourceNotFoundError(`Runtime endpoint "${name}" not found.`) }; } delete runtime.endpoints[endpointName]; if (Object.keys(runtime.endpoints).length === 0) { @@ -141,9 +145,9 @@ export class RuntimeEndpointPrimitive extends BasePrimitive { const result = await primitive.add(validOptions); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('already exists') }), + }) ); }); @@ -130,7 +133,7 @@ describe('ABTestPrimitive', () => { const result = await primitive.add(validOptions); - expect(result).toEqual(expect.objectContaining({ success: false, error: 'disk read error' })); + expect(result).toEqual(expect.objectContaining({ success: false, error: new Error('disk read error') })); }); it('returns error when writeProjectSpec fails', async () => { @@ -139,7 +142,7 @@ describe('ABTestPrimitive', () => { const result = await primitive.add(validOptions); - expect(result).toEqual(expect.objectContaining({ success: false, error: 'disk write error' })); + expect(result).toEqual(expect.objectContaining({ success: false, error: new Error('disk write error') })); }); it('returns error when variant weights do not sum to 100', async () => { @@ -175,8 +178,8 @@ describe('ABTestPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('NonExistent'); - expect(result.error).toContain('not found'); + expect(result.error.message).toContain('NonExistent'); + expect(result.error.message).toContain('not found'); } }); @@ -187,7 +190,7 @@ describe('ABTestPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toBe('io error'); + expect(result.error.message).toBe('io error'); } }); diff --git a/src/cli/primitives/__tests__/BasePrimitive.test.ts b/src/cli/primitives/__tests__/BasePrimitive.test.ts index 830cd4244..55255191f 100644 --- a/src/cli/primitives/__tests__/BasePrimitive.test.ts +++ b/src/cli/primitives/__tests__/BasePrimitive.test.ts @@ -1,5 +1,5 @@ import { BasePrimitive } from '../BasePrimitive'; -import type { AddResult, AddScreenComponent, RemovableResource, RemovalPreview, RemovalResult } from '../types'; +import type { AddScreenComponent, RemovableResource, RemovalPreview, Result } from '../types'; import type { Command } from '@commander-js/extra-typings'; import { describe, expect, it } from 'vitest'; import { z } from 'zod'; @@ -10,11 +10,11 @@ class StubPrimitive extends BasePrimitive { readonly label = 'Stub'; readonly primitiveSchema = z.object({ name: z.string() }); - add(_options: Record): Promise { + add(_options: Record): Promise { return Promise.resolve({ success: true }); } - remove(_name: string): Promise { + remove(_name: string): Promise { return Promise.resolve({ success: true }); } diff --git a/src/cli/primitives/__tests__/EvaluatorPrimitive.test.ts b/src/cli/primitives/__tests__/EvaluatorPrimitive.test.ts index b41545d20..cc25ea598 100644 --- a/src/cli/primitives/__tests__/EvaluatorPrimitive.test.ts +++ b/src/cli/primitives/__tests__/EvaluatorPrimitive.test.ts @@ -108,7 +108,10 @@ describe('EvaluatorPrimitive', () => { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('already exists') }), + }) ); }); @@ -121,7 +124,7 @@ describe('EvaluatorPrimitive', () => { config: validConfig, }); - expect(result).toEqual(expect.objectContaining({ success: false, error: 'disk read error' })); + expect(result).toEqual(expect.objectContaining({ success: false, error: new Error('disk read error') })); }); }); @@ -145,8 +148,8 @@ describe('EvaluatorPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('NonExistent'); - expect(result.error).toContain('not found'); + expect(result.error.message).toContain('NonExistent'); + expect(result.error.message).toContain('not found'); } }); @@ -159,8 +162,8 @@ describe('EvaluatorPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('referenced by online eval config'); - expect(result.error).toContain('MyOnlineConfig'); + expect(result.error.message).toContain('referenced by online eval config'); + expect(result.error.message).toContain('MyOnlineConfig'); } expect(mockWriteProjectSpec).not.toHaveBeenCalled(); }); @@ -172,7 +175,7 @@ describe('EvaluatorPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toBe('io error'); + expect(result.error.message).toBe('io error'); } }); }); diff --git a/src/cli/primitives/__tests__/OnlineEvalConfigPrimitive.test.ts b/src/cli/primitives/__tests__/OnlineEvalConfigPrimitive.test.ts index c81160a6c..563166394 100644 --- a/src/cli/primitives/__tests__/OnlineEvalConfigPrimitive.test.ts +++ b/src/cli/primitives/__tests__/OnlineEvalConfigPrimitive.test.ts @@ -126,7 +126,10 @@ describe('OnlineEvalConfigPrimitive', () => { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('already exists') }), + }) ); }); @@ -140,7 +143,7 @@ describe('OnlineEvalConfigPrimitive', () => { samplingRate: 10, }); - expect(result).toEqual(expect.objectContaining({ success: false, error: 'no project' })); + expect(result).toEqual(expect.objectContaining({ success: false, error: new Error('no project') })); }); }); @@ -169,8 +172,8 @@ describe('OnlineEvalConfigPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toContain('NonExistent'); - expect(result.error).toContain('not found'); + expect(result.error.message).toContain('NonExistent'); + expect(result.error.message).toContain('not found'); } }); @@ -181,7 +184,7 @@ describe('OnlineEvalConfigPrimitive', () => { expect(result.success).toBe(false); if (!result.success) { - expect(result.error).toBe('io error'); + expect(result.error.message).toBe('io error'); } }); }); diff --git a/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts b/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts index 46fe426f5..433d0c0e2 100644 --- a/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts +++ b/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts @@ -87,7 +87,12 @@ describe('RuntimeEndpointPrimitive', () => { endpoint: 'prod', }); - expect(result).toEqual(expect.objectContaining({ success: false, error: expect.stringContaining('not found') })); + expect(result).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('not found') }), + }) + ); }); it('returns error when endpoint already exists', async () => { @@ -100,7 +105,10 @@ describe('RuntimeEndpointPrimitive', () => { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('already exists') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('already exists') }), + }) ); }); @@ -134,7 +142,10 @@ describe('RuntimeEndpointPrimitive', () => { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('positive integer') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('positive integer') }), + }) ); }); @@ -181,7 +192,10 @@ describe('RuntimeEndpointPrimitive', () => { }); expect(result).toEqual( - expect.objectContaining({ success: false, error: expect.stringContaining('exceeds latest deployed version') }) + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('exceeds latest deployed version') }), + }) ); }); }); @@ -223,7 +237,12 @@ describe('RuntimeEndpointPrimitive', () => { const result = await primitive.remove('MyRuntime/nonexistent'); - expect(result).toEqual(expect.objectContaining({ success: false, error: expect.stringContaining('not found') })); + expect(result).toEqual( + expect.objectContaining({ + success: false, + error: expect.objectContaining({ message: expect.stringContaining('not found') }), + }) + ); }); it('cleans up empty endpoints dict after removing last endpoint', async () => { diff --git a/src/cli/primitives/index.ts b/src/cli/primitives/index.ts index 3f69da1ed..57f6d36ec 100644 --- a/src/cli/primitives/index.ts +++ b/src/cli/primitives/index.ts @@ -24,4 +24,4 @@ export { getPrimitive, } from './registry'; export { SOURCE_CODE_NOTE } from './constants'; -export type { AddResult, AddScreenComponent, RemovableResource, RemovalPreview, RemovalResult } from './types'; +export type { Result, AddScreenComponent, RemovableResource, RemovalPreview } from './types'; diff --git a/src/cli/primitives/types.ts b/src/cli/primitives/types.ts index 73842c972..15dbc292f 100644 --- a/src/cli/primitives/types.ts +++ b/src/cli/primitives/types.ts @@ -1,14 +1,7 @@ -import type { RemovalPreview, RemovalResult } from '../operations/remove/types'; +import type { RemovalPreview } from '../operations/remove/types'; import type { ComponentType } from 'react'; -/** - * Result of an add operation. - * Use the generic parameter to type extra fields on the success branch: - * AddResult<{ agentName: string }> → success branch has typed agentName - */ -export type AddResult = Record> = - | ({ success: true; message?: string } & T) - | { success: false; error: string }; +export type { Result } from '../../lib/result'; /** * Represents a resource that can be removed. @@ -21,7 +14,7 @@ export interface RemovableResource { /** * Re-export removal types from shared types. */ -export type { RemovalPreview, RemovalResult }; +export type { RemovalPreview }; /** * Screen component type for TUI add flows. diff --git a/src/cli/telemetry/cli-command-run.ts b/src/cli/telemetry/cli-command-run.ts index 987f05730..f6e5e3797 100644 --- a/src/cli/telemetry/cli-command-run.ts +++ b/src/cli/telemetry/cli-command-run.ts @@ -1,5 +1,5 @@ import { getErrorMessage } from '../errors'; -import type { AddResult } from '../primitives/types.js'; +import type { Result } from '../primitives/types.js'; import { TelemetryClientAccessor } from './client-accessor.js'; import type { Command, CommandAttrs } from './schemas/command-run.js'; @@ -44,8 +44,8 @@ export async function cliCommandRun( export async function withAddTelemetry>( command: C, attrs: CommandAttrs, - fn: () => Promise> -): Promise> { + fn: () => Promise> +): Promise> { let client; try { client = await TelemetryClientAccessor.get(); @@ -53,18 +53,18 @@ export async function withAddTelemetry | undefined; + let result: Result | undefined; try { await client.withCommandRun(command, async () => { result = await fn(); - if (!result.success) throw new Error(result.error); + if (!result.success) throw result.error; return attrs; }); } catch (err) { // withCommandRun re-throws after recording failure telemetry. // result is set if fn() ran; if not, fn() itself threw. if (!result) { - return { success: false, error: getErrorMessage(err) }; + return { success: false, error: err instanceof Error ? err : new Error(getErrorMessage(err)) }; } } return result!; diff --git a/src/cli/tui/hooks/useCreateABTest.ts b/src/cli/tui/hooks/useCreateABTest.ts index e54666074..489a1e146 100644 --- a/src/cli/tui/hooks/useCreateABTest.ts +++ b/src/cli/tui/hooks/useCreateABTest.ts @@ -43,7 +43,7 @@ export function useCreateABTest() { enableOnCreate: config.enableOnCreate, }); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create AB test'); + throw new Error(addResult.error.message ?? 'Failed to create AB test'); } setStatus({ state: 'success' }); return { ok: true as const, testName: config.name }; @@ -59,7 +59,7 @@ export function useCreateABTest() { try { const addResult = await abTestPrimitive.addTargetBased(config); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create target-based AB test'); + throw new Error(addResult.error.message ?? 'Failed to create target-based AB test'); } setStatus({ state: 'success' }); return { ok: true as const, testName: config.name }; diff --git a/src/cli/tui/hooks/useCreateConfigBundle.ts b/src/cli/tui/hooks/useCreateConfigBundle.ts index 864501eed..27d7e79e7 100644 --- a/src/cli/tui/hooks/useCreateConfigBundle.ts +++ b/src/cli/tui/hooks/useCreateConfigBundle.ts @@ -25,7 +25,7 @@ export function useCreateConfigBundle() { commitMessage: config.commitMessage, }); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create configuration bundle'); + throw new Error(addResult.error.message ?? 'Failed to create configuration bundle'); } setStatus({ state: 'success' }); return { ok: true as const, bundleName: config.name }; diff --git a/src/cli/tui/hooks/useCreateEvaluator.ts b/src/cli/tui/hooks/useCreateEvaluator.ts index f1cad666f..68fa4cde8 100644 --- a/src/cli/tui/hooks/useCreateEvaluator.ts +++ b/src/cli/tui/hooks/useCreateEvaluator.ts @@ -32,7 +32,7 @@ export function useCreateEvaluator() { }) ); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create evaluator'); + throw new Error(addResult.error.message ?? 'Failed to create evaluator'); } setStatus({ state: 'success' }); return { ok: true as const, evaluatorName: config.name, codePath: addResult.codePath }; diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index ec91666d0..ef747162f 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -53,7 +53,7 @@ export function useCreateGateway() { }) ); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create gateway'); + throw new Error(addResult.error.message ?? 'Failed to create gateway'); } const result: CreateGatewayResult = { name: config.name }; setStatus({ state: 'success', result }); diff --git a/src/cli/tui/hooks/useCreateMemory.ts b/src/cli/tui/hooks/useCreateMemory.ts index d4196582f..af7ba5902 100644 --- a/src/cli/tui/hooks/useCreateMemory.ts +++ b/src/cli/tui/hooks/useCreateMemory.ts @@ -45,7 +45,7 @@ export function useCreateMemory() { }) ); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create memory'); + throw new Error(addResult.error.message ?? 'Failed to create memory'); } // Read back the memory object const configIO = new ConfigIO(); diff --git a/src/cli/tui/hooks/useCreateOnlineEval.ts b/src/cli/tui/hooks/useCreateOnlineEval.ts index b853fed05..c08393e25 100644 --- a/src/cli/tui/hooks/useCreateOnlineEval.ts +++ b/src/cli/tui/hooks/useCreateOnlineEval.ts @@ -38,7 +38,7 @@ export function useCreateOnlineEval() { }) ); if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create online eval config'); + throw new Error(addResult.error.message ?? 'Failed to create online eval config'); } setStatus({ state: 'success' }); return { ok: true as const, configName: config.name }; diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index 7682479d3..9e05e8785 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -1,6 +1,7 @@ +import type { Result } from '../../../lib/result'; import type { ResourceType } from '../../commands/remove/types'; import { RemoveLogger } from '../../logging'; -import type { RemovableGatewayTarget, RemovalPreview, RemovalResult } from '../../operations/remove'; +import type { RemovableGatewayTarget, RemovalPreview } from '../../operations/remove'; import type { RemovableCredential } from '../../primitives/CredentialPrimitive'; import type { RemovableMemory } from '../../primitives/MemoryPrimitive'; import type { RemovablePolicyResource } from '../../primitives/PolicyPrimitive'; @@ -60,7 +61,7 @@ function useRemovableResources(loader: () => Promise) { * All useRemove* hooks delegate to this. */ function useRemoveResource( - removeFn: (id: TIdentifier) => Promise, + removeFn: (id: TIdentifier) => Promise, resourceType: ResourceType, getResourceName: (id: TIdentifier) => string ) { @@ -83,7 +84,7 @@ function useRemoveResource( resourceType: resourceTypeRef.current, resourceName: getNameRef.current(id), }); - logger.logRemoval(preview, result.success, result.success ? undefined : result.error); + logger.logRemoval(preview, result.success, result.success ? undefined : result.error.message); logPath = logger.getAbsoluteLogPath(); setLogFilePath(logPath); } @@ -291,10 +292,10 @@ export function useRemovalPreview() { interface RemovalState { isLoading: boolean; - result: RemovalResult | null; + result: Result | null; } -type RemoveResult = RemovalResult & { logFilePath?: string }; +type RemoveResult = Result & { logFilePath?: string }; export function useRemoveAgent() { return useRemoveResource( diff --git a/src/cli/tui/screens/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index e2fabe140..1d5684e3b 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -1,5 +1,6 @@ import { APP_DIR, ConfigIO, NoProjectError, findConfigRoot, setEnvVar } from '../../../../lib'; import type { AgentEnvSpec, DirectoryPath, FilePath } from '../../../../schema'; +import { ConflictError } from '../../../errors'; import { type PythonSetupResult, setupPythonProject } from '../../../operations'; import { createConfigBundleForAgent } from '../../../operations/agent/config-bundle-defaults'; import { @@ -165,7 +166,7 @@ export function useAddAgent() { () => addAgentInner(config) ); if (!result.success) { - return { ok: false, error: result.error }; + return { ok: false, error: result.error.message }; } return result.outcome; } finally { @@ -182,24 +183,24 @@ export function useAddAgent() { type AddAgentInnerResult = | { success: true; outcome: AddAgentCreateResult | AddAgentByoResult } - | { success: false; error: string }; + | { success: false; error: Error }; async function addAgentInner(config: AddAgentConfig): Promise { const configBaseDir = findConfigRoot(); if (!configBaseDir) { - return { success: false, error: new NoProjectError().message }; + return { success: false, error: new NoProjectError() }; } const configIO = new ConfigIO({ baseDir: configBaseDir }); if (!configIO.configExists('project')) { - return { success: false, error: new NoProjectError().message }; + return { success: false, error: new NoProjectError() }; } const project = await configIO.readProjectSpec(); const existingAgent = project.runtimes.find(agent => agent.name === config.name); if (existingAgent) { - return { success: false, error: `Agent "${config.name}" already exists in this project.` }; + return { success: false, error: new ConflictError(`Agent "${config.name}" already exists in this project.`) }; } let outcome: AddAgentCreateResult | AddAgentByoResult | AddAgentError; @@ -212,7 +213,7 @@ async function addAgentInner(config: AddAgentConfig): Promise [ ...prev, diff --git a/src/cli/tui/screens/eval/EvalScreen.tsx b/src/cli/tui/screens/eval/EvalScreen.tsx index 3f57999d9..b7cfc7efa 100644 --- a/src/cli/tui/screens/eval/EvalScreen.tsx +++ b/src/cli/tui/screens/eval/EvalScreen.tsx @@ -339,7 +339,7 @@ export function EvalScreen({ onExit }: EvalScreenProps) { } const result = handleListEvalRuns({}); if (!result.success) { - setState({ phase: 'error', runs: [], error: result.error ?? 'Unknown error' }); + setState({ phase: 'error', runs: [], error: result.error.message ?? 'Unknown error' }); return; } setState({ phase: 'loaded', runs: result.runs ?? [], error: null }); diff --git a/src/cli/tui/screens/identity/useCreateIdentity.ts b/src/cli/tui/screens/identity/useCreateIdentity.ts index e214d1e67..7d441062f 100644 --- a/src/cli/tui/screens/identity/useCreateIdentity.ts +++ b/src/cli/tui/screens/identity/useCreateIdentity.ts @@ -25,7 +25,7 @@ export function useCreateIdentity() { () => credentialPrimitive.add(config) ); if (!result.success) { - throw new Error(result.error ?? 'Failed to create credential'); + throw new Error(result.error.message ?? 'Failed to create credential'); } // Read back the credential object const configIO = new ConfigIO(); diff --git a/src/cli/tui/screens/import/ImportFlow.tsx b/src/cli/tui/screens/import/ImportFlow.tsx index ed82962d3..0a0843f7e 100644 --- a/src/cli/tui/screens/import/ImportFlow.tsx +++ b/src/cli/tui/screens/import/ImportFlow.tsx @@ -151,7 +151,7 @@ export function ImportFlow({ onBack, onNavigate }: ImportFlowProps) { Name: {result.resourceName} - {result.resourceId && ( + {'resourceId' in result && result.resourceId && ( ID: {result.resourceId} diff --git a/src/cli/tui/screens/import/ImportProgressScreen.tsx b/src/cli/tui/screens/import/ImportProgressScreen.tsx index 3771ffbab..a0f0229cf 100644 --- a/src/cli/tui/screens/import/ImportProgressScreen.tsx +++ b/src/cli/tui/screens/import/ImportProgressScreen.tsx @@ -59,9 +59,9 @@ export function ImportProgressScreen({ onSuccess(result); } else { setSteps(prev => - prev.map(s => (s.status === 'running' ? { ...s, status: 'error', error: result.error } : s)) + prev.map(s => (s.status === 'running' ? { ...s, status: 'error', error: result.error.message } : s)) ); - onError(result.error ?? 'Import failed'); + onError(result.error.message ?? 'Import failed'); } } else { // Starter toolkit @@ -75,9 +75,9 @@ export function ImportProgressScreen({ onSuccess(result); } else { setSteps(prev => - prev.map(s => (s.status === 'running' ? { ...s, status: 'error', error: result.error } : s)) + prev.map(s => (s.status === 'running' ? { ...s, status: 'error', error: result.error.message } : s)) ); - onError(result.error ?? 'Import failed'); + onError(result.error.message ?? 'Import failed'); } } }; diff --git a/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx b/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx index 03961473e..06115a0fc 100644 --- a/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx +++ b/src/cli/tui/screens/online-eval/OnlineEvalDashboard.tsx @@ -156,7 +156,7 @@ export function OnlineEvalDashboard({ onExit }: OnlineEvalDashboardProps) { setState(prev => ({ ...prev, phase: 'toggling' })); void handlePauseResume({ name: item.name }, action).then(result => { if (!result.success) { - setState(prev => ({ ...prev, phase: 'loaded', error: result.error ?? 'Toggle failed' })); + setState(prev => ({ ...prev, phase: 'loaded', error: result.error.message ?? 'Toggle failed' })); return; } return fetchDashboardConfigs().then(configs => { diff --git a/src/cli/tui/screens/policy/AddPolicyFlow.tsx b/src/cli/tui/screens/policy/AddPolicyFlow.tsx index 9b3542cb8..64eaa4f5c 100644 --- a/src/cli/tui/screens/policy/AddPolicyFlow.tsx +++ b/src/cli/tui/screens/policy/AddPolicyFlow.tsx @@ -139,7 +139,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD () => policyEnginePrimitive.add({ name: engineName }) ); if (!result.success) { - setFlow({ name: 'error', message: result.error }); + setFlow({ name: 'error', message: result.error.message }); return; } setEngineNames(prev => [...prev, engineName]); @@ -184,7 +184,7 @@ export function AddPolicyFlow({ isInteractive = true, onExit, onBack, onDev, onD setPolicyNames(prev => [...prev, config.name]); setFlow({ name: 'policy-success', policyName: config.name, engineName: config.engine }); } else { - setFlow({ name: 'error', message: result.error }); + setFlow({ name: 'error', message: result.error.message }); } }, []); diff --git a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx index b28344df3..f3604b67e 100644 --- a/src/cli/tui/screens/recommendation/RecommendationFlow.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationFlow.tsx @@ -33,7 +33,12 @@ type FlowState = recommendationId?: string; region?: string; } - | { name: 'results'; result: RunRecommendationCommandResult; config: RecommendationWizardConfig; filePath?: string } + | { + name: 'results'; + result: Extract; + config: RecommendationWizardConfig; + filePath?: string; + } | { name: 'creds-error'; message: string } | { name: 'error'; message: string; logFilePath?: string }; @@ -194,13 +199,17 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { setFlow(prev => { if (prev.name !== 'running') return prev; const steps = prev.steps.map(s => - s.status === 'running' ? { ...s, status: 'error' as const, error: result.error } : s + s.status === 'running' ? { ...s, status: 'error' as const, error: result.error.message } : s ); return { ...prev, steps }; }); await new Promise(resolve => setTimeout(resolve, 2000)); if (cancelled) return; - setFlow({ name: 'error', message: result.error ?? 'Recommendation failed', logFilePath: result.logFilePath }); + setFlow({ + name: 'error', + message: result.error.message ?? 'Recommendation failed', + logFilePath: result.logFilePath, + }); return; } @@ -333,7 +342,7 @@ export function RecommendationFlow({ onExit }: RecommendationFlowProps) { // ───────────────────────────────────────────────────────────────────────────── interface ResultsViewProps { - result: RunRecommendationCommandResult; + result: Extract; config: RecommendationWizardConfig; filePath?: string; onRunAnother: () => void; @@ -369,7 +378,7 @@ function ResultsView({ result, config, filePath, onRunAnother, onExit }: Results message: `New bundle version (${applyResult.newVersionId}) created with recommended changes. Local config updated.`, }); } else { - setApplyStatus({ applied: false, message: applyResult.error ?? 'Unknown error' }); + setApplyStatus({ applied: false, message: applyResult.error.message ?? 'Unknown error' }); } } catch (err) { setApplyStatus({ applied: false, message: getErrorMessage(err) }); diff --git a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx index adac72ef8..eea776e50 100644 --- a/src/cli/tui/screens/recommendation/RecommendationScreen.tsx +++ b/src/cli/tui/screens/recommendation/RecommendationScreen.tsx @@ -152,7 +152,7 @@ export function RecommendationScreen({ const { region } = await detectRegion(); const agentResult = resolveAgent(context, { runtime: wizard.config.agent }); if (!agentResult.success) { - if (!cancelled) setSessionResult({ key: fetchKey, phase: 'error', message: agentResult.error }); + if (!cancelled) setSessionResult({ key: fetchKey, phase: 'error', message: agentResult.error.message }); return; } diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index 7001fde27..696107486 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -331,7 +331,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'agent-success', agentName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-agent', agentName, preview: result.preview }); @@ -353,7 +353,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'gateway-success', gatewayName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-gateway', gatewayName, preview: result.preview }); @@ -375,7 +375,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'tool-success', toolName: tool.name }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-gateway-target', tool, preview: result.preview }); @@ -397,7 +397,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'memory-success', memoryName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-memory', memoryName, preview: result.preview }); @@ -419,7 +419,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'identity-success', identityName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-identity', identityName, preview: result.preview }); @@ -441,7 +441,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'evaluator-success', evaluatorName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-evaluator', evaluatorName, preview: result.preview }); @@ -463,7 +463,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'online-eval-success', configName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-online-eval', configName, preview: result.preview }); @@ -485,7 +485,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'policy-engine-success', engineName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-policy-engine', engineName, preview: result.preview }); @@ -510,7 +510,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'policy-success', policyName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-policy', compositeKey, policyName, preview: result.preview }); @@ -532,7 +532,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'config-bundle-success', bundleName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-config-bundle', bundleName, preview: result.preview }); @@ -554,7 +554,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'ab-test-success', testName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-ab-test', testName, preview: result.preview }); @@ -576,7 +576,7 @@ export function RemoveFlow({ if (removeResult.success) { setFlow({ name: 'runtime-endpoint-success', endpointName }); } else { - setFlow({ name: 'error', message: removeResult.error }); + setFlow({ name: 'error', message: removeResult.error.message }); } } else { setFlow({ name: 'confirm-runtime-endpoint', endpointName, preview: result.preview }); @@ -662,7 +662,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'agent-success', agentName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -678,7 +678,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'gateway-success', gatewayName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -694,7 +694,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'tool-success', toolName: tool.name, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -710,7 +710,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'memory-success', memoryName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -726,7 +726,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'identity-success', identityName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -742,7 +742,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'evaluator-success', evaluatorName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -758,7 +758,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'online-eval-success', configName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -774,7 +774,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'policy-engine-success', engineName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -790,7 +790,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'policy-success', policyName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -806,7 +806,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'config-bundle-success', bundleName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -822,7 +822,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'ab-test-success', testName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, @@ -838,7 +838,7 @@ export function RemoveFlow({ if (result.success) { pendingResultRef.current = { name: 'runtime-endpoint-success', endpointName, logFilePath: result.logFilePath }; } else { - pendingResultRef.current = { name: 'error', message: result.error }; + pendingResultRef.current = { name: 'error', message: result.error.message }; } setResultReady(true); }, diff --git a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx index 38c03c150..90fa8261b 100644 --- a/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunBatchEvalFlow.tsx @@ -260,7 +260,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { setFlow(prev => { if (prev.name !== 'running') return prev; const steps = prev.steps.map(s => - s.status === 'running' ? { ...s, status: 'error' as const, error: result.error } : s + s.status === 'running' ? { ...s, status: 'error' as const, error: result.error.message } : s ); return { ...prev, steps }; }); @@ -268,7 +268,7 @@ export function RunBatchEvalFlow({ onExit }: RunBatchEvalFlowProps) { if (cancelled) return; setFlow({ name: 'error', - message: result.error ?? 'Batch evaluation failed', + message: result.error.message ?? 'Batch evaluation failed', logFilePath: result.logFilePath, }); return; @@ -472,7 +472,7 @@ function BatchEvalWizard({ agents, evaluators: rawEvaluators, onComplete, onExit const region = targetRegion ?? detectedRegion; const agentResult = resolveAgent(context, { runtime: config.agent }); if (!agentResult.success) { - if (!cancelled) setSessionResult({ key: fetchKey, phase: 'error', message: agentResult.error }); + if (!cancelled) setSessionResult({ key: fetchKey, phase: 'error', message: agentResult.error.message }); return; } diff --git a/src/cli/tui/screens/run-eval/RunEvalFlow.tsx b/src/cli/tui/screens/run-eval/RunEvalFlow.tsx index 6b4ced516..231589b2d 100644 --- a/src/cli/tui/screens/run-eval/RunEvalFlow.tsx +++ b/src/cli/tui/screens/run-eval/RunEvalFlow.tsx @@ -20,7 +20,7 @@ type FlowState = | { name: 'loading' } | { name: 'wizard'; data: RunEvalFlowData } | { name: 'running'; config: RunEvalConfig } - | { name: 'results'; result: RunEvalResult; run: EvalRunResult } + | { name: 'results'; result: RunEvalResult; run: EvalRunResult; filePath: string } | { name: 'creds-error'; message: string } | { name: 'error'; message: string }; @@ -145,12 +145,12 @@ export function RunEvalFlow({ onExit, onViewRuns }: RunEvalFlowProps) { if (cancelled) return; - if (!result.success || !result.run) { - setFlow({ name: 'error', message: result.error ?? 'Evaluation failed' }); + if (!result.success) { + setFlow({ name: 'error', message: result.error.message }); return; } - setFlow({ name: 'results', result, run: result.run }); + setFlow({ name: 'results', result, run: result.run, filePath: result.filePath }); } catch (err) { if (!cancelled) setFlow({ name: 'error', message: getErrorMessage(err) }); } @@ -196,7 +196,7 @@ export function RunEvalFlow({ onExit, onViewRuns }: RunEvalFlowProps) { return ( setFlow({ name: 'loading' })} onViewRuns={onViewRuns} onExit={onExit} diff --git a/src/cli/tui/screens/run-eval/RunEvalScreen.tsx b/src/cli/tui/screens/run-eval/RunEvalScreen.tsx index d98a7431c..aee0a4142 100644 --- a/src/cli/tui/screens/run-eval/RunEvalScreen.tsx +++ b/src/cli/tui/screens/run-eval/RunEvalScreen.tsx @@ -84,7 +84,7 @@ export function RunEvalScreen({ agents, evaluatorItems: rawEvaluatorItems, onCom const { region } = await detectRegion(); const agentResult = resolveAgent(context, { runtime: wizard.config.agent }); if (!agentResult.success) { - if (!cancelled) setSessionResult({ key: fetchKey, phase: 'error', message: agentResult.error }); + if (!cancelled) setSessionResult({ key: fetchKey, phase: 'error', message: agentResult.error.message }); return; } diff --git a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx index 83bda78e5..7b8400010 100644 --- a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx +++ b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx @@ -95,7 +95,7 @@ export function AddRuntimeEndpointFlow({ }); return; } - setFlow({ name: 'error', message: result.error ?? 'Unknown error' }); + setFlow({ name: 'error', message: result.error.message ?? 'Unknown error' }); }); }, []); diff --git a/src/cli/tui/screens/status/useStatusFlow.ts b/src/cli/tui/screens/status/useStatusFlow.ts index 2260e1e82..4861a9b0a 100644 --- a/src/cli/tui/screens/status/useStatusFlow.ts +++ b/src/cli/tui/screens/status/useStatusFlow.ts @@ -99,7 +99,7 @@ export function useStatusFlow() { ...prev, phase: 'ready', statusesLoaded: true, - statusesError: result.error, + statusesError: result.error.message, })); return; } diff --git a/src/lib/__tests__/result.test.ts b/src/lib/__tests__/result.test.ts new file mode 100644 index 000000000..02541e704 --- /dev/null +++ b/src/lib/__tests__/result.test.ts @@ -0,0 +1,18 @@ +import type { Result } from '../result'; +import { describe, expectTypeOf, it } from 'vitest'; + +describe('Result type', () => { + it('Result narrows correctly on success', () => { + const result: Result<{ name: string }> = { success: true, name: 'test' }; + if (result.success) { + expectTypeOf(result.name).toBeString(); + } + }); + + it('Result narrows correctly on failure', () => { + const result: Result<{ name: string }> = { success: false, error: new Error('fail') }; + if (!result.success) { + expectTypeOf(result.error).toEqualTypeOf(); + } + }); +}); diff --git a/src/lib/errors/index.ts b/src/lib/errors/index.ts index f03c2281a..2acd167c8 100644 --- a/src/lib/errors/index.ts +++ b/src/lib/errors/index.ts @@ -1 +1,2 @@ export * from './config'; +export * from './types'; diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts new file mode 100644 index 000000000..006ef180b --- /dev/null +++ b/src/lib/errors/types.ts @@ -0,0 +1,91 @@ +/** + * Error indicating no agentcore project was found in the working directory. + */ +export class NoProjectError extends Error { + constructor(message?: string) { + super(message ?? 'No agentcore project found. Run "agentcore create" first.'); + this.name = 'NoProjectError'; + } +} + +/** + * Error thrown when an agent with the same name already exists. + */ +export class AgentAlreadyExistsError extends Error { + constructor(agentName: string) { + super(`An agent named "${agentName}" already exists in the schema.`); + this.name = 'AgentAlreadyExistsError'; + } +} + +/** + * Error indicating an AWS permissions failure (AccessDenied / AccessDeniedException). + */ +export class AccessDeniedError extends Error { + constructor(message: string) { + super(message); + this.name = 'AccessDeniedError'; + } +} + +/** + * Error indicating missing system dependencies required for an operation. + */ +export class DependencyCheckError extends Error { + readonly errors: string[]; + constructor(errors: string[]) { + super(errors.join('\n')); + this.name = 'DependencyCheckError'; + this.errors = errors; + } +} + +/** + * Error indicating git repository initialization failed. + */ +export class GitInitError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitInitError'; + } +} + +/** + * Error indicating a referenced resource could not be found. + */ +export class ResourceNotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = 'ResourceNotFoundError'; + } +} + +/** + * Error indicating a precondition or input validation check failed. + */ +export class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +/** + * Error indicating an operation exceeded its time limit. + */ +export class OperationTimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'OperationTimeoutError'; + } +} + +/** + * Error indicating a resource already exists (name collision). + */ +export class ConflictError extends Error { + constructor(message: string) { + super(message); + this.name = 'ConflictError'; + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts index cab20d720..05898e883 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -27,3 +27,4 @@ export * from './utils'; // Schema I/O utilities export * from './schemas/io'; +export { resultToJson, type Result } from './result'; diff --git a/src/lib/result.ts b/src/lib/result.ts new file mode 100644 index 000000000..137bd6084 --- /dev/null +++ b/src/lib/result.ts @@ -0,0 +1,37 @@ +/** + * Discriminated union for fallible operations, inspired by Rust's Result. + * + * Success branch spreads T onto the result; failure branch carries an Error. + * E extends Error so callers always get stack traces, cause chains, and instanceof narrowing. + * + * @example + * Result // { success: true } | { success: false; error: Error } + * Result<{ name: string }> // { success: true; name: string } | { success: false; error: Error } + * Result<{ name: string }, ValidationError> // { success: true; name: string } | { success: false; error: ValidationError } + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type Result = {}, E extends Error = Error> = + | ({ success: true } & T) + | { success: false; error: E }; + +/** Serialize a Result for JSON output, converting error to its message string. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function resultToJson(result: { success: boolean; error?: Error } & Record): string { + if (!result.success && result.error) { + const { error, ...rest } = result; + const serialized: Record = { + ...rest, + error: error.message, + errorType: error.name, + }; + // Preserve structured data from custom error classes + if ('errors' in error && Array.isArray(error.errors)) { + serialized.errors = error.errors; + } + if (error.cause instanceof Error) { + serialized.cause = error.cause.message; + } + return JSON.stringify(serialized); + } + return JSON.stringify(result); +} diff --git a/src/lib/schemas/io/path-resolver.ts b/src/lib/schemas/io/path-resolver.ts index 9737c4a2b..8a46e9c93 100644 --- a/src/lib/schemas/io/path-resolver.ts +++ b/src/lib/schemas/io/path-resolver.ts @@ -1,19 +1,11 @@ import { CLI_LOGS_DIR, CLI_SYSTEM_DIR, CONFIG_DIR, CONFIG_FILES as _CONFIG_FILES } from '../../constants'; +import { NoProjectError } from '../../errors'; import { existsSync } from 'fs'; import { dirname, join } from 'path'; // Re-export for backward compatibility export const CONFIG_FILES = _CONFIG_FILES; - -/** - * Error thrown when no AgentCore project is found. - */ -export class NoProjectError extends Error { - constructor(message?: string) { - super(message ?? 'No agentcore project found. Run "agentcore create" first.'); - this.name = 'NoProjectError'; - } -} +export { NoProjectError }; /** * Get the working directory where the user invoked the CLI.