diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index bfa09a1f..bc6b14ce 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -18,6 +18,7 @@ import { synthesizeCdk, validateProject, } from '../../operations/deploy'; +import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; import type { DeployResult } from './types'; export interface ValidatedDeployOptions { @@ -316,12 +317,20 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { const gatewayUrls = Object.entries(gateways) .map(([name, gateway]) => `${name}: ${gateway.gatewayArn}`) .join(', '); logger.log(`Gateway URLs: ${gatewayUrls}`); + + // Query target sync statuses (non-blocking) + for (const [, gateway] of Object.entries(gateways)) { + const statuses = await getGatewayTargetStatuses(gateway.gatewayId, target.region); + for (const targetStatus of statuses) { + logger.log(` ${targetStatus.name}: ${formatTargetStatus(targetStatus.status)}`); + } + } } endStep('success'); diff --git a/src/cli/operations/deploy/__tests__/gateway-status.test.ts b/src/cli/operations/deploy/__tests__/gateway-status.test.ts new file mode 100644 index 00000000..9efd30a6 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/gateway-status.test.ts @@ -0,0 +1,83 @@ +import { formatTargetStatus, getGatewayTargetStatuses } from '../gateway-status.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockSend } = vi.hoisted(() => ({ + mockSend: vi.fn(), +})); + +vi.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({ + BedrockAgentCoreControlClient: class { + send = mockSend; + }, + ListGatewayTargetsCommand: class { + constructor(public input: unknown) {} + }, +})); + +describe('getGatewayTargetStatuses', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns statuses for all targets', async () => { + mockSend.mockResolvedValue({ + items: [ + { name: 'target-1', status: 'READY' }, + { name: 'target-2', status: 'SYNCHRONIZING' }, + { name: 'target-3', status: 'READY' }, + ], + }); + + const result = await getGatewayTargetStatuses('gw-123', 'us-east-1'); + + expect(result).toEqual([ + { name: 'target-1', status: 'READY' }, + { name: 'target-2', status: 'SYNCHRONIZING' }, + { name: 'target-3', status: 'READY' }, + ]); + }); + + it('returns empty array on API error', async () => { + mockSend.mockRejectedValue(new Error('Access denied')); + + const result = await getGatewayTargetStatuses('gw-123', 'us-east-1'); + + expect(result).toEqual([]); + }); + + it('returns empty array when no targets', async () => { + mockSend.mockResolvedValue({ items: [] }); + + const result = await getGatewayTargetStatuses('gw-123', 'us-east-1'); + + expect(result).toEqual([]); + }); + + it('handles undefined items', async () => { + mockSend.mockResolvedValue({}); + + const result = await getGatewayTargetStatuses('gw-123', 'us-east-1'); + + expect(result).toEqual([]); + }); +}); + +describe('formatTargetStatus', () => { + it('formats READY', () => { + expect(formatTargetStatus('READY')).toBe('✓ synced'); + }); + + it('formats SYNCHRONIZING', () => { + expect(formatTargetStatus('SYNCHRONIZING')).toBe('⟳ syncing...'); + }); + + it('formats SYNCHRONIZE_UNSUCCESSFUL', () => { + expect(formatTargetStatus('SYNCHRONIZE_UNSUCCESSFUL')).toBe('⚠ sync failed'); + }); + + it('formats FAILED', () => { + expect(formatTargetStatus('FAILED')).toBe('✗ failed'); + }); + + it('returns raw status for unknown values', () => { + expect(formatTargetStatus('UNKNOWN_STATUS')).toBe('UNKNOWN_STATUS'); + }); +}); diff --git a/src/cli/operations/deploy/gateway-status.ts b/src/cli/operations/deploy/gateway-status.ts new file mode 100644 index 00000000..de20b1e4 --- /dev/null +++ b/src/cli/operations/deploy/gateway-status.ts @@ -0,0 +1,42 @@ +/** + * Query gateway target sync statuses after deployment. + */ +import { BedrockAgentCoreControlClient, ListGatewayTargetsCommand } from '@aws-sdk/client-bedrock-agentcore-control'; + +export interface TargetSyncStatus { + name: string; + status: string; +} + +const STATUS_DISPLAY: Record = { + READY: '✓ synced', + SYNCHRONIZING: '⟳ syncing...', + SYNCHRONIZE_UNSUCCESSFUL: '⚠ sync failed', + CREATING: '⟳ creating...', + UPDATING: '⟳ updating...', + UPDATE_UNSUCCESSFUL: '⚠ update failed', + FAILED: '✗ failed', + DELETING: '⟳ deleting...', +}; + +export function formatTargetStatus(status: string): string { + return STATUS_DISPLAY[status] ?? status; +} + +/** + * Get sync statuses for all targets in a gateway. + * Returns empty array on error (non-blocking). + */ +export async function getGatewayTargetStatuses(gatewayId: string, region: string): Promise { + try { + const client = new BedrockAgentCoreControlClient({ region }); + const response = await client.send(new ListGatewayTargetsCommand({ gatewayIdentifier: gatewayId, maxResults: 100 })); + + return (response.items ?? []).map(target => ({ + name: target.name ?? 'unknown', + status: target.status ?? 'UNKNOWN', + })); + } catch { + return []; + } +} diff --git a/src/cli/tui/screens/deploy/DeployScreen.tsx b/src/cli/tui/screens/deploy/DeployScreen.tsx index 55e05e67..4806b73c 100644 --- a/src/cli/tui/screens/deploy/DeployScreen.tsx +++ b/src/cli/tui/screens/deploy/DeployScreen.tsx @@ -1,5 +1,6 @@ import { ConfigIO } from '../../../../lib'; import type { AgentCoreMcpSpec } from '../../../../schema'; +import { formatTargetStatus } from '../../../operations/deploy/gateway-status'; import { AwsTargetConfigUI, ConfirmPrompt, @@ -58,6 +59,7 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p isComplete, hasStartedCfn, logFilePath, + targetStatuses, missingCredentials, startDeploy, confirmTeardown, @@ -279,6 +281,18 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p )} + {allSuccess && targetStatuses.length > 0 && ( + + Gateway Targets: + {targetStatuses.map(t => ( + + {' '} + {t.name}: {formatTargetStatus(t.status)} + + ))} + + )} + {logFilePath && ( diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index e0783463..80b975c2 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -4,6 +4,7 @@ import { buildDeployedState, getStackOutputs, parseAgentOutputs, parseGatewayOut import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from '../../../errors'; import { ExecLogger } from '../../../logging'; import { performStackTeardown } from '../../../operations/deploy'; +import { getGatewayTargetStatuses } from '../../../operations/deploy/gateway-status'; import { type Step, areStepsComplete, hasStepError } from '../../components'; import { type MissingCredential, type PreflightContext, useCdkPreflight } from '../../hooks'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -45,6 +46,7 @@ interface DeployFlowState { deployOutput: string | null; deployMessages: DeployMessage[]; stackOutputs: Record; + targetStatuses: { name: string; status: string }[]; hasError: boolean; /** True if the error is specifically due to expired/invalid AWS credentials */ hasTokenExpiredError: boolean; @@ -96,6 +98,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const [deployOutput, setDeployOutput] = useState(null); const [deployMessages, setDeployMessages] = useState([]); const [stackOutputs, setStackOutputs] = useState>({}); + const [targetStatuses, setTargetStatuses] = useState<{ name: string; status: string }[]>([]); const [shouldStartDeploy, setShouldStartDeploy] = useState(false); const [hasTokenExpiredError, setHasTokenExpiredError] = useState(false); // Track if CloudFormation has started (received first resource event) @@ -196,6 +199,16 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState Object.keys(oauthCredentials).length > 0 ? oauthCredentials : undefined ); await configIO.writeDeployedState(deployedState); + + // Query gateway target sync statuses (non-blocking) + const allStatuses: { name: string; status: string }[] = []; + for (const [, gateway] of Object.entries(gateways)) { + const statuses = await getGatewayTargetStatuses(gateway.gatewayId, target.region); + allStatuses.push(...statuses); + } + if (allStatuses.length > 0) { + setTargetStatuses(allStatuses); + } }, [context, stackNames, logger, identityKmsKeyArn, oauthCredentials]); // Start deploy when preflight completes OR when shouldStartDeploy is set @@ -400,6 +413,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState deployOutput, deployMessages, stackOutputs, + targetStatuses, hasError, hasTokenExpiredError: combinedTokenExpiredError, hasCredentialsError: preflight.hasCredentialsError,