From a68d1c5448700ebee8ae3667717f22b0d9ab90ff Mon Sep 17 00:00:00 2001 From: notgitika Date: Mon, 2 Mar 2026 20:12:09 -0500 Subject: [PATCH] feat: support individual memory deployment without agents --- .../__tests__/outputs-extended.test.ts | 32 +++- .../cloudformation/__tests__/outputs.test.ts | 140 +++++++++++++++--- src/cli/cloudformation/outputs.ts | 57 +++++-- .../commands/deploy/__tests__/deploy.test.ts | 4 +- src/cli/commands/deploy/actions.ts | 26 +++- .../deploy/__tests__/preflight.test.ts | 20 ++- src/cli/operations/deploy/preflight.ts | 5 +- src/cli/tui/screens/add/AddFlow.tsx | 10 +- src/cli/tui/screens/add/AddScreen.tsx | 10 +- .../screens/add/__tests__/AddScreen.test.tsx | 3 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 60 ++++---- .../schemas/__tests__/deployed-state.test.ts | 37 +++++ src/schema/schemas/deployed-state.ts | 12 ++ 13 files changed, 326 insertions(+), 90 deletions(-) diff --git a/src/cli/cloudformation/__tests__/outputs-extended.test.ts b/src/cli/cloudformation/__tests__/outputs-extended.test.ts index be2672da..85aab1c8 100644 --- a/src/cli/cloudformation/__tests__/outputs-extended.test.ts +++ b/src/cli/cloudformation/__tests__/outputs-extended.test.ts @@ -157,7 +157,7 @@ describe('buildDeployedState', () => { }, }; - const state = buildDeployedState('default', 'MyStack', agents, {}); + const state = buildDeployedState({ targetName: 'default', stackName: 'MyStack', agents, gateways: {} }); expect(state.targets.default).toBeDefined(); expect(state.targets.default!.resources?.agents).toEqual(agents); expect(state.targets.default!.resources?.stackName).toBe('MyStack'); @@ -181,7 +181,13 @@ describe('buildDeployedState', () => { DevAgent: { runtimeId: 'rt-d', runtimeArn: 'arn:rt-d', roleArn: 'arn:role-d' }, }; - const state = buildDeployedState('dev', 'DevStack', devAgents, {}, existing); + const state = buildDeployedState({ + targetName: 'dev', + stackName: 'DevStack', + agents: devAgents, + gateways: {}, + existingState: existing, + }); expect(state.targets.prod).toBeDefined(); expect(state.targets.dev).toBeDefined(); expect(state.targets.prod!.resources?.stackName).toBe('ProdStack'); @@ -197,22 +203,34 @@ describe('buildDeployedState', () => { }, }; - const state = buildDeployedState('default', 'NewStack', {}, {}, existing); + const state = buildDeployedState({ + targetName: 'default', + stackName: 'NewStack', + agents: {}, + gateways: {}, + existingState: existing, + }); expect(state.targets.default!.resources?.stackName).toBe('NewStack'); }); it('includes identityKmsKeyArn when provided', () => { - const state = buildDeployedState('default', 'Stack', {}, {}, undefined, 'arn:aws:kms:key'); + const state = buildDeployedState({ + targetName: 'default', + stackName: 'Stack', + agents: {}, + gateways: {}, + identityKmsKeyArn: 'arn:aws:kms:key', + }); expect(state.targets.default!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:key'); }); it('omits identityKmsKeyArn when undefined', () => { - const state = buildDeployedState('default', 'Stack', {}, {}); + const state = buildDeployedState({ targetName: 'default', stackName: 'Stack', agents: {}, gateways: {} }); expect(state.targets.default!.resources?.identityKmsKeyArn).toBeUndefined(); }); it('handles empty agents record', () => { - const state = buildDeployedState('default', 'Stack', {}, {}); - expect(state.targets.default!.resources?.agents).toEqual({}); + const state = buildDeployedState({ targetName: 'default', stackName: 'Stack', agents: {}, gateways: {} }); + expect(state.targets.default!.resources?.agents).toBeUndefined(); }); }); diff --git a/src/cli/cloudformation/__tests__/outputs.test.ts b/src/cli/cloudformation/__tests__/outputs.test.ts index 39745b4a..b6dfa0e5 100644 --- a/src/cli/cloudformation/__tests__/outputs.test.ts +++ b/src/cli/cloudformation/__tests__/outputs.test.ts @@ -1,4 +1,4 @@ -import { buildDeployedState, parseGatewayOutputs } from '../outputs'; +import { buildDeployedState, parseGatewayOutputs, parseMemoryOutputs } from '../outputs'; import { describe, expect, it } from 'vitest'; describe('buildDeployedState', () => { @@ -11,14 +11,13 @@ describe('buildDeployedState', () => { }, }; - const result = buildDeployedState( - 'default', - 'TestStack', + const result = buildDeployedState({ + targetName: 'default', + stackName: 'TestStack', agents, - {}, - undefined, - 'arn:aws:kms:us-east-1:123456789012:key/abc-123' - ); + gateways: {}, + identityKmsKeyArn: 'arn:aws:kms:us-east-1:123456789012:key/abc-123', + }); expect(result.targets.default!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:us-east-1:123456789012:key/abc-123'); }); @@ -32,7 +31,7 @@ describe('buildDeployedState', () => { }, }; - const result = buildDeployedState('default', 'TestStack', agents, {}); + const result = buildDeployedState({ targetName: 'default', stackName: 'TestStack', agents, gateways: {} }); expect(result.targets.default!.resources?.identityKmsKeyArn).toBeUndefined(); }); @@ -49,14 +48,14 @@ describe('buildDeployedState', () => { }, }; - const result = buildDeployedState( - 'dev', - 'DevStack', - {}, - {}, + const result = buildDeployedState({ + targetName: 'dev', + stackName: 'DevStack', + agents: {}, + gateways: {}, existingState, - 'arn:aws:kms:us-east-1:123456789012:key/dev-key' - ); + identityKmsKeyArn: 'arn:aws:kms:us-east-1:123456789012:key/dev-key', + }); expect(result.targets.prod!.resources?.stackName).toBe('ProdStack'); expect(result.targets.dev!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:us-east-1:123456789012:key/dev-key'); @@ -77,7 +76,13 @@ describe('buildDeployedState', () => { }, }; - const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, credentials); + const result = buildDeployedState({ + targetName: 'default', + stackName: 'TestStack', + agents, + gateways: {}, + credentials, + }); expect(result.targets.default!.resources?.credentials).toEqual(credentials); }); @@ -91,7 +96,7 @@ describe('buildDeployedState', () => { }, }; - const result = buildDeployedState('default', 'TestStack', agents, {}); + const result = buildDeployedState({ targetName: 'default', stackName: 'TestStack', agents, gateways: {} }); expect(result.targets.default!.resources?.credentials).toBeUndefined(); }); @@ -105,10 +110,53 @@ describe('buildDeployedState', () => { }, }; - const result = buildDeployedState('default', 'TestStack', agents, {}, undefined, undefined, {}); + const result = buildDeployedState({ + targetName: 'default', + stackName: 'TestStack', + agents, + gateways: {}, + credentials: {}, + }); expect(result.targets.default!.resources?.credentials).toBeUndefined(); }); + + it('includes memories in deployed state when provided', () => { + const memories = { + 'my-memory': { + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock:us-east-1:123456789012:memory/mem-123', + }, + }; + + const result = buildDeployedState({ + targetName: 'default', + stackName: 'TestStack', + agents: {}, + gateways: {}, + memories, + }); + + expect(result.targets.default!.resources?.memories).toEqual(memories); + }); + + it('omits memories field when memories is empty object', () => { + const result = buildDeployedState({ + targetName: 'default', + stackName: 'TestStack', + agents: {}, + gateways: {}, + memories: {}, + }); + + expect(result.targets.default!.resources?.memories).toBeUndefined(); + }); + + it('omits agents field when agents is empty object', () => { + const result = buildDeployedState({ targetName: 'default', stackName: 'TestStack', agents: {}, gateways: {} }); + + expect(result.targets.default!.resources?.agents).toBeUndefined(); + }); }); describe('parseGatewayOutputs', () => { @@ -183,3 +231,57 @@ describe('parseGatewayOutputs', () => { expect(result['third-gateway']?.gatewayUrl).toBe('https://third.url'); }); }); + +describe('parseMemoryOutputs', () => { + it('extracts memory outputs matching pattern', () => { + const outputs = { + ApplicationMemoryMyMemoryIdOutputABC123: 'mem-123', + ApplicationMemoryMyMemoryArnOutputDEF456: 'arn:aws:bedrock:us-east-1:123:memory/mem-123', + UnrelatedOutput: 'some-value', + }; + + const result = parseMemoryOutputs(outputs, ['my-memory']); + + expect(result).toEqual({ + 'my-memory': { + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock:us-east-1:123:memory/mem-123', + }, + }); + }); + + it('handles multiple memories', () => { + const outputs = { + ApplicationMemoryFirstMemoryIdOutput123: 'mem-1', + ApplicationMemoryFirstMemoryArnOutput123: 'arn:mem-1', + ApplicationMemorySecondMemoryIdOutput456: 'mem-2', + ApplicationMemorySecondMemoryArnOutput456: 'arn:mem-2', + }; + + const result = parseMemoryOutputs(outputs, ['first-memory', 'second-memory']); + + expect(Object.keys(result)).toHaveLength(2); + expect(result['first-memory']?.memoryId).toBe('mem-1'); + expect(result['second-memory']?.memoryId).toBe('mem-2'); + }); + + it('returns empty record when no memory outputs found', () => { + const outputs = { + UnrelatedOutput: 'some-value', + }; + + const result = parseMemoryOutputs(outputs, ['my-memory']); + + expect(result).toEqual({}); + }); + + it('skips incomplete memory outputs (missing ARN)', () => { + const outputs = { + ApplicationMemoryMyMemoryIdOutputABC123: 'mem-123', + }; + + const result = parseMemoryOutputs(outputs, ['my-memory']); + + expect(result).toEqual({}); + }); +}); diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 8f4ba433..df083c31 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -1,4 +1,4 @@ -import type { AgentCoreDeployedState, DeployedState, TargetDeployedState } from '../../schema'; +import type { AgentCoreDeployedState, DeployedState, MemoryDeployedState, TargetDeployedState } from '../../schema'; import { getCredentialProvider } from '../aws'; import { toPascalId } from './logical-ids'; import { getStackName } from './stack-discovery'; @@ -172,21 +172,56 @@ export function parseAgentOutputs( return agents; } +/** + * Parse stack outputs into deployed state for memories. + * + * Looks up outputs by constructing the expected key prefix from known memory names + * + * Output key pattern: ApplicationMemory{PascalName}(Id|Arn)Output{Hash} + */ +export function parseMemoryOutputs(outputs: StackOutputs, memoryNames: string[]): Record { + const memories: Record = {}; + const outputKeys = Object.keys(outputs); + + for (const memoryName of memoryNames) { + const pascal = toPascalId(memoryName); + const idPrefix = `ApplicationMemory${pascal}IdOutput`; + const arnPrefix = `ApplicationMemory${pascal}ArnOutput`; + + const idKey = outputKeys.find(k => k.startsWith(idPrefix)); + const arnKey = outputKeys.find(k => k.startsWith(arnPrefix)); + + if (idKey && arnKey) { + memories[memoryName] = { + memoryId: outputs[idKey]!, + memoryArn: outputs[arnKey]!, + }; + } + } + + return memories; +} + +export interface BuildDeployedStateOptions { + targetName: string; + stackName: string; + agents: Record; + gateways: Record; + existingState?: DeployedState; + identityKmsKeyArn?: string; + credentials?: Record; + memories?: Record; +} + /** * Build deployed state from stack outputs. */ -export function buildDeployedState( - targetName: string, - stackName: string, - agents: Record, - gateways: Record, - existingState?: DeployedState, - identityKmsKeyArn?: string, - credentials?: Record -): DeployedState { +export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedState { + const { targetName, stackName, agents, gateways, existingState, identityKmsKeyArn, credentials, memories } = opts; const targetState: TargetDeployedState = { resources: { - agents, + agents: Object.keys(agents).length > 0 ? agents : undefined, + memories: memories && Object.keys(memories).length > 0 ? memories : undefined, stackName, identityKmsKeyArn, }, diff --git a/src/cli/commands/deploy/__tests__/deploy.test.ts b/src/cli/commands/deploy/__tests__/deploy.test.ts index 8327f7ca..cda72b10 100644 --- a/src/cli/commands/deploy/__tests__/deploy.test.ts +++ b/src/cli/commands/deploy/__tests__/deploy.test.ts @@ -52,12 +52,12 @@ describe('deploy without agents', () => { await rm(noAgentTestDir, { recursive: true, force: true }); }); - it('rejects deploy when no agents are defined', async () => { + it('rejects deploy when no resources are defined', async () => { const result = await runCLI(['deploy', '--json'], noAgentProjectDir); expect(result.exitCode).toBe(1); const json = JSON.parse(result.stdout); expect(json.success).toBe(false); expect(json.error).toBeDefined(); - expect(json.error.toLowerCase()).toContain('no agents'); + expect(json.error.toLowerCase()).toContain('no resources defined'); }); }); diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 6dacc3ef..32ef6094 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -2,7 +2,13 @@ import { ConfigIO, SecureCredentials } from '../../../lib'; import type { DeployedState } from '../../../schema'; import { validateAwsCredentials } from '../../aws/account'; import { createSwitchableIoHost } from '../../cdk/toolkit-lib'; -import { buildDeployedState, getStackOutputs, parseAgentOutputs, parseGatewayOutputs } from '../../cloudformation'; +import { + buildDeployedState, + getStackOutputs, + parseAgentOutputs, + parseGatewayOutputs, + parseMemoryOutputs, +} from '../../cloudformation'; import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; import { @@ -31,7 +37,8 @@ export interface ValidatedDeployOptions { onResourceEvent?: (message: string) => void; } -const NEXT_STEPS = ['agentcore invoke', 'agentcore status']; +const AGENT_NEXT_STEPS = ['agentcore invoke', 'agentcore status']; +const MEMORY_ONLY_NEXT_STEPS = ['agentcore add agent', 'agentcore status']; export async function handleDeploy(options: ValidatedDeployOptions): Promise { let toolkitWrapper = null; @@ -321,6 +328,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise a.name) || []; const agents = parseAgentOutputs(outputs, agentNames, stackName); + // Parse memory outputs + const memoryNames = (context.projectSpec.memories ?? []).map(m => m.name); + const memories = parseMemoryOutputs(outputs, memoryNames); + // Parse gateway outputs const gatewaySpecs = mcpSpec?.agentCoreGateways?.reduce( @@ -333,15 +344,16 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise undefined); - const deployedState = buildDeployedState( - target.name, + const deployedState = buildDeployedState({ + targetName: target.name, stackName, agents, gateways, existingState, identityKmsKeyArn, - deployedCredentials - ); + credentials: deployedCredentials, + memories, + }); await configIO.writeDeployedState(deployedState); // Show gateway URLs and target sync status @@ -370,7 +382,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? AGENT_NEXT_STEPS : MEMORY_ONLY_NEXT_STEPS, }; } catch (err: unknown) { logger.log(getErrorMessage(err), 'error'); diff --git a/src/cli/operations/deploy/__tests__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts index 6687147b..dd148df4 100644 --- a/src/cli/operations/deploy/__tests__/preflight.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight.test.ts @@ -81,10 +81,28 @@ describe('validateProject', () => { mockReadDeployedState.mockRejectedValue(new Error('No deployed state')); await expect(validateProject()).rejects.toThrow( - 'No agents or gateways defined in project. Add at least one agent with "agentcore add agent" or gateway with "agentcore add gateway" before deploying.' + 'No resources defined in project. Add an agent with "agentcore add agent", a memory with "agentcore add memory", or a gateway with "agentcore add gateway" before deploying.' ); }); + it('allows deploy when memories exist but no agents or gateways', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + mockReadProjectSpec.mockResolvedValue({ + name: 'test-project', + agents: [], + memories: [{ name: 'test-memory', strategies: [] }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockReadMcpSpec.mockRejectedValue(new Error('No mcp.json')); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + + expect(result.projectSpec.name).toBe('test-project'); + expect(result.isTeardownDeploy).toBe(false); + }); + it('allows deploy when both agents and gateways exist', async () => { mockRequireConfigRoot.mockReturnValue('/project/agentcore'); mockValidate.mockReturnValue(undefined); diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 61f7c048..9c5025a5 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -81,6 +81,7 @@ export async function validateProject(): Promise { // reliable indicator of whether a CloudFormation stack exists for this project. let isTeardownDeploy = false; const hasAgents = projectSpec.agents && projectSpec.agents.length > 0; + const hasMemories = projectSpec.memories && projectSpec.memories.length > 0; // Check for gateways in mcp.json let hasGateways = false; @@ -91,7 +92,7 @@ export async function validateProject(): Promise { // No mcp.json or invalid — no gateways } - if (!hasAgents && !hasGateways) { + if (!hasAgents && !hasGateways && !hasMemories) { let hasExistingStack = false; try { const deployedState = await configIO.readDeployedState(); @@ -101,7 +102,7 @@ export async function validateProject(): Promise { } if (!hasExistingStack) { throw new Error( - 'No agents or gateways defined in project. Add at least one agent with "agentcore add agent" or gateway with "agentcore add gateway" before deploying.' + 'No resources defined in project. Add an agent with "agentcore add agent", a memory with "agentcore add memory", or a gateway with "agentcore add gateway" before deploying.' ); } isTeardownDeploy = true; diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index 313b439f..c4ef461d 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -141,7 +141,7 @@ interface AddFlowProps { export function AddFlow(props: AddFlowProps) { const { addAgent, reset: resetAgent } = useAddAgent(); - const { agents, isLoading: isLoadingAgents, refresh: refreshAgents } = useAvailableAgents(); + const { agents, refresh: refreshAgents } = useAvailableAgents(); const [flow, setFlow] = useState({ name: 'select' }); // In non-interactive mode, exit after success (but not while loading) @@ -215,13 +215,7 @@ export function AddFlow(props: AddFlowProps) { if (flow.name === 'select') { // Show screen immediately - loading is instant for local files - return ( - 0} - /> - ); + return ; } // Agent wizard - now uses AddAgentFlow with mode selection diff --git a/src/cli/tui/screens/add/AddScreen.tsx b/src/cli/tui/screens/add/AddScreen.tsx index 848659f7..bc3f1ae3 100644 --- a/src/cli/tui/screens/add/AddScreen.tsx +++ b/src/cli/tui/screens/add/AddScreen.tsx @@ -15,19 +15,17 @@ export type AddResourceType = (typeof ADD_RESOURCES)[number]['id']; interface AddScreenProps { onSelect: (resourceType: AddResourceType) => void; onExit: () => void; - /** Whether at least one agent exists in the project */ - hasAgents: boolean; } -export function AddScreen({ onSelect, onExit, hasAgents }: AddScreenProps) { +export function AddScreen({ onSelect, onExit }: AddScreenProps) { const items: SelectableItem[] = useMemo( () => ADD_RESOURCES.map(r => ({ ...r, - disabled: Boolean('disabled' in r && r.disabled) || (r.id === 'memory' && !hasAgents), - description: r.id === 'memory' && !hasAgents ? 'Add an agent first' : r.description, + disabled: Boolean('disabled' in r && r.disabled), + description: r.description, })), - [hasAgents] + [] ); const isDisabled = (item: SelectableItem) => item.disabled ?? false; diff --git a/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx index 714a0ced..4dff9cf2 100644 --- a/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx +++ b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx @@ -8,10 +8,9 @@ describe('AddScreen', () => { const onSelect = vi.fn(); const onExit = vi.fn(); - const { lastFrame } = render(); + const { lastFrame } = render(); expect(lastFrame()).toContain('Gateway'); expect(lastFrame()).toContain('Gateway Target'); - expect(lastFrame()).not.toContain('Add an agent first'); }); }); diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index 80b975c2..cefef8c6 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -1,6 +1,12 @@ import { ConfigIO } from '../../../../lib'; import type { CdkToolkitWrapper, DeployMessage, SwitchableIoHost } from '../../../cdk/toolkit-lib'; -import { buildDeployedState, getStackOutputs, parseAgentOutputs, parseGatewayOutputs } from '../../../cloudformation'; +import { + buildDeployedState, + getStackOutputs, + parseAgentOutputs, + parseGatewayOutputs, + parseMemoryOutputs, +} from '../../../cloudformation'; import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from '../../../errors'; import { ExecLogger } from '../../../logging'; import { performStackTeardown } from '../../../operations/deploy'; @@ -136,28 +142,27 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState const configIO = new ConfigIO(); const agentNames = ctx.projectSpec.agents?.map((a: { name: string }) => a.name) || []; - // Try to get outputs from CDK stream first (immediate, no API call) - let outputs = streamOutputsRef.current ?? {}; - - // Fallback to DescribeStacks if stream outputs not available - if (Object.keys(outputs).length === 0) { - logger.log('Stream outputs not available, falling back to DescribeStacks API'); - for (let attempt = 1; attempt <= MAX_OUTPUT_POLL_ATTEMPTS; attempt += 1) { - logger.log(`Polling stack outputs (attempt ${attempt}/${MAX_OUTPUT_POLL_ATTEMPTS})...`); - outputs = await getStackOutputs(target.region, currentStackName); - if (Object.keys(outputs).length > 0) { - logger.log(`Retrieved ${Object.keys(outputs).length} output(s) from stack`); - break; - } - if (attempt < MAX_OUTPUT_POLL_ATTEMPTS) { - logger.log(`No outputs yet, retrying in ${OUTPUT_POLL_DELAY_MS / 1000}s...`); - await new Promise(resolve => setTimeout(resolve, OUTPUT_POLL_DELAY_MS)); - } + // CDK stream (I5900) only includes outputs without exportName. + // Per-resource outputs (memory, agent, gateway) use exportName, so we + // always need DescribeStacks for the full set. Merge stream outputs as a base. + let outputs = { ...(streamOutputsRef.current ?? {}) }; + + for (let attempt = 1; attempt <= MAX_OUTPUT_POLL_ATTEMPTS; attempt += 1) { + logger.log(`Polling stack outputs (attempt ${attempt}/${MAX_OUTPUT_POLL_ATTEMPTS})...`); + const apiOutputs = await getStackOutputs(target.region, currentStackName); + if (Object.keys(apiOutputs).length > 0) { + outputs = { ...outputs, ...apiOutputs }; + logger.log(`Retrieved ${Object.keys(apiOutputs).length} output(s) from stack`); + break; } - if (Object.keys(outputs).length === 0) { - logger.log('Warning: Could not retrieve stack outputs after polling', 'warn'); + if (attempt < MAX_OUTPUT_POLL_ATTEMPTS) { + logger.log(`No outputs yet, retrying in ${OUTPUT_POLL_DELAY_MS / 1000}s...`); + await new Promise(resolve => setTimeout(resolve, OUTPUT_POLL_DELAY_MS)); } } + if (Object.keys(outputs).length === 0) { + logger.log('Warning: Could not retrieve stack outputs after polling', 'warn'); + } const agents = parseAgentOutputs(outputs, agentNames, currentStackName); @@ -185,19 +190,24 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState logger.log(`Failed to read gateway configuration: ${getErrorMessage(error)}`, 'warn'); } + // Parse memory outputs + const memoryNames = (ctx.projectSpec.memories ?? []).map((m: { name: string }) => m.name); + const memories = parseMemoryOutputs(outputs, memoryNames); + // Expose outputs to UI setStackOutputs(outputs); const existingState = await configIO.readDeployedState().catch(() => undefined); - const deployedState = buildDeployedState( - target.name, - currentStackName, + const deployedState = buildDeployedState({ + targetName: target.name, + stackName: currentStackName, agents, gateways, existingState, identityKmsKeyArn, - Object.keys(oauthCredentials).length > 0 ? oauthCredentials : undefined - ); + memories, + credentials: Object.keys(oauthCredentials).length > 0 ? oauthCredentials : undefined, + }); await configIO.writeDeployedState(deployedState); // Query gateway target sync statuses (non-blocking) diff --git a/src/schema/schemas/__tests__/deployed-state.test.ts b/src/schema/schemas/__tests__/deployed-state.test.ts index 74c9f6ee..5662128d 100644 --- a/src/schema/schemas/__tests__/deployed-state.test.ts +++ b/src/schema/schemas/__tests__/deployed-state.test.ts @@ -8,6 +8,7 @@ import { McpDeployedStateSchema, McpLambdaDeployedStateSchema, McpRuntimeDeployedStateSchema, + MemoryDeployedStateSchema, VpcConfigSchema, createValidatedDeployedStateSchema, } from '../deployed-state.js'; @@ -51,6 +52,30 @@ describe('AgentCoreDeployedStateSchema', () => { }); }); +describe('MemoryDeployedStateSchema', () => { + it('accepts valid memory state', () => { + expect( + MemoryDeployedStateSchema.safeParse({ + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock:us-east-1:123:memory/mem-123', + }).success + ).toBe(true); + }); + + it('rejects empty memoryId', () => { + expect( + MemoryDeployedStateSchema.safeParse({ + memoryId: '', + memoryArn: 'arn:valid', + }).success + ).toBe(false); + }); + + it('rejects missing required fields', () => { + expect(MemoryDeployedStateSchema.safeParse({ memoryId: 'mem-123' }).success).toBe(false); + }); +}); + describe('GatewayDeployedStateSchema', () => { it('accepts valid gateway state', () => { expect( @@ -205,6 +230,18 @@ describe('DeployedResourceStateSchema', () => { expect(result.success).toBe(true); }); + it('accepts resource state with memories', () => { + const result = DeployedResourceStateSchema.safeParse({ + memories: { + MyMemory: { + memoryId: 'mem-123', + memoryArn: 'arn:aws:bedrock:us-east-1:123:memory/mem-123', + }, + }, + }); + expect(result.success).toBe(true); + }); + it('accepts resource state with credentials', () => { const result = DeployedResourceStateSchema.safeParse({ credentials: { diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index b82b40a7..7f4913d1 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -17,6 +17,17 @@ export const AgentCoreDeployedStateSchema = z.object({ export type AgentCoreDeployedState = z.infer; +// ============================================================================ +// Memory Deployed State +// ============================================================================ + +export const MemoryDeployedStateSchema = z.object({ + memoryId: z.string().min(1), + memoryArn: z.string().min(1), +}); + +export type MemoryDeployedState = z.infer; + // ============================================================================ // MCP Gateway Deployed State // ============================================================================ @@ -114,6 +125,7 @@ export type CredentialDeployedState = z.infer