diff --git a/.gitignore b/.gitignore index d22e52199..6613a8f02 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ ProtocolTesting/ # Auto-cloned CDK constructs (from scripts/bundle.mjs) .cdk-constructs-clone/ +.omc/ # Browser tests browser-tests/.browser-test-env diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 8151fcac8..28ced03c1 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -6,6 +6,7 @@ import type { OnlineEvalDeployedState, PolicyDeployedState, PolicyEngineDeployedState, + RuntimeEndpointDeployedState, TargetDeployedState, } from '../../schema'; import { getCredentialProvider } from '../aws'; @@ -338,6 +339,40 @@ export function parsePolicyOutputs( return policies; } +/** + * Parse stack outputs into deployed state for runtime endpoints. + * + * Output key pattern: ApplicationAgent{AgentPascal}Endpoint{AgentPascal}{EndpointPascal}(Id|Arn)Output{Hash} + * The Agent{PascalName} prefix comes from the AgentEnvironment construct in the CDK tree. + */ +export function parseRuntimeEndpointOutputs( + outputs: StackOutputs, + endpointSpecs: { agentName: string; endpointName: string }[] +): Record { + const endpoints: Record = {}; + const outputKeys = Object.keys(outputs); + + for (const { agentName, endpointName } of endpointSpecs) { + const agentPascal = toPascalId(agentName); + const endpointPascal = toPascalId('Endpoint', agentName, endpointName); + const idPrefix = `ApplicationAgent${agentPascal}${endpointPascal}IdOutput`; + const arnPrefix = `ApplicationAgent${agentPascal}${endpointPascal}ArnOutput`; + + const idKey = outputKeys.find(k => k.startsWith(idPrefix)); + const arnKey = outputKeys.find(k => k.startsWith(arnPrefix)); + + if (idKey && arnKey) { + const key = `${agentName}/${endpointName}`; + endpoints[key] = { + endpointId: outputs[idKey]!, + endpointArn: outputs[arnKey]!, + }; + } + } + + return endpoints; +} + export interface BuildDeployedStateOptions { targetName: string; stackName: string; @@ -351,6 +386,7 @@ export interface BuildDeployedStateOptions { onlineEvalConfigs?: Record; policyEngines?: Record; policies?: Record; + runtimeEndpoints?: Record; } /** @@ -370,6 +406,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta onlineEvalConfigs, policyEngines, policies, + runtimeEndpoints, } = opts; const targetState: TargetDeployedState = { resources: { @@ -404,6 +441,11 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta targetState.resources!.onlineEvalConfigs = onlineEvalConfigs; } + // Add runtime endpoint state if endpoints exist + if (runtimeEndpoints && Object.keys(runtimeEndpoints).length > 0) { + targetState.resources!.runtimeEndpoints = runtimeEndpoints; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index b3d7ad55b..ed88c1dfe 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -13,6 +13,7 @@ import { parseOnlineEvalOutputs, parsePolicyEngineOutputs, parsePolicyOutputs, + parseRuntimeEndpointOutputs, } from '../../cloudformation'; import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; @@ -403,6 +404,17 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise({ getIdentifier, getLocalDetail, getDeployedKey, + getParentName, }: { resourceType: ResourceStatusEntry['resourceType']; localItems: TLocal[]; @@ -86,6 +89,7 @@ function diffResourceSet({ getIdentifier: (deployed: TDeployed) => string | undefined; getLocalDetail?: (item: TLocal) => string | undefined; getDeployedKey?: (item: TLocal) => string; + getParentName?: (item: TLocal) => string | undefined; }): ResourceStatusEntry[] { const entries: ResourceStatusEntry[] = []; const localKeys = new Set(localItems.map(item => (getDeployedKey ? getDeployedKey(item) : item.name))); @@ -99,16 +103,20 @@ function diffResourceSet({ deploymentState: deployed ? 'deployed' : 'local-only', identifier: deployed ? getIdentifier(deployed) : undefined, detail: getLocalDetail?.(item), + parentName: getParentName?.(item), }); } for (const [name, deployed] of Object.entries(deployedRecord)) { if (!localKeys.has(name)) { + // For pending-removal entries, try to extract parentName from composite key + const slashIdx = name.indexOf('/'); entries.push({ resourceType, name, deploymentState: 'pending-removal', identifier: getIdentifier(deployed), + parentName: getParentName && slashIdx > 0 ? name.substring(0, slashIdx) : undefined, }); } } @@ -202,8 +210,34 @@ export function computeResourceStatuses( getDeployedKey: item => `${item.engineName}/${item.name}`, }); + // Flatten runtime endpoints for diffing against deployed state + const localEndpoints: { name: string; agentName: string; version: number; description?: string }[] = []; + for (const runtime of project.runtimes) { + if (runtime.endpoints) { + for (const [epName, ep] of Object.entries(runtime.endpoints)) { + localEndpoints.push({ + name: epName, + agentName: runtime.name, + version: ep.version, + description: ep.description, + }); + } + } + } + + const runtimeEndpoints = diffResourceSet({ + resourceType: 'runtime-endpoint', + localItems: localEndpoints, + deployedRecord: resources?.runtimeEndpoints ?? {}, + getIdentifier: deployed => deployed.endpointArn, + getLocalDetail: item => `v${item.version}${item.description ? ` — ${item.description}` : ''}`, + getDeployedKey: item => `${item.agentName}/${item.name}`, + getParentName: item => item.agentName, + }); + return [ ...agents, + ...runtimeEndpoints, ...credentials, ...memories, ...gateways, diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index 9e6de5e37..76c4580ea 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -9,6 +9,7 @@ import { Box, Text, render } from 'ink'; const VALID_RESOURCE_TYPES = [ 'agent', + 'runtime-endpoint', 'memory', 'credential', 'gateway', @@ -58,7 +59,7 @@ export const registerStatus = (program: Command) => { .option('--target ', 'Select deployment target') .option( '--type ', - 'Filter by resource type (agent, memory, credential, gateway, evaluator, online-eval, policy-engine, policy)' + 'Filter by resource type (agent, runtime-endpoint, memory, credential, gateway, evaluator, online-eval, policy-engine, policy)' ) .option('--state ', 'Filter by deployment state (deployed, local-only, pending-removal)') .option('--runtime ', 'Filter to a specific runtime') @@ -135,6 +136,7 @@ export const registerStatus = (program: Command) => { const filtered = filterResources(result.resources, cliOptions); const agents = filtered.filter(r => r.resourceType === 'agent'); + const runtimeEndpoints = filtered.filter(r => r.resourceType === 'runtime-endpoint'); const credentials = filtered.filter(r => r.resourceType === 'credential'); const memories = filtered.filter(r => r.resourceType === 'memory'); const gateways = filtered.filter(r => r.resourceType === 'gateway'); @@ -153,15 +155,41 @@ export const registerStatus = (program: Command) => { {agents.length > 0 && ( Agents - {agents.map(entry => ( - - - {entry.invocationUrl && ( - - {' '}URL: {entry.invocationUrl} - - )} - + {agents.map(entry => { + // Find endpoints belonging to this agent + const agentEndpoints = runtimeEndpoints.filter(ep => ep.parentName === entry.name); + return ( + + + {entry.invocationUrl && ( + + {' '}URL: {entry.invocationUrl} + + )} + {agentEndpoints.map(ep => ( + + {' '}◉ {ep.name} {ep.detail}{' '} + + [{DEPLOYMENT_STATE_LABELS[ep.deploymentState] ?? ep.deploymentState}] + + + ))} + + ); + })} + + )} + + {agents.length === 0 && runtimeEndpoints.length > 0 && ( + + Runtime Endpoints + {runtimeEndpoints.map(ep => ( + + {' '}◉ {ep.parentName}/{ep.name} {ep.detail}{' '} + + [{DEPLOYMENT_STATE_LABELS[ep.deploymentState] ?? ep.deploymentState}] + + ))} )} diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index 2bfffcaa4..9ec7e43ad 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -13,6 +13,7 @@ export interface RemoveLoggerOptions { | 'credential' | 'gateway' | 'gateway-target' + | 'runtime-endpoint' | 'evaluator' | 'online-eval' | 'policy-engine' diff --git a/src/cli/primitives/RuntimeEndpointPrimitive.ts b/src/cli/primitives/RuntimeEndpointPrimitive.ts new file mode 100644 index 000000000..358b5fa69 --- /dev/null +++ b/src/cli/primitives/RuntimeEndpointPrimitive.ts @@ -0,0 +1,354 @@ +import { findConfigRoot } from '../../lib'; +import type { AgentCoreProjectSpec } from '../../schema'; +import { RuntimeEndpointSchema } from '../../schema'; +import type { ResourceType } from '../commands/remove/types'; +import { getErrorMessage } from '../errors'; +import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; +import { BasePrimitive } from './BasePrimitive'; +import { SOURCE_CODE_NOTE } from './constants'; +import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +/** + * Options for adding a runtime endpoint (CLI-level). + */ +export interface AddRuntimeEndpointOptions { + runtime: string; + endpoint: string; + version?: number; + description?: string; +} + +/** + * Represents a runtime endpoint that can be removed. + */ +export interface RemovableRuntimeEndpoint extends RemovableResource { + runtimeName: string; + endpointName: string; + version: number; + description?: string; +} + +/** + * RuntimeEndpointPrimitive handles all runtime endpoint (version alias) add/remove operations. + * Endpoints are sub-resources of runtimes, stored in the `endpoints` dictionary on each runtime. + */ +export class RuntimeEndpointPrimitive extends BasePrimitive { + readonly kind: ResourceType = 'runtime-endpoint'; + readonly label = 'Runtime Endpoint'; + readonly primitiveSchema = RuntimeEndpointSchema; + + async add(options: AddRuntimeEndpointOptions): Promise { + try { + const project = await this.readProjectSpec(); + + // Find the parent runtime + const runtime = project.runtimes.find(a => a.name === options.runtime); + if (!runtime) { + return { success: false, error: `Runtime "${options.runtime}" not found.` }; + } + + // Initialize endpoints dictionary if needed + runtime.endpoints ??= {}; + + // Check for duplicate endpoint name + if (runtime.endpoints[options.endpoint]) { + return { + success: false, + error: `Endpoint "${options.endpoint}" already exists on runtime "${options.runtime}".`, + }; + } + + // Validate version is a positive integer + const version = options.version ?? 1; + if (!Number.isInteger(version) || version < 1) { + return { success: false, error: `Version must be a positive integer (got ${version}).` }; + } + + // Check version against latest deployed version + try { + if (this.configIO.configExists('state')) { + const deployedState = await this.configIO.readDeployedState(); + for (const target of Object.values(deployedState.targets)) { + const deployedRuntime = target.resources?.runtimes?.[options.runtime]; + if (deployedRuntime?.runtimeVersion && version > deployedRuntime.runtimeVersion) { + return { + success: false, + error: `Version ${version} exceeds latest deployed version ${deployedRuntime.runtimeVersion} for runtime "${options.runtime}".`, + }; + } + } + } + } catch { + // Deployed state may not exist or be readable — skip version range check + } + + // Build and validate the endpoint config + const config = { + version, + ...(options.description ? { description: options.description } : {}), + }; + RuntimeEndpointSchema.parse(config); + + // Set the endpoint on the runtime + runtime.endpoints[options.endpoint] = config; + + // Write updated project spec + await this.writeProjectSpec(project); + + return { + success: true, + endpointName: options.endpoint, + agent: options.runtime, + version: config.version, + }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + async remove(name: string): Promise { + try { + const project = await this.readProjectSpec(); + + // Support composite key: runtimeName/endpointName + const slashIndex = name.indexOf('/'); + if (slashIndex > 0) { + const runtimeName = name.substring(0, slashIndex); + const endpointName = name.substring(slashIndex + 1); + const runtime = project.runtimes.find(r => r.name === runtimeName); + if (!runtime?.endpoints?.[endpointName]) { + return { success: false, error: `Runtime endpoint "${name}" not found.` }; + } + delete runtime.endpoints[endpointName]; + if (Object.keys(runtime.endpoints).length === 0) { + delete runtime.endpoints; + } + await this.writeProjectSpec(project); + return { success: true }; + } + + // Legacy: bare endpoint name — search all runtimes + for (const runtime of project.runtimes) { + if (runtime.endpoints?.[name]) { + delete runtime.endpoints[name]; + if (Object.keys(runtime.endpoints).length === 0) { + delete runtime.endpoints; + } + await this.writeProjectSpec(project); + return { success: true }; + } + } + + return { success: false, error: `Runtime endpoint "${name}" not found.` }; + } catch (err) { + return { success: false, error: getErrorMessage(err) }; + } + } + + async previewRemove(name: string): Promise { + const project = await this.readProjectSpec(); + + // Support composite key: runtimeName/endpointName + let runtimeName: string | undefined; + let endpointName: string = name; + let endpointConfig: { version: number; description?: string } | undefined; + + const slashIndex = name.indexOf('/'); + if (slashIndex > 0) { + runtimeName = name.substring(0, slashIndex); + endpointName = name.substring(slashIndex + 1); + const runtime = project.runtimes.find(r => r.name === runtimeName); + if (runtime?.endpoints?.[endpointName]) { + endpointConfig = runtime.endpoints[endpointName]; + } + } else { + // Legacy: bare endpoint name — search all runtimes + for (const runtime of project.runtimes) { + if (runtime.endpoints?.[name]) { + runtimeName = runtime.name; + endpointConfig = runtime.endpoints[name]; + break; + } + } + } + + if (!runtimeName || !endpointConfig) { + throw new Error(`Runtime endpoint "${name}" not found.`); + } + + const summary: string[] = []; + const schemaChanges: SchemaChange[] = []; + + summary.push(`Removing runtime endpoint: ${endpointName} (from runtime "${runtimeName}")`); + summary.push(` Version: ${endpointConfig.version}`); + if (endpointConfig.description) { + summary.push(` Description: ${endpointConfig.description}`); + } + + // Build after state + const afterProject = JSON.parse(JSON.stringify(project)) as AgentCoreProjectSpec; + const afterRuntime = afterProject.runtimes.find(a => a.name === runtimeName); + if (afterRuntime?.endpoints) { + delete afterRuntime.endpoints[endpointName]; + if (Object.keys(afterRuntime.endpoints).length === 0) { + delete afterRuntime.endpoints; + } + } + + schemaChanges.push({ + file: 'agentcore/agentcore.json', + before: project, + after: afterProject, + }); + + return { summary, directoriesToDelete: [], schemaChanges }; + } + + async getRemovable(): Promise { + try { + const project = await this.readProjectSpec(); + const removable: RemovableRuntimeEndpoint[] = []; + + for (const runtime of project.runtimes) { + if (!runtime.endpoints) continue; + + for (const [endpointName, endpointConfig] of Object.entries(runtime.endpoints)) { + removable.push({ + name: `${runtime.name}/${endpointName}`, + type: 'runtime-endpoint', + runtimeName: runtime.name, + endpointName, + version: endpointConfig.version, + description: endpointConfig.description, + }); + } + } + + return removable; + } catch { + return []; + } + } + + registerCommands(addCmd: Command, removeCmd: Command): void { + addCmd + .command('runtime-endpoint') + .description('Add a named endpoint (version alias) to a runtime') + .requiredOption('--runtime ', 'Runtime name to add the endpoint to') + .requiredOption('--endpoint ', 'Endpoint name (e.g., prod, staging)') + .option('--version ', 'Version number to alias (default: 1)', Number) + .option('--description ', 'Description of the endpoint') + .option('--json', 'Output as JSON [non-interactive]') + .action( + async (cliOptions: { + runtime: string; + endpoint: string; + version?: number; + description?: string; + json?: boolean; + }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + const result = await this.add({ + runtime: cliOptions.runtime, + endpoint: cliOptions.endpoint, + version: cliOptions.version, + description: cliOptions.description, + }); + + if (cliOptions.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added runtime endpoint '${cliOptions.endpoint}' to runtime '${cliOptions.runtime}'`); + } else { + console.error(result.error); + } + + process.exit(result.success ? 0 : 1); + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + } + ); + + removeCmd + .command('runtime-endpoint') + .description('Remove a runtime endpoint from the project') + .option('--name ', 'Name of resource to remove [non-interactive]') + .option('-y, --yes', 'Skip confirmation prompt [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .action(async (cliOptions: { name?: string; yes?: boolean; json?: boolean }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.name || cliOptions.yes || cliOptions.json) { + if (!cliOptions.name) { + console.log(JSON.stringify({ success: false, error: '--name is required' })); + process.exit(1); + } + + const result = await this.remove(cliOptions.name); + console.log( + JSON.stringify({ + success: result.success, + resourceType: this.kind, + resourceName: cliOptions.name, + message: result.success ? `Removed runtime endpoint '${cliOptions.name}'` : undefined, + note: result.success ? SOURCE_CODE_NOTE : undefined, + error: !result.success ? result.error : undefined, + }) + ); + process.exit(result.success ? 0 : 1); + } else { + const [{ render }, { default: React }, { RemoveFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/remove'), + ]); + const { clear, unmount } = render( + React.createElement(RemoveFlow, { + isInteractive: false, + force: cliOptions.yes, + initialResourceType: this.kind, + initialResourceName: cliOptions.name, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + }); + } + + addScreen(): AddScreenComponent { + return null; + } + + /** + * Stub for future cross-reference validation. + * Checks if any gateway targets reference a given runtime endpoint. + */ +} diff --git a/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts b/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts new file mode 100644 index 000000000..46fe426f5 --- /dev/null +++ b/src/cli/primitives/__tests__/RuntimeEndpointPrimitive.test.ts @@ -0,0 +1,354 @@ +import { RuntimeEndpointPrimitive } from '../RuntimeEndpointPrimitive.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); +const mockConfigExists = vi.fn(); +const mockReadDeployedState = vi.fn(); + +vi.mock('../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + configExists = mockConfigExists; + readDeployedState = mockReadDeployedState; + }, + findConfigRoot: () => '/fake/root', +})); + +function makeProject( + runtimes: { + name: string; + endpoints?: Record; + }[] = [] +) { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: runtimes.map(r => ({ + name: r.name, + build: 'CodeZip' as const, + entrypoint: 'main.py' as any, + codeLocation: `app/${r.name}/` as any, + runtimeVersion: 'PYTHON_3_14' as any, + ...(r.endpoints && { endpoints: r.endpoints }), + })), + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + }; +} + +const primitive = new RuntimeEndpointPrimitive(); + +describe('RuntimeEndpointPrimitive', () => { + afterEach(() => vi.clearAllMocks()); + + it('has kind "runtime-endpoint"', () => { + expect(primitive.kind).toBe('runtime-endpoint'); + }); + + it('has label "Runtime Endpoint"', () => { + expect(primitive.label).toBe('Runtime Endpoint'); + }); + + describe('add', () => { + it('successfully adds endpoint to a runtime', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'prod', + version: 3, + description: 'Production endpoint', + }); + + expect(result.success).toBe(true); + + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + const runtime = writtenSpec.runtimes.find((r: any) => r.name === 'MyRuntime'); + expect(runtime.endpoints).toHaveProperty('prod'); + expect(runtime.endpoints.prod.version).toBe(3); + expect(runtime.endpoints.prod.description).toBe('Production endpoint'); + }); + + it('returns error when runtime not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'OtherRuntime' }])); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'NonExistent', + endpoint: 'prod', + }); + + expect(result).toEqual(expect.objectContaining({ success: false, error: expect.stringContaining('not found') })); + }); + + it('returns error when endpoint already exists', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime', endpoints: { prod: { version: 1 } } }])); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'prod', + }); + + expect(result).toEqual( + expect.objectContaining({ success: false, error: expect.stringContaining('already exists') }) + ); + }); + + it('defaults version to 1 when not provided', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'staging', + }); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.runtimes[0].endpoints.staging.version).toBe(1); + }); + + it.each([ + { version: 0, label: 'zero' }, + { version: -1, label: 'negative' }, + { version: 3.5, label: 'non-integer' }, + ])('returns error when version is $label ($version)', async ({ version }) => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'prod', + version, + }); + + expect(result).toEqual( + expect.objectContaining({ success: false, error: expect.stringContaining('positive integer') }) + ); + }); + + it('returns richer JSON response with endpointName, agent, and version', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockConfigExists.mockReturnValue(false); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'prod', + version: 2, + }); + + expect(result).toEqual( + expect.objectContaining({ + success: true, + endpointName: 'prod', + agent: 'MyRuntime', + version: 2, + }) + ); + }); + + it('returns error when version exceeds latest deployed version', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + mockConfigExists.mockReturnValue(true); + mockReadDeployedState.mockResolvedValue({ + targets: { + 'us-east-1': { + resources: { + runtimes: { + MyRuntime: { runtimeVersion: 3 }, + }, + }, + }, + }, + }); + + const result = await primitive.add({ + runtime: 'MyRuntime', + endpoint: 'prod', + version: 5, + }); + + expect(result).toEqual( + expect.objectContaining({ success: false, error: expect.stringContaining('exceeds latest deployed version') }) + ); + }); + }); + + describe('remove', () => { + it('removes endpoint using composite key runtimeName/endpointName', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject([ + { + name: 'MyRuntime', + endpoints: { prod: { version: 1 }, staging: { version: 2 } }, + }, + ]) + ); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('MyRuntime/prod'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + const runtime = writtenSpec.runtimes[0]; + expect(runtime.endpoints).not.toHaveProperty('prod'); + expect(runtime.endpoints).toHaveProperty('staging'); + }); + + it('removes endpoint using legacy bare name (fallback)', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime', endpoints: { prod: { version: 1 } } }])); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('prod'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.runtimes[0].endpoints).toBeUndefined(); + }); + + it('returns error when endpoint not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + + const result = await primitive.remove('MyRuntime/nonexistent'); + + expect(result).toEqual(expect.objectContaining({ success: false, error: expect.stringContaining('not found') })); + }); + + it('cleans up empty endpoints dict after removing last endpoint', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime', endpoints: { prod: { version: 1 } } }])); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('MyRuntime/prod'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.runtimes[0].endpoints).toBeUndefined(); + }); + + it('correctly targets the right runtime when same endpoint name exists on multiple runtimes', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject([ + { name: 'RuntimeA', endpoints: { prod: { version: 1 } } }, + { name: 'RuntimeB', endpoints: { prod: { version: 2 } } }, + ]) + ); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('RuntimeB/prod'); + + expect(result.success).toBe(true); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0]; + // RuntimeA should still have its prod endpoint + expect(writtenSpec.runtimes[0].endpoints).toHaveProperty('prod'); + // RuntimeB should have had its prod endpoint removed + expect(writtenSpec.runtimes[1].endpoints).toBeUndefined(); + }); + }); + + describe('previewRemove', () => { + it('returns summary with correct runtime and endpoint info using composite key', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject([ + { + name: 'MyRuntime', + endpoints: { prod: { version: 3, description: 'Production' } }, + }, + ]) + ); + + const preview = await primitive.previewRemove('MyRuntime/prod'); + + expect(preview.summary).toEqual( + expect.arrayContaining([expect.stringContaining('prod'), expect.stringContaining('MyRuntime')]) + ); + expect(preview.summary).toEqual(expect.arrayContaining([expect.stringContaining('Version: 3')])); + expect(preview.summary).toEqual(expect.arrayContaining([expect.stringContaining('Production')])); + }); + + it('returns schemaChanges showing before/after agentcore.json', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime', endpoints: { prod: { version: 1 } } }])); + + const preview = await primitive.previewRemove('MyRuntime/prod'); + + expect(preview.schemaChanges).toHaveLength(1); + expect(preview.schemaChanges[0]!.file).toBe('agentcore/agentcore.json'); + + // Before should have the endpoint + const before = preview.schemaChanges[0]!.before as any; + expect(before.runtimes[0].endpoints).toHaveProperty('prod'); + + // After should not have the endpoint (and endpoints dict cleaned up) + const after = preview.schemaChanges[0]!.after as any; + expect(after.runtimes[0].endpoints).toBeUndefined(); + }); + + it('throws when endpoint not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + + await expect(primitive.previewRemove('MyRuntime/missing')).rejects.toThrow('not found'); + }); + }); + + describe('getRemovable', () => { + it('returns all endpoints across all runtimes', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject([ + { name: 'RuntimeA', endpoints: { prod: { version: 1 }, staging: { version: 2 } } }, + { name: 'RuntimeB', endpoints: { beta: { version: 3 } } }, + ]) + ); + + const result = await primitive.getRemovable(); + + expect(result).toHaveLength(3); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'RuntimeA/prod', runtimeName: 'RuntimeA', endpointName: 'prod', version: 1 }), + expect.objectContaining({ + name: 'RuntimeA/staging', + runtimeName: 'RuntimeA', + endpointName: 'staging', + version: 2, + }), + expect.objectContaining({ name: 'RuntimeB/beta', runtimeName: 'RuntimeB', endpointName: 'beta', version: 3 }), + ]) + ); + }); + + it('uses composite key format runtimeName/endpointName for name field', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime', endpoints: { prod: { version: 1 } } }])); + + const result = await primitive.getRemovable(); + + expect(result[0]!.name).toBe('MyRuntime/prod'); + }); + + it('returns empty array when no endpoints exist', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject([{ name: 'MyRuntime' }])); + + const result = await primitive.getRemovable(); + + expect(result).toEqual([]); + }); + + it('returns empty array when no runtimes exist', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + const result = await primitive.getRemovable(); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/cli/primitives/index.ts b/src/cli/primitives/index.ts index 2ef948e57..2f19f9f3a 100644 --- a/src/cli/primitives/index.ts +++ b/src/cli/primitives/index.ts @@ -6,6 +6,8 @@ export { EvaluatorPrimitive } from './EvaluatorPrimitive'; export { OnlineEvalConfigPrimitive } from './OnlineEvalConfigPrimitive'; export { GatewayPrimitive } from './GatewayPrimitive'; export { GatewayTargetPrimitive } from './GatewayTargetPrimitive'; +export { RuntimeEndpointPrimitive } from './RuntimeEndpointPrimitive'; +export type { AddRuntimeEndpointOptions, RemovableRuntimeEndpoint } from './RuntimeEndpointPrimitive'; export { ALL_PRIMITIVES, agentPrimitive, @@ -15,6 +17,7 @@ export { onlineEvalConfigPrimitive, gatewayPrimitive, gatewayTargetPrimitive, + runtimeEndpointPrimitive, getPrimitive, } from './registry'; export { SOURCE_CODE_NOTE } from './constants'; diff --git a/src/cli/primitives/registry.ts b/src/cli/primitives/registry.ts index fd46a6be7..2680d1ea6 100644 --- a/src/cli/primitives/registry.ts +++ b/src/cli/primitives/registry.ts @@ -8,6 +8,7 @@ import { MemoryPrimitive } from './MemoryPrimitive'; import { OnlineEvalConfigPrimitive } from './OnlineEvalConfigPrimitive'; import { PolicyEnginePrimitive } from './PolicyEnginePrimitive'; import { PolicyPrimitive } from './PolicyPrimitive'; +import { RuntimeEndpointPrimitive } from './RuntimeEndpointPrimitive'; import type { RemovableResource } from './types'; /** @@ -22,6 +23,7 @@ export const gatewayPrimitive = new GatewayPrimitive(); export const gatewayTargetPrimitive = new GatewayTargetPrimitive(); export const policyEnginePrimitive = new PolicyEnginePrimitive(); export const policyPrimitive = new PolicyPrimitive(); +export const runtimeEndpointPrimitive = new RuntimeEndpointPrimitive(); /** * All primitives in display order. @@ -36,6 +38,7 @@ export const ALL_PRIMITIVES: BasePrimitive[] = [ gatewayTargetPrimitive, policyEnginePrimitive, policyPrimitive, + runtimeEndpointPrimitive, ]; /** diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index 26fdcbd1a..44bcf0b77 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -20,6 +20,7 @@ const ICONS = { 'online-eval': '↻', 'policy-engine': '▣', policy: '▢', + 'runtime-endpoint': '◉', } as const; interface ResourceGraphProps { @@ -180,17 +181,34 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res const runtimeStatus = rsEntry?.error ? 'error' : rsEntry?.detail; const runtimeStatusColor = rsEntry?.error ? 'red' : getStatusColor(runtimeStatus); return ( - + + + {agent.endpoints && + Object.entries(agent.endpoints).map(([epName, ep]) => { + // Endpoints inherit deployment state from parent runtime + const parentState = rsEntry?.deploymentState; + const epState = parentState === 'deployed' ? 'deployed' : 'local-only'; + const badge = getDeploymentBadge(epState); + return ( + + {' '} + {ICONS['runtime-endpoint']} {epName} + v{ep.version} + {ep.description && {ep.description}} + {badge && [{badge.text}]} + + ); + })} + ); })} diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index ad97362ac..8331d38e6 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -4,6 +4,7 @@ import type { RemovableGatewayTarget, RemovalPreview, RemovalResult } from '../. import type { RemovableCredential } from '../../primitives/CredentialPrimitive'; import type { RemovableMemory } from '../../primitives/MemoryPrimitive'; import type { RemovablePolicyResource } from '../../primitives/PolicyPrimitive'; +import type { RemovableRuntimeEndpoint } from '../../primitives/RuntimeEndpointPrimitive'; import { agentPrimitive, credentialPrimitive, @@ -14,6 +15,7 @@ import { onlineEvalConfigPrimitive, policyEnginePrimitive, policyPrimitive, + runtimeEndpointPrimitive, } from '../../primitives/registry'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -23,6 +25,7 @@ export type { RemovableCredential as RemovableIdentity, RemovableGatewayTarget, RemovablePolicyResource, + RemovableRuntimeEndpoint, }; // ============================================================================ @@ -147,6 +150,13 @@ export function useRemovablePolicies() { return { policies, ...rest }; } +export function useRemovableRuntimeEndpoints() { + const { items: endpoints, ...rest } = useRemovableResources(() => + runtimeEndpointPrimitive.getRemovable() + ); + return { endpoints, ...rest }; +} + // ============================================================================ // Preview Hook // ============================================================================ @@ -218,6 +228,10 @@ export function useRemovalPreview() { (compositeKey: string) => loadPreview(k => policyPrimitive.previewRemove(k), compositeKey), [loadPreview] ); + const loadRuntimeEndpointPreview = useCallback( + (name: string) => loadPreview(n => runtimeEndpointPrimitive.previewRemove(n), name), + [loadPreview] + ); const reset = useCallback(() => { setState({ isLoading: false, preview: null, error: null }); @@ -234,6 +248,7 @@ export function useRemovalPreview() { loadOnlineEvalPreview, loadPolicyEnginePreview, loadPolicyPreview, + loadRuntimeEndpointPreview, reset, }; } @@ -320,3 +335,11 @@ export function useRemovePolicy() { k => k ); } + +export function useRemoveRuntimeEndpoint() { + return useRemoveResource( + (name: string) => runtimeEndpointPrimitive.remove(name), + 'runtime-endpoint', + name => name + ); +} diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index 491d1a72b..ac2e8e40b 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -13,6 +13,7 @@ import { AddGatewayFlow, AddGatewayTargetFlow } from '../mcp'; import { AddMemoryFlow } from '../memory/AddMemoryFlow'; import { AddOnlineEvalFlow } from '../online-eval'; import { AddPolicyFlow } from '../policy'; +import { AddRuntimeEndpointFlow } from '../runtime-endpoint'; import type { AddResourceType } from './AddScreen'; import { AddScreen } from './AddScreen'; import { AddSuccessScreen } from './AddSuccessScreen'; @@ -30,6 +31,7 @@ type FlowState = | { name: 'evaluator-wizard' } | { name: 'online-eval-wizard' } | { name: 'policy-wizard' } + | { name: 'runtime-endpoint-wizard' } | { name: 'agent-create-success'; agentName: string; @@ -179,6 +181,8 @@ function getInitialFlowState(resource?: AddResourceType): FlowState { return { name: 'online-eval-wizard' }; case 'policy': return { name: 'policy-wizard' }; + case 'runtime-endpoint': + return { name: 'runtime-endpoint-wizard' }; default: return { name: 'select' }; } @@ -226,6 +230,9 @@ export function AddFlow(props: AddFlowProps) { case 'policy': setFlow({ name: 'policy-wizard' }); break; + case 'runtime-endpoint': + setFlow({ name: 'runtime-endpoint-wizard' }); + break; } }, []); @@ -459,6 +466,18 @@ export function AddFlow(props: AddFlowProps) { ); } + if (flow.name === 'runtime-endpoint-wizard') { + return ( + setFlow({ name: 'select' })} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + return ( (null); @@ -215,6 +232,7 @@ export function RemoveFlow({ 'online-eval-success', 'policy-engine-success', 'policy-success', + 'runtime-endpoint-success', ]; if (successStates.includes(flow.name)) { onExit(); @@ -254,6 +272,9 @@ export function RemoveFlow({ case 'policy': setFlow({ name: 'select-policy' }); break; + case 'runtime-endpoint': + setFlow({ name: 'select-runtime-endpoint' }); + break; case 'all': setFlow({ name: 'remove-all' }); break; @@ -464,6 +485,28 @@ export function RemoveFlow({ [loadPolicyPreview, force, removePolicyOp] ); + const handleSelectRuntimeEndpoint = useCallback( + async (endpointName: string) => { + const result = await loadRuntimeEndpointPreview(endpointName); + if (result.ok) { + if (force) { + setFlow({ name: 'loading', message: `Removing runtime endpoint ${endpointName}...` }); + const removeResult = await removeRuntimeEndpointOp(endpointName, result.preview); + if (removeResult.success) { + setFlow({ name: 'runtime-endpoint-success', endpointName }); + } else { + setFlow({ name: 'error', message: removeResult.error }); + } + } else { + setFlow({ name: 'confirm-runtime-endpoint', endpointName, preview: result.preview }); + } + } else { + setFlow({ name: 'error', message: result.error }); + } + }, + [loadRuntimeEndpointPreview, force, removeRuntimeEndpointOp] + ); + // Auto-select resource when initialResourceName is provided and data is loaded useEffect(() => { if (!initialResourceName || isLoading || hasTriggeredInitialSelection.current) { @@ -500,6 +543,9 @@ export function RemoveFlow({ case 'policy': void handleSelectPolicy(initialResourceName); break; + case 'runtime-endpoint': + void handleSelectRuntimeEndpoint(initialResourceName); + break; } }, 0); }, [ @@ -514,6 +560,7 @@ export function RemoveFlow({ handleSelectOnlineEval, handleSelectPolicyEngine, handleSelectPolicy, + handleSelectRuntimeEndpoint, ]); // Confirm handlers - pass preview for logging @@ -661,6 +708,22 @@ export function RemoveFlow({ [removePolicyOp] ); + const handleConfirmRuntimeEndpoint = useCallback( + async (endpointName: string, preview: RemovalPreview) => { + pendingResultRef.current = null; + setResultReady(false); + setFlow({ name: 'loading', message: `Removing runtime endpoint ${endpointName}...` }); + const result = await removeRuntimeEndpointOp(endpointName, preview); + if (result.success) { + pendingResultRef.current = { name: 'runtime-endpoint-success', endpointName, logFilePath: result.logFilePath }; + } else { + pendingResultRef.current = { name: 'error', message: result.error }; + } + setResultReady(true); + }, + [removeRuntimeEndpointOp] + ); + const resetAll = useCallback(() => { resetPreview(); resetRemoveAgent(); @@ -672,6 +735,7 @@ export function RemoveFlow({ resetRemoveOnlineEval(); resetRemovePolicyEngine(); resetRemovePolicy(); + resetRemoveRuntimeEndpoint(); }, [ resetPreview, resetRemoveAgent, @@ -683,6 +747,7 @@ export function RemoveFlow({ resetRemoveOnlineEval, resetRemovePolicyEngine, resetRemovePolicy, + resetRemoveRuntimeEndpoint, ]); const refreshAll = useCallback(async () => { @@ -696,6 +761,7 @@ export function RemoveFlow({ refreshOnlineEvals(), refreshPolicyEngines(), refreshPolicies(), + refreshRuntimeEndpoints(), ]); }, [ refreshAgents, @@ -707,6 +773,7 @@ export function RemoveFlow({ refreshOnlineEvals, refreshPolicyEngines, refreshPolicies, + refreshRuntimeEndpoints, ]); // Select screen - wait for data to load to avoid arrow position issues @@ -727,6 +794,7 @@ export function RemoveFlow({ onlineEvalCount={onlineEvalConfigs.length} policyEngineCount={policyEngines.length} policyCount={policies.length} + runtimeEndpointCount={runtimeEndpoints.length} /> ); } @@ -861,6 +929,19 @@ export function RemoveFlow({ ); } + if (flow.name === 'select-runtime-endpoint') { + if (initialResourceName && isLoading) { + return null; + } + return ( + void handleSelectRuntimeEndpoint(name)} + onExit={() => setFlow({ name: 'select' })} + /> + ); + } + // Confirmation screens if (flow.name === 'confirm-agent') { return ( @@ -961,6 +1042,17 @@ export function RemoveFlow({ ); } + if (flow.name === 'confirm-runtime-endpoint') { + return ( + void handleConfirmRuntimeEndpoint(flow.endpointName, flow.preview)} + onCancel={() => setFlow({ name: 'select-runtime-endpoint' })} + /> + ); + } + // Success screens if (flow.name === 'agent-success') { return ( @@ -1106,6 +1198,22 @@ export function RemoveFlow({ ); } + if (flow.name === 'runtime-endpoint-success') { + return ( + { + resetAll(); + void refreshAll().then(() => setFlow({ name: 'select' })); + }} + onExit={onExit} + /> + ); + } + // Remove all screen if (flow.name === 'remove-all') { return ; diff --git a/src/cli/tui/screens/remove/RemoveRuntimeEndpointScreen.tsx b/src/cli/tui/screens/remove/RemoveRuntimeEndpointScreen.tsx new file mode 100644 index 000000000..b60b11599 --- /dev/null +++ b/src/cli/tui/screens/remove/RemoveRuntimeEndpointScreen.tsx @@ -0,0 +1,29 @@ +import type { RemovableRuntimeEndpoint } from '../../../primitives/RuntimeEndpointPrimitive'; +import { SelectScreen } from '../../components'; +import React from 'react'; + +interface RemoveRuntimeEndpointScreenProps { + /** List of runtime endpoints that can be removed */ + endpoints: RemovableRuntimeEndpoint[]; + /** Called when an endpoint is selected for removal */ + onSelect: (name: string) => void; + /** Called when user cancels */ + onExit: () => void; +} + +export function RemoveRuntimeEndpointScreen({ endpoints, onSelect, onExit }: RemoveRuntimeEndpointScreenProps) { + const items = endpoints.map(ep => ({ + id: ep.name, + title: ep.name, + description: `${ep.runtimeName} v${ep.version}${ep.description ? ` — ${ep.description}` : ''}`, + })); + + return ( + onSelect(item.id)} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index f562b0ff6..83488ed3a 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -12,6 +12,7 @@ const REMOVE_RESOURCES = [ { id: 'policy', title: 'Policy', description: 'Remove a policy from a policy engine' }, { id: 'gateway', title: 'Gateway', description: 'Remove a gateway' }, { id: 'gateway-target', title: 'Gateway Target', description: 'Remove a gateway target' }, + { id: 'runtime-endpoint', title: 'Runtime Endpoint', description: 'Remove a runtime endpoint' }, { id: 'all', title: 'All', description: 'Reset entire agentcore project' }, ] as const; @@ -38,6 +39,8 @@ interface RemoveScreenProps { policyEngineCount: number; /** Number of policies available for removal */ policyCount: number; + /** Number of runtime endpoints available for removal */ + runtimeEndpointCount: number; } export function RemoveScreen({ @@ -52,6 +55,7 @@ export function RemoveScreen({ onlineEvalCount, policyEngineCount, policyCount, + runtimeEndpointCount, }: RemoveScreenProps) { const items: SelectableItem[] = useMemo(() => { return REMOVE_RESOURCES.map(r => { @@ -113,6 +117,12 @@ export function RemoveScreen({ description = 'No policies to remove'; } break; + case 'runtime-endpoint': + if (runtimeEndpointCount === 0) { + disabled = true; + description = 'No runtime endpoints to remove'; + } + break; case 'all': // 'all' is always available break; @@ -130,6 +140,7 @@ export function RemoveScreen({ onlineEvalCount, policyEngineCount, policyCount, + runtimeEndpointCount, ]); const isDisabled = (item: SelectableItem) => item.disabled ?? false; diff --git a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx index 92087fc66..16e0223e3 100644 --- a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -21,6 +21,7 @@ describe('RemoveScreen', () => { onlineEvalCount={1} policyEngineCount={1} policyCount={1} + runtimeEndpointCount={1} /> ); @@ -51,6 +52,7 @@ describe('RemoveScreen', () => { onlineEvalCount={0} policyEngineCount={0} policyCount={0} + runtimeEndpointCount={0} /> ); diff --git a/src/cli/tui/screens/remove/index.ts b/src/cli/tui/screens/remove/index.ts index 79ebfb8c1..8d77b9b10 100644 --- a/src/cli/tui/screens/remove/index.ts +++ b/src/cli/tui/screens/remove/index.ts @@ -10,5 +10,6 @@ export { RemoveMemoryScreen } from './RemoveMemoryScreen'; export { RemoveOnlineEvalScreen } from './RemoveOnlineEvalScreen'; export { RemovePolicyEngineScreen } from './RemovePolicyEngineScreen'; export { RemovePolicyScreen } from './RemovePolicyScreen'; +export { RemoveRuntimeEndpointScreen } from './RemoveRuntimeEndpointScreen'; export { RemoveScreen, type RemoveResourceType } from './RemoveScreen'; export { RemoveSuccessScreen } from './RemoveSuccessScreen'; diff --git a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx new file mode 100644 index 000000000..2404109fc --- /dev/null +++ b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointFlow.tsx @@ -0,0 +1,131 @@ +import { ConfigIO } from '../../../../lib'; +import { runtimeEndpointPrimitive } from '../../../primitives/registry'; +import { ErrorPrompt } from '../../components'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddRuntimeEndpointScreen } from './AddRuntimeEndpointScreen'; +import type { RuntimeEndpointWizardConfig } from './types'; +import React, { useCallback, useEffect, useState } from 'react'; + +/** Map of runtime name → latest deployed version (undefined if not deployed) */ +export type RuntimeVersionMap = Record; + +type FlowState = + | { name: 'loading' } + | { name: 'create-wizard'; runtimeNames: string[]; runtimeVersions: RuntimeVersionMap } + | { name: 'create-success'; endpointName: string; runtimeName: string } + | { name: 'error'; message: string }; + +interface AddRuntimeEndpointFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function AddRuntimeEndpointFlow({ + isInteractive = true, + onExit, + onBack, + onDev, + onDeploy, +}: AddRuntimeEndpointFlowProps) { + const [flow, setFlow] = useState({ name: 'loading' }); + + // Load runtimes and deployed version info on mount + useEffect(() => { + void (async () => { + try { + const configIO = new ConfigIO(); + const spec = await configIO.readProjectSpec(); + const runtimeNames = spec.runtimes.map(r => r.name); + if (runtimeNames.length === 0) { + setFlow({ name: 'error', message: 'No runtimes found. Add a runtime first with `agentcore add agent`.' }); + return; + } + + // Load deployed state to get version info per runtime + const runtimeVersions: RuntimeVersionMap = {}; + if (configIO.configExists('state')) { + try { + const deployedState = await configIO.readDeployedState(); + for (const target of Object.values(deployedState.targets)) { + const runtimes = target.resources?.runtimes ?? {}; + for (const [name, state] of Object.entries(runtimes)) { + if (state.runtimeVersion) { + runtimeVersions[name] = state.runtimeVersion; + } + } + } + } catch { + // Deployed state may not exist yet — that's fine + } + } + + setFlow({ name: 'create-wizard', runtimeNames, runtimeVersions }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setFlow({ name: 'error', message }); + } + })(); + }, []); + + // In non-interactive mode, exit after success + useEffect(() => { + if (!isInteractive && flow.name === 'create-success') { + onExit(); + } + }, [isInteractive, flow.name, onExit]); + + const handleCreateComplete = useCallback((config: RuntimeEndpointWizardConfig) => { + void runtimeEndpointPrimitive + .add({ + runtime: config.runtimeName, + endpoint: config.endpointName, + version: config.version, + description: config.description, + }) + .then(result => { + if (result.success) { + setFlow({ + name: 'create-success', + endpointName: config.endpointName, + runtimeName: config.runtimeName, + }); + return; + } + setFlow({ name: 'error', message: result.error ?? 'Unknown error' }); + }); + }, []); + + if (flow.name === 'loading') { + return null; + } + + if (flow.name === 'create-wizard') { + return ( + + ); + } + + if (flow.name === 'create-success') { + return ( + + ); + } + + return ; +} diff --git a/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointScreen.tsx b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointScreen.tsx new file mode 100644 index 000000000..44c884ad0 --- /dev/null +++ b/src/cli/tui/screens/runtime-endpoint/AddRuntimeEndpointScreen.tsx @@ -0,0 +1,268 @@ +import type { SelectableItem } from '../../components'; +import { ConfirmReview, Cursor, Panel, Screen, StepIndicator, WizardSelect } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import type { RuntimeVersionMap } from './AddRuntimeEndpointFlow'; +import type { RuntimeEndpointWizardConfig, RuntimeEndpointWizardStep } from './types'; +import { useAddRuntimeEndpointWizard } from './useAddRuntimeEndpointWizard'; +import { Box, Text, useInput } from 'ink'; +import React, { useMemo, useState } from 'react'; + +const STEP_LABELS: Record = { + runtime: 'Runtime', + endpoint: 'Endpoint', + confirm: 'Confirm', +}; + +type EndpointField = 'name' | 'version' | 'description'; +const ENDPOINT_FIELDS: EndpointField[] = ['name', 'version', 'description']; + +interface AddRuntimeEndpointScreenProps { + onComplete: (config: RuntimeEndpointWizardConfig) => void; + onExit: () => void; + runtimeNames: string[]; + runtimeVersions: RuntimeVersionMap; +} + +export function AddRuntimeEndpointScreen({ + onComplete, + onExit, + runtimeNames, + runtimeVersions, +}: AddRuntimeEndpointScreenProps) { + const skipRuntimeStep = runtimeNames.length === 1; + const wizard = useAddRuntimeEndpointWizard({ skipRuntimeStep }); + + const singleRuntime = skipRuntimeStep ? runtimeNames[0]! : ''; + + // Auto-select the only runtime when skipping runtime step + const effectiveConfig = useMemo(() => { + if (skipRuntimeStep && !wizard.config.runtimeName) { + return { ...wizard.config, runtimeName: singleRuntime }; + } + return wizard.config; + }, [skipRuntimeStep, wizard.config, singleRuntime]); + + // If we skip runtime step, set it immediately on first render + React.useEffect(() => { + if (skipRuntimeStep && !wizard.config.runtimeName) { + wizard.setRuntime(singleRuntime); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const isRuntimeStep = wizard.step === 'runtime'; + const isEndpointStep = wizard.step === 'endpoint'; + const isConfirmStep = wizard.step === 'confirm'; + + // Get the max deployed version for the selected runtime + const maxVersion = effectiveConfig.runtimeName ? runtimeVersions[effectiveConfig.runtimeName] : undefined; + const isDeployed = maxVersion !== undefined; + + // Multi-field state for endpoint step (CustomClaimForm pattern) + const [activeField, setActiveField] = useState('name'); + const [endpointName, setEndpointName] = useState(''); + const [endpointVersion, setEndpointVersion] = useState('1'); + const [endpointDescription, setEndpointDescription] = useState(''); + const [error, setError] = useState(null); + + // Runtime selection items + const runtimeItems: SelectableItem[] = useMemo( + () => runtimeNames.map(name => ({ id: name, title: name })), + [runtimeNames] + ); + + const runtimeNav = useListNavigation({ + items: runtimeItems, + onSelect: item => wizard.setRuntime(item.id), + onExit: () => onExit(), + isActive: isRuntimeStep, + }); + + // Multi-field input handler for endpoint step + useInput( + (input, key) => { + if (!isEndpointStep) return; + + if (key.escape) { + if (skipRuntimeStep) { + onExit(); + } else { + wizard.goBack(); + } + return; + } + + // Tab / Up / Down to cycle fields + if (key.tab || key.upArrow || key.downArrow) { + const idx = ENDPOINT_FIELDS.indexOf(activeField); + if (key.shift || key.upArrow) { + setActiveField(ENDPOINT_FIELDS[(idx - 1 + ENDPOINT_FIELDS.length) % ENDPOINT_FIELDS.length]!); + } else { + setActiveField(ENDPOINT_FIELDS[(idx + 1) % ENDPOINT_FIELDS.length]!); + } + setError(null); + return; + } + + // Enter: advance to next field, or submit on last field + if (key.return) { + const idx = ENDPOINT_FIELDS.indexOf(activeField); + if (idx < ENDPOINT_FIELDS.length - 1) { + // Validate current field before advancing + if (activeField === 'name') { + if (!endpointName.trim()) { + setError('Endpoint name is required'); + return; + } + if (!/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/.test(endpointName.trim())) { + setError('Must begin with a letter, alphanumeric + underscores only (max 48 chars)'); + return; + } + } + if (activeField === 'version') { + const num = Number(endpointVersion); + if (!Number.isInteger(num) || num < 1) { + setError('Version must be a positive integer'); + return; + } + if (isDeployed && num > maxVersion) { + setError(`Version must be between 1 and ${maxVersion} (latest deployed version)`); + return; + } + } + setActiveField(ENDPOINT_FIELDS[idx + 1]!); + setError(null); + return; + } + // Last field — validate and submit + if (!endpointName.trim()) { + setError('Endpoint name is required'); + return; + } + if (!/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/.test(endpointName.trim())) { + setError('Must begin with a letter, alphanumeric + underscores only (max 48 chars)'); + return; + } + const ver = Number(endpointVersion); + if (!Number.isInteger(ver) || ver < 1) { + setError('Version must be a positive integer'); + return; + } + if (isDeployed && ver > maxVersion) { + setError(`Version must be between 1 and ${maxVersion} (latest deployed version)`); + return; + } + const desc = endpointDescription.trim() || undefined; + wizard.setEndpointDetails(endpointName.trim(), ver, desc); + return; + } + + // Text input for active field + if (activeField === 'name' || activeField === 'version' || activeField === 'description') { + if (key.backspace || key.delete) { + if (activeField === 'name') setEndpointName(v => v.slice(0, -1)); + else if (activeField === 'version') setEndpointVersion(v => v.slice(0, -1)); + else setEndpointDescription(v => v.slice(0, -1)); + setError(null); + return; + } + if (input && !key.ctrl && !key.meta) { + if (activeField === 'name') setEndpointName(v => v + input); + else if (activeField === 'version') setEndpointVersion(v => v + input); + else setEndpointDescription(v => v + input); + setError(null); + return; + } + } + }, + { isActive: isEndpointStep } + ); + + // Confirm step navigation + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(effectiveConfig), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + const helpText = isRuntimeStep + ? HELP_TEXT.NAVIGATE_SELECT + : isEndpointStep + ? 'tab/↑↓ switch fields ⏎ submit' + : HELP_TEXT.CONFIRM_CANCEL; + + const headerContent = ; + + const confirmFields = useMemo( + () => [ + { label: 'Runtime', value: effectiveConfig.runtimeName }, + { label: 'Endpoint', value: effectiveConfig.endpointName }, + { label: 'Version', value: String(effectiveConfig.version) }, + ...(effectiveConfig.description ? [{ label: 'Description', value: effectiveConfig.description }] : []), + ], + [effectiveConfig] + ); + + return ( + + + {isRuntimeStep && ( + + )} + + {isEndpointStep && ( + + Runtime: {effectiveConfig.runtimeName} + {isDeployed && Current deployed version: {maxVersion}} + + + Endpoint name: + {activeField === 'name' && !endpointName && } + + {endpointName || e.g., prod, staging} + + {activeField === 'name' && endpointName && } + + + + + Version{isDeployed ? ` (1-${maxVersion})` : ''}:{' '} + + {activeField === 'version' && !endpointVersion && } + + {endpointVersion || 1} + + {activeField === 'version' && endpointVersion && } + + + + Description: + {activeField === 'description' && !endpointDescription && } + + {endpointDescription || (optional)} + + {activeField === 'description' && endpointDescription && } + + + + {error && ( + + {error} + + )} + + )} + + {isConfirmStep && } + + + ); +} diff --git a/src/cli/tui/screens/runtime-endpoint/index.ts b/src/cli/tui/screens/runtime-endpoint/index.ts new file mode 100644 index 000000000..92ff091fe --- /dev/null +++ b/src/cli/tui/screens/runtime-endpoint/index.ts @@ -0,0 +1,2 @@ +export { AddRuntimeEndpointFlow } from './AddRuntimeEndpointFlow'; +export type { RuntimeEndpointWizardConfig } from './types'; diff --git a/src/cli/tui/screens/runtime-endpoint/types.ts b/src/cli/tui/screens/runtime-endpoint/types.ts new file mode 100644 index 000000000..eac8e05c3 --- /dev/null +++ b/src/cli/tui/screens/runtime-endpoint/types.ts @@ -0,0 +1,8 @@ +export interface RuntimeEndpointWizardConfig { + runtimeName: string; + endpointName: string; + version: number; + description?: string; +} + +export type RuntimeEndpointWizardStep = 'runtime' | 'endpoint' | 'confirm'; diff --git a/src/cli/tui/screens/runtime-endpoint/useAddRuntimeEndpointWizard.ts b/src/cli/tui/screens/runtime-endpoint/useAddRuntimeEndpointWizard.ts new file mode 100644 index 000000000..40b109bba --- /dev/null +++ b/src/cli/tui/screens/runtime-endpoint/useAddRuntimeEndpointWizard.ts @@ -0,0 +1,61 @@ +import type { RuntimeEndpointWizardConfig, RuntimeEndpointWizardStep } from './types'; +import { useCallback, useMemo, useState } from 'react'; + +const ALL_STEPS: RuntimeEndpointWizardStep[] = ['runtime', 'endpoint', 'confirm']; + +function getDefaultConfig(): RuntimeEndpointWizardConfig { + return { + runtimeName: '', + endpointName: '', + version: 1, + }; +} + +export function useAddRuntimeEndpointWizard(options?: { skipRuntimeStep?: boolean }) { + const [config, setConfig] = useState(getDefaultConfig); + const [step, setStep] = useState(options?.skipRuntimeStep ? 'endpoint' : 'runtime'); + + const steps = useMemo( + () => (options?.skipRuntimeStep ? ALL_STEPS.filter(s => s !== 'runtime') : ALL_STEPS), + [options?.skipRuntimeStep] + ); + + const currentIndex = steps.indexOf(step); + + const goBack = useCallback(() => { + const idx = steps.indexOf(step); + const prevStep = steps[idx - 1]; + if (prevStep) setStep(prevStep); + }, [steps, step]); + + const setRuntime = useCallback((runtimeName: string) => { + setConfig(c => ({ ...c, runtimeName })); + setStep('endpoint'); + }, []); + + const setEndpointDetails = useCallback((endpointName: string, version: number, description?: string) => { + setConfig(c => ({ + ...c, + endpointName, + version, + ...(description ? { description } : {}), + })); + setStep('confirm'); + }, []); + + const reset = useCallback(() => { + setConfig(getDefaultConfig()); + setStep(options?.skipRuntimeStep ? 'endpoint' : 'runtime'); + }, [options?.skipRuntimeStep]); + + return { + config, + step, + steps, + currentIndex, + goBack, + setRuntime, + setEndpointDetails, + reset, + }; +} diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index 81b20ef99..1b8d1b668 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -9,6 +9,8 @@ import { InstrumentationSchema, LifecycleConfigurationSchema, NetworkConfigSchema, + RuntimeEndpointNameSchema, + RuntimeEndpointSchema, } from '../agent-env.js'; import { describe, expect, it } from 'vitest'; @@ -540,3 +542,129 @@ describe('AgentEnvSpecSchema - lifecycleConfiguration', () => { } }); }); + +describe('RuntimeEndpointNameSchema', () => { + it.each(['prod', 'staging', 'myEndpoint', 'v1', 'A', 'a' + '0'.repeat(47)])( + 'accepts valid endpoint name "%s"', + name => { + expect(RuntimeEndpointNameSchema.safeParse(name).success).toBe(true); + } + ); + + it('rejects empty string', () => { + expect(RuntimeEndpointNameSchema.safeParse('').success).toBe(false); + }); + + it('rejects name starting with digit', () => { + expect(RuntimeEndpointNameSchema.safeParse('1prod').success).toBe(false); + }); + + it('rejects name with hyphens', () => { + expect(RuntimeEndpointNameSchema.safeParse('my-endpoint').success).toBe(false); + }); + + it('rejects name with special characters', () => { + expect(RuntimeEndpointNameSchema.safeParse('prod!').success).toBe(false); + expect(RuntimeEndpointNameSchema.safeParse('my@endpoint').success).toBe(false); + }); + + it('rejects name exceeding 48 chars', () => { + const name = 'A' + 'b'.repeat(48); + expect(name).toHaveLength(49); + expect(RuntimeEndpointNameSchema.safeParse(name).success).toBe(false); + }); +}); + +describe('RuntimeEndpointSchema', () => { + it('accepts endpoint with version only', () => { + const result = RuntimeEndpointSchema.safeParse({ version: 1 }); + expect(result.success).toBe(true); + }); + + it('accepts endpoint with version and description', () => { + const result = RuntimeEndpointSchema.safeParse({ version: 3, description: 'Production endpoint' }); + expect(result.success).toBe(true); + }); + + it('rejects version < 1', () => { + expect(RuntimeEndpointSchema.safeParse({ version: 0 }).success).toBe(false); + expect(RuntimeEndpointSchema.safeParse({ version: -1 }).success).toBe(false); + }); + + it('rejects non-integer version', () => { + expect(RuntimeEndpointSchema.safeParse({ version: 1.5 }).success).toBe(false); + }); + + it('rejects missing version', () => { + expect(RuntimeEndpointSchema.safeParse({}).success).toBe(false); + expect(RuntimeEndpointSchema.safeParse({ description: 'no version' }).success).toBe(false); + }); +}); + +describe('AgentEnvSpecSchema - endpoints', () => { + const validAgent = { + name: 'TestAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: 'app/TestAgent/', + runtimeVersion: 'PYTHON_3_12', + }; + + it('accepts valid endpoints dictionary', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + endpoints: { + prod: { version: 3, description: 'Production' }, + staging: { version: 2 }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.endpoints).toEqual({ + prod: { version: 3, description: 'Production' }, + staging: { version: 2 }, + }); + } + }); + + it('accepts agent without endpoints (optional)', () => { + const result = AgentEnvSpecSchema.safeParse(validAgent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.endpoints).toBeUndefined(); + } + }); + + it('rejects invalid endpoint name with special characters', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + endpoints: { + 'my-endpoint': { version: 1 }, + }, + }); + expect(result.success).toBe(false); + }); + + it('rejects endpoint with version < 1', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + endpoints: { + prod: { version: 0 }, + }, + }); + expect(result.success).toBe(false); + }); + + it('accepts endpoint with only version (no description)', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validAgent, + endpoints: { + prod: { version: 5 }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.endpoints!.prod).toEqual({ version: 5 }); + } + }); +}); diff --git a/src/schema/schemas/__tests__/deployed-state.test.ts b/src/schema/schemas/__tests__/deployed-state.test.ts index e468e9e85..f1791712b 100644 --- a/src/schema/schemas/__tests__/deployed-state.test.ts +++ b/src/schema/schemas/__tests__/deployed-state.test.ts @@ -50,6 +50,38 @@ describe('AgentCoreDeployedStateSchema', () => { it('rejects missing required fields', () => { expect(AgentCoreDeployedStateSchema.safeParse({ runtimeId: 'rt-123' }).success).toBe(false); }); + + it('accepts runtimeVersion when provided as a valid integer >= 1', () => { + const result = AgentCoreDeployedStateSchema.safeParse({ + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123:role/TestRole', + runtimeVersion: 1, + }); + expect(result.success).toBe(true); + }); + + it('accepts state without runtimeVersion (backwards compatible)', () => { + const result = AgentCoreDeployedStateSchema.safeParse({ + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123:role/TestRole', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.runtimeVersion).toBeUndefined(); + } + }); + + it('rejects runtimeVersion of 0 (must be >= 1)', () => { + const result = AgentCoreDeployedStateSchema.safeParse({ + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock:us-east-1:123:agent-runtime/rt-123', + roleArn: 'arn:aws:iam::123:role/TestRole', + runtimeVersion: 0, + }); + expect(result.success).toBe(false); + }); }); describe('MemoryDeployedStateSchema', () => { diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 04348f3f1..789109a38 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -188,6 +188,31 @@ export const LifecycleConfigurationSchema = z }); export type LifecycleConfiguration = z.infer; +// ============================================================================ +// Runtime Endpoint Schema +// ============================================================================ + +/** + * Endpoint name follows the AgentCore API regex for endpoint aliases. + */ +export const RuntimeEndpointNameSchema = z + .string() + .min(1, 'Endpoint name is required') + .max(48) + .regex( + /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, + 'Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)' + ); + +export const RuntimeEndpointSchema = z.object({ + /** Version number this endpoint points to. Must be >= 1. */ + version: z.number().int().min(1), + /** Optional human-readable description of this endpoint. */ + description: z.string().max(200).optional(), +}); + +export type RuntimeEndpoint = z.infer; + /** * AgentEnvSpec - represents an AgentCore Runtime. * This is a top-level resource in the schema. @@ -231,6 +256,8 @@ export const AgentEnvSpecSchema = z lifecycleConfiguration: LifecycleConfigurationSchema.optional(), /** Filesystem configurations for session-scoped persistent storage. */ filesystemConfigurations: z.array(FilesystemConfigurationSchema).optional(), + /** Named endpoints (version aliases) for this runtime. Keys are endpoint names. */ + endpoints: z.record(RuntimeEndpointNameSchema, RuntimeEndpointSchema).optional(), }) .superRefine((data, ctx) => { if (data.networkMode === 'VPC' && !data.networkConfig) { diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 2454d6b09..6eeff21b7 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -14,6 +14,8 @@ export const AgentCoreDeployedStateSchema = z.object({ memoryIds: z.array(z.string()).optional(), browserId: z.string().optional(), codeInterpreterId: z.string().optional(), + /** The latest deployed version number of this runtime. */ + runtimeVersion: z.number().int().min(1).optional(), }); export type AgentCoreDeployedState = z.infer; @@ -168,6 +170,17 @@ export const OnlineEvalDeployedStateSchema = z.object({ export type OnlineEvalDeployedState = z.infer; +// ============================================================================ +// Runtime Endpoint Deployed State +// ============================================================================ + +export const RuntimeEndpointDeployedStateSchema = z.object({ + endpointId: z.string().min(1), + endpointArn: z.string().min(1), +}); + +export type RuntimeEndpointDeployedState = z.infer; + // ============================================================================ // Deployed Resource State // ============================================================================ @@ -182,6 +195,7 @@ export const DeployedResourceStateSchema = z.object({ onlineEvalConfigs: z.record(z.string(), OnlineEvalDeployedStateSchema).optional(), policyEngines: z.record(z.string(), PolicyEngineDeployedStateSchema).optional(), policies: z.record(z.string(), PolicyDeployedStateSchema).optional(), + runtimeEndpoints: z.record(z.string(), RuntimeEndpointDeployedStateSchema).optional(), stackName: z.string().optional(), identityKmsKeyArn: z.string().optional(), });