diff --git a/e2e-tests/e2e-helper.ts b/e2e-tests/e2e-helper.ts index 0e403b77..6f924f72 100644 --- a/e2e-tests/e2e-helper.ts +++ b/e2e-tests/e2e-helper.ts @@ -9,6 +9,7 @@ import { import { BedrockAgentCoreControlClient, DeleteApiKeyCredentialProviderCommand, + GetAgentRuntimeCommand, } from '@aws-sdk/client-bedrock-agentcore-control'; import { execSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; @@ -26,6 +27,11 @@ interface E2EConfig { requiredEnvVar?: string; build?: string; memory?: string; + /** Lifecycle configuration to pass via --idle-timeout / --max-lifetime flags. */ + lifecycleConfig?: { + idleTimeout?: number; + maxLifetime?: number; + }; } export function createE2ESuite(cfg: E2EConfig) { @@ -63,6 +69,13 @@ export function createE2ESuite(cfg: E2EConfig) { createArgs.push('--build', cfg.build); } + if (cfg.lifecycleConfig?.idleTimeout !== undefined) { + createArgs.push('--idle-timeout', String(cfg.lifecycleConfig.idleTimeout)); + } + if (cfg.lifecycleConfig?.maxLifetime !== undefined) { + createArgs.push('--max-lifetime', String(cfg.lifecycleConfig.maxLifetime)); + } + // Pass API key so the credential is registered in the project and .env.local const apiKey = cfg.requiredEnvVar ? process.env[cfg.requiredEnvVar] : undefined; if (apiKey) { @@ -262,6 +275,30 @@ export function createE2ESuite(cfg: E2EConfig) { }, 120000 ); + + // ── Lifecycle configuration verification ───────────────────────── + if (cfg.lifecycleConfig) { + it.skipIf(!canRun)( + 'runtime has lifecycle configuration set via AWS API', + async () => { + expect(runtimeId, 'Runtime ID should have been extracted from status').toBeTruthy(); + + // Query the runtime via AWS API to verify lifecycle config + const region = process.env.AWS_REGION ?? 'us-east-1'; + const client = new BedrockAgentCoreControlClient({ region }); + const response = await client.send(new GetAgentRuntimeCommand({ agentRuntimeId: runtimeId })); + + expect(response.lifecycleConfiguration).toBeDefined(); + if (cfg.lifecycleConfig!.idleTimeout !== undefined) { + expect(response.lifecycleConfiguration!.idleRuntimeSessionTimeout).toBe(cfg.lifecycleConfig!.idleTimeout); + } + if (cfg.lifecycleConfig!.maxLifetime !== undefined) { + expect(response.lifecycleConfiguration!.maxLifetime).toBe(cfg.lifecycleConfig!.maxLifetime); + } + }, + 180000 + ); + } }); } diff --git a/e2e-tests/strands-bedrock.test.ts b/e2e-tests/strands-bedrock.test.ts index 2acbe2b0..212ecedc 100644 --- a/e2e-tests/strands-bedrock.test.ts +++ b/e2e-tests/strands-bedrock.test.ts @@ -1,3 +1,6 @@ import { createE2ESuite } from './e2e-helper.js'; -createE2ESuite({ framework: 'Strands', modelProvider: 'Bedrock' }); +createE2ESuite({ + framework: 'Strands', + modelProvider: 'Bedrock', +}); diff --git a/integ-tests/lifecycle-config.test.ts b/integ-tests/lifecycle-config.test.ts new file mode 100644 index 00000000..e6c6bb2d --- /dev/null +++ b/integ-tests/lifecycle-config.test.ts @@ -0,0 +1,322 @@ +import { readProjectConfig, runCLI } from '../src/test-utils/index.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('integration: lifecycle configuration', () => { + let testDir: string; + let projectPath: string; + + beforeAll(async () => { + testDir = join(tmpdir(), `agentcore-integ-lifecycle-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const result = await runCLI(['create', '--name', 'LifecycleTest', '--no-agent', '--json'], testDir); + expect(result.exitCode, `setup stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + projectPath = json.projectPath; + }); + + afterAll(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + describe('create with lifecycle flags', () => { + let createDir: string; + + beforeAll(async () => { + createDir = join(tmpdir(), `agentcore-integ-lifecycle-create-${randomUUID()}`); + await mkdir(createDir, { recursive: true }); + }); + + afterAll(async () => { + await rm(createDir, { recursive: true, force: true }); + }); + + it('creates project with --idle-timeout and --max-lifetime', async () => { + const name = `LcCreate${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'create', + '--name', + name, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--idle-timeout', + '300', + '--max-lifetime', + '7200', + '--json', + ], + createDir + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(json.projectPath); + const agents = config.agents as Record[]; + expect(agents.length).toBe(1); + + const agent = agents[0]!; + const lifecycle = agent.lifecycleConfiguration as Record; + expect(lifecycle).toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBe(300); + expect(lifecycle.maxLifetime).toBe(7200); + }); + + it('creates project with only --idle-timeout', async () => { + const name = `LcIdle${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'create', + '--name', + name, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--idle-timeout', + '600', + '--json', + ], + createDir + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(json.projectPath); + const agents = config.agents as Record[]; + const agent = agents[0]!; + const lifecycle = agent.lifecycleConfiguration as Record; + expect(lifecycle).toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBe(600); + expect(lifecycle.maxLifetime).toBeUndefined(); + }); + + it('creates project without lifecycle flags — no lifecycleConfiguration in config', async () => { + const name = `LcNone${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'create', + '--name', + name, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + createDir + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(json.projectPath); + const agents = config.agents as Record[]; + const agent = agents[0]!; + expect(agent.lifecycleConfiguration).toBeUndefined(); + }); + }); + + describe('add agent with lifecycle flags', () => { + it('adds BYO agent with lifecycle config', async () => { + const name = `LcByo${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--type', + 'byo', + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--code-location', + `app/${name}/`, + '--idle-timeout', + '120', + '--max-lifetime', + '3600', + '--json', + ], + projectPath + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(projectPath); + const agents = config.agents as Record[]; + const agent = agents.find(a => a.name === name); + expect(agent).toBeDefined(); + const lifecycle = agent!.lifecycleConfiguration as Record; + expect(lifecycle).toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBe(120); + expect(lifecycle.maxLifetime).toBe(3600); + }); + + it('adds template agent with only --max-lifetime', async () => { + const name = `LcTmpl${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--language', + 'Python', + '--max-lifetime', + '14400', + '--json', + ], + projectPath + ); + + expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(projectPath); + const agents = config.agents as Record[]; + const agent = agents.find(a => a.name === name); + expect(agent).toBeDefined(); + const lifecycle = agent!.lifecycleConfiguration as Record; + expect(lifecycle).toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBeUndefined(); + expect(lifecycle.maxLifetime).toBe(14400); + }); + }); + + describe('validation rejects invalid lifecycle values', () => { + it('rejects idle-timeout below 60', async () => { + const name = `LcLow${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--type', + 'byo', + '--language', + 'Python', + '--code-location', + `app/${name}/`, + '--idle-timeout', + '30', + '--json', + ], + projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + + it('rejects max-lifetime above 28800', async () => { + const name = `LcHigh${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--type', + 'byo', + '--language', + 'Python', + '--code-location', + `app/${name}/`, + '--max-lifetime', + '99999', + '--json', + ], + projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + + it('rejects idle-timeout > max-lifetime', async () => { + const name = `LcCross${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--type', + 'byo', + '--language', + 'Python', + '--code-location', + `app/${name}/`, + '--idle-timeout', + '5000', + '--max-lifetime', + '3000', + '--json', + ], + projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + + it('rejects non-integer idle-timeout', async () => { + const name = `LcFloat${Date.now().toString().slice(-6)}`; + const result = await runCLI( + [ + 'add', + 'agent', + '--name', + name, + '--type', + 'byo', + '--language', + 'Python', + '--code-location', + `app/${name}/`, + '--idle-timeout', + '120.5', + '--json', + ], + projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + }); +}); diff --git a/integ-tests/tui/lifecycle-config.test.ts b/integ-tests/tui/lifecycle-config.test.ts new file mode 100644 index 00000000..4a3f0071 --- /dev/null +++ b/integ-tests/tui/lifecycle-config.test.ts @@ -0,0 +1,446 @@ +/** + * TUI Integration Test: Lifecycle Configuration in Create and Add Agent flows + * + * Drives the TUI through both: + * 1. `create` wizard (GenerateWizard) — advanced settings with lifecycle config + * 2. `add agent` BYO flow (AddAgentScreen) — advanced settings with lifecycle config + * + * Verifies lifecycle values end up in agentcore.json after confirmation. + */ +import { TuiSession, WaitForTimeoutError } from '../../src/tui-harness/index.js'; +import { createMinimalProjectDir } from './helpers.js'; +import type { MinimalProjectDirResult } from './helpers.js'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; + +// --------------------------------------------------------------------------- +// Paths & Constants +// --------------------------------------------------------------------------- + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const CLI_DIST = join(__dirname, '..', '..', 'dist', 'cli', 'index.mjs'); +const SCREENSHOTS_DIR = '/tmp/tui-test-lifecycle/screenshots'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function saveTextScreenshot(session: TuiSession, name: string): string { + const screen = session.readScreen({ numbered: true }); + const nonEmpty = screen.lines.filter((l: string) => l.trim() !== ''); + const { cols, rows } = screen.dimensions; + const header = `Screenshot: ${name} (${cols}x${rows})`; + const border = '='.repeat(Math.max(header.length, 60)); + const text = `${border}\n${header}\n${border}\n${nonEmpty.join('\n')}\n${border}\n`; + const path = join(SCREENSHOTS_DIR, `${name}.txt`); + writeFileSync(path, text, 'utf-8'); + return path; +} + +function getScreenText(session: TuiSession): string { + return session.readScreen().lines.join('\n'); +} + +async function safeWaitFor(session: TuiSession, pattern: string | RegExp, timeoutMs = 10_000): Promise { + try { + await session.waitFor(pattern, timeoutMs); + return true; + } catch (err) { + if (err instanceof WaitForTimeoutError) { + return false; + } + throw err; + } +} + +const settle = (ms = 400) => new Promise(r => setTimeout(r, ms)); + +function readAgentcoreJson(projectDir: string): Record { + const path = join(projectDir, 'agentcore', 'agentcore.json'); + return JSON.parse(readFileSync(path, 'utf-8')); +} + +// --------------------------------------------------------------------------- +// Test Suite: Create Flow with Lifecycle Config +// --------------------------------------------------------------------------- + +describe('Create Flow: Lifecycle Configuration via TUI', () => { + let session: TuiSession; + let _tmpDir: string; + + beforeAll(() => { + mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + }); + + afterEach(async () => { + if (session?.alive) { + await session.close(); + } + }); + + it('lifecycle config flows from create wizard advanced settings into agentcore.json', async () => { + // Create a temp directory for the new project + const { dir: parentDir, cleanup } = await createMinimalProjectDir({ projectName: 'lifecycle-create-test' }); + + try { + // Launch the CLI in "create" mode directly + session = await TuiSession.launch({ + command: process.execPath, + args: [ + CLI_DIST, + 'create', + '--name', + 'LcTuiCreate', + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + ], + cwd: parentDir, + cols: 120, + rows: 35, + }); + + // Should reach the "Advanced" step (yes/no) + const atAdvanced = await safeWaitFor(session, 'Advanced', 15_000); + if (!atAdvanced) { + saveTextScreenshot(session, 'create-01-advanced-fail'); + } + expect(atAdvanced, 'Should reach Advanced config step').toBe(true); + saveTextScreenshot(session, 'create-01-advanced'); + + // Select "Yes" for advanced (first option) + await session.sendSpecialKey('enter'); + const atNetworkMode = await safeWaitFor(session, 'Network', 5_000); + if (!atNetworkMode) { + saveTextScreenshot(session, 'create-02-network-fail'); + } + expect(atNetworkMode, 'Should reach network mode step').toBe(true); + saveTextScreenshot(session, 'create-02-network'); + + // Select PUBLIC (first option) + await session.sendSpecialKey('enter'); + + // Should reach request header allowlist step + const _atHeaders = await safeWaitFor(session, /header|allowlist/i, 5_000); + saveTextScreenshot(session, 'create-03-headers'); + + // Skip headers (Enter with empty) + await session.sendSpecialKey('enter'); + + // Should reach idle timeout step + const atIdleTimeout = await safeWaitFor(session, /idle.*timeout/i, 5_000); + if (!atIdleTimeout) { + saveTextScreenshot(session, 'create-04-idle-fail'); + } + expect(atIdleTimeout, 'Should reach idle timeout step').toBe(true); + saveTextScreenshot(session, 'create-04-idle-timeout'); + + // Enter idle timeout value: 120 + await session.sendKeys('120'); + await settle(300); + await session.sendSpecialKey('enter'); + + // Should reach max lifetime step + const atMaxLifetime = await safeWaitFor(session, /max.*lifetime/i, 5_000); + if (!atMaxLifetime) { + saveTextScreenshot(session, 'create-05-maxlife-fail'); + } + expect(atMaxLifetime, 'Should reach max lifetime step').toBe(true); + saveTextScreenshot(session, 'create-05-max-lifetime'); + + // Enter max lifetime value: 3600 + await session.sendKeys('3600'); + await settle(300); + await session.sendSpecialKey('enter'); + + // Should reach confirm step + const atConfirm = await safeWaitFor(session, /confirm|review/i, 5_000); + if (!atConfirm) { + saveTextScreenshot(session, 'create-06-confirm-fail'); + } + expect(atConfirm, 'Should reach confirm step').toBe(true); + saveTextScreenshot(session, 'create-06-confirm'); + + // Verify the review screen shows lifecycle values + const reviewText = getScreenText(session); + expect(reviewText).toContain('120'); + expect(reviewText).toContain('3600'); + + // Confirm the creation (press Enter or 'y') + await session.sendKeys('y'); + + // Wait for project creation to complete + const created = await safeWaitFor(session, /created|success|Commands/i, 30_000); + saveTextScreenshot(session, 'create-07-result'); + + if (created) { + // Find the created project directory + const { readdirSync } = await import('node:fs'); + const entries = readdirSync(parentDir); + const projectDirName = entries.find(e => e.startsWith('LcTuiCreate') || e === 'LcTuiCreate'); + if (projectDirName) { + const projectPath = join(parentDir, projectDirName); + const config = readAgentcoreJson(projectPath); + const agents = config.agents as Record[]; + expect(agents.length).toBeGreaterThan(0); + + const agent = agents[0]!; + const lifecycle = agent.lifecycleConfiguration as Record; + expect(lifecycle, 'agentcore.json should have lifecycleConfiguration').toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBe(120); + expect(lifecycle.maxLifetime).toBe(3600); + } + } + } finally { + await cleanup(); + } + }, 60_000); +}); + +// --------------------------------------------------------------------------- +// Test Suite: Add Agent BYO Flow with Lifecycle Config +// --------------------------------------------------------------------------- + +describe('Add Agent BYO Flow: Lifecycle Configuration via TUI', () => { + let session: TuiSession; + let projectDir: MinimalProjectDirResult; + + beforeAll(async () => { + mkdirSync(SCREENSHOTS_DIR, { recursive: true }); + projectDir = await createMinimalProjectDir({ projectName: 'lifecycle-byo-test' }); + }); + + afterAll(async () => { + if (session?.alive) { + await session.close(); + } + if (projectDir) { + await projectDir.cleanup(); + } + }); + + it('Step 1: launch TUI and navigate to Add Agent', async () => { + session = await TuiSession.launch({ + command: process.execPath, + args: [CLI_DIST], + cwd: projectDir.dir, + cols: 120, + rows: 35, + }); + + const found = await safeWaitFor(session, 'Commands', 15_000); + if (!found) { + saveTextScreenshot(session, 'byo-01-commands-fail'); + } + expect(found).toBe(true); + saveTextScreenshot(session, 'byo-01-helpscreen'); + + // Filter to "add" and open + await session.sendKeys('add'); + await settle(); + await session.sendSpecialKey('enter'); + + const atAdd = await safeWaitFor(session, 'Add Resource', 5_000); + expect(atAdd).toBe(true); + saveTextScreenshot(session, 'byo-02-add-resource'); + + // Select Agent (first option) + await session.sendSpecialKey('enter'); + + const atAgent = await safeWaitFor(session, /agent|Name/i, 5_000); + expect(atAgent).toBe(true); + saveTextScreenshot(session, 'byo-03-add-agent'); + }); + + it('Step 2: select BYO agent type and enter name', async () => { + // The add agent screen first asks for type: Template or BYO + // Check if we see a type selector + const text = getScreenText(session); + + if (text.includes('Template') || text.includes('BYO') || text.includes('Bring')) { + // Navigate to BYO option (should be second) + await session.sendSpecialKey('down'); + await settle(); + await session.sendSpecialKey('enter'); + await settle(); + } + + // Now should be at name entry + const _atName = await safeWaitFor(session, /name/i, 5_000); + saveTextScreenshot(session, 'byo-04-name'); + + // Enter agent name + await session.sendKeys('ByoLifecycle'); + await settle(300); + await session.sendSpecialKey('enter'); + + await settle(500); + saveTextScreenshot(session, 'byo-05-after-name'); + }); + + it('Step 3: navigate through BYO config to advanced settings', async () => { + // After name, BYO agents ask for: language, buildType, protocol, framework, modelProvider, + // codeLocation, entrypoint, then advanced + + // Language (Python first) + let found = await safeWaitFor(session, /language|Python/i, 5_000); + if (found) { + await session.sendSpecialKey('enter'); // select Python + await settle(); + } + saveTextScreenshot(session, 'byo-06-language'); + + // Build type (CodeZip first) + found = await safeWaitFor(session, /build|CodeZip/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // select CodeZip + await settle(); + } + saveTextScreenshot(session, 'byo-07-build'); + + // Protocol (HTTP first) + found = await safeWaitFor(session, /protocol|HTTP/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // select HTTP + await settle(); + } + saveTextScreenshot(session, 'byo-08-protocol'); + + // Framework + found = await safeWaitFor(session, /framework|Strands/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // select Strands + await settle(); + } + saveTextScreenshot(session, 'byo-09-framework'); + + // Model provider + found = await safeWaitFor(session, /model.*provider|Bedrock/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // select Bedrock + await settle(); + } + saveTextScreenshot(session, 'byo-10-model-provider'); + + // Runtime version + found = await safeWaitFor(session, /runtime.*version|PYTHON/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); + await settle(); + } + saveTextScreenshot(session, 'byo-11-runtime-version'); + + // Code location + found = await safeWaitFor(session, /code.*location|directory/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // accept default + await settle(); + } + saveTextScreenshot(session, 'byo-12-code-location'); + + // Entrypoint + found = await safeWaitFor(session, /entrypoint|main/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); // accept default + await settle(); + } + saveTextScreenshot(session, 'byo-13-entrypoint'); + + // Should reach Advanced + const atAdvanced = await safeWaitFor(session, /advanced/i, 5_000); + saveTextScreenshot(session, 'byo-14-advanced'); + expect(atAdvanced, 'Should reach Advanced config').toBe(true); + }); + + it('Step 4: enable advanced settings and enter lifecycle config', async () => { + // Select "Yes" for advanced config + await session.sendSpecialKey('enter'); + await settle(); + saveTextScreenshot(session, 'byo-15-after-advanced-yes'); + + // Network mode — select PUBLIC + let found = await safeWaitFor(session, /network/i, 5_000); + if (found) { + await session.sendSpecialKey('enter'); + await settle(); + } + saveTextScreenshot(session, 'byo-16-network'); + + // Request header allowlist — skip + found = await safeWaitFor(session, /header|allowlist/i, 3_000); + if (found) { + await session.sendSpecialKey('enter'); + await settle(); + } + saveTextScreenshot(session, 'byo-17-headers'); + + // Idle timeout step + found = await safeWaitFor(session, /idle.*timeout/i, 5_000); + if (!found) { + saveTextScreenshot(session, 'byo-18-idle-fail'); + } + expect(found, 'Should reach idle timeout step').toBe(true); + saveTextScreenshot(session, 'byo-18-idle-timeout'); + + // Enter idle timeout: 600 + await session.sendKeys('600'); + await settle(300); + await session.sendSpecialKey('enter'); + + // Max lifetime step + found = await safeWaitFor(session, /max.*lifetime/i, 5_000); + if (!found) { + saveTextScreenshot(session, 'byo-19-maxlife-fail'); + } + expect(found, 'Should reach max lifetime step').toBe(true); + saveTextScreenshot(session, 'byo-19-max-lifetime'); + + // Enter max lifetime: 14400 + await session.sendKeys('14400'); + await settle(300); + await session.sendSpecialKey('enter'); + + // Should reach confirm/review + found = await safeWaitFor(session, /confirm|review/i, 5_000); + if (!found) { + saveTextScreenshot(session, 'byo-20-confirm-fail'); + } + expect(found, 'Should reach confirm step').toBe(true); + saveTextScreenshot(session, 'byo-20-confirm'); + }); + + it('Step 5: review shows lifecycle values and confirm writes to agentcore.json', async () => { + // Verify the review screen shows lifecycle values + const reviewText = getScreenText(session); + expect(reviewText).toContain('600'); + expect(reviewText).toContain('14400'); + saveTextScreenshot(session, 'byo-21-review-values'); + + // Confirm + await session.sendKeys('y'); + await settle(1000); + + // Wait for the agent to be added + const _done = await safeWaitFor(session, /added|success|Commands/i, 10_000); + saveTextScreenshot(session, 'byo-22-after-confirm'); + + // Read the agentcore.json and verify lifecycle config + const config = readAgentcoreJson(projectDir.dir); + const agents = config.agents as Record[]; + const agent = agents.find((a: Record) => a.name === 'ByoLifecycle'); + expect(agent, 'Agent should be in agentcore.json').toBeDefined(); + + const lifecycle = agent!.lifecycleConfiguration as Record; + expect(lifecycle, 'Should have lifecycleConfiguration in agentcore.json').toBeDefined(); + expect(lifecycle.idleRuntimeSessionTimeout).toBe(600); + expect(lifecycle.maxLifetime).toBe(14400); + }); +}); diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 89e89742..25522e42 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -1322,3 +1322,102 @@ describe('validateAddAgentOptions - VPC validation', () => { expect(result.error).toContain('only valid with --network-mode VPC'); }); }); + +describe('validateAddAgentOptions - lifecycle configuration', () => { + const baseOptions: AddAgentOptions = { + name: 'TestAgent', + type: 'byo', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + build: 'CodeZip', + codeLocation: './app/test/', + }; + + it('accepts valid idle-timeout', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 900 }); + expect(result.valid).toBe(true); + }); + + it('accepts valid max-lifetime', () => { + const result = validateAddAgentOptions({ ...baseOptions, maxLifetime: 28800 }); + expect(result.valid).toBe(true); + }); + + it('accepts both when idle <= max', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 600, maxLifetime: 3600 }); + expect(result.valid).toBe(true); + }); + + it('accepts both when idle === max', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 3600, maxLifetime: 3600 }); + expect(result.valid).toBe(true); + }); + + it('rejects idle-timeout below 60', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 59 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects idle-timeout above 28800', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 28801 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects max-lifetime below 60', () => { + const result = validateAddAgentOptions({ ...baseOptions, maxLifetime: 59 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('rejects max-lifetime above 28800', () => { + const result = validateAddAgentOptions({ ...baseOptions, maxLifetime: 28801 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('rejects idle > max', () => { + const result = validateAddAgentOptions({ ...baseOptions, idleTimeout: 5000, maxLifetime: 1000 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout must be <= --max-lifetime'); + }); + + it('passes without lifecycle options (defaults handled server-side)', () => { + const result = validateAddAgentOptions(baseOptions); + expect(result.valid).toBe(true); + }); + + it('accepts lifecycle options for import path', () => { + const importOptions: AddAgentOptions = { + name: 'TestAgent', + type: 'import', + agentId: 'AGENT123', + agentAliasId: 'ALIAS123', + region: 'us-east-1', + framework: 'Strands', + memory: 'none', + idleTimeout: 600, + maxLifetime: 7200, + }; + const result = validateAddAgentOptions(importOptions); + expect(result.valid).toBe(true); + }); + + it('rejects invalid lifecycle for import path', () => { + const importOptions: AddAgentOptions = { + name: 'TestAgent', + type: 'import', + agentId: 'AGENT123', + agentAliasId: 'ALIAS123', + region: 'us-east-1', + framework: 'Strands', + memory: 'none', + idleTimeout: 50000, + }; + const result = validateAddAgentOptions(importOptions); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); +}); diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index fb05a1d1..21ecd7f9 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -33,6 +33,8 @@ export interface AddAgentOptions extends VpcOptions { customClaims?: string; clientId?: string; clientSecret?: string; + idleTimeout?: number | string; + maxLifetime?: number | string; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 39bb1dc7..e87b1513 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -14,6 +14,7 @@ import { getSupportedModelProviders, matchEnumValue, } from '../../../schema'; +import { parseAndValidateLifecycleOptions } from '../shared/lifecycle-utils'; import { validateVpcOptions } from '../shared/vpc-utils'; import { validateJwtAuthorizerOptions } from './auth-options'; import type { @@ -136,6 +137,12 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes error: `Invalid memory option: ${options.memory}. Use none, shortTerm, or longAndShortTerm`, }; } + // Parse and validate lifecycle configuration for import path + const lcResult = parseAndValidateLifecycleOptions(options); + if (!lcResult.valid) return lcResult; + if (lcResult.idleTimeout !== undefined) options.idleTimeout = lcResult.idleTimeout; + if (lcResult.maxLifetime !== undefined) options.maxLifetime = lcResult.maxLifetime; + // Force import defaults options.modelProvider = 'Bedrock' as typeof options.modelProvider; options.language = 'Python' as typeof options.language; @@ -166,6 +173,12 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes return { valid: false, error: '--code-location is required for BYO path' }; } + // Parse and validate lifecycle configuration for MCP path + const mcpLcResult = parseAndValidateLifecycleOptions(options); + if (!mcpLcResult.valid) return mcpLcResult; + if (mcpLcResult.idleTimeout !== undefined) options.idleTimeout = mcpLcResult.idleTimeout; + if (mcpLcResult.maxLifetime !== undefined) options.maxLifetime = mcpLcResult.maxLifetime; + return { valid: true }; } @@ -234,6 +247,12 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes } } + // Parse and validate lifecycle configuration + const lifecycleResult = parseAndValidateLifecycleOptions(options); + if (!lifecycleResult.valid) return lifecycleResult; + if (lifecycleResult.idleTimeout !== undefined) options.idleTimeout = lifecycleResult.idleTimeout; + if (lifecycleResult.maxLifetime !== undefined) options.maxLifetime = lifecycleResult.maxLifetime; + // Validate VPC options const vpcResult = validateVpcOptions(options); if (!vpcResult.valid) { diff --git a/src/cli/commands/create/__tests__/validate.test.ts b/src/cli/commands/create/__tests__/validate.test.ts index e465d44e..57a98206 100644 --- a/src/cli/commands/create/__tests__/validate.test.ts +++ b/src/cli/commands/create/__tests__/validate.test.ts @@ -280,3 +280,65 @@ describe('validateCreateOptions - VPC validation', () => { expect(result.error).toContain('--subnets is required'); }); }); + +describe('validateCreateOptions - lifecycle configuration', () => { + const cwd = join(tmpdir(), `create-lifecycle-${randomUUID()}`); + + const baseOptions = { + name: 'TestProject', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }; + + it('accepts valid idle-timeout', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: '900' }, cwd); + expect(result.valid).toBe(true); + }); + + it('accepts valid max-lifetime', () => { + const result = validateCreateOptions({ ...baseOptions, maxLifetime: '28800' }, cwd); + expect(result.valid).toBe(true); + }); + + it('accepts both when idle <= max', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: '600', maxLifetime: '3600' }, cwd); + expect(result.valid).toBe(true); + }); + + it('rejects idle-timeout below 60', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: '30' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects max-lifetime above 28800', () => { + const result = validateCreateOptions({ ...baseOptions, maxLifetime: '99999' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('rejects idle-timeout > max-lifetime', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: '5000', maxLifetime: '3000' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout must be <= --max-lifetime'); + }); + + it('rejects non-integer idle-timeout', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: '120.5' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects non-numeric idle-timeout', () => { + const result = validateCreateOptions({ ...baseOptions, idleTimeout: 'abc' }, cwd); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('accepts no lifecycle flags', () => { + const result = validateCreateOptions({ ...baseOptions }, cwd); + expect(result.valid).toBe(true); + }); +}); diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index ac11e14e..f947ccbe 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -140,6 +140,8 @@ export interface CreateWithAgentOptions { agentId?: string; agentAliasId?: string; region?: string; + idleTimeout?: number; + maxLifetime?: number; skipGit?: boolean; skipPythonSetup?: boolean; onProgress?: ProgressCallback; @@ -160,6 +162,8 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P subnets, securityGroups, requestHeaderAllowlist, + idleTimeout, + maxLifetime: maxLifetimeOpt, skipGit, skipPythonSetup, onProgress, @@ -237,6 +241,8 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P subnets, securityGroups, requestHeaderAllowlist, + ...(idleTimeout !== undefined && { idleRuntimeSessionTimeout: idleTimeout }), + ...(maxLifetimeOpt !== undefined && { maxLifetime: maxLifetimeOpt }), }; // Resolve credential strategy FIRST (new project has no existing credentials) diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 693053b0..5c0a0b47 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -7,6 +7,7 @@ import type { SDKFramework, TargetLanguage, } from '../../../schema'; +import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema'; import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { CreateScreen } from '../../tui/screens/create'; @@ -136,6 +137,8 @@ async function handleCreateCLI(options: CreateOptions): Promise { networkMode: options.networkMode as NetworkMode | undefined, subnets: parseCommaSeparatedList(options.subnets), securityGroups: parseCommaSeparatedList(options.securityGroups), + idleTimeout: options.idleTimeout ? Number(options.idleTimeout) : undefined, + maxLifetime: options.maxLifetime ? Number(options.maxLifetime) : undefined, skipGit: options.skipGit, skipPythonSetup: options.skipPythonSetup, onProgress, @@ -176,6 +179,14 @@ export const registerCreate = (program: Command) => { .option('--network-mode ', 'Network mode (PUBLIC, VPC) [non-interactive]') .option('--subnets ', 'Comma-separated subnet IDs (required for VPC mode) [non-interactive]') .option('--security-groups ', 'Comma-separated security group IDs (required for VPC mode) [non-interactive]') + .option( + '--idle-timeout ', + `Idle session timeout in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}) [non-interactive]` + ) + .option( + '--max-lifetime ', + `Max instance lifetime in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}) [non-interactive]` + ) .option('--output-dir ', 'Output directory (default: current directory) [non-interactive]') .option('--skip-git', 'Skip git repository initialization [non-interactive]') .option('--skip-python-setup', 'Skip Python virtual environment setup [non-interactive]') diff --git a/src/cli/commands/create/types.ts b/src/cli/commands/create/types.ts index b17a767b..fd9fb13a 100644 --- a/src/cli/commands/create/types.ts +++ b/src/cli/commands/create/types.ts @@ -15,6 +15,8 @@ export interface CreateOptions extends VpcOptions { agentId?: string; agentAliasId?: string; region?: string; + idleTimeout?: number | string; + maxLifetime?: number | string; outputDir?: string; skipGit?: boolean; skipPythonSetup?: boolean; diff --git a/src/cli/commands/create/validate.ts b/src/cli/commands/create/validate.ts index 6920b0ec..b4feec35 100644 --- a/src/cli/commands/create/validate.ts +++ b/src/cli/commands/create/validate.ts @@ -10,6 +10,7 @@ import { matchEnumValue, } from '../../../schema'; import type { ProtocolMode } from '../../../schema'; +import { parseAndValidateLifecycleOptions } from '../shared/lifecycle-utils'; import { validateVpcOptions } from '../shared/vpc-utils'; import type { CreateOptions } from './types'; import { existsSync } from 'fs'; @@ -200,5 +201,11 @@ export function validateCreateOptions(options: CreateOptions, cwd?: string): Val return { valid: false, error: vpcResult.error }; } + // Parse and validate lifecycle configuration + const lifecycleResult = parseAndValidateLifecycleOptions(options); + if (!lifecycleResult.valid) return lifecycleResult; + if (lifecycleResult.idleTimeout !== undefined) options.idleTimeout = lifecycleResult.idleTimeout; + if (lifecycleResult.maxLifetime !== undefined) options.maxLifetime = lifecycleResult.maxLifetime; + return { valid: true }; } diff --git a/src/cli/commands/shared/__tests__/lifecycle-utils.test.ts b/src/cli/commands/shared/__tests__/lifecycle-utils.test.ts new file mode 100644 index 00000000..03f02c68 --- /dev/null +++ b/src/cli/commands/shared/__tests__/lifecycle-utils.test.ts @@ -0,0 +1,92 @@ +import { parseAndValidateLifecycleOptions } from '../lifecycle-utils'; +import { describe, expect, it } from 'vitest'; + +describe('parseAndValidateLifecycleOptions', () => { + it('returns valid when no options are set', () => { + expect(parseAndValidateLifecycleOptions({})).toEqual({ valid: true }); + }); + + it('accepts valid idleTimeout and returns parsed value', () => { + const result = parseAndValidateLifecycleOptions({ idleTimeout: 900 }); + expect(result).toEqual({ valid: true, idleTimeout: 900 }); + }); + + it('accepts valid maxLifetime and returns parsed value', () => { + const result = parseAndValidateLifecycleOptions({ maxLifetime: 3600 }); + expect(result).toEqual({ valid: true, maxLifetime: 3600 }); + }); + + it('accepts both when idle <= max', () => { + const result = parseAndValidateLifecycleOptions({ idleTimeout: 600, maxLifetime: 3600 }); + expect(result).toEqual({ valid: true, idleTimeout: 600, maxLifetime: 3600 }); + }); + + it('accepts boundary values (60 and 28800)', () => { + const result = parseAndValidateLifecycleOptions({ idleTimeout: 60, maxLifetime: 28800 }); + expect(result).toEqual({ valid: true, idleTimeout: 60, maxLifetime: 28800 }); + }); + + it('accepts equal values', () => { + const result = parseAndValidateLifecycleOptions({ idleTimeout: 3600, maxLifetime: 3600 }); + expect(result).toEqual({ valid: true, idleTimeout: 3600, maxLifetime: 3600 }); + }); + + it('rejects idleTimeout below 60', () => { + const result = parseAndValidateLifecycleOptions({ idleTimeout: 59 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects idleTimeout above 28800', () => { + const result = parseAndValidateLifecycleOptions({ idleTimeout: 28801 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects maxLifetime below 60', () => { + const result = parseAndValidateLifecycleOptions({ maxLifetime: 59 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('rejects maxLifetime above 28800', () => { + const result = parseAndValidateLifecycleOptions({ maxLifetime: 28801 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('rejects idle > max', () => { + const result = parseAndValidateLifecycleOptions({ idleTimeout: 5000, maxLifetime: 3000 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout must be <= --max-lifetime'); + }); + + it('rejects non-integer idleTimeout', () => { + const result = parseAndValidateLifecycleOptions({ idleTimeout: 120.5 }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects NaN string idleTimeout', () => { + const result = parseAndValidateLifecycleOptions({ idleTimeout: 'abc' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--idle-timeout'); + }); + + it('rejects NaN string maxLifetime', () => { + const result = parseAndValidateLifecycleOptions({ maxLifetime: 'abc' }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--max-lifetime'); + }); + + it('parses string values to numbers without mutating input', () => { + const opts = { idleTimeout: '300', maxLifetime: '7200' }; + const result = parseAndValidateLifecycleOptions(opts); + expect(result.valid).toBe(true); + expect(result.idleTimeout).toBe(300); + expect(result.maxLifetime).toBe(7200); + // Original input is NOT mutated + expect(opts.idleTimeout).toBe('300'); + expect(opts.maxLifetime).toBe('7200'); + }); +}); diff --git a/src/cli/commands/shared/lifecycle-utils.ts b/src/cli/commands/shared/lifecycle-utils.ts new file mode 100644 index 00000000..9bcd8d36 --- /dev/null +++ b/src/cli/commands/shared/lifecycle-utils.ts @@ -0,0 +1,50 @@ +import { LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../../schema'; + +export interface LifecycleOptions { + idleTimeout?: number | string; + maxLifetime?: number | string; +} + +export interface LifecycleValidationResult { + valid: boolean; + error?: string; + idleTimeout?: number; + maxLifetime?: number; +} + +/** + * Parse and validate lifecycle CLI options. + * Coerces string values to numbers and validates range constraints. + * Returns the parsed numeric values in the result — does NOT mutate the input. + */ +export function parseAndValidateLifecycleOptions(options: LifecycleOptions): LifecycleValidationResult { + let idleTimeout: number | undefined; + let maxLifetime: number | undefined; + + if (options.idleTimeout !== undefined) { + const val = Number(options.idleTimeout); + if (isNaN(val) || !Number.isInteger(val) || val < LIFECYCLE_TIMEOUT_MIN || val > LIFECYCLE_TIMEOUT_MAX) { + return { + valid: false, + error: `--idle-timeout must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX} seconds`, + }; + } + idleTimeout = val; + } + if (options.maxLifetime !== undefined) { + const val = Number(options.maxLifetime); + if (isNaN(val) || !Number.isInteger(val) || val < LIFECYCLE_TIMEOUT_MIN || val > LIFECYCLE_TIMEOUT_MAX) { + return { + valid: false, + error: `--max-lifetime must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX} seconds`, + }; + } + maxLifetime = val; + } + if (idleTimeout !== undefined && maxLifetime !== undefined) { + if (idleTimeout > maxLifetime) { + return { valid: false, error: '--idle-timeout must be <= --max-lifetime' }; + } + } + return { valid: true, idleTimeout, maxLifetime }; +} diff --git a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts index f9c0cc47..4117534f 100644 --- a/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts +++ b/src/cli/operations/agent/generate/__tests__/schema-mapper.test.ts @@ -401,3 +401,61 @@ describe('mapByoConfigToAgent - VPC support', () => { expect(result.networkConfig).toBeUndefined(); }); }); + +describe('mapGenerateConfigToAgent - lifecycleConfiguration', () => { + it('includes lifecycleConfiguration when idleRuntimeSessionTimeout is set', () => { + const result = mapGenerateConfigToAgent({ ...baseConfig, idleRuntimeSessionTimeout: 600 }); + expect(result.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 600 }); + }); + + it('includes lifecycleConfiguration when maxLifetime is set', () => { + const result = mapGenerateConfigToAgent({ ...baseConfig, maxLifetime: 14400 }); + expect(result.lifecycleConfiguration).toEqual({ maxLifetime: 14400 }); + }); + + it('includes both fields when both are set', () => { + const result = mapGenerateConfigToAgent({ ...baseConfig, idleRuntimeSessionTimeout: 300, maxLifetime: 7200 }); + expect(result.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 300, maxLifetime: 7200 }); + }); + + it('omits lifecycleConfiguration when neither field is set', () => { + const result = mapGenerateConfigToAgent(baseConfig); + expect(result.lifecycleConfiguration).toBeUndefined(); + }); +}); + +describe('mapByoConfigToAgent - lifecycleConfiguration', () => { + const baseByoConfig = { + name: 'ByoAgent', + agentType: 'byo' as const, + codeLocation: 'app/ByoAgent/', + entrypoint: 'main.py', + language: 'Python' as const, + buildType: 'CodeZip' as const, + protocol: 'HTTP' as const, + framework: 'Strands' as const, + modelProvider: 'Bedrock' as const, + pythonVersion: 'PYTHON_3_12' as const, + memory: 'none' as const, + }; + + it('includes lifecycleConfiguration when idleRuntimeSessionTimeout is set', () => { + const result = mapByoConfigToAgent({ ...baseByoConfig, idleRuntimeSessionTimeout: 900 }); + expect(result.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 900 }); + }); + + it('includes lifecycleConfiguration when maxLifetime is set', () => { + const result = mapByoConfigToAgent({ ...baseByoConfig, maxLifetime: 28800 }); + expect(result.lifecycleConfiguration).toEqual({ maxLifetime: 28800 }); + }); + + it('includes both fields when both are set', () => { + const result = mapByoConfigToAgent({ ...baseByoConfig, idleRuntimeSessionTimeout: 600, maxLifetime: 3600 }); + expect(result.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 600, maxLifetime: 3600 }); + }); + + it('omits lifecycleConfiguration when neither field is set', () => { + const result = mapByoConfigToAgent(baseByoConfig); + expect(result.lifecycleConfiguration).toBeUndefined(); + }); +}); diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index 6d99a24d..6f15deec 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -138,6 +138,16 @@ export function mapGenerateConfigToAgent(config: GenerateConfig): AgentEnvSpec { config.jwtConfig && { authorizerConfiguration: buildAuthorizerConfigFromJwtConfig(config.jwtConfig), }), + ...(config.idleRuntimeSessionTimeout !== undefined || config.maxLifetime !== undefined + ? { + lifecycleConfiguration: { + ...(config.idleRuntimeSessionTimeout !== undefined && { + idleRuntimeSessionTimeout: config.idleRuntimeSessionTimeout, + }), + ...(config.maxLifetime !== undefined && { maxLifetime: config.maxLifetime }), + }, + } + : {}), ...(protocol !== 'MCP' && { modelProvider: config.modelProvider }), // MCP uses mcp.run() which is incompatible with the opentelemetry-instrument wrapper ...(protocol === 'MCP' && { instrumentation: { enableOtel: false } }), diff --git a/src/cli/operations/agent/import/index.ts b/src/cli/operations/agent/import/index.ts index 0ef77fdc..5b13c4c2 100644 --- a/src/cli/operations/agent/import/index.ts +++ b/src/cli/operations/agent/import/index.ts @@ -29,6 +29,8 @@ export interface ExecuteImportAgentParams { configBaseDir: string; authorizerType?: RuntimeAuthorizerType; jwtConfig?: JwtConfigOptions; + idleTimeout?: number; + maxLifetime?: number; } export async function executeImportAgent( @@ -44,6 +46,8 @@ export async function executeImportAgent( configBaseDir, authorizerType, jwtConfig, + idleTimeout, + maxLifetime, } = params; const projectRoot = dirname(configBaseDir); const agentPath = join(projectRoot, APP_DIR, name); @@ -92,6 +96,8 @@ export async function executeImportAgent( protocol: 'HTTP' as const, authorizerType, jwtConfig, + idleRuntimeSessionTimeout: idleTimeout, + maxLifetime, }; await writeAgentToProject(generateConfig, { configBaseDir }); diff --git a/src/cli/primitives/AgentPrimitive.tsx b/src/cli/primitives/AgentPrimitive.tsx index 33785cd5..07040697 100644 --- a/src/cli/primitives/AgentPrimitive.tsx +++ b/src/cli/primitives/AgentPrimitive.tsx @@ -12,7 +12,7 @@ import type { SDKFramework, TargetLanguage, } from '../../schema'; -import { AgentEnvSpecSchema, CREDENTIAL_PROVIDERS } from '../../schema'; +import { AgentEnvSpecSchema, CREDENTIAL_PROVIDERS, LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN } from '../../schema'; import type { AddAgentOptions as CLIAddAgentOptions } from '../commands/add/types'; import { validateAddAgentOptions } from '../commands/add/validate'; import type { VpcOptions } from '../commands/shared/vpc-utils'; @@ -64,6 +64,8 @@ export interface AddAgentOptions extends VpcOptions { customClaims?: CustomClaimValidation[]; clientId?: string; clientSecret?: string; + idleTimeout?: number; + maxLifetime?: number; } /** @@ -78,6 +80,15 @@ export class AgentPrimitive extends BasePrimitive> { try { const configBaseDir = findConfigRoot(); @@ -212,6 +223,14 @@ export class AgentPrimitive extends BasePrimitive', 'Custom claim validations as JSON array (for CUSTOM_JWT) [non-interactive]') .option('--client-id ', 'OAuth client ID for agent bearer token [non-interactive]') .option('--client-secret ', 'OAuth client secret [non-interactive]') + .option( + '--idle-timeout ', + `Idle session timeout in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}) [non-interactive]` + ) + .option( + '--max-lifetime ', + `Max instance lifetime in seconds (${LIFECYCLE_TIMEOUT_MIN}-${LIFECYCLE_TIMEOUT_MAX}) [non-interactive]` + ) .option('--json', 'Output as JSON [non-interactive]') .action(async options => { if (!findConfigRoot()) { @@ -264,6 +283,8 @@ export class AgentPrimitive extends BasePrimitive('AWS_IAM'); @@ -249,6 +253,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg generateWizard.config.jwtConfig && { jwtConfig: generateWizard.config.jwtConfig, }), + idleRuntimeSessionTimeout: generateWizard.config.idleRuntimeSessionTimeout, + maxLifetime: generateWizard.config.maxLifetime, pythonVersion: DEFAULT_PYTHON_VERSION, memory: generateWizard.config.memory, }; @@ -285,6 +291,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg ...networkSteps, 'requestHeaderAllowlist', 'authorizerType', + 'idleTimeout', + 'maxLifetime', ...steps.slice(afterAdvanced), ]; } @@ -352,6 +360,8 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg ...(requestHeaderAllowlist.length > 0 && { requestHeaderAllowlist }), ...(byoAuthorizerType !== 'AWS_IAM' && { authorizerType: byoAuthorizerType }), ...(byoAuthorizerType === 'CUSTOM_JWT' && byoJwtConfig && { jwtConfig: byoJwtConfig }), + ...(byoConfig.idleTimeout && { idleRuntimeSessionTimeout: Number(byoConfig.idleTimeout) }), + ...(byoConfig.maxLifetime && { maxLifetime: Number(byoConfig.maxLifetime) }), pythonVersion: DEFAULT_PYTHON_VERSION, memory: 'none', }; @@ -402,7 +412,14 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg setByoStep('networkMode'); } else { setByoAdvancedSelected(false); - setByoConfig(c => ({ ...c, networkMode: 'PUBLIC' as NetworkMode, subnets: '', securityGroups: '' })); + setByoConfig(c => ({ + ...c, + networkMode: 'PUBLIC' as NetworkMode, + subnets: '', + securityGroups: '', + idleTimeout: '', + maxLifetime: '', + })); setByoStep('confirm'); } }, @@ -702,7 +719,9 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg byoStep === 'apiKey' || byoStep === 'subnets' || byoStep === 'securityGroups' || - byoStep === 'requestHeaderAllowlist' + byoStep === 'requestHeaderAllowlist' || + byoStep === 'idleTimeout' || + byoStep === 'maxLifetime' ) { return HELP_TEXT.TEXT_INPUT; } @@ -1075,6 +1094,48 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg /> )} + {byoStep === 'idleTimeout' && ( + { + if (!value) return true; + const n = Number(value); + if (isNaN(n) || !Number.isInteger(n) || n < LIFECYCLE_TIMEOUT_MIN || n > LIFECYCLE_TIMEOUT_MAX) + return `Must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX}`; + return true; + }} + onSubmit={value => { + setByoConfig(c => ({ ...c, idleTimeout: value })); + setByoStep('maxLifetime'); + }} + onCancel={handleByoBack} + /> + )} + + {byoStep === 'maxLifetime' && ( + { + if (!value) return true; + const n = Number(value); + if (isNaN(n) || !Number.isInteger(n) || n < LIFECYCLE_TIMEOUT_MIN || n > LIFECYCLE_TIMEOUT_MAX) + return `Must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX}`; + if (byoConfig.idleTimeout) { + const idle = Number(byoConfig.idleTimeout); + if (!isNaN(idle) && n < idle) return 'Must be >= idle timeout'; + } + return true; + }} + onSubmit={value => { + setByoConfig(c => ({ ...c, maxLifetime: value })); + setByoStep('confirm'); + }} + onCancel={handleByoBack} + /> + )} + {byoStep === 'confirm' && ( )} diff --git a/src/cli/tui/screens/agent/types.ts b/src/cli/tui/screens/agent/types.ts index ef7a6acd..a22ff3c1 100644 --- a/src/cli/tui/screens/agent/types.ts +++ b/src/cli/tui/screens/agent/types.ts @@ -52,6 +52,8 @@ export type AddAgentStep = | 'requestHeaderAllowlist' | 'authorizerType' | 'jwtConfig' + | 'idleTimeout' + | 'maxLifetime' | 'memory' | 'region' | 'bedrockAgent' @@ -85,6 +87,10 @@ export interface AddAgentConfig { authorizerType?: RuntimeAuthorizerType; /** JWT config for CUSTOM_JWT authorizer */ jwtConfig?: JwtConfigOptions; + /** Idle session timeout in seconds (LIFECYCLE_TIMEOUT_MIN-LIFECYCLE_TIMEOUT_MAX) */ + idleRuntimeSessionTimeout?: number; + /** Max instance lifetime in seconds (LIFECYCLE_TIMEOUT_MIN-LIFECYCLE_TIMEOUT_MAX) */ + maxLifetime?: number; /** Python version (only for Python agents) */ pythonVersion: PythonRuntime; /** Memory option (create path only) */ @@ -114,6 +120,8 @@ export const ADD_AGENT_STEP_LABELS: Record = { requestHeaderAllowlist: 'Headers', authorizerType: 'Auth', jwtConfig: 'JWT Config', + idleTimeout: 'Idle Timeout', + maxLifetime: 'Max Lifetime', memory: 'Memory', region: 'Region', bedrockAgent: 'Agent', diff --git a/src/cli/tui/screens/agent/useAddAgent.ts b/src/cli/tui/screens/agent/useAddAgent.ts index 894aec19..951ec4d1 100644 --- a/src/cli/tui/screens/agent/useAddAgent.ts +++ b/src/cli/tui/screens/agent/useAddAgent.ts @@ -80,6 +80,16 @@ export function mapByoConfigToAgent(config: AddAgentConfig): AgentEnvSpec { config.jwtConfig && { authorizerConfiguration: buildAuthorizerConfigFromJwtConfig(config.jwtConfig), }), + ...(config.idleRuntimeSessionTimeout !== undefined || config.maxLifetime !== undefined + ? { + lifecycleConfiguration: { + ...(config.idleRuntimeSessionTimeout !== undefined && { + idleRuntimeSessionTimeout: config.idleRuntimeSessionTimeout, + }), + ...(config.maxLifetime !== undefined && { maxLifetime: config.maxLifetime }), + }, + } + : {}), }; } @@ -101,6 +111,8 @@ function mapAddAgentConfigToGenerateConfig(config: AddAgentConfig): GenerateConf requestHeaderAllowlist: config.requestHeaderAllowlist, authorizerType: config.authorizerType, jwtConfig: config.jwtConfig, + idleRuntimeSessionTimeout: config.idleRuntimeSessionTimeout, + maxLifetime: config.maxLifetime, }; } @@ -263,6 +275,8 @@ async function handleImportPath( configBaseDir, authorizerType: config.authorizerType, jwtConfig: config.jwtConfig, + idleTimeout: config.idleRuntimeSessionTimeout, + maxLifetime: config.maxLifetime, }); if (!result.success) { diff --git a/src/cli/tui/screens/generate/GenerateWizardUI.tsx b/src/cli/tui/screens/generate/GenerateWizardUI.tsx index 4805d89a..c1f2c086 100644 --- a/src/cli/tui/screens/generate/GenerateWizardUI.tsx +++ b/src/cli/tui/screens/generate/GenerateWizardUI.tsx @@ -1,5 +1,5 @@ import type { ModelProvider, NetworkMode, RuntimeAuthorizerType } from '../../../../schema'; -import { DEFAULT_MODEL_IDS, ProjectNameSchema } from '../../../../schema'; +import { DEFAULT_MODEL_IDS, LIFECYCLE_TIMEOUT_MAX, LIFECYCLE_TIMEOUT_MIN, ProjectNameSchema } from '../../../../schema'; import { parseAndNormalizeHeaders, validateHeaderAllowlist } from '../../../commands/shared/header-utils'; import { validateSecurityGroupIds, validateSubnetIds } from '../../../commands/shared/vpc-utils'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; @@ -102,6 +102,8 @@ export function GenerateWizardUI({ const isSecurityGroupsStep = wizard.step === 'securityGroups'; const isRequestHeaderAllowlistStep = wizard.step === 'requestHeaderAllowlist'; const isJwtConfigStep = wizard.step === 'jwtConfig'; + const isIdleTimeoutStep = wizard.step === 'idleTimeout'; + const isMaxLifetimeStep = wizard.step === 'maxLifetime'; const isConfirmStep = wizard.step === 'confirm'; const handleSelect = (item: SelectableItem) => { @@ -285,6 +287,53 @@ export function GenerateWizardUI({ /> )} + {isIdleTimeoutStep && ( + { + if (!value) return true; + const n = Number(value); + if (isNaN(n) || !Number.isInteger(n) || n < LIFECYCLE_TIMEOUT_MIN || n > LIFECYCLE_TIMEOUT_MAX) + return `Must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX}`; + return true; + }} + onSubmit={value => { + if (value) { + wizard.setIdleTimeout(Number(value)); + } else { + wizard.skipIdleTimeout(); + } + }} + onCancel={onBack} + /> + )} + + {isMaxLifetimeStep && ( + { + if (!value) return true; + const n = Number(value); + if (isNaN(n) || !Number.isInteger(n) || n < LIFECYCLE_TIMEOUT_MIN || n > LIFECYCLE_TIMEOUT_MAX) + return `Must be an integer between ${LIFECYCLE_TIMEOUT_MIN} and ${LIFECYCLE_TIMEOUT_MAX}`; + if (wizard.config.idleRuntimeSessionTimeout !== undefined && n < wizard.config.idleRuntimeSessionTimeout) { + return 'Must be >= idle timeout'; + } + return true; + }} + onSubmit={value => { + if (value) { + wizard.setMaxLifetime(Number(value)); + } else { + wizard.skipMaxLifetime(); + } + }} + onCancel={onBack} + /> + )} + {isConfirmStep && } ); @@ -296,7 +345,14 @@ export function GenerateWizardUI({ // eslint-disable-next-line react-refresh/only-export-components export function getWizardHelpText(step: GenerateStep): string { if (step === 'confirm') return 'Enter/Y confirm · Esc back'; - if (step === 'projectName' || step === 'subnets' || step === 'securityGroups' || step === 'requestHeaderAllowlist') + if ( + step === 'projectName' || + step === 'subnets' || + step === 'securityGroups' || + step === 'requestHeaderAllowlist' || + step === 'idleTimeout' || + step === 'maxLifetime' + ) return 'Enter submit · Esc cancel'; if (step === 'apiKey') return 'Enter submit · Tab show/hide · Esc back'; if (step === 'jwtConfig') return 'Enter submit · Esc back'; @@ -424,6 +480,18 @@ function ConfirmView({ config, credentialProjectName }: { config: GenerateConfig )} )} + {config.idleRuntimeSessionTimeout !== undefined && ( + + Idle Timeout: + {config.idleRuntimeSessionTimeout}s + + )} + {config.maxLifetime !== undefined && ( + + Max Lifetime: + {config.maxLifetime}s + + )} ); diff --git a/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx b/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx index 78a080ee..cbaeb008 100644 --- a/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx +++ b/src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx @@ -126,6 +126,8 @@ describe('useGenerateWizard — advanced config gate', () => { 'networkMode', 'requestHeaderAllowlist', 'authorizerType', + 'idleTimeout', + 'maxLifetime', 'confirm', ]); }); @@ -146,6 +148,8 @@ describe('useGenerateWizard — advanced config gate', () => { 'securityGroups', 'requestHeaderAllowlist', 'authorizerType', + 'idleTimeout', + 'maxLifetime', 'confirm', ]); }); diff --git a/src/cli/tui/screens/generate/types.ts b/src/cli/tui/screens/generate/types.ts index 2967c406..a4df6c68 100644 --- a/src/cli/tui/screens/generate/types.ts +++ b/src/cli/tui/screens/generate/types.ts @@ -26,6 +26,8 @@ export type GenerateStep = | 'requestHeaderAllowlist' | 'authorizerType' | 'jwtConfig' + | 'idleTimeout' + | 'maxLifetime' | 'confirm'; export type MemoryOption = 'none' | 'shortTerm' | 'longAndShortTerm'; @@ -52,6 +54,10 @@ export interface GenerateConfig { authorizerType?: RuntimeAuthorizerType; /** JWT config for CUSTOM_JWT authorizer */ jwtConfig?: JwtConfigOptions; + /** Idle session timeout in seconds (LIFECYCLE_TIMEOUT_MIN-LIFECYCLE_TIMEOUT_MAX) */ + idleRuntimeSessionTimeout?: number; + /** Max instance lifetime in seconds (LIFECYCLE_TIMEOUT_MIN-LIFECYCLE_TIMEOUT_MAX) */ + maxLifetime?: number; } /** Base steps - apiKey, memory, subnets, securityGroups are conditionally added based on selections */ @@ -83,6 +89,8 @@ export const STEP_LABELS: Record = { requestHeaderAllowlist: 'Headers', authorizerType: 'Auth', jwtConfig: 'JWT Config', + idleTimeout: 'Idle Timeout', + maxLifetime: 'Max Lifetime', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/generate/useGenerateWizard.ts b/src/cli/tui/screens/generate/useGenerateWizard.ts index 0eb9a929..4808ab22 100644 --- a/src/cli/tui/screens/generate/useGenerateWizard.ts +++ b/src/cli/tui/screens/generate/useGenerateWizard.ts @@ -66,6 +66,8 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { ...networkSteps, 'requestHeaderAllowlist', 'authorizerType', + 'idleTimeout', + 'maxLifetime', ...filtered.slice(afterAdvanced), ]; } @@ -185,6 +187,8 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { subnets: undefined, securityGroups: undefined, requestHeaderAllowlist: undefined, + idleRuntimeSessionTimeout: undefined, + maxLifetime: undefined, })); setStep('confirm'); } @@ -224,12 +228,30 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { setStep('jwtConfig'); } else { setConfig(c => ({ ...c, authorizerType, jwtConfig: undefined })); - setStep('confirm'); + setStep('idleTimeout'); } }, []); const setJwtConfig = useCallback((jwtConfig: JwtConfigOptions) => { setConfig(c => ({ ...c, jwtConfig })); + setStep('idleTimeout'); + }, []); + + const setIdleTimeout = useCallback((value: number | undefined) => { + setConfig(c => ({ ...c, idleRuntimeSessionTimeout: value })); + setStep('maxLifetime'); + }, []); + + const skipIdleTimeout = useCallback(() => { + setStep('maxLifetime'); + }, []); + + const setMaxLifetime = useCallback((value: number | undefined) => { + setConfig(c => ({ ...c, maxLifetime: value })); + setStep('confirm'); + }, []); + + const skipMaxLifetime = useCallback(() => { setStep('confirm'); }, []); @@ -283,6 +305,10 @@ export function useGenerateWizard(options?: UseGenerateWizardOptions) { skipRequestHeaderAllowlist, setAuthorizerType, setJwtConfig, + setIdleTimeout, + skipIdleTimeout, + setMaxLifetime, + skipMaxLifetime, goBack, reset, initWithName, diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index 3240dcf0..8af8a6aa 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -7,6 +7,7 @@ import { EnvVarSchema, GatewayNameSchema, InstrumentationSchema, + LifecycleConfigurationSchema, NetworkConfigSchema, } from '../agent-env.js'; import { describe, expect, it } from 'vitest'; @@ -349,3 +350,118 @@ describe('NetworkConfigSchema', () => { expect(result.success).toBe(false); }); }); + +describe('LifecycleConfigurationSchema', () => { + it('accepts empty object (both fields optional)', () => { + expect(LifecycleConfigurationSchema.safeParse({}).success).toBe(true); + }); + + it('accepts valid idleRuntimeSessionTimeout only', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 900 }).success).toBe(true); + }); + + it('accepts valid maxLifetime only', () => { + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 28800 }).success).toBe(true); + }); + + it('accepts both fields when idle <= maxLifetime', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 900, maxLifetime: 28800 }).success).toBe( + true + ); + }); + + it('accepts both fields when idle === maxLifetime', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 3600, maxLifetime: 3600 }).success).toBe( + true + ); + }); + + it('accepts minimum value (60)', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 60 }).success).toBe(true); + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 60 }).success).toBe(true); + }); + + it('accepts maximum value (28800)', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 28800 }).success).toBe(true); + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 28800 }).success).toBe(true); + }); + + it('rejects idle > maxLifetime', () => { + const result = LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 10000, maxLifetime: 5000 }); + expect(result.success).toBe(false); + }); + + it('rejects value below minimum (59)', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 59 }).success).toBe(false); + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 59 }).success).toBe(false); + }); + + it('rejects value above maximum (28801)', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 28801 }).success).toBe(false); + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 28801 }).success).toBe(false); + }); + + it('rejects non-integer values', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: 900.5 }).success).toBe(false); + expect(LifecycleConfigurationSchema.safeParse({ maxLifetime: 100.1 }).success).toBe(false); + }); + + it('rejects non-number values', () => { + expect(LifecycleConfigurationSchema.safeParse({ idleRuntimeSessionTimeout: '900' }).success).toBe(false); + }); +}); + +describe('AgentEnvSpecSchema - lifecycleConfiguration', () => { + const validAgent = { + type: 'AgentCoreRuntime', + name: 'TestAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: 'app/TestAgent/', + runtimeVersion: 'PYTHON_3_12', + }; + + it('accepts agent without lifecycleConfiguration', () => { + expect(AgentEnvSpecSchema.safeParse(validAgent).success).toBe(true); + }); + + it('accepts agent with valid lifecycleConfiguration', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + lifecycleConfiguration: { idleRuntimeSessionTimeout: 300, maxLifetime: 7200 }, + }); + expect(result.success).toBe(true); + }); + + it('accepts agent with only idleRuntimeSessionTimeout', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + lifecycleConfiguration: { idleRuntimeSessionTimeout: 600 }, + }); + expect(result.success).toBe(true); + }); + + it('accepts agent with only maxLifetime', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + lifecycleConfiguration: { maxLifetime: 14400 }, + }); + expect(result.success).toBe(true); + }); + + it('rejects agent with idle > maxLifetime in lifecycleConfiguration', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + lifecycleConfiguration: { idleRuntimeSessionTimeout: 10000, maxLifetime: 5000 }, + }); + expect(result.success).toBe(false); + }); + + it('omits lifecycleConfiguration from parsed output when not provided', () => { + const result = AgentEnvSpecSchema.safeParse(validAgent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.lifecycleConfiguration).toBeUndefined(); + } + }); +}); diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 5c966664..19d73b36 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -141,6 +141,35 @@ export const RequestHeaderAllowlistSchema = z ) .max(MAX_HEADER_ALLOWLIST_SIZE, `Maximum ${MAX_HEADER_ALLOWLIST_SIZE} headers allowed`); +/** Minimum allowed value for lifecycle timeout fields (seconds). */ +export const LIFECYCLE_TIMEOUT_MIN = 60; +/** Maximum allowed value for lifecycle timeout fields (seconds). */ +export const LIFECYCLE_TIMEOUT_MAX = 28800; + +/** + * Lifecycle configuration for runtime sessions. + * Controls idle timeout and max lifetime of runtime instances. + */ +export const LifecycleConfigurationSchema = z + .object({ + /** Idle session timeout in seconds. API default: 900s. */ + idleRuntimeSessionTimeout: z.number().int().min(LIFECYCLE_TIMEOUT_MIN).max(LIFECYCLE_TIMEOUT_MAX).optional(), + /** Max instance lifetime in seconds. API default: 28800s. */ + maxLifetime: z.number().int().min(LIFECYCLE_TIMEOUT_MIN).max(LIFECYCLE_TIMEOUT_MAX).optional(), + }) + .superRefine((data, ctx) => { + if (data.idleRuntimeSessionTimeout !== undefined && data.maxLifetime !== undefined) { + if (data.idleRuntimeSessionTimeout > data.maxLifetime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'idleRuntimeSessionTimeout must be <= maxLifetime', + path: ['idleRuntimeSessionTimeout'], + }); + } + } + }); +export type LifecycleConfiguration = z.infer; + /** * AgentEnvSpec - represents an AgentCore Runtime. * This is a top-level resource in the schema. @@ -172,6 +201,8 @@ export const AgentEnvSpecSchema = z /** Authorizer configuration. Required when authorizerType is CUSTOM_JWT. */ authorizerConfiguration: AuthorizerConfigSchema.optional(), tags: TagsSchema.optional(), + /** Lifecycle configuration for runtime sessions. */ + lifecycleConfiguration: LifecycleConfigurationSchema.optional(), }) .superRefine((data, ctx) => { if (data.networkMode === 'VPC' && !data.networkConfig) {