Skip to content

Commit f010c12

Browse files
authored
refactor: unify result types with discriminated Result<T, E> union (#1125)
* refactor: unify result types with discriminated Result<T, E> union Introduce a shared Result<T, E> 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<T, E> discriminated union type - Add toError() helper in src/cli/errors.ts for catch blocks - Migrate all command, operation, and primitive result types to Result<T> - 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 * docs: update AGENTS.md and telemetry README to reflect Result<T, E> type
1 parent 7d27f47 commit f010c12

147 files changed

Lines changed: 1569 additions & 968 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/cli/AGENTS.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ primitives/
8282
├── registry.ts # Singleton instances + ALL_PRIMITIVES array
8383
├── credential-utils.ts # Shared credential env var name computation
8484
├── constants.ts # SOURCE_CODE_NOTE and other shared constants
85-
├── types.ts # AddResult, RemovableResource, RemovalResult, etc.
85+
├── types.ts # RemovableResource, AddScreenComponent, etc.
8686
└── index.ts # Barrel exports
8787
```
8888

@@ -92,8 +92,8 @@ Every primitive extends `BasePrimitive<TAddOptions, TRemovable>` and implements:
9292

9393
- `kind` — resource identifier (`'agent'`, `'memory'`, `'identity'`, `'gateway'`, `'mcp-tool'`)
9494
- `label` — human-readable name (`'Agent'`, `'Memory'`, `'Identity'`)
95-
- `add(options)` — create a resource, returns `AddResult`
96-
- `remove(name)` — remove a resource, returns `RemovalResult`
95+
- `add(options)` — create a resource, returns `Result<T>`
96+
- `remove(name)` — remove a resource, returns `Result`
9797
- `previewRemove(name)` — preview what removal will do
9898
- `getRemovable()` — list resources available for removal
9999
- `registerCommands(addCmd, removeCmd)` — register CLI subcommands
@@ -119,7 +119,8 @@ BasePrimitive provides shared helpers:
119119
- **Absorb, don't wrap.** Each primitive owns its logic directly. Do not create facade files that delegate to
120120
primitives.
121121
- **No backward-compatibility shims.** This is a CLI, not a library. If the CLI functions the same, delete old files.
122-
- **Use `{ success, error? }` result format** throughout (never `{ ok, error }`). See `AddResult` and `RemovalResult`.
122+
- **Use the discriminated `Result<T, E>` union** from `src/lib/result.ts` throughout. See typed error classes in
123+
`src/lib/errors/types.ts`.
123124
- **Dynamic imports for ink/React only.** TUI components (ink, react, screen components) must be dynamically imported
124125
inside Commander action handlers to prevent esbuild async module propagation issues. All other imports go at the top
125126
of the file. See the esbuild section below.

src/cli/__tests__/errors.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import { AgentAlreadyExistsError, toError } from '../../lib';
12
import {
2-
AgentAlreadyExistsError,
33
getErrorMessage,
44
isChangesetInProgressError,
55
isExpiredTokenError,
@@ -204,4 +204,35 @@ describe('errors', () => {
204204
expect(isChangesetInProgressError({})).toBe(false);
205205
});
206206
});
207+
208+
describe('toError', () => {
209+
it('returns Error instance as-is', () => {
210+
const err = new Error('original');
211+
expect(toError(err)).toBe(err);
212+
});
213+
214+
it('wraps string in Error', () => {
215+
const result = toError('string error');
216+
expect(result).toBeInstanceOf(Error);
217+
expect(result.message).toBe('string error');
218+
});
219+
220+
it('handles null', () => {
221+
const result = toError(null);
222+
expect(result).toBeInstanceOf(Error);
223+
expect(result.message).toBe('null');
224+
});
225+
226+
it('handles undefined', () => {
227+
const result = toError(undefined);
228+
expect(result).toBeInstanceOf(Error);
229+
expect(result.message).toBe('undefined');
230+
});
231+
232+
it('handles non-Error objects', () => {
233+
const result = toError({ code: 42 });
234+
expect(result).toBeInstanceOf(Error);
235+
expect(result.message).toBe('[object Object]');
236+
});
237+
});
207238
});

src/cli/aws/__tests__/transaction-search.test.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { enableTransactionSearch } from '../transaction-search.js';
2+
import assert from 'node:assert';
23
import { beforeEach, describe, expect, it, vi } from 'vitest';
34

45
const { mockAppSignalsSend, mockLogsSend, mockXRaySend } = vi.hoisted(() => ({
@@ -162,17 +163,17 @@ describe('enableTransactionSearch', () => {
162163

163164
const result = await enableTransactionSearch('us-east-1', '123456789012');
164165

165-
expect(result.success).toBe(false);
166-
expect(result.error).toContain('Insufficient permissions to enable Application Signals');
166+
assert(!result.success);
167+
expect(result.error.message).toContain('Insufficient permissions to enable Application Signals');
167168
});
168169

169170
it('returns error when Application Signals fails with generic error', async () => {
170171
mockAppSignalsSend.mockRejectedValue(new Error('Service unavailable'));
171172

172173
const result = await enableTransactionSearch('us-east-1', '123456789012');
173174

174-
expect(result.success).toBe(false);
175-
expect(result.error).toContain('Failed to enable Application Signals');
175+
assert(!result.success);
176+
expect(result.error.message).toContain('Failed to enable Application Signals');
176177
});
177178

178179
it('returns error when CloudWatch Logs policy fails with AccessDenied', async () => {
@@ -183,8 +184,8 @@ describe('enableTransactionSearch', () => {
183184

184185
const result = await enableTransactionSearch('us-east-1', '123456789012');
185186

186-
expect(result.success).toBe(false);
187-
expect(result.error).toContain('Insufficient permissions to configure CloudWatch Logs policy');
187+
assert(!result.success);
188+
expect(result.error.message).toContain('Insufficient permissions to configure CloudWatch Logs policy');
188189
});
189190

190191
it('returns error when trace destination fails', async () => {
@@ -194,8 +195,8 @@ describe('enableTransactionSearch', () => {
194195

195196
const result = await enableTransactionSearch('us-east-1', '123456789012');
196197

197-
expect(result.success).toBe(false);
198-
expect(result.error).toContain('Failed to configure trace destination');
198+
assert(!result.success);
199+
expect(result.error.message).toContain('Failed to configure trace destination');
199200
});
200201

201202
it('returns error when indexing rule update fails', async () => {
@@ -214,8 +215,8 @@ describe('enableTransactionSearch', () => {
214215

215216
const result = await enableTransactionSearch('us-east-1', '123456789012');
216217

217-
expect(result.success).toBe(false);
218-
expect(result.error).toContain('Failed to configure indexing rules');
218+
assert(!result.success);
219+
expect(result.error.message).toContain('Failed to configure indexing rules');
219220
});
220221

221222
it('does not proceed to later steps when an earlier step fails', async () => {

src/cli/aws/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export {
1717
type GetAgentRuntimeStatusOptions,
1818
} from './agentcore-control';
1919
export { streamLogs, searchLogs, type LogEvent, type StreamLogsOptions, type SearchLogsOptions } from './cloudwatch';
20-
export { enableTransactionSearch, type TransactionSearchEnableResult } from './transaction-search';
20+
export { enableTransactionSearch } from './transaction-search';
2121
export {
2222
startPolicyGeneration,
2323
getPolicyGeneration,

src/cli/aws/transaction-search.ts

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { AccessDeniedError } from '../../lib';
2+
import type { Result } from '../../lib/result';
13
import { getErrorMessage, isAccessDeniedError } from '../errors';
24
import { getCredentialProvider } from './account';
35
import { arnPrefix } from './partition';
@@ -14,11 +16,6 @@ import {
1416
XRayClient,
1517
} from '@aws-sdk/client-xray';
1618

17-
export interface TransactionSearchEnableResult {
18-
success: boolean;
19-
error?: string;
20-
}
21-
2219
const RESOURCE_POLICY_NAME = 'TransactionSearchXRayAccess';
2320

2421
/**
@@ -34,7 +31,7 @@ export async function enableTransactionSearch(
3431
region: string,
3532
accountId: string,
3633
indexPercentage = 100
37-
): Promise<TransactionSearchEnableResult> {
34+
): Promise<Result> {
3835
const credentials = getCredentialProvider();
3936

4037
// Step 1: Enable Application Signals (creates service-linked role, idempotent)
@@ -44,9 +41,14 @@ export async function enableTransactionSearch(
4441
} catch (err: unknown) {
4542
const message = getErrorMessage(err);
4643
if (isAccessDeniedError(err)) {
47-
return { success: false, error: `Insufficient permissions to enable Application Signals: ${message}` };
44+
return {
45+
success: false,
46+
error: new AccessDeniedError(`Insufficient permissions to enable Application Signals: ${message}`, {
47+
cause: err,
48+
}),
49+
};
4850
}
49-
return { success: false, error: `Failed to enable Application Signals: ${message}` };
51+
return { success: false, error: new Error(`Failed to enable Application Signals: ${message}`, { cause: err }) };
5052
}
5153

5254
// Step 2: Create CloudWatch Logs resource policy for X-Ray (if needed)
@@ -80,9 +82,17 @@ export async function enableTransactionSearch(
8082
} catch (err: unknown) {
8183
const message = getErrorMessage(err);
8284
if (isAccessDeniedError(err)) {
83-
return { success: false, error: `Insufficient permissions to configure CloudWatch Logs policy: ${message}` };
85+
return {
86+
success: false,
87+
error: new AccessDeniedError(`Insufficient permissions to configure CloudWatch Logs policy: ${message}`, {
88+
cause: err,
89+
}),
90+
};
8491
}
85-
return { success: false, error: `Failed to configure CloudWatch Logs policy: ${message}` };
92+
return {
93+
success: false,
94+
error: new Error(`Failed to configure CloudWatch Logs policy: ${message}`, { cause: err }),
95+
};
8696
}
8797

8898
const xrayClient = new XRayClient({ region, credentials });
@@ -96,9 +106,14 @@ export async function enableTransactionSearch(
96106
} catch (err: unknown) {
97107
const message = getErrorMessage(err);
98108
if (isAccessDeniedError(err)) {
99-
return { success: false, error: `Insufficient permissions to configure trace destination: ${message}` };
109+
return {
110+
success: false,
111+
error: new AccessDeniedError(`Insufficient permissions to configure trace destination: ${message}`, {
112+
cause: err,
113+
}),
114+
};
100115
}
101-
return { success: false, error: `Failed to configure trace destination: ${message}` };
116+
return { success: false, error: new Error(`Failed to configure trace destination: ${message}`, { cause: err }) };
102117
}
103118

104119
// Step 4: Set indexing to 100% on the built-in Default rule (always exists, idempotent)
@@ -112,9 +127,14 @@ export async function enableTransactionSearch(
112127
} catch (err: unknown) {
113128
const message = getErrorMessage(err);
114129
if (isAccessDeniedError(err)) {
115-
return { success: false, error: `Insufficient permissions to configure indexing rules: ${message}` };
130+
return {
131+
success: false,
132+
error: new AccessDeniedError(`Insufficient permissions to configure indexing rules: ${message}`, {
133+
cause: err,
134+
}),
135+
};
116136
}
117-
return { success: false, error: `Failed to configure indexing rules: ${message}` };
137+
return { success: false, error: new Error(`Failed to configure indexing rules: ${message}`, { cause: err }) };
118138
}
119139

120140
return { success: true };

src/cli/commands/add/types.ts

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,6 @@ export interface AddAgentOptions extends VpcOptions {
4141
json?: boolean;
4242
}
4343

44-
export interface AddAgentResult {
45-
success: boolean;
46-
agentName?: string;
47-
agentPath?: string;
48-
error?: string;
49-
}
50-
5144
// Gateway types
5245
export interface AddGatewayOptions {
5346
name?: string;
@@ -68,12 +61,6 @@ export interface AddGatewayOptions {
6861
json?: boolean;
6962
}
7063

71-
export interface AddGatewayResult {
72-
success: boolean;
73-
gatewayName?: string;
74-
error?: string;
75-
}
76-
7764
// Gateway Target types
7865
export interface AddGatewayTargetOptions {
7966
name?: string;
@@ -100,13 +87,6 @@ export interface AddGatewayTargetOptions {
10087
json?: boolean;
10188
}
10289

103-
export interface AddGatewayTargetResult {
104-
success: boolean;
105-
toolName?: string;
106-
sourcePath?: string;
107-
error?: string;
108-
}
109-
11090
// Memory types (v2: no owner/user concept)
11191
export interface AddMemoryOptions {
11292
name?: string;
@@ -119,12 +99,6 @@ export interface AddMemoryOptions {
11999
json?: boolean;
120100
}
121101

122-
export interface AddMemoryResult {
123-
success: boolean;
124-
memoryName?: string;
125-
error?: string;
126-
}
127-
128102
// Credential types (v2: credential, no owner/user concept)
129103
export interface AddCredentialOptions {
130104
name?: string;
@@ -139,9 +113,3 @@ export interface AddCredentialOptions {
139113

140114
/** @deprecated Use AddCredentialOptions */
141115
export type AddIdentityOptions = AddCredentialOptions;
142-
143-
export interface AddCredentialResult {
144-
success: boolean;
145-
credentialName?: string;
146-
error?: string;
147-
}

0 commit comments

Comments
 (0)