Skip to content

Commit 1ca0750

Browse files
authored
feat: add runtime lifecycle configuration (idle timeout and max lifetime) (#653)
* feat: add runtime lifecycle configuration (idle timeout and max lifetime) Add --idle-timeout and --max-lifetime flags to create/add commands, with full TUI support in both GenerateWizard (create) and AddAgent (BYO) flows. Values map to lifecycleConfiguration in the agent schema and flow through to the CDK construct. Includes schema validation (60-28800s range, idle <= max), CLI flag validation, E2E test support with AWS API verification, integration tests, and TUI integration tests. * fix: remove lifecycle config from strands-bedrock e2e test The strands-bedrock e2e test reuses an existing deployed runtime that was created without lifecycle config. Adding lifecycle assertions to it causes failures because the already-deployed runtime has default values (900s) rather than the overridden ones (120s). Runtime lifecycle configuration should be validated via a dedicated test, not by modifying shared framework e2e tests. * fix: address review feedback on lifecycle configuration - Extract LIFECYCLE_TIMEOUT_MIN/MAX constants from schema, use everywhere instead of hardcoded 60/28800 values (review comment 2) - Rename validateLifecycleOptions → parseAndValidateLifecycleOptions to clarify intent; return parsed values instead of mutating input (comment 3) - Unify create/add option types to number | string for consistency (comment 4)
1 parent 247de18 commit 1ca0750

File tree

27 files changed

+1606
-8
lines changed

27 files changed

+1606
-8
lines changed

e2e-tests/e2e-helper.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import {
1010
BedrockAgentCoreControlClient,
1111
DeleteApiKeyCredentialProviderCommand,
12+
GetAgentRuntimeCommand,
1213
} from '@aws-sdk/client-bedrock-agentcore-control';
1314
import { execSync } from 'node:child_process';
1415
import { randomUUID } from 'node:crypto';
@@ -26,6 +27,11 @@ interface E2EConfig {
2627
requiredEnvVar?: string;
2728
build?: string;
2829
memory?: string;
30+
/** Lifecycle configuration to pass via --idle-timeout / --max-lifetime flags. */
31+
lifecycleConfig?: {
32+
idleTimeout?: number;
33+
maxLifetime?: number;
34+
};
2935
}
3036

3137
export function createE2ESuite(cfg: E2EConfig) {
@@ -63,6 +69,13 @@ export function createE2ESuite(cfg: E2EConfig) {
6369
createArgs.push('--build', cfg.build);
6470
}
6571

72+
if (cfg.lifecycleConfig?.idleTimeout !== undefined) {
73+
createArgs.push('--idle-timeout', String(cfg.lifecycleConfig.idleTimeout));
74+
}
75+
if (cfg.lifecycleConfig?.maxLifetime !== undefined) {
76+
createArgs.push('--max-lifetime', String(cfg.lifecycleConfig.maxLifetime));
77+
}
78+
6679
// Pass API key so the credential is registered in the project and .env.local
6780
const apiKey = cfg.requiredEnvVar ? process.env[cfg.requiredEnvVar] : undefined;
6881
if (apiKey) {
@@ -262,6 +275,30 @@ export function createE2ESuite(cfg: E2EConfig) {
262275
},
263276
120000
264277
);
278+
279+
// ── Lifecycle configuration verification ─────────────────────────
280+
if (cfg.lifecycleConfig) {
281+
it.skipIf(!canRun)(
282+
'runtime has lifecycle configuration set via AWS API',
283+
async () => {
284+
expect(runtimeId, 'Runtime ID should have been extracted from status').toBeTruthy();
285+
286+
// Query the runtime via AWS API to verify lifecycle config
287+
const region = process.env.AWS_REGION ?? 'us-east-1';
288+
const client = new BedrockAgentCoreControlClient({ region });
289+
const response = await client.send(new GetAgentRuntimeCommand({ agentRuntimeId: runtimeId }));
290+
291+
expect(response.lifecycleConfiguration).toBeDefined();
292+
if (cfg.lifecycleConfig!.idleTimeout !== undefined) {
293+
expect(response.lifecycleConfiguration!.idleRuntimeSessionTimeout).toBe(cfg.lifecycleConfig!.idleTimeout);
294+
}
295+
if (cfg.lifecycleConfig!.maxLifetime !== undefined) {
296+
expect(response.lifecycleConfiguration!.maxLifetime).toBe(cfg.lifecycleConfig!.maxLifetime);
297+
}
298+
},
299+
180000
300+
);
301+
}
265302
});
266303
}
267304

e2e-tests/strands-bedrock.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
import { createE2ESuite } from './e2e-helper.js';
22

3-
createE2ESuite({ framework: 'Strands', modelProvider: 'Bedrock' });
3+
createE2ESuite({
4+
framework: 'Strands',
5+
modelProvider: 'Bedrock',
6+
});
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import { readProjectConfig, runCLI } from '../src/test-utils/index.js';
2+
import { randomUUID } from 'node:crypto';
3+
import { mkdir, rm } from 'node:fs/promises';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
7+
8+
describe('integration: lifecycle configuration', () => {
9+
let testDir: string;
10+
let projectPath: string;
11+
12+
beforeAll(async () => {
13+
testDir = join(tmpdir(), `agentcore-integ-lifecycle-${randomUUID()}`);
14+
await mkdir(testDir, { recursive: true });
15+
16+
const result = await runCLI(['create', '--name', 'LifecycleTest', '--no-agent', '--json'], testDir);
17+
expect(result.exitCode, `setup stderr: ${result.stderr}`).toBe(0);
18+
const json = JSON.parse(result.stdout);
19+
projectPath = json.projectPath;
20+
});
21+
22+
afterAll(async () => {
23+
await rm(testDir, { recursive: true, force: true });
24+
});
25+
26+
describe('create with lifecycle flags', () => {
27+
let createDir: string;
28+
29+
beforeAll(async () => {
30+
createDir = join(tmpdir(), `agentcore-integ-lifecycle-create-${randomUUID()}`);
31+
await mkdir(createDir, { recursive: true });
32+
});
33+
34+
afterAll(async () => {
35+
await rm(createDir, { recursive: true, force: true });
36+
});
37+
38+
it('creates project with --idle-timeout and --max-lifetime', async () => {
39+
const name = `LcCreate${Date.now().toString().slice(-6)}`;
40+
const result = await runCLI(
41+
[
42+
'create',
43+
'--name',
44+
name,
45+
'--language',
46+
'Python',
47+
'--framework',
48+
'Strands',
49+
'--model-provider',
50+
'Bedrock',
51+
'--memory',
52+
'none',
53+
'--idle-timeout',
54+
'300',
55+
'--max-lifetime',
56+
'7200',
57+
'--json',
58+
],
59+
createDir
60+
);
61+
62+
expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0);
63+
const json = JSON.parse(result.stdout);
64+
expect(json.success).toBe(true);
65+
66+
const config = await readProjectConfig(json.projectPath);
67+
const agents = config.agents as Record<string, unknown>[];
68+
expect(agents.length).toBe(1);
69+
70+
const agent = agents[0]!;
71+
const lifecycle = agent.lifecycleConfiguration as Record<string, unknown>;
72+
expect(lifecycle).toBeDefined();
73+
expect(lifecycle.idleRuntimeSessionTimeout).toBe(300);
74+
expect(lifecycle.maxLifetime).toBe(7200);
75+
});
76+
77+
it('creates project with only --idle-timeout', async () => {
78+
const name = `LcIdle${Date.now().toString().slice(-6)}`;
79+
const result = await runCLI(
80+
[
81+
'create',
82+
'--name',
83+
name,
84+
'--language',
85+
'Python',
86+
'--framework',
87+
'Strands',
88+
'--model-provider',
89+
'Bedrock',
90+
'--memory',
91+
'none',
92+
'--idle-timeout',
93+
'600',
94+
'--json',
95+
],
96+
createDir
97+
);
98+
99+
expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0);
100+
const json = JSON.parse(result.stdout);
101+
expect(json.success).toBe(true);
102+
103+
const config = await readProjectConfig(json.projectPath);
104+
const agents = config.agents as Record<string, unknown>[];
105+
const agent = agents[0]!;
106+
const lifecycle = agent.lifecycleConfiguration as Record<string, unknown>;
107+
expect(lifecycle).toBeDefined();
108+
expect(lifecycle.idleRuntimeSessionTimeout).toBe(600);
109+
expect(lifecycle.maxLifetime).toBeUndefined();
110+
});
111+
112+
it('creates project without lifecycle flags — no lifecycleConfiguration in config', async () => {
113+
const name = `LcNone${Date.now().toString().slice(-6)}`;
114+
const result = await runCLI(
115+
[
116+
'create',
117+
'--name',
118+
name,
119+
'--language',
120+
'Python',
121+
'--framework',
122+
'Strands',
123+
'--model-provider',
124+
'Bedrock',
125+
'--memory',
126+
'none',
127+
'--json',
128+
],
129+
createDir
130+
);
131+
132+
expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0);
133+
const json = JSON.parse(result.stdout);
134+
expect(json.success).toBe(true);
135+
136+
const config = await readProjectConfig(json.projectPath);
137+
const agents = config.agents as Record<string, unknown>[];
138+
const agent = agents[0]!;
139+
expect(agent.lifecycleConfiguration).toBeUndefined();
140+
});
141+
});
142+
143+
describe('add agent with lifecycle flags', () => {
144+
it('adds BYO agent with lifecycle config', async () => {
145+
const name = `LcByo${Date.now().toString().slice(-6)}`;
146+
const result = await runCLI(
147+
[
148+
'add',
149+
'agent',
150+
'--name',
151+
name,
152+
'--type',
153+
'byo',
154+
'--language',
155+
'Python',
156+
'--framework',
157+
'Strands',
158+
'--model-provider',
159+
'Bedrock',
160+
'--code-location',
161+
`app/${name}/`,
162+
'--idle-timeout',
163+
'120',
164+
'--max-lifetime',
165+
'3600',
166+
'--json',
167+
],
168+
projectPath
169+
);
170+
171+
expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0);
172+
const json = JSON.parse(result.stdout);
173+
expect(json.success).toBe(true);
174+
175+
const config = await readProjectConfig(projectPath);
176+
const agents = config.agents as Record<string, unknown>[];
177+
const agent = agents.find(a => a.name === name);
178+
expect(agent).toBeDefined();
179+
const lifecycle = agent!.lifecycleConfiguration as Record<string, unknown>;
180+
expect(lifecycle).toBeDefined();
181+
expect(lifecycle.idleRuntimeSessionTimeout).toBe(120);
182+
expect(lifecycle.maxLifetime).toBe(3600);
183+
});
184+
185+
it('adds template agent with only --max-lifetime', async () => {
186+
const name = `LcTmpl${Date.now().toString().slice(-6)}`;
187+
const result = await runCLI(
188+
[
189+
'add',
190+
'agent',
191+
'--name',
192+
name,
193+
'--framework',
194+
'Strands',
195+
'--model-provider',
196+
'Bedrock',
197+
'--memory',
198+
'none',
199+
'--language',
200+
'Python',
201+
'--max-lifetime',
202+
'14400',
203+
'--json',
204+
],
205+
projectPath
206+
);
207+
208+
expect(result.exitCode, `stderr: ${result.stderr}`).toBe(0);
209+
const json = JSON.parse(result.stdout);
210+
expect(json.success).toBe(true);
211+
212+
const config = await readProjectConfig(projectPath);
213+
const agents = config.agents as Record<string, unknown>[];
214+
const agent = agents.find(a => a.name === name);
215+
expect(agent).toBeDefined();
216+
const lifecycle = agent!.lifecycleConfiguration as Record<string, unknown>;
217+
expect(lifecycle).toBeDefined();
218+
expect(lifecycle.idleRuntimeSessionTimeout).toBeUndefined();
219+
expect(lifecycle.maxLifetime).toBe(14400);
220+
});
221+
});
222+
223+
describe('validation rejects invalid lifecycle values', () => {
224+
it('rejects idle-timeout below 60', async () => {
225+
const name = `LcLow${Date.now().toString().slice(-6)}`;
226+
const result = await runCLI(
227+
[
228+
'add',
229+
'agent',
230+
'--name',
231+
name,
232+
'--type',
233+
'byo',
234+
'--language',
235+
'Python',
236+
'--code-location',
237+
`app/${name}/`,
238+
'--idle-timeout',
239+
'30',
240+
'--json',
241+
],
242+
projectPath
243+
);
244+
245+
expect(result.exitCode).not.toBe(0);
246+
});
247+
248+
it('rejects max-lifetime above 28800', async () => {
249+
const name = `LcHigh${Date.now().toString().slice(-6)}`;
250+
const result = await runCLI(
251+
[
252+
'add',
253+
'agent',
254+
'--name',
255+
name,
256+
'--type',
257+
'byo',
258+
'--language',
259+
'Python',
260+
'--code-location',
261+
`app/${name}/`,
262+
'--max-lifetime',
263+
'99999',
264+
'--json',
265+
],
266+
projectPath
267+
);
268+
269+
expect(result.exitCode).not.toBe(0);
270+
});
271+
272+
it('rejects idle-timeout > max-lifetime', async () => {
273+
const name = `LcCross${Date.now().toString().slice(-6)}`;
274+
const result = await runCLI(
275+
[
276+
'add',
277+
'agent',
278+
'--name',
279+
name,
280+
'--type',
281+
'byo',
282+
'--language',
283+
'Python',
284+
'--code-location',
285+
`app/${name}/`,
286+
'--idle-timeout',
287+
'5000',
288+
'--max-lifetime',
289+
'3000',
290+
'--json',
291+
],
292+
projectPath
293+
);
294+
295+
expect(result.exitCode).not.toBe(0);
296+
});
297+
298+
it('rejects non-integer idle-timeout', async () => {
299+
const name = `LcFloat${Date.now().toString().slice(-6)}`;
300+
const result = await runCLI(
301+
[
302+
'add',
303+
'agent',
304+
'--name',
305+
name,
306+
'--type',
307+
'byo',
308+
'--language',
309+
'Python',
310+
'--code-location',
311+
`app/${name}/`,
312+
'--idle-timeout',
313+
'120.5',
314+
'--json',
315+
],
316+
projectPath
317+
);
318+
319+
expect(result.exitCode).not.toBe(0);
320+
});
321+
});
322+
});

0 commit comments

Comments
 (0)