diff --git a/src/cli/commands/status/__tests__/action.test.ts b/src/cli/commands/status/__tests__/action.test.ts new file mode 100644 index 00000000..a9ec8ef4 --- /dev/null +++ b/src/cli/commands/status/__tests__/action.test.ts @@ -0,0 +1,303 @@ +import type { AgentCoreMcpSpec, AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema/index.js'; +import { computeResourceStatuses } from '../action.js'; +import { describe, expect, it } from 'vitest'; + +const baseProject: AgentCoreProjectSpec = { + name: 'test-project', + version: 1, + agents: [], + memories: [], + credentials: [], +} as unknown as AgentCoreProjectSpec; + +describe('computeResourceStatuses', () => { + it('returns empty array for empty project with no deployed state', () => { + const result = computeResourceStatuses(baseProject, undefined); + expect(result).toEqual([]); + }); + + it('marks agent as deployed when in both local and deployed state', () => { + const project = { + ...baseProject, + agents: [{ name: 'my-agent' }], + } as unknown as AgentCoreProjectSpec; + + const resources: DeployedResourceState = { + agents: { + 'my-agent': { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123456789:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123456789:role/test', + }, + }, + }; + + const result = computeResourceStatuses(project, resources); + const agentEntry = result.find(r => r.resourceType === 'agent' && r.name === 'my-agent'); + + expect(agentEntry).toBeDefined(); + expect(agentEntry!.deploymentState).toBe('deployed'); + expect(agentEntry!.identifier).toBe('arn:aws:bedrock:us-east-1:123456789:agent-runtime/rt-123'); + }); + + it('marks agent as local-only when not in deployed state', () => { + const project = { + ...baseProject, + agents: [{ name: 'my-agent' }], + } as unknown as AgentCoreProjectSpec; + + const result = computeResourceStatuses(project, undefined); + const agentEntry = result.find(r => r.resourceType === 'agent' && r.name === 'my-agent'); + + expect(agentEntry).toBeDefined(); + expect(agentEntry!.deploymentState).toBe('local-only'); + expect(agentEntry!.identifier).toBeUndefined(); + }); + + it('marks agent as pending-removal when in deployed state but not in local schema', () => { + const resources: DeployedResourceState = { + agents: { + 'removed-agent': { + runtimeId: 'rt-456', + runtimeArn: 'arn:aws:bedrock:us-east-1:123456789:agent-runtime/rt-456', + roleArn: 'arn:aws:iam::123456789:role/test', + }, + }, + }; + + const result = computeResourceStatuses(baseProject, resources); + const agentEntry = result.find(r => r.resourceType === 'agent' && r.name === 'removed-agent'); + + expect(agentEntry).toBeDefined(); + expect(agentEntry!.deploymentState).toBe('pending-removal'); + expect(agentEntry!.identifier).toBe('arn:aws:bedrock:us-east-1:123456789:agent-runtime/rt-456'); + }); + + it('marks credential as deployed when in both local and deployed state', () => { + const project = { + ...baseProject, + credentials: [{ name: 'my-cred', type: 'OAuthCredentialProvider' }], + } as unknown as AgentCoreProjectSpec; + + const resources: DeployedResourceState = { + credentials: { + 'my-cred': { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789:credential-provider/my-cred', + }, + }, + }; + + const result = computeResourceStatuses(project, resources); + const credEntry = result.find(r => r.resourceType === 'credential' && r.name === 'my-cred'); + + expect(credEntry).toBeDefined(); + expect(credEntry!.deploymentState).toBe('deployed'); + expect(credEntry!.identifier).toBe('arn:aws:bedrock:us-east-1:123456789:credential-provider/my-cred'); + expect(credEntry!.detail).toBe('OAuth'); + }); + + it('marks credential as local-only when not in deployed state', () => { + const project = { + ...baseProject, + credentials: [{ name: 'my-cred', type: 'ApiKeyCredentialProvider' }], + } as unknown as AgentCoreProjectSpec; + + const result = computeResourceStatuses(project, undefined); + const credEntry = result.find(r => r.resourceType === 'credential' && r.name === 'my-cred'); + + expect(credEntry).toBeDefined(); + expect(credEntry!.deploymentState).toBe('local-only'); + expect(credEntry!.detail).toBe('ApiKey'); + }); + + it('marks credential as pending-removal when in deployed state but not in local schema', () => { + const resources: DeployedResourceState = { + credentials: { + 'removed-cred': { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789:credential-provider/removed-cred', + }, + }, + }; + + const result = computeResourceStatuses(baseProject, resources); + const credEntry = result.find(r => r.resourceType === 'credential' && r.name === 'removed-cred'); + + expect(credEntry).toBeDefined(); + expect(credEntry!.deploymentState).toBe('pending-removal'); + expect(credEntry!.identifier).toBe('arn:aws:bedrock:us-east-1:123456789:credential-provider/removed-cred'); + }); + + it('marks memory as deployed when in both local and deployed state', () => { + const project = { + ...baseProject, + memories: [{ name: 'my-memory', strategies: [{ type: 'SEMANTIC' }] }], + } as unknown as AgentCoreProjectSpec; + + const resources: DeployedResourceState = { + memories: { + 'my-memory': { + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock:us-east-1:123456789:memory/mem-123', + }, + }, + }; + + const result = computeResourceStatuses(project, resources); + const memEntry = result.find(r => r.resourceType === 'memory' && r.name === 'my-memory'); + + expect(memEntry).toBeDefined(); + expect(memEntry!.deploymentState).toBe('deployed'); + expect(memEntry!.identifier).toBe('arn:aws:bedrock:us-east-1:123456789:memory/mem-123'); + expect(memEntry!.detail).toBe('SEMANTIC'); + }); + + it('marks memory as local-only when not in deployed state', () => { + const project = { + ...baseProject, + memories: [{ name: 'my-memory', strategies: [{ type: 'SUMMARIZATION' }] }], + } as unknown as AgentCoreProjectSpec; + + const result = computeResourceStatuses(project, undefined); + const memEntry = result.find(r => r.resourceType === 'memory' && r.name === 'my-memory'); + + expect(memEntry).toBeDefined(); + expect(memEntry!.deploymentState).toBe('local-only'); + expect(memEntry!.detail).toBe('SUMMARIZATION'); + }); + + it('marks memory as pending-removal when in deployed state but not in local schema', () => { + const resources: DeployedResourceState = { + memories: { + 'removed-memory': { + memoryId: 'mem-456', + memoryArn: 'arn:aws:bedrock:us-east-1:123456789:memory/mem-456', + }, + }, + }; + + const result = computeResourceStatuses(baseProject, resources); + const pendingMemEntry = result.find(r => r.resourceType === 'memory' && r.deploymentState === 'pending-removal'); + + expect(pendingMemEntry).toBeDefined(); + expect(pendingMemEntry!.name).toBe('removed-memory'); + expect(pendingMemEntry!.identifier).toBe('arn:aws:bedrock:us-east-1:123456789:memory/mem-456'); + }); + + it('marks all resources as local-only when never deployed', () => { + const project = { + ...baseProject, + agents: [{ name: 'agent-a' }], + memories: [{ name: 'mem-a', strategies: [] }], + credentials: [{ name: 'cred-a', type: 'ApiKeyCredentialProvider' }], + } as unknown as AgentCoreProjectSpec; + + const result = computeResourceStatuses(project, undefined); + + expect(result).toHaveLength(3); + expect(result.every(r => r.deploymentState === 'local-only')).toBe(true); + }); + + it('marks gateway as deployed when in both local mcp spec and deployed state', () => { + const mcpSpec = { + agentCoreGateways: [{ name: 'my-gateway', targets: [{ name: 't1' }, { name: 't2' }] }], + } as unknown as AgentCoreMcpSpec; + + const resources: DeployedResourceState = { + mcp: { + gateways: { + 'my-gateway': { + gatewayId: 'gw-123', + gatewayArn: 'arn:aws:bedrock:us-east-1:123456789:gateway/gw-123', + }, + }, + }, + }; + + const result = computeResourceStatuses(baseProject, resources, mcpSpec); + const gwEntry = result.find(r => r.resourceType === 'gateway' && r.name === 'my-gateway'); + + expect(gwEntry).toBeDefined(); + expect(gwEntry!.deploymentState).toBe('deployed'); + expect(gwEntry!.identifier).toBe('gw-123'); + expect(gwEntry!.detail).toBe('2 targets'); + }); + + it('marks gateway as local-only when not in deployed state', () => { + const mcpSpec = { + agentCoreGateways: [{ name: 'my-gateway', targets: [{ name: 't1' }] }], + } as unknown as AgentCoreMcpSpec; + + const result = computeResourceStatuses(baseProject, undefined, mcpSpec); + const gwEntry = result.find(r => r.resourceType === 'gateway' && r.name === 'my-gateway'); + + expect(gwEntry).toBeDefined(); + expect(gwEntry!.deploymentState).toBe('local-only'); + expect(gwEntry!.detail).toBe('1 target'); + }); + + it('marks gateway as pending-removal when in deployed state but not in local mcp spec', () => { + const mcpSpec = { + agentCoreGateways: [], + } as unknown as AgentCoreMcpSpec; + + const resources: DeployedResourceState = { + mcp: { + gateways: { + 'removed-gateway': { + gatewayId: 'gw-456', + gatewayArn: 'arn:aws:bedrock:us-east-1:123456789:gateway/gw-456', + }, + }, + }, + }; + + const result = computeResourceStatuses(baseProject, resources, mcpSpec); + const gwEntry = result.find(r => r.resourceType === 'gateway' && r.name === 'removed-gateway'); + + expect(gwEntry).toBeDefined(); + expect(gwEntry!.deploymentState).toBe('pending-removal'); + expect(gwEntry!.identifier).toBe('gw-456'); + }); + + it('handles mixed deployed and local-only resources', () => { + const project = { + ...baseProject, + agents: [{ name: 'deployed-agent' }, { name: 'new-agent' }], + credentials: [{ name: 'deployed-cred', type: 'OAuthCredentialProvider' }], + } as unknown as AgentCoreProjectSpec; + + const resources: DeployedResourceState = { + agents: { + 'deployed-agent': { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123456789:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123456789:role/test', + }, + 'old-agent': { + runtimeId: 'rt-old', + runtimeArn: 'arn:aws:bedrock:us-east-1:123456789:agent-runtime/rt-old', + roleArn: 'arn:aws:iam::123456789:role/test', + }, + }, + credentials: { + 'deployed-cred': { + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789:credential-provider/deployed-cred', + }, + }, + }; + + const result = computeResourceStatuses(project, resources); + + const deployedAgent = result.find(r => r.name === 'deployed-agent'); + expect(deployedAgent!.deploymentState).toBe('deployed'); + + const newAgent = result.find(r => r.name === 'new-agent'); + expect(newAgent!.deploymentState).toBe('local-only'); + + const oldAgent = result.find(r => r.name === 'old-agent'); + expect(oldAgent!.deploymentState).toBe('pending-removal'); + + const deployedCred = result.find(r => r.name === 'deployed-cred'); + expect(deployedCred!.deploymentState).toBe('deployed'); + }); +}); diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 85176588..7eab20de 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -1,220 +1,322 @@ import { ConfigIO } from '../../../lib'; -import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../schema'; +import type { + AgentCoreMcpSpec, + AgentCoreProjectSpec, + AwsDeploymentTargets, + DeployedResourceState, + DeployedState, +} from '../../../schema'; import { getAgentRuntimeStatus } from '../../aws'; import { getErrorMessage } from '../../errors'; +import { ExecLogger } from '../../logging'; +import type { ResourceDeploymentState } from './constants'; -export interface StatusContext { - project: AgentCoreProjectSpec; - deployedState: DeployedState; - awsTargets: AwsDeploymentTargets; -} - -/** - * Loads configuration required for status check - */ -export async function loadStatusConfig(configIO: ConfigIO = new ConfigIO()): Promise { - return { - project: await configIO.readProjectSpec(), - deployedState: await configIO.readDeployedState(), - awsTargets: await configIO.readAWSDeploymentTargets(), - }; -} +export type { ResourceDeploymentState }; -export interface StatusOptions { - agentName?: string; - targetName?: string; - agentRuntimeId?: string; +export interface ResourceStatusEntry { + resourceType: 'agent' | 'memory' | 'credential' | 'gateway'; + name: string; + deploymentState: ResourceDeploymentState; + identifier?: string; + detail?: string; + error?: string; } -export interface StatusResult { +export interface ProjectStatusResult { success: boolean; - agentName?: string; - targetName?: string; - isDeployed?: boolean; - runtimeId?: string; - runtimeStatus?: string; + projectName: string; + targetName: string; + targetRegion?: string; + resources: ResourceStatusEntry[]; error?: string; + logPath?: string; } -export interface StatusEntry { - agentName: string; - isDeployed: boolean; - runtimeId?: string; - runtimeStatus?: string; - error?: string; +export interface StatusContext { + project: AgentCoreProjectSpec; + deployedState: DeployedState; + awsTargets: AwsDeploymentTargets; + mcpSpec?: AgentCoreMcpSpec; } -export interface StatusSummaryResult { +export interface RuntimeLookupResult { success: boolean; targetName?: string; - entries?: StatusEntry[]; + runtimeId?: string; + runtimeStatus?: string; error?: string; + logPath?: string; } /** - * Main status handler + * Loads configuration required for status check. + * Gracefully handles missing deployed-state by returning empty targets. */ -export async function handleStatus(context: StatusContext, options: StatusOptions = {}): Promise { - const { project, deployedState, awsTargets } = context; - - // Resolve target - const targetNames = options.agentRuntimeId - ? awsTargets.map(target => target.name) - : Object.keys(deployedState.targets); - if (targetNames.length === 0) { - return { - success: false, - error: options.agentRuntimeId - ? 'No deployment targets found. Run `agentcore create` first.' - : '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(', ')}` }; - } - - const targetState = selectedTargetName ? deployedState.targets[selectedTargetName] : undefined; - const targetConfig = awsTargets.find(target => target.name === selectedTargetName); +export async function loadStatusConfig(configIO: ConfigIO = new ConfigIO()): Promise { + const [project, awsTargets, deployedState, mcpSpec] = await Promise.all([ + configIO.readProjectSpec(), + configIO.readAWSDeploymentTargets(), + configIO.configExists('state') + ? configIO.readDeployedState() + : (Promise.resolve({ targets: {} }) as Promise), + configIO.configExists('mcp') ? configIO.readMcpSpec() : Promise.resolve(undefined), + ]); + + return { project, deployedState, awsTargets, mcpSpec }; +} - if (!targetConfig) { - return { success: false, error: `Target config '${selectedTargetName}' not found in aws-targets` }; +/** + * Diffs a set of local resources against deployed resources, producing status entries. + * Shared logic for all resource types (agents, credentials, memories, gateways). + */ +function diffResourceSet({ + resourceType, + localItems, + deployedRecord, + getIdentifier, + getLocalDetail, +}: { + resourceType: ResourceStatusEntry['resourceType']; + localItems: TLocal[]; + deployedRecord: Record; + getIdentifier: (deployed: TDeployed) => string | undefined; + getLocalDetail?: (item: TLocal) => string | undefined; +}): ResourceStatusEntry[] { + const entries: ResourceStatusEntry[] = []; + const localNames = new Set(localItems.map(item => item.name)); + + for (const item of localItems) { + const deployed = deployedRecord[item.name]; + entries.push({ + resourceType, + name: item.name, + deploymentState: deployed ? 'deployed' : 'local-only', + identifier: deployed ? getIdentifier(deployed) : undefined, + detail: getLocalDetail?.(item), + }); } - if (options.agentRuntimeId) { - try { - const runtimeStatus = await getAgentRuntimeStatus({ - region: targetConfig.region, - runtimeId: options.agentRuntimeId, + for (const [name, deployed] of Object.entries(deployedRecord)) { + if (!localNames.has(name)) { + entries.push({ + resourceType, + name, + deploymentState: 'pending-removal', + identifier: getIdentifier(deployed), }); - - return { - success: true, - targetName: selectedTargetName, - runtimeId: runtimeStatus.runtimeId, - runtimeStatus: runtimeStatus.status, - }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; } } - if (project.agents.length === 0) { - return { success: false, error: 'No agents defined in configuration' }; - } + return entries; +} - // Resolve agent - const agentNames = project.agents.map(a => a.name); - const agentSpec = options.agentName ? project.agents.find(a => a.name === options.agentName) : project.agents[0]; +export function computeResourceStatuses( + project: AgentCoreProjectSpec, + resources: DeployedResourceState | undefined, + mcpSpec?: AgentCoreMcpSpec +): ResourceStatusEntry[] { + const agents = diffResourceSet({ + resourceType: 'agent', + localItems: project.agents, + deployedRecord: resources?.agents ?? {}, + getIdentifier: deployed => deployed.runtimeArn, + }); + + const credentials = diffResourceSet({ + resourceType: 'credential', + localItems: project.credentials, + deployedRecord: resources?.credentials ?? {}, + getIdentifier: deployed => deployed.credentialProviderArn, + getLocalDetail: item => item.type?.replace('CredentialProvider', ''), + }); + + const memories = diffResourceSet({ + resourceType: 'memory', + localItems: project.memories, + deployedRecord: resources?.memories ?? {}, + getIdentifier: deployed => deployed.memoryArn, + getLocalDetail: item => { + if (!item.strategies?.length) return undefined; + return item.strategies.map(s => s.type).join(', '); + }, + }); + + const gateways = diffResourceSet({ + resourceType: 'gateway', + localItems: mcpSpec?.agentCoreGateways ?? [], + deployedRecord: resources?.mcp?.gateways ?? {}, + getIdentifier: deployed => deployed.gatewayId, + getLocalDetail: item => { + const count = item.targets?.length ?? 0; + return count > 0 ? `${count} target${count !== 1 ? 's' : ''}` : undefined; + }, + }); + + return [...agents, ...credentials, ...memories, ...gateways]; +} - if (options.agentName && !agentSpec) { - return { success: false, error: `Agent '${options.agentName}' not found. Available: ${agentNames.join(', ')}` }; - } +export async function handleProjectStatus( + context: StatusContext, + options: { targetName?: string } = {} +): Promise { + const logger = new ExecLogger({ command: 'status' }); + const { project, deployedState, awsTargets, mcpSpec } = context; - if (!agentSpec) { - return { success: false, error: 'No agents defined in configuration' }; - } + logger.startStep('Resolve target'); + const deployedTargetNames = Object.keys(deployedState.targets); + const targetNames = deployedTargetNames.length > 0 ? deployedTargetNames : awsTargets.map(t => t.name); + const selectedTargetName = options.targetName ?? targetNames[0]; - // Get the deployed state for this specific agent - const agentState = targetState?.resources?.agents?.[agentSpec.name]; + logger.log(`Project: ${project.name}`); + logger.log(`Available targets: ${targetNames.length > 0 ? targetNames.join(', ') : '(none)'}`); + logger.log(`Selected target: ${selectedTargetName ?? '(none)'}`); - if (!agentState) { + if (options.targetName && !targetNames.includes(options.targetName)) { + const error = + targetNames.length > 0 + ? `Target '${options.targetName}' not found. Available: ${targetNames.join(', ')}` + : `Target '${options.targetName}' not found. No targets configured.`; + logger.endStep('error', error); + logger.finalize(false); return { - success: true, - agentName: agentSpec.name, - targetName: selectedTargetName, - isDeployed: false, + success: false, + projectName: project.name, + targetName: options.targetName, + resources: [], + error, + logPath: logger.getRelativeLogPath(), }; } + logger.endStep('success'); - try { - const runtimeStatus = await getAgentRuntimeStatus({ - region: targetConfig.region, - runtimeId: agentState.runtimeId, - }); + logger.startStep('Compute resource statuses'); + const targetConfig = selectedTargetName ? awsTargets.find(t => t.name === selectedTargetName) : undefined; + const targetResources = selectedTargetName ? deployedState.targets[selectedTargetName]?.resources : undefined; - return { - success: true, - agentName: agentSpec.name, - targetName: selectedTargetName, - isDeployed: true, - runtimeId: runtimeStatus.runtimeId, - runtimeStatus: runtimeStatus.status, - }; - } catch (error) { - return { success: false, error: getErrorMessage(error) }; + const resources = computeResourceStatuses(project, targetResources, mcpSpec); + + const deployed = resources.filter(r => r.deploymentState === 'deployed').length; + const localOnly = resources.filter(r => r.deploymentState === 'local-only').length; + const pendingRemoval = resources.filter(r => r.deploymentState === 'pending-removal').length; + logger.log( + `Resources: ${resources.length} total (${deployed} deployed, ${localOnly} local-only, ${pendingRemoval} pending-removal)` + ); + for (const entry of resources) { + logger.log( + ` ${entry.resourceType}/${entry.name}: ${entry.deploymentState}${entry.identifier ? ` [${entry.identifier}]` : ''}` + ); } + logger.endStep('success'); + + // Enrich deployed agents with live runtime status (parallel, entries replaced by index) + if (targetConfig) { + const agentStates = targetResources?.agents ?? {}; + const deployedAgents = resources.filter( + (e, _i) => e.resourceType === 'agent' && e.deploymentState === 'deployed' && agentStates[e.name] + ); + + if (deployedAgents.length > 0) { + logger.startStep( + `Fetch runtime status (${deployedAgents.length} agent${deployedAgents.length !== 1 ? 's' : ''})` + ); + + await Promise.all( + resources.map(async (entry, i) => { + if (entry.resourceType !== 'agent' || entry.deploymentState !== 'deployed') return; + + const agentState = agentStates[entry.name]; + if (!agentState) return; + + try { + const runtimeStatus = await getAgentRuntimeStatus({ + region: targetConfig.region, + runtimeId: agentState.runtimeId, + }); + resources[i] = { ...entry, detail: runtimeStatus.status }; + logger.log(` ${entry.name}: ${runtimeStatus.status} (${agentState.runtimeId})`); + } catch (error) { + const errorMsg = getErrorMessage(error); + resources[i] = { ...entry, error: errorMsg }; + logger.log(` ${entry.name}: ERROR - ${errorMsg}`, 'error'); + } + }) + ); + + const hasErrors = resources.some(r => r.error); + logger.endStep(hasErrors ? 'error' : 'success'); + } + } + + logger.finalize(true); + return { + success: true, + projectName: project.name, + targetName: selectedTargetName ?? '', + targetRegion: targetConfig?.region, + resources, + logPath: logger.getRelativeLogPath(), + }; } -/** - * Status handler for all agents in a target. - */ -export async function handleStatusAll( +export async function handleRuntimeLookup( context: StatusContext, - options: StatusOptions = {} -): Promise { - const { project, deployedState, awsTargets } = context; + options: { agentRuntimeId: string; targetName?: string } +): Promise { + const logger = new ExecLogger({ command: 'status' }); + const { awsTargets } = context; - const targetNames = Object.keys(deployedState.targets); + logger.startStep('Resolve target'); + const targetNames = awsTargets.map(target => target.name); if (targetNames.length === 0) { - return { success: false, error: 'No deployed targets found. Run `agentcore deploy` first.' }; + const error = 'No deployment targets found. Run `agentcore create` first.'; + logger.endStep('error', error); + logger.finalize(false); + return { success: false, error, logPath: logger.getRelativeLogPath() }; } 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(', ')}` }; + const error = `Target '${options.targetName}' not found. Available: ${targetNames.join(', ')}`; + logger.endStep('error', error); + logger.finalize(false); + return { success: false, error, logPath: logger.getRelativeLogPath() }; } - const targetState = deployedState.targets[selectedTargetName]; const targetConfig = awsTargets.find(target => target.name === selectedTargetName); if (!targetConfig) { - return { success: false, error: `Target config '${selectedTargetName}' not found in aws-targets` }; + const error = `Target config '${selectedTargetName}' not found in aws-targets`; + logger.endStep('error', error); + logger.finalize(false); + return { success: false, error, logPath: logger.getRelativeLogPath() }; } - if (project.agents.length === 0) { - return { success: false, error: 'No agents defined in configuration' }; - } + logger.log(`Target: ${selectedTargetName} (${targetConfig.region})`); + logger.endStep('success'); - const entries = await Promise.all( - project.agents.map(async agentSpec => { - const agentState = targetState?.resources?.agents?.[agentSpec.name]; - - if (!agentState) { - return { - agentName: agentSpec.name, - isDeployed: false, - }; - } - - try { - const runtimeStatus = await getAgentRuntimeStatus({ - region: targetConfig.region, - runtimeId: agentState.runtimeId, - }); - - return { - agentName: agentSpec.name, - isDeployed: true, - runtimeId: runtimeStatus.runtimeId, - runtimeStatus: runtimeStatus.status, - }; - } catch (error) { - return { - agentName: agentSpec.name, - isDeployed: true, - runtimeId: agentState.runtimeId, - error: getErrorMessage(error), - }; - } - }) - ); + logger.startStep(`Lookup runtime ${options.agentRuntimeId}`); + try { + const runtimeStatus = await getAgentRuntimeStatus({ + region: targetConfig.region, + runtimeId: options.agentRuntimeId, + }); - return { - success: true, - targetName: selectedTargetName, - entries, - }; + logger.log(`Runtime: ${runtimeStatus.runtimeId} — ${runtimeStatus.status}`); + logger.endStep('success'); + logger.finalize(true); + + return { + success: true, + targetName: selectedTargetName, + runtimeId: runtimeStatus.runtimeId, + runtimeStatus: runtimeStatus.status, + logPath: logger.getRelativeLogPath(), + }; + } catch (error) { + const errorMsg = getErrorMessage(error); + logger.endStep('error', errorMsg); + logger.finalize(false); + return { success: false, error: errorMsg, logPath: logger.getRelativeLogPath() }; + } } diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index a9047dab..09279fd6 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -1,79 +1,177 @@ import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; -import { handleStatus, handleStatusAll, loadStatusConfig } from './action'; +import type { ResourceStatusEntry } from './action'; +import { handleProjectStatus, handleRuntimeLookup, loadStatusConfig } from './action'; +import { DEPLOYMENT_STATE_COLORS, DEPLOYMENT_STATE_LABELS } from './constants'; import type { Command } from '@commander-js/extra-typings'; import { Box, Text, render } from 'ink'; +const VALID_RESOURCE_TYPES = ['agent', 'memory', 'credential', 'gateway'] as const; +const VALID_STATES = ['deployed', 'local-only', 'pending-removal'] as const; + +interface StatusCliOptions { + agentRuntimeId?: string; + target?: string; + type?: string; + state?: string; + agent?: string; + json?: boolean; +} + +function filterResources( + resources: ResourceStatusEntry[], + options: { type?: string; state?: string; agent?: string } +): ResourceStatusEntry[] { + let filtered = resources; + + if (options.type) { + filtered = filtered.filter(r => r.resourceType === options.type); + } + + if (options.state) { + filtered = filtered.filter(r => r.deploymentState === options.state); + } + + if (options.agent) { + filtered = filtered.filter(r => r.resourceType !== 'agent' || r.name === options.agent); + } + + return filtered; +} + export const registerStatus = (program: Command) => { program .command('status') .alias('s') .description(COMMAND_DESCRIPTIONS.status) - .option('--agent ', 'Select specific agent') - .option('--agent-runtime-id ', 'Select specific agent runtime ID') + .option('--agent-runtime-id ', 'Look up a specific agent runtime by ID') .option('--target ', 'Select deployment target') - .action(async (cliOptions: { agent?: string; agentRuntimeId?: string; target?: string }) => { + .option('--type ', 'Filter by resource type (agent, memory, credential, gateway)') + .option('--state ', 'Filter by deployment state (deployed, local-only, pending-removal)') + .option('--agent ', 'Filter to a specific agent') + .option('--json', 'Output as JSON') + .action(async (cliOptions: StatusCliOptions) => { requireProject(); + // Validate --type + if (cliOptions.type && !(VALID_RESOURCE_TYPES as readonly string[]).includes(cliOptions.type)) { + render( + + Invalid resource type '{cliOptions.type}'. Valid types: {VALID_RESOURCE_TYPES.join(', ')} + + ); + return; + } + + // Validate --state + if (cliOptions.state && !(VALID_STATES as readonly string[]).includes(cliOptions.state)) { + render( + + Invalid state '{cliOptions.state}'. Valid states: {VALID_STATES.join(', ')} + + ); + return; + } + try { const context = await loadStatusConfig(); - if (!cliOptions.agent && !cliOptions.agentRuntimeId) { - const summary = await handleStatusAll(context, { + + // Direct runtime lookup by ID + if (cliOptions.agentRuntimeId) { + const result = await handleRuntimeLookup(context, { + agentRuntimeId: cliOptions.agentRuntimeId, targetName: cliOptions.target, }); - if (!summary.success || !summary.entries) { - render({summary.error}); + if (cliOptions.json) { + console.log(JSON.stringify(result, null, 2)); return; } + if (!result.success) { + render({result.error}); + return; + } + + const runtimeStatus = result.runtimeStatus ? `Runtime status: ${result.runtimeStatus}` : ''; + render( - - AgentCore Status (target: {summary.targetName}) - {summary.entries.map(entry => { - const deploymentStatus = entry.isDeployed ? 'Deployed' : 'Not deployed'; - const runtimeStatus = entry.runtimeStatus ? `Runtime status: ${entry.runtimeStatus}` : undefined; - const runtimeError = entry.error ? `Runtime error: ${entry.error}` : undefined; - const details = [deploymentStatus, runtimeStatus, runtimeError].filter(Boolean).join(' - '); - return ( - - {entry.agentName}: {details} - - ); - })} - + + AgentCore Status - {result.runtimeId} (target: {result.targetName}) + {runtimeStatus ? ` - ${runtimeStatus}` : ''} + ); - return; } - const result = await handleStatus(context, { - agentName: cliOptions.agent, - agentRuntimeId: cliOptions.agentRuntimeId, + // Default path: show all resource types with deployment state + const result = await handleProjectStatus(context, { targetName: cliOptions.target, }); + if (cliOptions.json) { + const filtered = filterResources(result.resources, cliOptions); + console.log(JSON.stringify({ ...result, resources: filtered }, null, 2)); + return; + } + if (!result.success) { render({result.error}); return; } - const subject = result.agentName ?? result.runtimeId ?? 'Agent'; + const filtered = filterResources(result.resources, cliOptions); + const agents = filtered.filter(r => r.resourceType === 'agent'); + const credentials = filtered.filter(r => r.resourceType === 'credential'); + const memories = filtered.filter(r => r.resourceType === 'memory'); + const gateways = filtered.filter(r => r.resourceType === 'gateway'); - function getDeploymentStatus(isDeployed: boolean | undefined): string | undefined { - if (isDeployed === undefined) return undefined; - return isDeployed ? 'Deployed' : 'Not deployed'; - } + render( + + + AgentCore Status (target: {result.targetName} + {result.targetRegion ? `, ${result.targetRegion}` : ''}) + - const deploymentStatus = getDeploymentStatus(result.isDeployed); - const runtimeStatus = result.runtimeStatus ? `Runtime status: ${result.runtimeStatus}` : undefined; - const details = [deploymentStatus, runtimeStatus].filter(Boolean).join(' - '); + {agents.length > 0 && ( + + Agents + {agents.map(entry => ( + + ))} + + )} - render( - - AgentCore Status - {subject} (target: {result.targetName}){details ? ` - ${details}` : ''} - + {memories.length > 0 && ( + + Memories + {memories.map(entry => ( + + ))} + + )} + + {credentials.length > 0 && ( + + Credentials + {credentials.map(entry => ( + + ))} + + )} + + {gateways.length > 0 && ( + + Gateways + {gateways.map(entry => ( + + ))} + + )} + + {filtered.length === 0 && No resources match the given filters.} + ); } catch (error) { render(Error: {getErrorMessage(error)}); @@ -81,3 +179,19 @@ export const registerStatus = (program: Command) => { } }); }; + +function ResourceEntry({ entry, showRuntime }: { entry: ResourceStatusEntry; showRuntime?: boolean }) { + return ( + + {' '} + {entry.name}:{' '} + + {DEPLOYMENT_STATE_LABELS[entry.deploymentState] ?? entry.deploymentState} + + {entry.detail && + (showRuntime ? - Runtime: {entry.detail} : ({entry.detail}))} + {entry.identifier && ({entry.identifier})} + {entry.error && - Error: {entry.error}} + + ); +} diff --git a/src/cli/commands/status/constants.ts b/src/cli/commands/status/constants.ts new file mode 100644 index 00000000..6dfbc08d --- /dev/null +++ b/src/cli/commands/status/constants.ts @@ -0,0 +1,15 @@ +import { STATUS_COLORS } from '../../tui/theme'; + +export type ResourceDeploymentState = 'deployed' | 'local-only' | 'pending-removal'; + +export const DEPLOYMENT_STATE_COLORS: Record = { + deployed: STATUS_COLORS.success, + 'local-only': STATUS_COLORS.warning, + 'pending-removal': STATUS_COLORS.error, +}; + +export const DEPLOYMENT_STATE_LABELS: Record = { + deployed: 'Deployed', + 'local-only': 'Local only', + 'pending-removal': 'Removed locally', +}; diff --git a/src/cli/commands/status/index.ts b/src/cli/commands/status/index.ts index fe6f184a..8eed3446 100644 --- a/src/cli/commands/status/index.ts +++ b/src/cli/commands/status/index.ts @@ -1,2 +1,10 @@ export { registerStatus } from './command'; -export { handleStatus, loadStatusConfig, type StatusContext, type StatusResult } from './action'; +export { + handleProjectStatus, + handleRuntimeLookup, + loadStatusConfig, + type StatusContext, + type ProjectStatusResult, + type ResourceStatusEntry, + type ResourceDeploymentState, +} from './action'; diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index 4fe99a85..816a96cc 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -1,12 +1,13 @@ import type { - AgentCoreDeployedState, AgentCoreGatewayTarget, AgentCoreMcpRuntimeTool, AgentCoreMcpSpec, AgentCoreProjectSpec, } from '../../../schema'; +import type { ResourceStatusEntry } from '../../commands/status/action'; +import { DEPLOYMENT_STATE_COLORS, DEPLOYMENT_STATE_LABELS } from '../../commands/status/constants'; import { Box, Text } from 'ink'; -import React from 'react'; +import React, { useMemo } from 'react'; const ICONS = { agent: '●', @@ -17,17 +18,11 @@ const ICONS = { runtime: '▶', } as const; -export interface AgentStatusInfo { - runtimeStatus?: string; - error?: string; -} - interface ResourceGraphProps { project: AgentCoreProjectSpec; mcp?: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] }; agentName?: string; - agentStatuses?: Record; - deployedAgents?: Record; + resourceStatuses?: ResourceStatusEntry[]; } function getStatusColor(status?: string): string { @@ -47,6 +42,15 @@ function getStatusColor(status?: string): string { } } +function getDeploymentBadge( + state: ResourceStatusEntry['deploymentState'] +): { text: string; color: string } | undefined { + if (state === 'pending-removal') return undefined; + const label = DEPLOYMENT_STATE_LABELS[state]; + const color = DEPLOYMENT_STATE_COLORS[state]; + return label && color ? { text: label, color } : undefined; +} + function SectionHeader({ children }: { children: string }) { return ( @@ -62,6 +66,8 @@ function ResourceRow({ detail, status, statusColor, + deploymentState, + identifier, }: { icon: string; color: string; @@ -69,24 +75,30 @@ function ResourceRow({ detail?: string; status?: string; statusColor?: string; + deploymentState?: ResourceStatusEntry['deploymentState']; + identifier?: string; }) { + const badge = deploymentState ? getDeploymentBadge(deploymentState) : undefined; + return ( - - {' '} - {icon} {name} - {detail && {detail}} - {status && [{status}]} - + + + {' '} + {icon} {name} + {detail && {detail}} + {status && [{status}]} + {badge && [{badge.text}]} + + {identifier && ( + + {' '}ID: {identifier} + + )} + ); } -export function ResourceGraph({ - project, - mcp, - agentName, - agentStatuses, - deployedAgents: _deployedAgents, -}: ResourceGraphProps) { +export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: ResourceGraphProps) { const allAgents = project.agents ?? []; const agents = agentName ? allAgents.filter(a => a.name === agentName) : allAgents; const memories = project.memories ?? []; @@ -95,37 +107,59 @@ export function ResourceGraph({ const mcpRuntimeTools = mcp?.mcpRuntimeTools ?? []; const unassignedTargets = mcp?.unassignedTargets ?? []; + // Build lookup map and collect pending-removal resources in a single pass + const { statusMap, pendingRemovals } = useMemo(() => { + const map = new Map(); + const pending: ResourceStatusEntry[] = []; + + if (resourceStatuses) { + for (const entry of resourceStatuses) { + map.set(`${entry.resourceType}:${entry.name}`, entry); + if (entry.deploymentState === 'pending-removal') { + pending.push(entry); + } + } + } + + return { statusMap: map, pendingRemovals: pending }; + }, [resourceStatuses]); + const hasContent = agents.length > 0 || memories.length > 0 || credentials.length > 0 || gateways.length > 0 || mcpRuntimeTools.length > 0 || - unassignedTargets.length > 0; + unassignedTargets.length > 0 || + pendingRemovals.length > 0; return ( - {/* Project name */} - - {project.name} - + {/* Project name — only when not embedded in a screen with its own header */} + {!resourceStatuses && ( + + {project.name} + + )} {/* Agents */} {agents.length > 0 && ( Agents {agents.map(agent => { - const statusInfo = agentStatuses?.[agent.name]; - const status = statusInfo?.error ? 'error' : statusInfo?.runtimeStatus; - const color = statusInfo?.error ? 'red' : getStatusColor(status); + const rsEntry = statusMap.get(`agent:${agent.name}`); + const runtimeStatus = rsEntry?.error ? 'error' : rsEntry?.detail; + const runtimeStatusColor = rsEntry?.error ? 'red' : getStatusColor(runtimeStatus); return ( ); })} @@ -138,8 +172,17 @@ export function ResourceGraph({ Memories {memories.map(memory => { const strategies = memory.strategies.map(s => s.type).join(', '); + const rsEntry = statusMap.get(`memory:${memory.name}`); return ( - + ); })} @@ -149,13 +192,35 @@ export function ResourceGraph({ {credentials.length > 0 && ( Credentials - {credentials.map(credential => ( + {credentials.map(credential => { + const rsEntry = statusMap.get(`credential:${credential.name}`); + return ( + + ); + })} + + )} + + {/* Removed locally — still deployed in AWS, will be torn down on next deploy */} + {pendingRemovals.length > 0 && ( + + Removed Locally + Still deployed — run `deploy` to tear down + {pendingRemovals.map(entry => ( ))} @@ -167,14 +232,16 @@ export function ResourceGraph({ Gateways {gateways.map(gateway => { const targets = gateway.targets ?? []; - const tools = targets.flatMap(t => t.toolDefinitions ?? []); + const rsEntry = statusMap.get(`gateway:${gateway.name}`); return ( 0 ? `${tools.length} tools` : undefined} + detail={rsEntry?.detail} + deploymentState={rsEntry?.deploymentState} + identifier={rsEntry?.identifier} /> {targets.map(target => { const displayText = @@ -233,8 +300,20 @@ export function ResourceGraph({ {ICONS.agent} agent{' '} {ICONS.memory} memory{' '} - {ICONS.credential} credential + {ICONS.credential} credential{' '} + {ICONS.gateway} gateway + {resourceStatuses && resourceStatuses.length > 0 && ( + + + [Deployed] + live in AWS + {' '} + [Local only] + not yet deployed + + + )} ); diff --git a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx index 358e1386..ec2a8e6a 100644 --- a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx +++ b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx @@ -1,4 +1,5 @@ import type { AgentCoreMcpSpec, AgentCoreProjectSpec } from '../../../../schema/index.js'; +import type { ResourceStatusEntry } from '../../../commands/status/action.js'; import { ResourceGraph } from '../ResourceGraph.js'; import { render } from 'ink-testing-library'; import React from 'react'; @@ -74,33 +75,37 @@ describe('ResourceGraph', () => { expect(lastFrame()).not.toContain('agent-b'); }); - it('renders agent status when provided', () => { + it('renders agent runtime status from resourceStatuses', () => { const project = { ...baseProject, agents: [{ name: 'my-agent' }], } as unknown as AgentCoreProjectSpec; - const { lastFrame } = render( - - ); + const resourceStatuses: ResourceStatusEntry[] = [ + { resourceType: 'agent', name: 'my-agent', deploymentState: 'deployed', detail: 'READY' }, + ]; + + const { lastFrame } = render(); expect(lastFrame()).toContain('READY'); }); - it('renders agent error status', () => { + it('renders agent error status from resourceStatuses', () => { const project = { ...baseProject, agents: [{ name: 'my-agent' }], } as unknown as AgentCoreProjectSpec; - const { lastFrame } = render( - - ); + const resourceStatuses: ResourceStatusEntry[] = [ + { resourceType: 'agent', name: 'my-agent', deploymentState: 'deployed', error: 'timeout' }, + ]; + + const { lastFrame } = render(); expect(lastFrame()).toContain('error'); }); - it('renders MCP gateways with tool counts', () => { + it('renders MCP gateways with targets', () => { const mcp: AgentCoreMcpSpec = { agentCoreGateways: [ { @@ -114,10 +119,37 @@ describe('ResourceGraph', () => { expect(lastFrame()).toContain('Gateways'); expect(lastFrame()).toContain('my-gateway'); - expect(lastFrame()).toContain('2 tools'); expect(lastFrame()).toContain('target-a'); }); + it('renders MCP gateway with deployment badge when resourceStatuses provided', () => { + const mcp: AgentCoreMcpSpec = { + agentCoreGateways: [ + { + name: 'my-gateway', + targets: [{ name: 'target-a' }], + }, + ], + } as unknown as AgentCoreMcpSpec; + + const resourceStatuses: ResourceStatusEntry[] = [ + { + resourceType: 'gateway', + name: 'my-gateway', + deploymentState: 'deployed', + detail: '1 target', + identifier: 'gw-123', + }, + ]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('my-gateway'); + expect(lastFrame()).toContain('1 target'); + expect(lastFrame()).toContain('[Deployed]'); + expect(lastFrame()).toContain('ID: gw-123'); + }); + it('renders MCP runtime tools', () => { const mcp: AgentCoreMcpSpec = { agentCoreGateways: [], @@ -175,4 +207,122 @@ describe('ResourceGraph', () => { expect(lastFrame()).not.toContain('⚠ Unassigned Targets'); }); + + describe('deployment state badges', () => { + it('renders Deployed badge for deployed agents', () => { + const project = { + ...baseProject, + agents: [{ name: 'my-agent' }], + } as unknown as AgentCoreProjectSpec; + + const resourceStatuses: ResourceStatusEntry[] = [ + { + resourceType: 'agent', + name: 'my-agent', + deploymentState: 'deployed', + identifier: 'arn:aws:bedrock:us-east-1:123456789:agent-runtime/rt-123', + }, + ]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('[Deployed]'); + expect(lastFrame()).toContain('ID: arn:aws:bedrock:us-east-1:123456789:agent-runtime/rt-123'); + }); + + it('renders Local only badge for local-only resources', () => { + const project = { + ...baseProject, + agents: [{ name: 'my-agent' }], + } as unknown as AgentCoreProjectSpec; + + const resourceStatuses: ResourceStatusEntry[] = [ + { resourceType: 'agent', name: 'my-agent', deploymentState: 'local-only' }, + ]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('[Local only]'); + }); + + it('renders Removed Locally section for resources removed from config', () => { + const resourceStatuses: ResourceStatusEntry[] = [ + { + resourceType: 'agent', + name: 'removed-agent', + deploymentState: 'pending-removal', + identifier: 'arn:aws:removed', + }, + ]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Removed Locally'); + expect(lastFrame()).toContain('removed-agent'); + expect(lastFrame()).toContain('deploy'); + }); + + it('renders deployment state legend when resourceStatuses provided', () => { + const project = { + ...baseProject, + agents: [{ name: 'my-agent' }], + } as unknown as AgentCoreProjectSpec; + + const resourceStatuses: ResourceStatusEntry[] = [ + { resourceType: 'agent', name: 'my-agent', deploymentState: 'deployed' }, + ]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('[Deployed]'); + expect(lastFrame()).toContain('live in AWS'); + expect(lastFrame()).toContain('[Local only]'); + expect(lastFrame()).toContain('not yet deployed'); + }); + + it('does not render deployment state legend when no resourceStatuses', () => { + const project = { + ...baseProject, + agents: [{ name: 'my-agent' }], + } as unknown as AgentCoreProjectSpec; + + const { lastFrame } = render(); + + // Should have the base legend but not the deployment state legend + expect(lastFrame()).toContain('agent'); + expect(lastFrame()).not.toContain('[Deployed]'); + }); + + it('renders removed credentials in Removed Locally section', () => { + const resourceStatuses: ResourceStatusEntry[] = [ + { + resourceType: 'credential', + name: 'old-cred', + deploymentState: 'pending-removal', + identifier: 'arn:aws:cred', + }, + ]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Removed Locally'); + expect(lastFrame()).toContain('old-cred'); + }); + + it('renders deployment badges on memory resources', () => { + const project = { + ...baseProject, + memories: [{ name: 'my-memory', strategies: [{ type: 'SEMANTIC' }] }], + } as unknown as AgentCoreProjectSpec; + + const resourceStatuses: ResourceStatusEntry[] = [ + { resourceType: 'memory', name: 'my-memory', deploymentState: 'deployed' }, + ]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('my-memory'); + expect(lastFrame()).toContain('[Deployed]'); + }); + }); }); diff --git a/src/cli/tui/components/index.ts b/src/cli/tui/components/index.ts index c07946aa..a2c5aefb 100644 --- a/src/cli/tui/components/index.ts +++ b/src/cli/tui/components/index.ts @@ -24,7 +24,7 @@ export { TextInput } from './TextInput'; export { TwoColumn } from './TwoColumn'; export { WizardSelect, WizardMultiSelect } from './WizardSelect'; export { AwsTargetConfigUI, getAwsConfigHelpText } from './AwsTargetConfigUI'; -export { ResourceGraph, type AgentStatusInfo } from './ResourceGraph'; +export { ResourceGraph } from './ResourceGraph'; export { LogLink } from './LogLink'; export { ScrollableText } from './ScrollableText'; export { DiffSummaryView, parseStackDiff, parseDiffResult, type StackDiffSummary } from './DiffSummaryView'; diff --git a/src/cli/tui/screens/status/StatusScreen.tsx b/src/cli/tui/screens/status/StatusScreen.tsx index b746fec8..db0f78c1 100644 --- a/src/cli/tui/screens/status/StatusScreen.tsx +++ b/src/cli/tui/screens/status/StatusScreen.tsx @@ -1,7 +1,7 @@ -import { type AgentStatusInfo, ResourceGraph, Screen } from '../../components'; +import { ResourceGraph, Screen } from '../../components'; import { useStatusFlow } from './useStatusFlow'; import { Box, Text, useInput } from 'ink'; -import React, { useMemo } from 'react'; +import React from 'react'; interface StatusScreenProps { /** Whether running in interactive TUI mode (from App.tsx) vs CLI mode */ @@ -18,26 +18,14 @@ export function StatusScreen({ isInteractive: _isInteractive, onExit }: StatusSc targetName, targetRegion, hasMultipleTargets, - allStatuses, + mcpSpec, + resourceStatuses, statusesLoading, statusesError, - deployedResources, cycleTarget, refreshStatuses, } = useStatusFlow(); - // Convert allStatuses to AgentStatusInfo format for ResourceGraph - const graphStatuses = useMemo>(() => { - const result: Record = {}; - for (const [agentName, entry] of Object.entries(allStatuses)) { - result[agentName] = { - runtimeStatus: entry.isDeployed ? entry.runtimeStatus : 'not deployed', - error: entry.error, - }; - } - return result; - }, [allStatuses]); - useInput( (input, key) => { if (phase !== 'ready' && phase !== 'fetching-statuses') return; @@ -104,65 +92,7 @@ export function StatusScreen({ isInteractive: _isInteractive, onExit }: StatusSc )} - {project && ( - - - - )} - - {/* Deployed MCP Resources - only show if there are MCP resources */} - {deployedResources?.mcp && - (Object.keys(deployedResources.mcp.gateways ?? {}).length > 0 || - Object.keys(deployedResources.mcp.runtimes ?? {}).length > 0 || - Object.keys(deployedResources.mcp.lambdas ?? {}).length > 0) && ( - - - ─ Deployed MCP Resources ─ - - - {/* MCP Gateways */} - {deployedResources.mcp.gateways && Object.keys(deployedResources.mcp.gateways).length > 0 && ( - - {Object.entries(deployedResources.mcp.gateways).map(([name, state]) => ( - - - ◆ {name} - {state.gatewayId} - - - ))} - - )} - - {/* MCP Runtimes */} - {deployedResources.mcp.runtimes && Object.keys(deployedResources.mcp.runtimes).length > 0 && ( - - {Object.entries(deployedResources.mcp.runtimes).map(([name, state]) => ( - - - ▶ {name} - {state.runtimeId} - - - ))} - - )} - - {/* MCP Lambdas */} - {deployedResources.mcp.lambdas && Object.keys(deployedResources.mcp.lambdas).length > 0 && ( - - {Object.entries(deployedResources.mcp.lambdas).map(([name, state]) => ( - - - λ {name} - {state.functionName} - - - ))} - - )} - - )} + {project && } ); } diff --git a/src/cli/tui/screens/status/useStatusFlow.ts b/src/cli/tui/screens/status/useStatusFlow.ts index 451fb5c7..7b315d29 100644 --- a/src/cli/tui/screens/status/useStatusFlow.ts +++ b/src/cli/tui/screens/status/useStatusFlow.ts @@ -1,30 +1,20 @@ -import type { - AgentCoreProjectSpec, - AwsDeploymentTargets, - DeployedResourceState, - DeployedState, -} from '../../../../schema'; -import type { StatusContext, StatusEntry } from '../../../commands/status/action'; -import { handleStatusAll, loadStatusConfig } from '../../../commands/status/action'; +import type { AgentCoreMcpSpec, AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState } from '../../../../schema'; +import type { ResourceStatusEntry, StatusContext } from '../../../commands/status/action'; +import { handleProjectStatus, loadStatusConfig } from '../../../commands/status/action'; import { getErrorMessage } from '../../../errors'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; type StatusPhase = 'loading' | 'ready' | 'fetching-statuses' | 'error'; -interface AgentEntry { - name: string; - isDeployed: boolean; - runtimeId?: string; -} - interface StatusState { phase: StatusPhase; error?: string; project?: AgentCoreProjectSpec; deployedState?: DeployedState; awsTargets?: AwsDeploymentTargets; + mcpSpec?: AgentCoreMcpSpec; targetIndex: number; - allStatuses: Record; + resourceStatuses: ResourceStatusEntry[]; statusesLoaded: boolean; statusesError?: string; } @@ -33,10 +23,13 @@ export function useStatusFlow() { const [state, setState] = useState({ phase: 'loading', targetIndex: 0, - allStatuses: {}, + resourceStatuses: [], statusesLoaded: false, }); + // Track the latest fetch so stale responses are ignored + const fetchIdRef = useRef(0); + // Initial load of project config and deployed state useEffect(() => { let active = true; @@ -44,28 +37,13 @@ export function useStatusFlow() { .then(context => { if (!active) return; - // Validate before setting ready - if (!context.project.agents.length) { - setState(prev => ({ ...prev, phase: 'error', error: 'No agents defined in configuration.' })); - return; - } - - const deployedTargets = Object.keys(context.deployedState.targets); - if (deployedTargets.length === 0) { - setState(prev => ({ - ...prev, - phase: 'error', - error: 'No deployed targets found. Run `agentcore deploy` first.', - })); - return; - } - setState(prev => ({ ...prev, phase: 'ready', project: context.project, deployedState: context.deployedState, awsTargets: context.awsTargets, + mcpSpec: context.mcpSpec, })); }) .catch((error: Error) => { @@ -84,13 +62,16 @@ export function useStatusFlow() { project: state.project, deployedState: state.deployedState, awsTargets: state.awsTargets, + mcpSpec: state.mcpSpec, }; - }, [state.awsTargets, state.deployedState, state.project]); + }, [state.awsTargets, state.deployedState, state.mcpSpec, state.project]); + // Derive target names — fall back to awsTargets when deployedState is empty const targetNames = useMemo(() => { - if (!state.deployedState) return []; - return Object.keys(state.deployedState.targets); - }, [state.deployedState]); + const deployedTargetNames = state.deployedState ? Object.keys(state.deployedState.targets) : []; + if (deployedTargetNames.length > 0) return deployedTargetNames; + return state.awsTargets?.map(t => t.name) ?? []; + }, [state.deployedState, state.awsTargets]); const targetName = targetNames[state.targetIndex]; @@ -99,28 +80,11 @@ export function useStatusFlow() { return state.awsTargets.find(target => target.name === targetName); }, [state.awsTargets, targetName]); - const agents = useMemo(() => { - if (!state.project) return []; - const deployedAgents = state.deployedState?.targets?.[targetName ?? '']?.resources?.agents; - return state.project.agents.map(agent => { - const agentState = deployedAgents?.[agent.name]; - return { - name: agent.name, - isDeployed: !!agentState, - runtimeId: agentState?.runtimeId, - }; - }); - }, [state.deployedState, state.project, targetName]); - - // Get deployed resources for current target - const deployedResources = useMemo(() => { - if (!state.deployedState || !targetName) return undefined; - return state.deployedState.targets[targetName]?.resources; - }, [state.deployedState, targetName]); - - // Fetch all statuses when ready and target changes - const fetchAllStatuses = useCallback(async () => { - if (!context || !targetName) return; + // Fetch project status with cancellation via fetch ID + const fetchProjectStatus = useCallback(async () => { + if (!context) return; + + const currentFetchId = ++fetchIdRef.current; setState(prev => ({ ...prev, @@ -129,7 +93,9 @@ export function useStatusFlow() { })); try { - const result = await handleStatusAll(context, { targetName }); + const result = await handleProjectStatus(context, { targetName }); + + if (fetchIdRef.current !== currentFetchId) return; if (!result.success) { setState(prev => ({ @@ -141,20 +107,16 @@ export function useStatusFlow() { return; } - // Convert entries array to record keyed by agent name - const statusMap: Record = {}; - for (const entry of result.entries ?? []) { - statusMap[entry.agentName] = entry; - } - setState(prev => ({ ...prev, phase: 'ready', - allStatuses: statusMap, + resourceStatuses: result.resources, statusesLoaded: true, statusesError: undefined, })); } catch (error) { + if (fetchIdRef.current !== currentFetchId) return; + setState(prev => ({ ...prev, phase: 'ready', @@ -167,13 +129,12 @@ export function useStatusFlow() { // Fetch statuses when ready and target changes useEffect(() => { if (state.phase === 'ready' && context && !state.statusesLoaded) { - void fetchAllStatuses(); + void fetchProjectStatus(); } - }, [state.phase, context, state.statusesLoaded, fetchAllStatuses]); + }, [state.phase, context, state.statusesLoaded, fetchProjectStatus]); - // Refresh statuses function const refreshStatuses = useCallback(() => { - if (state.phase !== 'ready' && state.phase !== 'fetching-statuses') return; + if (state.phase !== 'ready') return; setState(prev => ({ ...prev, statusesLoaded: false })); }, [state.phase]); @@ -182,7 +143,7 @@ export function useStatusFlow() { setState(prev => ({ ...prev, targetIndex: (prev.targetIndex + 1) % targetNames.length, - allStatuses: {}, + resourceStatuses: [], statusesLoaded: false, statusesError: undefined, })); @@ -193,14 +154,13 @@ export function useStatusFlow() { error: state.error, project: state.project, projectName: state.project?.name ?? 'Unknown', - targetName: targetName ?? 'Unknown', + targetName: targetName ?? 'No target configured', targetRegion: targetConfig?.region, - agents, hasMultipleTargets: targetNames.length > 1, - allStatuses: state.allStatuses, + mcpSpec: state.mcpSpec, + resourceStatuses: state.resourceStatuses, statusesLoading: state.phase === 'fetching-statuses', statusesError: state.statusesError, - deployedResources, cycleTarget, refreshStatuses, }; diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 7f4913d1..9741e69d 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -112,7 +112,7 @@ export type ExternallyManagedState = z.infer