diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c77082..80f9730 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,6 +29,12 @@ jobs: env: CI: "true" MISE_EXPERIMENTAL: "1" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AQUA_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Keep secret and dependency scanning enabled in CI; only disable the + # remaining tools that are intentionally skipped here. + MISE_DISABLE_TOOLS: "aqua:aquasecurity/trivy,grype,semgrep" steps: - name: Checkout uses: actions/checkout@v5 diff --git a/.gitignore b/.gitignore index 2d6c245..4c18cdf 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ junit.xml .cdk.staging/ .parcel-cache/ !/.github/workflows/docs.yml +local-docs/ diff --git a/cdk/package.json b/cdk/package.json index 85a046b..0feaf5b 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -18,6 +18,7 @@ "@aws-cdk/mixins-preview": "2.238.0-alpha.0", "@aws-sdk/client-bedrock-agentcore": "^3.1021.0", "@aws-sdk/client-bedrock-runtime": "^3.1021.0", + "@aws-sdk/client-ecs": "^3.1021.0", "@aws-sdk/client-dynamodb": "^3.1021.0", "@aws-sdk/client-lambda": "^3.1021.0", "@aws-sdk/client-secrets-manager": "^3.1021.0", diff --git a/cdk/src/constructs/ecs-agent-cluster.ts b/cdk/src/constructs/ecs-agent-cluster.ts new file mode 100644 index 0000000..285d9aa --- /dev/null +++ b/cdk/src/constructs/ecs-agent-cluster.ts @@ -0,0 +1,156 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; +import * as ecs from 'aws-cdk-lib/aws-ecs'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; + +export interface EcsAgentClusterProps { + readonly vpc: ec2.IVpc; + readonly agentImageAsset: ecr_assets.DockerImageAsset; + readonly taskTable: dynamodb.ITable; + readonly taskEventsTable: dynamodb.ITable; + readonly userConcurrencyTable: dynamodb.ITable; + readonly githubTokenSecret: secretsmanager.ISecret; + readonly memoryId?: string; +} + +export class EcsAgentCluster extends Construct { + public readonly cluster: ecs.Cluster; + public readonly taskDefinition: ecs.FargateTaskDefinition; + public readonly securityGroup: ec2.SecurityGroup; + public readonly containerName: string; + public readonly taskRoleArn: string; + public readonly executionRoleArn: string; + + constructor(scope: Construct, id: string, props: EcsAgentClusterProps) { + super(scope, id); + + this.containerName = 'AgentContainer'; + + // ECS Cluster with Fargate capacity provider and container insights + this.cluster = new ecs.Cluster(this, 'Cluster', { + vpc: props.vpc, + containerInsights: true, + }); + + // Security group — egress TCP 443 only + this.securityGroup = new ec2.SecurityGroup(this, 'TaskSG', { + vpc: props.vpc, + description: 'ECS Agent Tasks - egress TCP 443 only', + allowAllOutbound: false, + }); + + this.securityGroup.addEgressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(443), + 'Allow HTTPS egress (GitHub API, AWS services)', + ); + + // CloudWatch log group for agent task output + const logGroup = new logs.LogGroup(this, 'TaskLogGroup', { + retention: logs.RetentionDays.THREE_MONTHS, + removalPolicy: RemovalPolicy.DESTROY, + }); + + // Task execution role (used by ECS agent to pull images, write logs) + // CDK creates this automatically via taskDefinition, but we need to + // grant additional permissions to the task role. + + // Fargate task definition + this.taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', { + cpu: 2048, + memoryLimitMiB: 4096, + runtimePlatform: { + cpuArchitecture: ecs.CpuArchitecture.ARM64, + operatingSystemFamily: ecs.OperatingSystemFamily.LINUX, + }, + }); + + // Container + this.taskDefinition.addContainer(this.containerName, { + image: ecs.ContainerImage.fromDockerImageAsset(props.agentImageAsset), + logging: ecs.LogDrivers.awsLogs({ + logGroup, + streamPrefix: 'agent', + }), + environment: { + CLAUDE_CODE_USE_BEDROCK: '1', + TASK_TABLE_NAME: props.taskTable.tableName, + TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, + USER_CONCURRENCY_TABLE_NAME: props.userConcurrencyTable.tableName, + LOG_GROUP_NAME: logGroup.logGroupName, + GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, + ...(props.memoryId && { MEMORY_ID: props.memoryId }), + }, + }); + + // Task role permissions + const taskRole = this.taskDefinition.taskRole; + + // DynamoDB read/write on task tables + props.taskTable.grantReadWriteData(taskRole); + props.taskEventsTable.grantReadWriteData(taskRole); + props.userConcurrencyTable.grantReadWriteData(taskRole); + + // Secrets Manager read for GitHub token + props.githubTokenSecret.grantRead(taskRole); + + // Bedrock model invocation + taskRole.addToPrincipalPolicy(new iam.PolicyStatement({ + actions: [ + 'bedrock:InvokeModel', + 'bedrock:InvokeModelWithResponseStream', + ], + resources: ['*'], + })); + + // CloudWatch Logs write + logGroup.grantWrite(taskRole); + + // Expose role ARNs for scoped iam:PassRole in the orchestrator + this.taskRoleArn = taskRole.roleArn; + this.executionRoleArn = this.taskDefinition.executionRole!.roleArn; + + NagSuppressions.addResourceSuppressions(this.taskDefinition, [ + { + id: 'AwsSolutions-IAM5', + reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData; Bedrock InvokeModel requires * resource; Secrets Manager wildcards from CDK grantRead; CloudWatch Logs wildcards from CDK grantWrite', + }, + { + id: 'AwsSolutions-ECS2', + reason: 'Environment variables contain table names and configuration, not secrets — GitHub token is fetched from Secrets Manager at runtime', + }, + ], true); + + NagSuppressions.addResourceSuppressions(this.cluster, [ + { + id: 'AwsSolutions-ECS4', + reason: 'Container insights is enabled via the containerInsights prop', + }, + ], true); + } +} diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index 4d60278..a69b02a 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -100,6 +100,12 @@ export interface TaskApiProps { * First ARN is also passed as `RUNTIME_ARN` when the task record has no `agent_runtime_arn`. */ readonly agentCoreStopSessionRuntimeArns?: string[]; + + /** + * ECS cluster ARN for cancel-task to stop ECS-backed tasks. + * When provided, the cancel Lambda gets `ECS_CLUSTER_ARN` env var and `ecs:StopTask` permission. + */ + readonly ecsClusterArn?: string; } /** @@ -329,6 +335,9 @@ export class TaskApi extends Construct { if (stopSessionArns.length > 0) { cancelTaskEnv.RUNTIME_ARN = stopSessionArns[0]!; } + if (props.ecsClusterArn) { + cancelTaskEnv.ECS_CLUSTER_ARN = props.ecsClusterArn; + } const cancelTaskFn = new lambda.NodejsFunction(this, 'CancelTaskFn', { entry: path.join(handlersDir, 'cancel-task.ts'), @@ -363,6 +372,18 @@ export class TaskApi extends Construct { })); } + if (props.ecsClusterArn) { + cancelTaskFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['ecs:StopTask'], + resources: ['*'], + conditions: { + ArnEquals: { + 'ecs:cluster': props.ecsClusterArn, + }, + }, + })); + } + // Repo table read for onboarding gate if (props.repoTable) { props.repoTable.grantReadData(createTaskFn); diff --git a/cdk/src/constructs/task-orchestrator.ts b/cdk/src/constructs/task-orchestrator.ts index 7da3030..7ebf432 100644 --- a/cdk/src/constructs/task-orchestrator.ts +++ b/cdk/src/constructs/task-orchestrator.ts @@ -112,6 +112,21 @@ export interface TaskOrchestratorProps { * Bedrock Guardrail version. Required when guardrailId is provided. */ readonly guardrailVersion?: string; + + /** + * ECS Fargate compute strategy configuration. + * When provided, ECS-related env vars and IAM policies are added to the orchestrator. + * All fields are required — this makes the all-or-nothing constraint self-evident at the type level. + */ + readonly ecsConfig?: { + readonly clusterArn: string; + readonly taskDefinitionArn: string; + readonly subnets: string; + readonly securityGroup: string; + readonly containerName: string; + readonly taskRoleArn: string; + readonly executionRoleArn: string; + }; } /** @@ -173,6 +188,13 @@ export class TaskOrchestrator extends Construct { ...(props.memoryId && { MEMORY_ID: props.memoryId }), ...(props.guardrailId && { GUARDRAIL_ID: props.guardrailId }), ...(props.guardrailVersion && { GUARDRAIL_VERSION: props.guardrailVersion }), + ...(props.ecsConfig && { + ECS_CLUSTER_ARN: props.ecsConfig.clusterArn, + ECS_TASK_DEFINITION_ARN: props.ecsConfig.taskDefinitionArn, + ECS_SUBNETS: props.ecsConfig.subnets, + ECS_SECURITY_GROUP: props.ecsConfig.securityGroup, + ECS_CONTAINER_NAME: props.ecsConfig.containerName, + }), }, bundling: { externalModules: ['@aws-sdk/*'], @@ -213,6 +235,33 @@ export class TaskOrchestrator extends Construct { resources: runtimeResources, })); + // ECS compute strategy permissions (only when ECS is configured) + if (props.ecsConfig) { + this.fn.addToRolePolicy(new iam.PolicyStatement({ + actions: [ + 'ecs:RunTask', + 'ecs:DescribeTasks', + 'ecs:StopTask', + ], + resources: ['*'], + conditions: { + ArnEquals: { + 'ecs:cluster': props.ecsConfig.clusterArn, + }, + }, + })); + + this.fn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['iam:PassRole'], + resources: [props.ecsConfig.taskRoleArn, props.ecsConfig.executionRoleArn], + conditions: { + StringEquals: { + 'iam:PassedToService': 'ecs-tasks.amazonaws.com', + }, + }, + })); + } + // Per-repo Secrets Manager grants (e.g. per-repo GitHub tokens from Blueprints) for (const [index, secretArn] of (props.additionalSecretArns ?? []).entries()) { const secret = secretsmanager.Secret.fromSecretCompleteArn( @@ -264,7 +313,7 @@ export class TaskOrchestrator extends Construct { }, { id: 'AwsSolutions-IAM5', - reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData; AgentCore runtime/* required for sub-resource invocation; Secrets Manager wildcards generated by CDK grantRead; AgentCore Memory wildcards generated by CDK grantRead/grantWrite', + reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData; AgentCore runtime/* required for sub-resource invocation; Secrets Manager wildcards generated by CDK grantRead; AgentCore Memory wildcards generated by CDK grantRead/grantWrite; ECS RunTask/DescribeTasks/StopTask conditioned on cluster ARN; iam:PassRole scoped to ECS task/execution roles and conditioned on ecs-tasks.amazonaws.com', }, ], true); } diff --git a/cdk/src/handlers/cancel-task.ts b/cdk/src/handlers/cancel-task.ts index 10a1102..7da0812 100644 --- a/cdk/src/handlers/cancel-task.ts +++ b/cdk/src/handlers/cancel-task.ts @@ -19,6 +19,7 @@ import { BedrockAgentCoreClient, StopRuntimeSessionCommand } from '@aws-sdk/client-bedrock-agentcore'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { ECSClient, StopTaskCommand } from '@aws-sdk/client-ecs'; import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { ulid } from 'ulid'; @@ -31,10 +32,12 @@ import { computeTtlEpoch } from './shared/validation'; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); const agentCoreClient = new BedrockAgentCoreClient({}); +const ecsClient = new ECSClient({}); const TABLE_NAME = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME!; const TASK_RETENTION_DAYS = Number(process.env.TASK_RETENTION_DAYS ?? '90'); const RUNTIME_ARN = process.env.RUNTIME_ARN; +const ECS_CLUSTER_ARN = process.env.ECS_CLUSTER_ARN; /** * DELETE /v1/tasks/{task_id} — Cancel a task. @@ -107,19 +110,57 @@ export async function handler(event: APIGatewayProxyEvent): Promise = async (event, context) => { const { task_id: taskId } = event; @@ -116,26 +120,117 @@ const durableHandler: DurableExecutionHandler = asyn } }); - // Step 4: Start agent session — invoke runtime and transition to RUNNING - await context.step('start-session', async () => { + // Step 4: Start agent session — resolve compute strategy, invoke runtime, transition to RUNNING + // Returns the full SessionHandle (serializable) so ECS polling can use it in step 5. + const sessionHandle = await context.step('start-session', async () => { try { - return await startSession(task, payload, blueprintConfig); + const strategy = resolveComputeStrategy(blueprintConfig); + const handle = await strategy.startSession({ taskId, payload, blueprintConfig }); + + // Build compute metadata for the task record so cancel-task can stop the right backend + const computeMetadata: Record = handle.strategyType === 'ecs' + ? { clusterArn: handle.clusterArn, taskArn: handle.taskArn } + : { runtimeArn: handle.runtimeArn }; + + await transitionTask(taskId, TaskStatus.HYDRATING, TaskStatus.RUNNING, { + session_id: handle.sessionId, + started_at: new Date().toISOString(), + compute_type: handle.strategyType, + compute_metadata: computeMetadata, + ...(handle.strategyType === 'agentcore' && { agent_runtime_arn: handle.runtimeArn }), + }); + await emitTaskEvent(taskId, 'session_started', { + session_id: handle.sessionId, + strategy_type: handle.strategyType, + }); + + logger.info('Session started', { + task_id: taskId, + session_id: handle.sessionId, + strategy_type: handle.strategyType, + }); + + return handle; } catch (err) { await failTask(taskId, TaskStatus.HYDRATING, `Session start failed: ${String(err)}`, task.user_id, true); throw err; } }); + // Resolve the compute strategy once and reuse it across poll iterations + // instead of constructing a new instance on every cycle. + const computeStrategy = blueprintConfig.compute_type === 'ecs' + ? resolveComputeStrategy(blueprintConfig) + : undefined; + // Step 5: Wait for agent to finish // Polls DynamoDB on each interval. The agent writes terminal status when done. // While RUNNING, the runtime updates `agent_heartbeat_at`; if that timestamp // goes stale, `pollTaskStatus` sets `sessionUnhealthy` so we fail fast instead // of waiting the full MAX_POLL_ATTEMPTS window (~8.5h) after a silent crash. // HYDRATING without transition to RUNNING is still bounded by MAX_NON_RUNNING_POLLS (~5min). + const finalPollState = await context.waitForCondition( 'await-agent-completion', async (state) => { - return pollTaskStatus(taskId, state); + const ddbState = await pollTaskStatus(taskId, state); + let consecutiveEcsPollFailures = 0; + let consecutiveEcsCompletedPolls = 0; + + // ECS compute-level crash detection: if DDB is not terminal, check ECS task status + if ( + ddbState.lastStatus && + !TERMINAL_STATUSES.includes(ddbState.lastStatus) && + computeStrategy + ) { + try { + const ecsStatus = await computeStrategy.pollSession(sessionHandle); + if (ecsStatus.status === 'failed') { + const errorMsg = 'error' in ecsStatus ? ecsStatus.error : 'ECS task failed'; + logger.warn('ECS task failed before DDB terminal write', { + task_id: taskId, + error: errorMsg, + }); + await failTask(taskId, ddbState.lastStatus, `ECS container failed: ${errorMsg}`, task.user_id, true); + return { attempts: ddbState.attempts, lastStatus: TaskStatus.FAILED }; + } + if (ecsStatus.status === 'completed') { + consecutiveEcsCompletedPolls = (state.consecutiveEcsCompletedPolls ?? 0) + 1; + if (consecutiveEcsCompletedPolls >= MAX_CONSECUTIVE_ECS_COMPLETED_POLLS) { + // ECS task exited successfully but DDB never reached terminal — the agent + // likely crashed after container exit code 0 but before writing status. + logger.error('ECS task completed but DDB never caught up — failing task', { + task_id: taskId, + consecutive_completed_polls: consecutiveEcsCompletedPolls, + }); + await failTask(taskId, ddbState.lastStatus, `ECS task exited successfully but agent never wrote terminal status after ${consecutiveEcsCompletedPolls} polls`, task.user_id, true); + return { attempts: ddbState.attempts, lastStatus: TaskStatus.FAILED }; + } + logger.warn('ECS task completed but DDB not terminal — waiting for DDB catchup', { + task_id: taskId, + consecutive_completed_polls: consecutiveEcsCompletedPolls, + }); + } + } catch (err) { + consecutiveEcsPollFailures = (state.consecutiveEcsPollFailures ?? 0) + 1; + if (consecutiveEcsPollFailures >= MAX_CONSECUTIVE_ECS_POLL_FAILURES) { + logger.error('ECS pollSession failed repeatedly — failing task', { + task_id: taskId, + consecutive_failures: consecutiveEcsPollFailures, + error: err instanceof Error ? err.message : String(err), + }); + await failTask(taskId, ddbState.lastStatus, `ECS poll failed ${consecutiveEcsPollFailures} consecutive times: ${err instanceof Error ? err.message : String(err)}`, task.user_id, true); + return { attempts: ddbState.attempts, lastStatus: TaskStatus.FAILED }; + } + logger.warn('ECS pollSession check failed (non-fatal)', { + task_id: taskId, + consecutive_failures: consecutiveEcsPollFailures, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + return { ...ddbState, consecutiveEcsPollFailures, consecutiveEcsCompletedPolls }; }, { initialState: { attempts: 0 }, diff --git a/cdk/src/handlers/shared/compute-strategy.ts b/cdk/src/handlers/shared/compute-strategy.ts new file mode 100644 index 0000000..e3d3c1d --- /dev/null +++ b/cdk/src/handlers/shared/compute-strategy.ts @@ -0,0 +1,56 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { BlueprintConfig, ComputeType } from './repo-config'; +import { AgentCoreComputeStrategy } from './strategies/agentcore-strategy'; +import { EcsComputeStrategy } from './strategies/ecs-strategy'; + +export type SessionHandle = + | { readonly sessionId: string; readonly strategyType: 'agentcore'; readonly runtimeArn: string } + | { readonly sessionId: string; readonly strategyType: 'ecs'; readonly clusterArn: string; readonly taskArn: string }; + +export type SessionStatus = + | { readonly status: 'running' } + | { readonly status: 'completed' } + | { readonly status: 'failed'; readonly error: string }; + +export interface ComputeStrategy { + readonly type: ComputeType; + startSession(input: { + taskId: string; + payload: Record; + blueprintConfig: BlueprintConfig; + }): Promise; + pollSession(handle: SessionHandle): Promise; + stopSession(handle: SessionHandle): Promise; +} + +export function resolveComputeStrategy(blueprintConfig: BlueprintConfig): ComputeStrategy { + const computeType: ComputeType = blueprintConfig.compute_type; + switch (computeType) { + case 'agentcore': + return new AgentCoreComputeStrategy(); + case 'ecs': + return new EcsComputeStrategy(); + default: { + const _exhaustive: never = computeType; + throw new Error(`Unknown compute_type: '${_exhaustive}'`); + } + } +} diff --git a/cdk/src/handlers/shared/orchestrator.ts b/cdk/src/handlers/shared/orchestrator.ts index 1efbf02..ec7982a 100644 --- a/cdk/src/handlers/shared/orchestrator.ts +++ b/cdk/src/handlers/shared/orchestrator.ts @@ -17,11 +17,6 @@ * SOFTWARE. */ -// Task lifecycle engine: state transitions, runtime invoke, finalization. Design: docs/design/ORCHESTRATOR.md -// Tests: cdk/test/handlers/orchestrate-task.test.ts, cdk/test/constructs/task-orchestrator.test.ts - -import { randomUUID } from 'crypto'; -import { InvokeAgentRuntimeCommand, BedrockAgentCoreClient } from '@aws-sdk/client-bedrock-agentcore'; import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { ulid } from 'ulid'; @@ -35,7 +30,6 @@ import { computeTtlEpoch, DEFAULT_MAX_TURNS } from './validation'; import { TaskStatus, TERMINAL_STATUSES, VALID_TRANSITIONS, type TaskStatusType } from '../../constructs/task-status'; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); -const agentCoreClient = new BedrockAgentCoreClient({}); const TABLE_NAME = process.env.TASK_TABLE_NAME!; const EVENTS_TABLE_NAME = process.env.TASK_EVENTS_TABLE_NAME!; @@ -53,6 +47,10 @@ export interface PollState { readonly lastStatus?: TaskStatusType; /** True when the agent stopped sending heartbeats while still RUNNING (likely crash/OOM). */ readonly sessionUnhealthy?: boolean; + /** Consecutive ECS poll failures — escalated to error after 3. */ + readonly consecutiveEcsPollFailures?: number; + /** Consecutive polls where ECS reports completed but DDB is not terminal — escalated after 5. */ + readonly consecutiveEcsCompletedPolls?: number; } /** After RUNNING this long, we expect `agent_heartbeat_at` from the agent (if ever set). */ @@ -359,44 +357,6 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B return payload; } -/** - * Start an AgentCore runtime session and transition task to RUNNING. - * @param task - the task record. - * @param payload - the hydrated invocation payload. - * @param blueprintConfig - optional per-repo blueprint config for runtime ARN override. - * @returns the session ID. - */ -export async function startSession( - task: TaskRecord, - payload: Record, - blueprintConfig?: BlueprintConfig, -): Promise { - // AgentCore requires runtimeSessionId >= 33 chars; UUID v4 is 36 chars. - const sessionId = randomUUID(); - const runtimeArn = blueprintConfig?.runtime_arn ?? RUNTIME_ARN; - - const command = new InvokeAgentRuntimeCommand({ - agentRuntimeArn: runtimeArn, - runtimeSessionId: sessionId, - contentType: 'application/json', - accept: 'application/json', - payload: new TextEncoder().encode(JSON.stringify({ input: payload })), - }); - - await agentCoreClient.send(command); - - await transitionTask(task.task_id, TaskStatus.HYDRATING, TaskStatus.RUNNING, { - session_id: sessionId, - started_at: new Date().toISOString(), - agent_runtime_arn: runtimeArn, - }); - await emitTaskEvent(task.task_id, 'session_started', { session_id: sessionId }); - - logger.info('Session started', { task_id: task.task_id, session_id: sessionId }); - - return sessionId; -} - /** * Poll the task record in DynamoDB to check if the agent wrote a terminal status. * Returns the updated PollState; the waitStrategy decides whether to continue. diff --git a/cdk/src/handlers/shared/repo-config.ts b/cdk/src/handlers/shared/repo-config.ts index e80b7a0..60dd23a 100644 --- a/cdk/src/handlers/shared/repo-config.ts +++ b/cdk/src/handlers/shared/repo-config.ts @@ -25,12 +25,14 @@ import { logger } from './logger'; * Per-repository configuration written by the Blueprint CDK construct * and read at runtime by the task API gate and the orchestrator. */ +export type ComputeType = 'agentcore' | 'ecs'; + export interface RepoConfig { readonly repo: string; readonly status: 'active' | 'removed'; readonly onboarded_at: string; readonly updated_at: string; - readonly compute_type?: string; + readonly compute_type?: ComputeType; readonly runtime_arn?: string; readonly model_id?: string; readonly max_turns?: number; @@ -47,7 +49,7 @@ export interface RepoConfig { * settings with platform defaults. */ export interface BlueprintConfig { - readonly compute_type: string; + readonly compute_type: ComputeType; readonly runtime_arn: string; readonly model_id?: string; readonly max_turns?: number; diff --git a/cdk/src/handlers/shared/strategies/agentcore-strategy.ts b/cdk/src/handlers/shared/strategies/agentcore-strategy.ts new file mode 100644 index 0000000..27604ad --- /dev/null +++ b/cdk/src/handlers/shared/strategies/agentcore-strategy.ts @@ -0,0 +1,99 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { randomUUID } from 'crypto'; +import { BedrockAgentCoreClient, InvokeAgentRuntimeCommand, StopRuntimeSessionCommand } from '@aws-sdk/client-bedrock-agentcore'; +import type { ComputeStrategy, SessionHandle, SessionStatus } from '../compute-strategy'; +import { logger } from '../logger'; +import type { BlueprintConfig } from '../repo-config'; + +let sharedClient: BedrockAgentCoreClient | undefined; +function getClient(): BedrockAgentCoreClient { + if (!sharedClient) { + sharedClient = new BedrockAgentCoreClient({}); + } + return sharedClient; +} + +export class AgentCoreComputeStrategy implements ComputeStrategy { + readonly type = 'agentcore'; + + async startSession(input: { + taskId: string; + payload: Record; + blueprintConfig: BlueprintConfig; + }): Promise { + // AgentCore requires runtimeSessionId >= 33 chars; UUID v4 is 36 chars. + const sessionId = randomUUID(); + const runtimeArn = input.blueprintConfig.runtime_arn; + + const command = new InvokeAgentRuntimeCommand({ + agentRuntimeArn: runtimeArn, + runtimeSessionId: sessionId, + contentType: 'application/json', + accept: 'application/json', + payload: new TextEncoder().encode(JSON.stringify({ input: input.payload })), + }); + + await getClient().send(command); + + logger.info('AgentCore session invoked', { task_id: input.taskId, session_id: sessionId, runtime_arn: runtimeArn }); + + return { + sessionId, + strategyType: 'agentcore', + runtimeArn, + }; + } + + async pollSession(_handle: SessionHandle): Promise { + return { status: 'running' }; + } + + async stopSession(handle: SessionHandle): Promise { + if (handle.strategyType !== 'agentcore') { + throw new Error('stopSession called with non-agentcore handle'); + } + const { runtimeArn } = handle; + + try { + await getClient().send(new StopRuntimeSessionCommand({ + agentRuntimeArn: runtimeArn, + runtimeSessionId: handle.sessionId, + })); + logger.info('AgentCore session stopped', { session_id: handle.sessionId }); + } catch (err) { + const errName = err instanceof Error ? err.name : undefined; + if (errName === 'ResourceNotFoundException') { + logger.info('AgentCore session already gone', { session_id: handle.sessionId }); + } else if (errName === 'ThrottlingException' || errName === 'AccessDeniedException') { + logger.error('Failed to stop AgentCore session', { + session_id: handle.sessionId, + error_type: errName, + error: err instanceof Error ? err.message : String(err), + }); + } else { + logger.warn('Failed to stop AgentCore session (best-effort)', { + session_id: handle.sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } +} diff --git a/cdk/src/handlers/shared/strategies/ecs-strategy.ts b/cdk/src/handlers/shared/strategies/ecs-strategy.ts new file mode 100644 index 0000000..5c0ad67 --- /dev/null +++ b/cdk/src/handlers/shared/strategies/ecs-strategy.ts @@ -0,0 +1,217 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ECSClient, RunTaskCommand, DescribeTasksCommand, StopTaskCommand } from '@aws-sdk/client-ecs'; +import type { ComputeStrategy, SessionHandle, SessionStatus } from '../compute-strategy'; +import { logger } from '../logger'; +import type { BlueprintConfig } from '../repo-config'; + +let sharedClient: ECSClient | undefined; +function getClient(): ECSClient { + if (!sharedClient) { + sharedClient = new ECSClient({}); + } + return sharedClient; +} + +const ECS_CLUSTER_ARN = process.env.ECS_CLUSTER_ARN; +const ECS_TASK_DEFINITION_ARN = process.env.ECS_TASK_DEFINITION_ARN; +const ECS_SUBNETS = process.env.ECS_SUBNETS; +const ECS_SECURITY_GROUP = process.env.ECS_SECURITY_GROUP; +const ECS_CONTAINER_NAME = process.env.ECS_CONTAINER_NAME ?? 'AgentContainer'; + +export class EcsComputeStrategy implements ComputeStrategy { + readonly type = 'ecs'; + + async startSession(input: { + taskId: string; + payload: Record; + blueprintConfig: BlueprintConfig; + }): Promise { + if (!ECS_CLUSTER_ARN || !ECS_TASK_DEFINITION_ARN || !ECS_SUBNETS || !ECS_SECURITY_GROUP) { + throw new Error( + 'ECS compute strategy requires ECS_CLUSTER_ARN, ECS_TASK_DEFINITION_ARN, ECS_SUBNETS, and ECS_SECURITY_GROUP environment variables', + ); + } + + const subnets = ECS_SUBNETS.split(',').map(s => s.trim()).filter(Boolean); + const { taskId, payload, blueprintConfig } = input; + + // The ECS container's default CMD starts the FastAPI server (uvicorn) which + // waits for HTTP POST to /invocations — but in standalone ECS nobody sends + // that request. We override the container command to invoke run_task() + // directly with the full orchestrator payload (including hydrated_context). + // This avoids the server entirely and runs the agent in batch mode. + const payloadJson = JSON.stringify(payload); + + const containerEnv = [ + { name: 'TASK_ID', value: taskId }, + { name: 'REPO_URL', value: String(payload.repo_url ?? '') }, + ...(payload.prompt ? [{ name: 'TASK_DESCRIPTION', value: String(payload.prompt) }] : []), + ...(payload.issue_number ? [{ name: 'ISSUE_NUMBER', value: String(payload.issue_number) }] : []), + { name: 'MAX_TURNS', value: String(payload.max_turns ?? 100) }, + ...(payload.max_budget_usd !== undefined ? [{ name: 'MAX_BUDGET_USD', value: String(payload.max_budget_usd) }] : []), + ...(blueprintConfig.model_id ? [{ name: 'ANTHROPIC_MODEL', value: blueprintConfig.model_id }] : []), + ...(blueprintConfig.system_prompt_overrides ? [{ name: 'SYSTEM_PROMPT_OVERRIDES', value: blueprintConfig.system_prompt_overrides }] : []), + { name: 'CLAUDE_CODE_USE_BEDROCK', value: '1' }, + // Full orchestrator payload as JSON — the Python wrapper reads this to + // call run_task() with all fields including hydrated_context. + { name: 'AGENT_PAYLOAD', value: payloadJson }, + ...(payload.github_token_secret_arn + ? [{ name: 'GITHUB_TOKEN_SECRET_ARN', value: String(payload.github_token_secret_arn) }] + : []), + ...(payload.memory_id ? [{ name: 'MEMORY_ID', value: String(payload.memory_id) }] : []), + ]; + + // Override the container command to run a Python one-liner that: + // 1. Reads the AGENT_PAYLOAD env var (full orchestrator payload JSON) + // 2. Calls entrypoint.run_task() directly with all fields + // 3. Exits with code 0 on success, 1 on failure + // This bypasses the uvicorn server entirely — no HTTP, no OTEL noise. + const bootCommand = [ + 'python', '-c', + 'import json, os, sys; ' + + 'sys.path.insert(0, "/app"); ' + + 'from entrypoint import run_task; ' + + 'p = json.loads(os.environ["AGENT_PAYLOAD"]); ' + + 'r = run_task(' + + 'repo_url=p.get("repo_url",""), ' + + 'task_description=p.get("prompt",""), ' + + 'issue_number=str(p.get("issue_number","")), ' + + 'github_token=p.get("github_token",""), ' + + 'anthropic_model=p.get("model_id",""), ' + + 'max_turns=int(p.get("max_turns",100)), ' + + 'max_budget_usd=p.get("max_budget_usd"), ' + + 'aws_region=os.environ.get("AWS_REGION",""), ' + + 'task_id=p.get("task_id",""), ' + + 'hydrated_context=p.get("hydrated_context"), ' + + 'system_prompt_overrides=p.get("system_prompt_overrides",""), ' + + 'prompt_version=p.get("prompt_version",""), ' + + 'memory_id=p.get("memory_id",""), ' + + 'task_type=p.get("task_type","new_task"), ' + + 'branch_name=p.get("branch_name",""), ' + + 'pr_number=str(p.get("pr_number",""))' + + '); ' + + 'sys.exit(0 if r.get("status")=="success" else 1)', + ]; + + const command = new RunTaskCommand({ + cluster: ECS_CLUSTER_ARN, + taskDefinition: ECS_TASK_DEFINITION_ARN, + launchType: 'FARGATE', + networkConfiguration: { + awsvpcConfiguration: { + subnets, + securityGroups: [ECS_SECURITY_GROUP], + assignPublicIp: 'DISABLED', + }, + }, + overrides: { + containerOverrides: [{ + name: ECS_CONTAINER_NAME, + environment: containerEnv, + command: bootCommand, + }], + }, + }); + + const result = await getClient().send(command); + + const ecsTask = result.tasks?.[0]; + if (!ecsTask?.taskArn) { + const failures = result.failures?.map(f => `${f.arn}: ${f.reason}`).join('; ') ?? 'unknown'; + throw new Error(`ECS RunTask returned no task: ${failures}`); + } + + logger.info('ECS Fargate task started', { + task_id: taskId, + ecs_task_arn: ecsTask.taskArn, + cluster: ECS_CLUSTER_ARN, + }); + + return { + sessionId: ecsTask.taskArn, + strategyType: 'ecs', + clusterArn: ECS_CLUSTER_ARN, + taskArn: ecsTask.taskArn, + }; + } + + async pollSession(handle: SessionHandle): Promise { + if (handle.strategyType !== 'ecs') { + throw new Error('pollSession called with non-ecs handle'); + } + const { clusterArn, taskArn } = handle; + + const result = await getClient().send(new DescribeTasksCommand({ + cluster: clusterArn, + tasks: [taskArn], + })); + + const ecsTask = result.tasks?.[0]; + if (!ecsTask) { + return { status: 'failed', error: `ECS task ${taskArn} not found` }; + } + + const lastStatus = ecsTask.lastStatus; + + if (lastStatus === 'STOPPED') { + const container = ecsTask.containers?.[0]; + const exitCode = container?.exitCode; + const stoppedReason = ecsTask.stoppedReason ?? container?.reason ?? 'unknown'; + + if (exitCode === 0) { + return { status: 'completed' }; + } + if (exitCode === undefined || exitCode === null) { + return { status: 'failed', error: `Task stopped: ${stoppedReason}` }; + } + return { status: 'failed', error: `Exit code ${exitCode}: ${stoppedReason}` }; + } + + // PENDING, PROVISIONING, ACTIVATING, RUNNING, DEACTIVATING, DEPROVISIONING + return { status: 'running' }; + } + + async stopSession(handle: SessionHandle): Promise { + if (handle.strategyType !== 'ecs') { + throw new Error('stopSession called with non-ecs handle'); + } + const { clusterArn, taskArn } = handle; + + try { + await getClient().send(new StopTaskCommand({ + cluster: clusterArn, + task: taskArn, + reason: 'Stopped by orchestrator', + })); + logger.info('ECS task stopped', { task_arn: taskArn }); + } catch (err) { + const errName = err instanceof Error ? err.name : undefined; + if (errName === 'InvalidParameterException' || errName === 'ResourceNotFoundException') { + logger.info('ECS task already stopped or not found', { task_arn: taskArn }); + } else { + logger.error('Failed to stop ECS task', { + task_arn: taskArn, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } +} diff --git a/cdk/src/handlers/shared/types.ts b/cdk/src/handlers/shared/types.ts index c54dda9..df7862b 100644 --- a/cdk/src/handlers/shared/types.ts +++ b/cdk/src/handlers/shared/types.ts @@ -17,6 +17,7 @@ * SOFTWARE. */ +import type { ComputeType } from './repo-config'; import type { TaskStatusType } from '../../constructs/task-status'; /** Valid task types for task creation. */ @@ -63,6 +64,8 @@ export interface TaskRecord { readonly max_budget_usd?: number; readonly prompt_version?: string; readonly memory_written?: boolean; + readonly compute_type?: ComputeType; + readonly compute_metadata?: Record; readonly ttl?: number; } diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index d7fa064..7d16690 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -23,6 +23,8 @@ import * as bedrock from '@aws-cdk/aws-bedrock-alpha'; import * as agentcoremixins from '@aws-cdk/mixins-preview/aws-bedrockagentcore'; import { Stack, StackProps, RemovalPolicy, CfnOutput, CfnResource } from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; +// ecr_assets import is only needed when the ECS block below is uncommented +// import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; import * as iam from 'aws-cdk-lib/aws-iam'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; @@ -34,6 +36,7 @@ import { AgentVpc } from '../constructs/agent-vpc'; import { Blueprint } from '../constructs/blueprint'; import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; import { DnsFirewall } from '../constructs/dns-firewall'; +// import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; import { RepoTable } from '../constructs/repo-table'; import { TaskApi } from '../constructs/task-api'; import { TaskDashboard } from '../constructs/task-dashboard'; @@ -273,6 +276,26 @@ export class AgentStack extends Stack { inputGuardrail.createVersion('Initial version'); + // --- ECS Fargate compute backend (optional) --- + // To enable ECS as an alternative compute backend, uncomment the block below + // and the EcsAgentCluster import at the top of this file. Repos can then use + // compute_type: 'ecs' in their blueprint config to route tasks to ECS Fargate. + // + // const agentImageAsset = new ecr_assets.DockerImageAsset(this, 'AgentImage', { + // directory: runnerPath, + // platform: ecr_assets.Platform.LINUX_ARM64, + // }); + // + // const ecsCluster = new EcsAgentCluster(this, 'EcsAgentCluster', { + // vpc: agentVpc.vpc, + // agentImageAsset, + // taskTable: taskTable.table, + // taskEventsTable: taskEventsTable.table, + // userConcurrencyTable: userConcurrencyTable.table, + // githubTokenSecret, + // memoryId: agentMemory.memory.memoryId, + // }); + // --- Task Orchestrator (durable Lambda function) --- const orchestrator = new TaskOrchestrator(this, 'TaskOrchestrator', { taskTable: taskTable.table, @@ -284,6 +307,16 @@ export class AgentStack extends Stack { memoryId: agentMemory.memory.memoryId, guardrailId: inputGuardrail.guardrailId, guardrailVersion: inputGuardrail.guardrailVersion, + // To wire ECS, uncomment the ecsCluster block above and add: + // ecsConfig: { + // clusterArn: ecsCluster.cluster.clusterArn, + // taskDefinitionArn: ecsCluster.taskDefinition.taskDefinitionArn, + // subnets: agentVpc.vpc.selectSubnets({ subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }).subnetIds.join(','), + // securityGroup: ecsCluster.securityGroup.securityGroupId, + // containerName: ecsCluster.containerName, + // taskRoleArn: ecsCluster.taskRoleArn, + // executionRoleArn: ecsCluster.executionRoleArn, + // }, }); // Grant the orchestrator Lambda read+write access to memory @@ -306,6 +339,8 @@ export class AgentStack extends Stack { guardrailId: inputGuardrail.guardrailId, guardrailVersion: inputGuardrail.guardrailVersion, agentCoreStopSessionRuntimeArns: [runtime.agentRuntimeArn], + // To allow cancel-task to stop ECS-backed tasks, uncomment: + // ecsClusterArn: ecsCluster.cluster.clusterArn, }); // --- Operator dashboard --- diff --git a/cdk/test/constructs/ecs-agent-cluster.test.ts b/cdk/test/constructs/ecs-agent-cluster.test.ts new file mode 100644 index 0000000..3e5854d --- /dev/null +++ b/cdk/test/constructs/ecs-agent-cluster.test.ts @@ -0,0 +1,202 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as path from 'path'; +import { App, Stack } from 'aws-cdk-lib'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { EcsAgentCluster } from '../../src/constructs/ecs-agent-cluster'; + +function createStack(overrides?: { memoryId?: string }): { stack: Stack; template: Template } { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + + const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 }); + + const agentImageAsset = new ecr_assets.DockerImageAsset(stack, 'AgentImage', { + directory: path.join(__dirname, '..', '..', '..', 'agent'), + }); + + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + + const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, + }); + + const userConcurrencyTable = new dynamodb.Table(stack, 'UserConcurrencyTable', { + partitionKey: { name: 'user_id', type: dynamodb.AttributeType.STRING }, + }); + + const githubTokenSecret = new secretsmanager.Secret(stack, 'GitHubTokenSecret'); + + new EcsAgentCluster(stack, 'EcsAgentCluster', { + vpc, + agentImageAsset, + taskTable, + taskEventsTable, + userConcurrencyTable, + githubTokenSecret, + memoryId: overrides?.memoryId, + }); + + const template = Template.fromStack(stack); + return { stack, template }; +} + +describe('EcsAgentCluster construct', () => { + test('creates an ECS Cluster with container insights', () => { + const { template } = createStack(); + template.hasResourceProperties('AWS::ECS::Cluster', { + ClusterSettings: Match.arrayWith([ + Match.objectLike({ + Name: 'containerInsights', + Value: 'enabled', + }), + ]), + }); + }); + + test('creates a Fargate task definition with 2 vCPU and 4 GB', () => { + const { template } = createStack(); + template.hasResourceProperties('AWS::ECS::TaskDefinition', { + Cpu: '2048', + Memory: '4096', + RequiresCompatibilities: ['FARGATE'], + RuntimePlatform: { + CpuArchitecture: 'ARM64', + OperatingSystemFamily: 'LINUX', + }, + }); + }); + + test('creates a security group with TCP 443 egress only', () => { + const { template } = createStack(); + template.hasResourceProperties('AWS::EC2::SecurityGroup', { + GroupDescription: 'ECS Agent Tasks - egress TCP 443 only', + SecurityGroupEgress: Match.arrayWith([ + Match.objectLike({ + IpProtocol: 'tcp', + FromPort: 443, + ToPort: 443, + CidrIp: '0.0.0.0/0', + }), + ]), + }); + }); + + test('creates a CloudWatch log group with 3-month retention and CDK-generated name', () => { + const { template } = createStack(); + template.hasResourceProperties('AWS::Logs::LogGroup', { + RetentionInDays: 90, + }); + // Verify no hardcoded log group name — CDK auto-generates a unique name + const logGroups = template.findResources('AWS::Logs::LogGroup'); + for (const [, lg] of Object.entries(logGroups)) { + expect((lg as any).Properties).not.toHaveProperty('LogGroupName'); + } + }); + + test('task role has DynamoDB read/write permissions', () => { + const { template } = createStack(); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: Match.arrayWith([ + 'dynamodb:PutItem', + 'dynamodb:UpdateItem', + ]), + Effect: 'Allow', + }), + ]), + }, + }); + }); + + test('task role has Secrets Manager read permission', () => { + const { template } = createStack(); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: Match.arrayWith([ + 'secretsmanager:GetSecretValue', + ]), + Effect: 'Allow', + }), + ]), + }, + }); + }); + + test('task role has Bedrock InvokeModel permissions', () => { + const { template } = createStack(); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: [ + 'bedrock:InvokeModel', + 'bedrock:InvokeModelWithResponseStream', + ], + Effect: 'Allow', + Resource: '*', + }), + ]), + }, + }); + }); + + test('container has required environment variables', () => { + const { template } = createStack(); + template.hasResourceProperties('AWS::ECS::TaskDefinition', { + ContainerDefinitions: Match.arrayWith([ + Match.objectLike({ + Name: 'AgentContainer', + Environment: Match.arrayWith([ + Match.objectLike({ Name: 'CLAUDE_CODE_USE_BEDROCK', Value: '1' }), + Match.objectLike({ Name: 'TASK_TABLE_NAME', Value: Match.anyValue() }), + Match.objectLike({ Name: 'TASK_EVENTS_TABLE_NAME', Value: Match.anyValue() }), + Match.objectLike({ Name: 'USER_CONCURRENCY_TABLE_NAME', Value: Match.anyValue() }), + Match.objectLike({ Name: 'LOG_GROUP_NAME', Value: Match.anyValue() }), + ]), + }), + ]), + }); + }); + + test('includes MEMORY_ID in container env when provided', () => { + const { template } = createStack({ memoryId: 'mem-test-123' }); + template.hasResourceProperties('AWS::ECS::TaskDefinition', { + ContainerDefinitions: Match.arrayWith([ + Match.objectLike({ + Environment: Match.arrayWith([ + Match.objectLike({ Name: 'MEMORY_ID', Value: 'mem-test-123' }), + ]), + }), + ]), + }); + }); +}); diff --git a/cdk/test/constructs/task-api.test.ts b/cdk/test/constructs/task-api.test.ts index 862564f..ee415a8 100644 --- a/cdk/test/constructs/task-api.test.ts +++ b/cdk/test/constructs/task-api.test.ts @@ -303,6 +303,44 @@ describe('TaskApi construct', () => { }, }); }); + + test('cancelTask Lambda gets ECS_CLUSTER_ARN env var and ecs:StopTask when ecsClusterArn is set', () => { + const { template } = createStack({ + ecsClusterArn: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', + }); + + // Cancel Lambda should have the ECS_CLUSTER_ARN env var + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + ECS_CLUSTER_ARN: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', + }), + }, + }); + + // Should have ecs:StopTask permission + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'ecs:StopTask', + Effect: 'Allow', + }), + ]), + }, + }); + }); + + test('cancelTask Lambda does not get ECS env vars when ecsClusterArn is not set', () => { + const { template } = createStack(); + + // Find all Lambda functions and verify none have ECS_CLUSTER_ARN + const functions = template.findResources('AWS::Lambda::Function'); + for (const [, fn] of Object.entries(functions)) { + const vars = (fn as any).Properties?.Environment?.Variables ?? {}; + expect(vars).not.toHaveProperty('ECS_CLUSTER_ARN'); + } + }); }); describe('TaskApi construct with webhooks', () => { diff --git a/cdk/test/constructs/task-orchestrator.test.ts b/cdk/test/constructs/task-orchestrator.test.ts index 0b2c2ef..0612020 100644 --- a/cdk/test/constructs/task-orchestrator.test.ts +++ b/cdk/test/constructs/task-orchestrator.test.ts @@ -32,6 +32,15 @@ interface StackOverrides { memoryId?: string; guardrailId?: string; guardrailVersion?: string; + ecsConfig?: { + clusterArn: string; + taskDefinitionArn: string; + subnets: string; + securityGroup: string; + containerName: string; + taskRoleArn: string; + executionRoleArn: string; + }; } function createStack(overrides?: StackOverrides): { stack: Stack; template: Template } { @@ -57,7 +66,16 @@ function createStack(overrides?: StackOverrides): { stack: Stack; template: Temp }) : undefined; - const { includeRepoTable: _, additionalRuntimeArns, additionalSecretArns, memoryId, guardrailId, guardrailVersion, ...rest } = overrides ?? {}; + const { + includeRepoTable: _, + additionalRuntimeArns, + additionalSecretArns, + memoryId, + guardrailId, + guardrailVersion, + ecsConfig, + ...rest + } = overrides ?? {}; new TaskOrchestrator(stack, 'TaskOrchestrator', { taskTable, @@ -70,6 +88,7 @@ function createStack(overrides?: StackOverrides): { stack: Stack; template: Temp ...(memoryId && { memoryId }), ...(guardrailId && { guardrailId }), ...(guardrailVersion && { guardrailVersion }), + ...(ecsConfig && { ecsConfig }), ...rest, }); @@ -415,4 +434,103 @@ describe('TaskOrchestrator construct', () => { 'guardrailId is required when guardrailVersion is provided', ); }); + + describe('ECS compute strategy', () => { + const ecsOverrides = { + ecsConfig: { + clusterArn: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', + taskDefinitionArn: 'arn:aws:ecs:us-east-1:123456789012:task-definition/agent:1', + subnets: 'subnet-aaa,subnet-bbb', + securityGroup: 'sg-12345', + containerName: 'AgentContainer', + taskRoleArn: 'arn:aws:iam::123456789012:role/TaskRole', + executionRoleArn: 'arn:aws:iam::123456789012:role/ExecutionRole', + }, + }; + + test('includes ECS env vars when ECS props are provided', () => { + const { template } = createStack(ecsOverrides); + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + ECS_CLUSTER_ARN: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', + ECS_TASK_DEFINITION_ARN: 'arn:aws:ecs:us-east-1:123456789012:task-definition/agent:1', + ECS_SUBNETS: 'subnet-aaa,subnet-bbb', + ECS_SECURITY_GROUP: 'sg-12345', + ECS_CONTAINER_NAME: 'AgentContainer', + }), + }, + }); + }); + + test('does not include ECS env vars when ECS props are omitted', () => { + const { template } = createStack(); + const functions = template.findResources('AWS::Lambda::Function'); + for (const [, fn] of Object.entries(functions)) { + const envVars = (fn as any).Properties.Environment?.Variables ?? {}; + expect(envVars).not.toHaveProperty('ECS_CLUSTER_ARN'); + expect(envVars).not.toHaveProperty('ECS_TASK_DEFINITION_ARN'); + } + }); + + test('grants ECS RunTask/DescribeTasks/StopTask permissions when ECS props are provided', () => { + const { template } = createStack(ecsOverrides); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: [ + 'ecs:RunTask', + 'ecs:DescribeTasks', + 'ecs:StopTask', + ], + Effect: 'Allow', + Resource: '*', + Condition: { + ArnEquals: { + 'ecs:cluster': 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', + }, + }, + }), + ]), + }, + }); + }); + + test('grants iam:PassRole scoped to task/execution role ARNs', () => { + const { template } = createStack(ecsOverrides); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'iam:PassRole', + Effect: 'Allow', + Resource: Match.arrayWith([ + 'arn:aws:iam::123456789012:role/TaskRole', + 'arn:aws:iam::123456789012:role/ExecutionRole', + ]), + Condition: { + StringEquals: { + 'iam:PassedToService': 'ecs-tasks.amazonaws.com', + }, + }, + }), + ]), + }, + }); + }); + + test('does not grant ECS permissions when ECS props are omitted', () => { + const { template } = createStack(); + const policies = template.findResources('AWS::IAM::Policy'); + for (const [, policy] of Object.entries(policies)) { + const statements = (policy as any).Properties.PolicyDocument.Statement; + for (const stmt of statements) { + if (Array.isArray(stmt.Action)) { + expect(stmt.Action).not.toContain('ecs:RunTask'); + } + } + } + }); + }); }); diff --git a/cdk/test/handlers/cancel-task.test.ts b/cdk/test/handlers/cancel-task.test.ts index ca83729..68e7b1d 100644 --- a/cdk/test/handlers/cancel-task.test.ts +++ b/cdk/test/handlers/cancel-task.test.ts @@ -22,10 +22,15 @@ import type { APIGatewayProxyEvent } from 'aws-lambda'; // --- Mocks --- const mockSend = jest.fn(); const mockAgentCoreSend = jest.fn(); +const mockEcsSend = jest.fn(); jest.mock('@aws-sdk/client-bedrock-agentcore', () => ({ BedrockAgentCoreClient: jest.fn(() => ({ send: mockAgentCoreSend })), StopRuntimeSessionCommand: jest.fn((input: unknown) => ({ _type: 'StopRuntimeSession', input })), })); +jest.mock('@aws-sdk/client-ecs', () => ({ + ECSClient: jest.fn(() => ({ send: mockEcsSend })), + StopTaskCommand: jest.fn((input: unknown) => ({ _type: 'StopTask', input })), +})); jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); jest.mock('@aws-sdk/lib-dynamodb', () => ({ DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockSend })) }, @@ -40,6 +45,7 @@ process.env.TASK_TABLE_NAME = 'Tasks'; process.env.TASK_EVENTS_TABLE_NAME = 'TaskEvents'; process.env.TASK_RETENTION_DAYS = '90'; process.env.RUNTIME_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/default'; +process.env.ECS_CLUSTER_ARN = 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster'; import { handler } from '../../src/handlers/cancel-task'; @@ -107,6 +113,7 @@ function makeEvent(overrides: Partial = {}): APIGatewayPro beforeEach(() => { jest.clearAllMocks(); mockAgentCoreSend.mockResolvedValue({}); + mockEcsSend.mockResolvedValue({}); // Default: GetCommand returns running task, UpdateCommand + PutCommand succeed mockSend .mockResolvedValueOnce({ Item: RUNNING_TASK }) // GetCommand @@ -274,4 +281,89 @@ describe('cancel-task handler', () => { expect(result.statusCode).toBe(200); expect(mockAgentCoreSend).toHaveBeenCalled(); }); + + test('cancels ECS-backed running task via StopTask', async () => { + mockSend.mockReset(); + const ecsTask = { + ...RUNNING_TASK, + compute_type: 'ecs', + compute_metadata: { + clusterArn: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', + taskArn: 'arn:aws:ecs:us-east-1:123456789012:task/abc123', + }, + }; + mockSend + .mockResolvedValueOnce({ Item: ecsTask }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(200); + expect(mockEcsSend).toHaveBeenCalledTimes(1); + const cmd = mockEcsSend.mock.calls[0][0]; + expect(cmd.input.cluster).toBe('arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster'); + expect(cmd.input.task).toBe('arn:aws:ecs:us-east-1:123456789012:task/abc123'); + // AgentCore should NOT have been called + expect(mockAgentCoreSend).not.toHaveBeenCalled(); + }); + + test('skips ECS StopTask when compute_metadata.taskArn is missing', async () => { + mockSend.mockReset(); + const ecsTask = { + ...RUNNING_TASK, + compute_type: 'ecs', + compute_metadata: { + clusterArn: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', + }, + }; + mockSend + .mockResolvedValueOnce({ Item: ecsTask }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(200); + expect(mockEcsSend).not.toHaveBeenCalled(); + expect(mockAgentCoreSend).not.toHaveBeenCalled(); + }); + + test('falls back to AgentCore stop when compute_type is unrecognized but RUNTIME_ARN is available', async () => { + mockSend.mockReset(); + const unknownTask = { + ...RUNNING_TASK, + compute_type: 'unknown', + }; + delete (unknownTask as { agent_runtime_arn?: string }).agent_runtime_arn; + mockSend + .mockResolvedValueOnce({ Item: unknownTask }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(200); + expect(mockEcsSend).not.toHaveBeenCalled(); + // Falls back to AgentCore because RUNTIME_ARN env var is set + expect(mockAgentCoreSend).toHaveBeenCalledTimes(1); + }); + + test('returns 200 when ECS StopTask fails', async () => { + mockSend.mockReset(); + mockEcsSend.mockRejectedValueOnce(new Error('ECS error')); + const ecsTask = { + ...RUNNING_TASK, + compute_type: 'ecs', + compute_metadata: { + clusterArn: 'arn:aws:ecs:us-east-1:123456789012:cluster/agent-cluster', + taskArn: 'arn:aws:ecs:us-east-1:123456789012:task/abc123', + }, + }; + mockSend + .mockResolvedValueOnce({ Item: ecsTask }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(200); + expect(mockEcsSend).toHaveBeenCalled(); + }); }); diff --git a/cdk/test/handlers/orchestrate-task.test.ts b/cdk/test/handlers/orchestrate-task.test.ts index abaafa8..b21f0f8 100644 --- a/cdk/test/handlers/orchestrate-task.test.ts +++ b/cdk/test/handlers/orchestrate-task.test.ts @@ -76,7 +76,6 @@ import { loadBlueprintConfig, loadTask, pollTaskStatus, - startSession, transitionTask, } from '../../src/handlers/shared/orchestrator'; @@ -236,25 +235,6 @@ describe('hydrateAndTransition', () => { }); }); -describe('startSession', () => { - test('invokes agent runtime and transitions to RUNNING', async () => { - mockAgentCoreSend.mockResolvedValueOnce({}); // InvokeAgentRuntime - mockDdbSend.mockResolvedValue({}); // transitionTask + emitTaskEvent - - const sessionId = await startSession(baseTask as any, { repo_url: 'org/repo', task_id: 'TASK001' }); - // Session ID is a UUID v4 (36 chars), not a ULID - expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); - expect(mockAgentCoreSend).toHaveBeenCalledTimes(1); - const transitionCall = mockDdbSend.mock.calls.find( - (c: any[]) => c[0]._type === 'Update' && c[0].input.ExpressionAttributeValues?.[':toStatus'] === 'RUNNING', - ); - expect(transitionCall).toBeDefined(); - expect(transitionCall![0].input.ExpressionAttributeValues[':attr_agent_runtime_arn']).toBe( - 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/test', - ); - }); -}); - describe('pollTaskStatus', () => { test('increments attempt count and reads status', async () => { mockDdbSend.mockResolvedValueOnce({ Item: { status: 'RUNNING' } }); @@ -547,21 +527,6 @@ describe('hydrateAndTransition with blueprint config', () => { }); }); -describe('startSession with blueprint config', () => { - test('uses blueprint runtime ARN override', async () => { - mockAgentCoreSend.mockResolvedValueOnce({}); - mockDdbSend.mockResolvedValue({}); - - await startSession(baseTask as any, { repo_url: 'org/repo', task_id: 'TASK001' }, { - compute_type: 'agentcore', - runtime_arn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/custom', - }); - - const invokeCall = mockAgentCoreSend.mock.calls[0][0]; - expect(invokeCall.input.agentRuntimeArn).toBe('arn:aws:bedrock-agentcore:us-east-1:123:runtime/custom'); - }); -}); - describe('finalizeTask', () => { test('handles already-terminal task', async () => { mockDdbSend diff --git a/cdk/test/handlers/shared/compute-strategy.test.ts b/cdk/test/handlers/shared/compute-strategy.test.ts new file mode 100644 index 0000000..1fac73d --- /dev/null +++ b/cdk/test/handlers/shared/compute-strategy.test.ts @@ -0,0 +1,55 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +jest.mock('@aws-sdk/client-bedrock-agentcore', () => ({ + BedrockAgentCoreClient: jest.fn(() => ({ send: jest.fn() })), + InvokeAgentRuntimeCommand: jest.fn(), + StopRuntimeSessionCommand: jest.fn(), +})); + +jest.mock('@aws-sdk/client-ecs', () => ({ + ECSClient: jest.fn(() => ({ send: jest.fn() })), + RunTaskCommand: jest.fn(), + DescribeTasksCommand: jest.fn(), + StopTaskCommand: jest.fn(), +})); + +import { resolveComputeStrategy } from '../../../src/handlers/shared/compute-strategy'; +import { AgentCoreComputeStrategy } from '../../../src/handlers/shared/strategies/agentcore-strategy'; +import { EcsComputeStrategy } from '../../../src/handlers/shared/strategies/ecs-strategy'; + +describe('resolveComputeStrategy', () => { + test('returns AgentCoreComputeStrategy for compute_type agentcore', () => { + const strategy = resolveComputeStrategy({ + compute_type: 'agentcore', + runtime_arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/test', + }); + expect(strategy).toBeInstanceOf(AgentCoreComputeStrategy); + expect(strategy.type).toBe('agentcore'); + }); + + test('returns EcsComputeStrategy for compute_type ecs', () => { + const strategy = resolveComputeStrategy({ + compute_type: 'ecs', + runtime_arn: 'arn:test', + }); + expect(strategy).toBeInstanceOf(EcsComputeStrategy); + expect(strategy.type).toBe('ecs'); + }); +}); diff --git a/cdk/test/handlers/shared/preflight.test.ts b/cdk/test/handlers/shared/preflight.test.ts index 780e047..ee7b9f6 100644 --- a/cdk/test/handlers/shared/preflight.test.ts +++ b/cdk/test/handlers/shared/preflight.test.ts @@ -40,7 +40,7 @@ const mockFetch = jest.fn(); global.fetch = mockFetch as unknown as typeof fetch; const baseBlueprintConfig: BlueprintConfig = { - compute_type: 'AGENTCORE', + compute_type: 'agentcore', runtime_arn: 'arn:aws:bedrock:us-east-1:123456789012:agent-runtime/test', github_token_secret_arn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:github-token', }; diff --git a/cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts b/cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts new file mode 100644 index 0000000..46f3f7e --- /dev/null +++ b/cdk/test/handlers/shared/strategies/agentcore-strategy.test.ts @@ -0,0 +1,205 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const mockSend = jest.fn(); +jest.mock('@aws-sdk/client-bedrock-agentcore', () => ({ + BedrockAgentCoreClient: jest.fn(() => ({ send: mockSend })), + InvokeAgentRuntimeCommand: jest.fn((input: unknown) => ({ _type: 'InvokeAgentRuntime', input })), + StopRuntimeSessionCommand: jest.fn((input: unknown) => ({ _type: 'StopRuntimeSession', input })), +})); + +import { BedrockAgentCoreClient } from '@aws-sdk/client-bedrock-agentcore'; +import { AgentCoreComputeStrategy } from '../../../../src/handlers/shared/strategies/agentcore-strategy'; + +const MockedClient = jest.mocked(BedrockAgentCoreClient); +const defaultRuntimeArn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/default'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('AgentCoreComputeStrategy', () => { + test('type is agentcore', () => { + const strategy = new AgentCoreComputeStrategy(); + expect(strategy.type).toBe('agentcore'); + }); + + describe('startSession', () => { + test('invokes agent runtime and returns SessionHandle', async () => { + mockSend.mockResolvedValueOnce({}); + const strategy = new AgentCoreComputeStrategy(); + + const handle = await strategy.startSession({ + taskId: 'TASK001', + payload: { repo_url: 'org/repo', task_id: 'TASK001' }, + blueprintConfig: { compute_type: 'agentcore', runtime_arn: defaultRuntimeArn }, + }); + + expect(handle.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(handle.strategyType).toBe('agentcore'); + const acHandle = handle as Extract; + expect(acHandle.runtimeArn).toBe(defaultRuntimeArn); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + test('uses runtime_arn from blueprintConfig (single source of truth)', async () => { + mockSend.mockResolvedValueOnce({}); + const strategy = new AgentCoreComputeStrategy(); + const runtimeArn = 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/custom'; + + const handle = await strategy.startSession({ + taskId: 'TASK001', + payload: { repo_url: 'org/repo', task_id: 'TASK001' }, + blueprintConfig: { compute_type: 'agentcore', runtime_arn: runtimeArn }, + }); + + const acHandle = handle as Extract; + expect(acHandle.runtimeArn).toBe(runtimeArn); + const invokeCall = mockSend.mock.calls[0][0]; + expect(invokeCall.input.agentRuntimeArn).toBe(runtimeArn); + }); + + test('reuses shared BedrockAgentCoreClient across instances', async () => { + // The lazy singleton may already be initialized from prior tests. + // Record the current call count, then verify no additional constructor calls happen. + const callsBefore = MockedClient.mock.calls.length; + + mockSend.mockResolvedValue({}); + const strategy1 = new AgentCoreComputeStrategy(); + const strategy2 = new AgentCoreComputeStrategy(); + + await strategy1.startSession({ + taskId: 'T1', + payload: {}, + blueprintConfig: { compute_type: 'agentcore', runtime_arn: defaultRuntimeArn }, + }); + await strategy2.startSession({ + taskId: 'T2', + payload: {}, + blueprintConfig: { compute_type: 'agentcore', runtime_arn: defaultRuntimeArn }, + }); + + // Lazy singleton: at most one constructor call total across all strategy instances + const callsAfter = MockedClient.mock.calls.length; + expect(callsAfter - callsBefore).toBeLessThanOrEqual(1); + expect(mockSend).toHaveBeenCalledTimes(2); + }); + }); + + describe('pollSession', () => { + test('returns running status (AgentCore polling is done via DDB)', async () => { + const strategy = new AgentCoreComputeStrategy(); + const result = await strategy.pollSession({ + sessionId: 'test-session', + strategyType: 'agentcore', + runtimeArn: defaultRuntimeArn, + }); + expect(result).toEqual({ status: 'running' }); + }); + }); + + describe('stopSession', () => { + test('sends StopRuntimeSessionCommand', async () => { + mockSend.mockResolvedValueOnce({}); + const strategy = new AgentCoreComputeStrategy(); + + await strategy.stopSession({ + sessionId: 'test-session', + strategyType: 'agentcore', + runtimeArn: defaultRuntimeArn, + }); + + expect(mockSend).toHaveBeenCalledTimes(1); + const call = mockSend.mock.calls[0][0]; + expect(call.input.agentRuntimeArn).toBe(defaultRuntimeArn); + expect(call.input.runtimeSessionId).toBe('test-session'); + }); + + test('logs info for ResourceNotFoundException (session already gone)', async () => { + const err = new Error('Not found'); + err.name = 'ResourceNotFoundException'; + mockSend.mockRejectedValueOnce(err); + const strategy = new AgentCoreComputeStrategy(); + + await expect( + strategy.stopSession({ + sessionId: 'test-session', + strategyType: 'agentcore', + runtimeArn: defaultRuntimeArn, + }), + ).resolves.toBeUndefined(); + }); + + test('logs error for ThrottlingException', async () => { + const err = new Error('Rate exceeded'); + err.name = 'ThrottlingException'; + mockSend.mockRejectedValueOnce(err); + const strategy = new AgentCoreComputeStrategy(); + + await expect( + strategy.stopSession({ + sessionId: 'test-session', + strategyType: 'agentcore', + runtimeArn: defaultRuntimeArn, + }), + ).resolves.toBeUndefined(); + }); + + test('logs error for AccessDeniedException', async () => { + const err = new Error('Access denied'); + err.name = 'AccessDeniedException'; + mockSend.mockRejectedValueOnce(err); + const strategy = new AgentCoreComputeStrategy(); + + await expect( + strategy.stopSession({ + sessionId: 'test-session', + strategyType: 'agentcore', + runtimeArn: defaultRuntimeArn, + }), + ).resolves.toBeUndefined(); + }); + + test('logs warn for unknown errors (best-effort)', async () => { + mockSend.mockRejectedValueOnce(new Error('Network error')); + const strategy = new AgentCoreComputeStrategy(); + + await expect( + strategy.stopSession({ + sessionId: 'test-session', + strategyType: 'agentcore', + runtimeArn: defaultRuntimeArn, + }), + ).resolves.toBeUndefined(); + }); + + test('throws when handle is not agentcore type', async () => { + const strategy = new AgentCoreComputeStrategy(); + + await expect( + strategy.stopSession({ + sessionId: 'test-session', + strategyType: 'ecs', + clusterArn: 'arn:test', + taskArn: 'arn:test', + }), + ).rejects.toThrow('stopSession called with non-agentcore handle'); + }); + }); +}); diff --git a/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts new file mode 100644 index 0000000..0e365a1 --- /dev/null +++ b/cdk/test/handlers/shared/strategies/ecs-strategy.test.ts @@ -0,0 +1,335 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const CLUSTER_ARN = 'arn:aws:ecs:us-east-1:123456789012:cluster/test-cluster'; +const TASK_DEF_ARN = 'arn:aws:ecs:us-east-1:123456789012:task-definition/agent:1'; +const TASK_ARN = 'arn:aws:ecs:us-east-1:123456789012:task/test-cluster/abc123'; + +// Set env vars BEFORE import — EcsComputeStrategy reads them as module-level constants +process.env.ECS_CLUSTER_ARN = CLUSTER_ARN; +process.env.ECS_TASK_DEFINITION_ARN = TASK_DEF_ARN; +process.env.ECS_SUBNETS = 'subnet-aaa,subnet-bbb'; +process.env.ECS_SECURITY_GROUP = 'sg-12345'; +process.env.ECS_CONTAINER_NAME = 'AgentContainer'; + +const mockSend = jest.fn(); +jest.mock('@aws-sdk/client-ecs', () => ({ + ECSClient: jest.fn(() => ({ send: mockSend })), + RunTaskCommand: jest.fn((input: unknown) => ({ _type: 'RunTask', input })), + DescribeTasksCommand: jest.fn((input: unknown) => ({ _type: 'DescribeTasks', input })), + StopTaskCommand: jest.fn((input: unknown) => ({ _type: 'StopTask', input })), +})); + +import { EcsComputeStrategy } from '../../../../src/handlers/shared/strategies/ecs-strategy'; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('EcsComputeStrategy', () => { + test('type is ecs', () => { + const strategy = new EcsComputeStrategy(); + expect(strategy.type).toBe('ecs'); + }); + + describe('startSession', () => { + test('sends RunTaskCommand with correct params and returns SessionHandle', async () => { + mockSend.mockResolvedValueOnce({ + tasks: [{ taskArn: TASK_ARN }], + }); + + const strategy = new EcsComputeStrategy(); + const handle = await strategy.startSession({ + taskId: 'TASK001', + payload: { repo_url: 'org/repo', prompt: 'Fix the bug', issue_number: 42, max_turns: 50 }, + blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, + }); + + expect(handle.sessionId).toBe(TASK_ARN); + expect(handle.strategyType).toBe('ecs'); + const ecsHandle = handle as Extract; + expect(ecsHandle.clusterArn).toBe(CLUSTER_ARN); + expect(ecsHandle.taskArn).toBe(TASK_ARN); + expect(mockSend).toHaveBeenCalledTimes(1); + + const call = mockSend.mock.calls[0][0]; + expect(call.input.cluster).toBe(CLUSTER_ARN); + expect(call.input.taskDefinition).toBe(TASK_DEF_ARN); + expect(call.input.launchType).toBe('FARGATE'); + expect(call.input.networkConfiguration.awsvpcConfiguration.subnets).toEqual(['subnet-aaa', 'subnet-bbb']); + expect(call.input.networkConfiguration.awsvpcConfiguration.securityGroups).toEqual(['sg-12345']); + expect(call.input.networkConfiguration.awsvpcConfiguration.assignPublicIp).toBe('DISABLED'); + + const override = call.input.overrides.containerOverrides[0]; + const envVars = override.environment; + expect(envVars).toEqual(expect.arrayContaining([ + { name: 'TASK_ID', value: 'TASK001' }, + { name: 'REPO_URL', value: 'org/repo' }, + { name: 'TASK_DESCRIPTION', value: 'Fix the bug' }, + { name: 'ISSUE_NUMBER', value: '42' }, + { name: 'MAX_TURNS', value: '50' }, + { name: 'CLAUDE_CODE_USE_BEDROCK', value: '1' }, + ])); + + // AGENT_PAYLOAD contains the full orchestrator payload for direct run_task() invocation + const agentPayload = envVars.find((e: { name: string }) => e.name === 'AGENT_PAYLOAD'); + expect(agentPayload).toBeDefined(); + const parsed = JSON.parse(agentPayload.value); + expect(parsed.repo_url).toBe('org/repo'); + expect(parsed.prompt).toBe('Fix the bug'); + + // Container command override — runs Python directly instead of uvicorn + expect(override.command).toBeDefined(); + expect(override.command[0]).toBe('python'); + }); + + test('throws when RunTask returns no task', async () => { + mockSend.mockResolvedValueOnce({ + tasks: [], + failures: [{ arn: 'arn:test', reason: 'RESOURCE:ENI' }], + }); + + const strategy = new EcsComputeStrategy(); + await expect( + strategy.startSession({ + taskId: 'TASK001', + payload: { repo_url: 'org/repo' }, + blueprintConfig: { compute_type: 'ecs', runtime_arn: '' }, + }), + ).rejects.toThrow('ECS RunTask returned no task: arn:test: RESOURCE:ENI'); + }); + + test('includes model_id and system_prompt_overrides from blueprintConfig', async () => { + mockSend.mockResolvedValueOnce({ + tasks: [{ taskArn: TASK_ARN }], + }); + + const strategy = new EcsComputeStrategy(); + await strategy.startSession({ + taskId: 'TASK001', + payload: { repo_url: 'org/repo' }, + blueprintConfig: { + compute_type: 'ecs', + runtime_arn: '', + model_id: 'anthropic.claude-sonnet-4-6', + system_prompt_overrides: 'Be concise', + }, + }); + + const call = mockSend.mock.calls[0][0]; + const envVars = call.input.overrides.containerOverrides[0].environment; + expect(envVars).toEqual(expect.arrayContaining([ + { name: 'ANTHROPIC_MODEL', value: 'anthropic.claude-sonnet-4-6' }, + { name: 'SYSTEM_PROMPT_OVERRIDES', value: 'Be concise' }, + ])); + }); + }); + + describe('pollSession', () => { + const makeHandle = () => ({ + sessionId: TASK_ARN, + strategyType: 'ecs' as const, + clusterArn: CLUSTER_ARN, + taskArn: TASK_ARN, + }); + + test('returns running for RUNNING status', async () => { + mockSend.mockResolvedValueOnce({ + tasks: [{ lastStatus: 'RUNNING' }], + }); + + const strategy = new EcsComputeStrategy(); + const result = await strategy.pollSession(makeHandle()); + expect(result).toEqual({ status: 'running' }); + }); + + test('returns running for PENDING status', async () => { + mockSend.mockResolvedValueOnce({ + tasks: [{ lastStatus: 'PENDING' }], + }); + + const strategy = new EcsComputeStrategy(); + const result = await strategy.pollSession(makeHandle()); + expect(result).toEqual({ status: 'running' }); + }); + + test('returns completed for STOPPED with exit code 0', async () => { + mockSend.mockResolvedValueOnce({ + tasks: [{ + lastStatus: 'STOPPED', + containers: [{ exitCode: 0 }], + }], + }); + + const strategy = new EcsComputeStrategy(); + const result = await strategy.pollSession(makeHandle()); + expect(result).toEqual({ status: 'completed' }); + }); + + test('returns failed for STOPPED with undefined exit code (container never started)', async () => { + mockSend.mockResolvedValueOnce({ + tasks: [{ + lastStatus: 'STOPPED', + stoppedReason: 'CannotPullContainerError', + containers: [{}], + }], + }); + + const strategy = new EcsComputeStrategy(); + const result = await strategy.pollSession(makeHandle()); + expect(result).toEqual({ + status: 'failed', + error: 'Task stopped: CannotPullContainerError', + }); + }); + + test('returns failed for STOPPED with no containers', async () => { + mockSend.mockResolvedValueOnce({ + tasks: [{ + lastStatus: 'STOPPED', + stoppedReason: 'EssentialContainerExited', + containers: [], + }], + }); + + const strategy = new EcsComputeStrategy(); + const result = await strategy.pollSession(makeHandle()); + expect(result).toEqual({ + status: 'failed', + error: 'Task stopped: EssentialContainerExited', + }); + }); + + test('returns failed for STOPPED with non-zero exit code', async () => { + mockSend.mockResolvedValueOnce({ + tasks: [{ + lastStatus: 'STOPPED', + stoppedReason: 'OutOfMemoryError', + containers: [{ exitCode: 137 }], + }], + }); + + const strategy = new EcsComputeStrategy(); + const result = await strategy.pollSession(makeHandle()); + expect(result).toEqual({ + status: 'failed', + error: 'Exit code 137: OutOfMemoryError', + }); + }); + + test('returns failed when task not found', async () => { + mockSend.mockResolvedValueOnce({ tasks: [] }); + + const strategy = new EcsComputeStrategy(); + const result = await strategy.pollSession(makeHandle()); + expect(result).toEqual({ + status: 'failed', + error: `ECS task ${TASK_ARN} not found`, + }); + }); + + test('throws when handle is not ecs type', async () => { + const strategy = new EcsComputeStrategy(); + await expect( + strategy.pollSession({ + sessionId: 'test', + strategyType: 'agentcore', + runtimeArn: 'arn:test', + }), + ).rejects.toThrow('pollSession called with non-ecs handle'); + }); + }); + + describe('stopSession', () => { + test('sends StopTaskCommand', async () => { + mockSend.mockResolvedValueOnce({}); + + const strategy = new EcsComputeStrategy(); + await strategy.stopSession({ + sessionId: TASK_ARN, + strategyType: 'ecs', + clusterArn: CLUSTER_ARN, + taskArn: TASK_ARN, + }); + + expect(mockSend).toHaveBeenCalledTimes(1); + const call = mockSend.mock.calls[0][0]; + expect(call.input.cluster).toBe(CLUSTER_ARN); + expect(call.input.task).toBe(TASK_ARN); + expect(call.input.reason).toBe('Stopped by orchestrator'); + }); + + test('handles InvalidParameterException gracefully', async () => { + const err = new Error('Invalid'); + err.name = 'InvalidParameterException'; + mockSend.mockRejectedValueOnce(err); + + const strategy = new EcsComputeStrategy(); + await expect( + strategy.stopSession({ + sessionId: TASK_ARN, + strategyType: 'ecs', + clusterArn: CLUSTER_ARN, + taskArn: TASK_ARN, + }), + ).resolves.toBeUndefined(); + }); + + test('handles ResourceNotFoundException gracefully', async () => { + const err = new Error('Not found'); + err.name = 'ResourceNotFoundException'; + mockSend.mockRejectedValueOnce(err); + + const strategy = new EcsComputeStrategy(); + await expect( + strategy.stopSession({ + sessionId: TASK_ARN, + strategyType: 'ecs', + clusterArn: CLUSTER_ARN, + taskArn: TASK_ARN, + }), + ).resolves.toBeUndefined(); + }); + + test('throws when handle is not ecs type', async () => { + const strategy = new EcsComputeStrategy(); + await expect( + strategy.stopSession({ + sessionId: 'test', + strategyType: 'agentcore', + runtimeArn: 'arn:test', + }), + ).rejects.toThrow('stopSession called with non-ecs handle'); + }); + + test('logs error for unknown errors (best-effort)', async () => { + mockSend.mockRejectedValueOnce(new Error('Network error')); + + const strategy = new EcsComputeStrategy(); + await expect( + strategy.stopSession({ + sessionId: TASK_ARN, + strategyType: 'ecs', + clusterArn: CLUSTER_ARN, + taskArn: TASK_ARN, + }), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/cdk/test/handlers/start-session-composition.test.ts b/cdk/test/handlers/start-session-composition.test.ts new file mode 100644 index 0000000..e9592d6 --- /dev/null +++ b/cdk/test/handlers/start-session-composition.test.ts @@ -0,0 +1,158 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Integration-style tests for the start-session step composition: + * resolveComputeStrategy → strategy.startSession → transitionTask → emitTaskEvent + * These verify that the orchestrate-task handler's step 4 logic correctly + * wires the strategy, state transitions, and event emission together. + */ + +const mockDdbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + UpdateCommand: jest.fn((input: unknown) => ({ _type: 'Update', input })), +})); + +const mockAgentCoreSend = jest.fn(); +jest.mock('@aws-sdk/client-bedrock-agentcore', () => ({ + BedrockAgentCoreClient: jest.fn(() => ({ send: mockAgentCoreSend })), + InvokeAgentRuntimeCommand: jest.fn((input: unknown) => ({ _type: 'InvokeAgentRuntime', input })), + StopRuntimeSessionCommand: jest.fn((input: unknown) => ({ _type: 'StopRuntimeSession', input })), +})); + +jest.mock('../../src/handlers/shared/repo-config', () => ({ + loadRepoConfig: jest.fn(), + checkRepoOnboarded: jest.fn(), +})); + +jest.mock('../../src/handlers/shared/memory', () => ({ + writeMinimalEpisode: jest.fn(), +})); + +jest.mock('../../src/handlers/shared/prompt-version', () => ({ + computePromptVersion: jest.fn().mockReturnValue('abc123'), +})); + +jest.mock('../../src/handlers/shared/context-hydration', () => ({ + hydrateContext: jest.fn(), +})); + +let ulidCounter = 0; +jest.mock('ulid', () => ({ ulid: jest.fn(() => `ULID${ulidCounter++}`) })); + +process.env.TASK_TABLE_NAME = 'Tasks'; +process.env.TASK_EVENTS_TABLE_NAME = 'TaskEvents'; +process.env.USER_CONCURRENCY_TABLE_NAME = 'UserConcurrency'; +process.env.RUNTIME_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/test'; +process.env.MAX_CONCURRENT_TASKS_PER_USER = '3'; +process.env.TASK_RETENTION_DAYS = '90'; + +import { TaskStatus } from '../../src/constructs/task-status'; +import { resolveComputeStrategy } from '../../src/handlers/shared/compute-strategy'; +import { transitionTask, emitTaskEvent, failTask } from '../../src/handlers/shared/orchestrator'; +import type { BlueprintConfig } from '../../src/handlers/shared/repo-config'; + +beforeEach(() => { + jest.clearAllMocks(); + ulidCounter = 0; +}); + +describe('start-session step composition', () => { + const taskId = 'TASK001'; + const userId = 'user-123'; + const blueprintConfig: BlueprintConfig = { + compute_type: 'agentcore', + runtime_arn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/test', + }; + const payload = { repo_url: 'org/repo', task_id: taskId }; + + test('happy path: strategy.startSession → transitionTask → emitTaskEvent', async () => { + mockAgentCoreSend.mockResolvedValueOnce({}); + mockDdbSend.mockResolvedValue({}); // transitionTask + emitTaskEvent + + const strategy = resolveComputeStrategy(blueprintConfig); + const handle = await strategy.startSession({ taskId, payload, blueprintConfig }); + + await transitionTask(taskId, TaskStatus.HYDRATING, TaskStatus.RUNNING, { + session_id: handle.sessionId, + started_at: expect.any(String), + }); + await emitTaskEvent(taskId, 'session_started', { + session_id: handle.sessionId, + strategy_type: handle.strategyType, + }); + + // Verify AgentCore was invoked + expect(mockAgentCoreSend).toHaveBeenCalledTimes(1); + // Verify DDB was called for transition + event + expect(mockDdbSend).toHaveBeenCalledTimes(2); + // Verify handle shape + expect(handle.strategyType).toBe('agentcore'); + expect(handle.sessionId).toBeDefined(); + }); + + test('error path: strategy.startSession fails → failTask is called', async () => { + mockAgentCoreSend.mockRejectedValueOnce(new Error('InvokeAgent failed')); + mockDdbSend.mockResolvedValue({}); // failTask transitions + + const strategy = resolveComputeStrategy(blueprintConfig); + + try { + await strategy.startSession({ taskId, payload, blueprintConfig }); + fail('Expected startSession to throw'); + } catch (err) { + await failTask(taskId, TaskStatus.HYDRATING, `Session start failed: ${String(err)}`, userId, true); + } + + // failTask should have been called — transitions to FAILED + emits event + decrements concurrency + // transitionTask (1) + emitTaskEvent (1) + decrementConcurrency (1) = 3 DDB calls + expect(mockDdbSend).toHaveBeenCalledTimes(3); + }); + + test('partial failure: strategy succeeds but transitionTask throws', async () => { + mockAgentCoreSend.mockResolvedValueOnce({}); + const condErr = new Error('Conditional check failed'); + condErr.name = 'ConditionalCheckFailedException'; + mockDdbSend + .mockRejectedValueOnce(condErr) // transitionTask fails + .mockResolvedValue({}); // failTask calls + + const strategy = resolveComputeStrategy(blueprintConfig); + const handle = await strategy.startSession({ taskId, payload, blueprintConfig }); + + try { + await transitionTask(taskId, TaskStatus.HYDRATING, TaskStatus.RUNNING, { + session_id: handle.sessionId, + started_at: new Date().toISOString(), + }); + fail('Expected transitionTask to throw'); + } catch (err) { + await failTask(taskId, TaskStatus.HYDRATING, `Session start failed: ${String(err)}`, userId, true); + } + + // AgentCore was invoked + expect(mockAgentCoreSend).toHaveBeenCalledTimes(1); + // transitionTask failed (1) + failTask: transitionTask (1) + emitTaskEvent (1) + decrement (1) = 4 + expect(mockDdbSend).toHaveBeenCalledTimes(4); + }); +}); diff --git a/docs/design/ARCHITECTURE.md b/docs/design/ARCHITECTURE.md index 662356a..baa953a 100644 --- a/docs/design/ARCHITECTURE.md +++ b/docs/design/ARCHITECTURE.md @@ -90,6 +90,20 @@ The steps below are the blueprint in action: deterministic orchestration (1–2, 3. **Agentic:** The agent runs in the isolated environment: clone repo, create branch, edit code, commit often, run tests and lint, create PR. Commits are attributed via git trailers (`Task-Id`, `Prompt-Version`). At task end, the agent writes memory (task episode + repo learnings) to AgentCore Memory. The orchestrator does not execute this logic; it only waits for the session to finish. 4. **Deterministic:** The orchestrator infers the result (e.g. by querying GitHub for a PR on the agent's branch), updates task status, and finalizes (result inference, cleanup). If the agent did not write memory (crash, timeout), the orchestrator writes a fallback episode. A validation step may run here (e.g. configurable post-agent checks); see repo onboarding for customizing these steps. +### Why the orchestrator and agent are separate loops + +The orchestrator (deterministic) and the agent workload (non-deterministic) could in theory run as a single process, but they are deliberately separated. This separation is the architectural foundation for several guarantees: + +**Reliability boundary.** The agent is the component most likely to fail — LLM hallucination, OOM, session crash, idle timeout. The orchestrator wraps the agent with durable execution (checkpoint/resume via Lambda Durable Functions) so that when the agent dies mid-task, the platform still drives the task to a terminal state: it detects the failure via heartbeat/poll, transitions the task to FAILED or TIMED_OUT, releases concurrency counters, writes a fallback memory episode, and emits cleanup events. Without this boundary, a crashed agent would leave orphaned state — stuck counters, no terminal status, no user notification. + +**Cost separation.** Orchestrator steps are Lambda invocations costing fractions of a cent. Agent steps burn compute-hours and LLM inference tokens (the dominant cost at $0.20–0.60 per task). Keeping admission control, context hydration, result inference, and finalization out of the compute session avoids paying compute and token costs for bookkeeping work that requires no LLM reasoning. + +**Trust boundary.** The agent runs inside a sandboxed MicroVM (AgentCore Runtime) with a blast radius limited to one branch in one repository. The orchestrator runs in the trusted platform layer (Lambda + DynamoDB) and enforces invariants the agent cannot bypass: concurrency limits, cancellation, timeout enforcement, and conditional state transitions (`ConditionExpression` guards on DynamoDB writes). The agent's own state writes are guarded to prevent it from overwriting orchestrator-managed status (e.g. an agent writing COMPLETED over an orchestrator-set CANCELLED). + +**Testability.** Deterministic steps can be unit-tested without LLM calls, compute sessions, or GitHub API access. The orchestrator's admission control, context hydration, result inference, and state transitions are covered by fast, isolated Jest tests (`cdk/test/handlers/shared/`). The agent workload requires integration testing with a live model and compute environment. Keeping them separate means platform logic can be validated cheaply and quickly, independent of model behavior. + +**Independent evolution.** The orchestrator and agent communicate through a narrow contract: the orchestrator passes a hydrated prompt and environment variables; the agent pushes commits, creates a PR, and exits. Either side can change independently as long as the contract holds — the orchestrator can add new pre/post steps, switch durable execution engines, or change polling strategies without touching the agent code, and the agent can change its tool set, prompting strategy, or coding workflow without affecting the orchestrator. + For the API contract — endpoints, request/response schemas, error codes, authentication, and pagination — see [API_CONTRACT.md](./API_CONTRACT.md). ## Onboarding pipeline diff --git a/docs/src/content/docs/design/Architecture.md b/docs/src/content/docs/design/Architecture.md index e069011..97ea314 100644 --- a/docs/src/content/docs/design/Architecture.md +++ b/docs/src/content/docs/design/Architecture.md @@ -94,6 +94,20 @@ The steps below are the blueprint in action: deterministic orchestration (1–2, 3. **Agentic:** The agent runs in the isolated environment: clone repo, create branch, edit code, commit often, run tests and lint, create PR. Commits are attributed via git trailers (`Task-Id`, `Prompt-Version`). At task end, the agent writes memory (task episode + repo learnings) to AgentCore Memory. The orchestrator does not execute this logic; it only waits for the session to finish. 4. **Deterministic:** The orchestrator infers the result (e.g. by querying GitHub for a PR on the agent's branch), updates task status, and finalizes (result inference, cleanup). If the agent did not write memory (crash, timeout), the orchestrator writes a fallback episode. A validation step may run here (e.g. configurable post-agent checks); see repo onboarding for customizing these steps. +### Why the orchestrator and agent are separate loops + +The orchestrator (deterministic) and the agent workload (non-deterministic) could in theory run as a single process, but they are deliberately separated. This separation is the architectural foundation for several guarantees: + +**Reliability boundary.** The agent is the component most likely to fail — LLM hallucination, OOM, session crash, idle timeout. The orchestrator wraps the agent with durable execution (checkpoint/resume via Lambda Durable Functions) so that when the agent dies mid-task, the platform still drives the task to a terminal state: it detects the failure via heartbeat/poll, transitions the task to FAILED or TIMED_OUT, releases concurrency counters, writes a fallback memory episode, and emits cleanup events. Without this boundary, a crashed agent would leave orphaned state — stuck counters, no terminal status, no user notification. + +**Cost separation.** Orchestrator steps are Lambda invocations costing fractions of a cent. Agent steps burn compute-hours and LLM inference tokens (the dominant cost at $0.20–0.60 per task). Keeping admission control, context hydration, result inference, and finalization out of the compute session avoids paying compute and token costs for bookkeeping work that requires no LLM reasoning. + +**Trust boundary.** The agent runs inside a sandboxed MicroVM (AgentCore Runtime) with a blast radius limited to one branch in one repository. The orchestrator runs in the trusted platform layer (Lambda + DynamoDB) and enforces invariants the agent cannot bypass: concurrency limits, cancellation, timeout enforcement, and conditional state transitions (`ConditionExpression` guards on DynamoDB writes). The agent's own state writes are guarded to prevent it from overwriting orchestrator-managed status (e.g. an agent writing COMPLETED over an orchestrator-set CANCELLED). + +**Testability.** Deterministic steps can be unit-tested without LLM calls, compute sessions, or GitHub API access. The orchestrator's admission control, context hydration, result inference, and state transitions are covered by fast, isolated Jest tests (`cdk/test/handlers/shared/`). The agent workload requires integration testing with a live model and compute environment. Keeping them separate means platform logic can be validated cheaply and quickly, independent of model behavior. + +**Independent evolution.** The orchestrator and agent communicate through a narrow contract: the orchestrator passes a hydrated prompt and environment variables; the agent pushes commits, creates a PR, and exits. Either side can change independently as long as the contract holds — the orchestrator can add new pre/post steps, switch durable execution engines, or change polling strategies without touching the agent code, and the agent can change its tool set, prompting strategy, or coding workflow without affecting the orchestrator. + For the API contract — endpoints, request/response schemas, error codes, authentication, and pagination — see [API_CONTRACT.md](/design/api-contract). ## Onboarding pipeline diff --git a/yarn.lock b/yarn.lock index ecdc6cd..86a7e7e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -454,6 +454,52 @@ "@smithy/util-waiter" "^4.2.14" tslib "^2.6.2" +"@aws-sdk/client-ecs@^3.1021.0": + version "3.1027.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-ecs/-/client-ecs-3.1027.0.tgz#fdc05b3c8a8d9457776791cb3ac4acb57da298a2" + integrity sha512-HS6Ca0kX8agG5D/+wHsTNkbgawTyXPgxwDA3KwuRBXU5e/BzK+gHVBOya+IEmUPr25IvEfY8hQm4yd0xyXBUPw== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.973.27" + "@aws-sdk/credential-provider-node" "^3.972.30" + "@aws-sdk/middleware-host-header" "^3.972.9" + "@aws-sdk/middleware-logger" "^3.972.9" + "@aws-sdk/middleware-recursion-detection" "^3.972.10" + "@aws-sdk/middleware-user-agent" "^3.972.29" + "@aws-sdk/region-config-resolver" "^3.972.11" + "@aws-sdk/types" "^3.973.7" + "@aws-sdk/util-endpoints" "^3.996.6" + "@aws-sdk/util-user-agent-browser" "^3.972.9" + "@aws-sdk/util-user-agent-node" "^3.973.15" + "@smithy/config-resolver" "^4.4.14" + "@smithy/core" "^3.23.14" + "@smithy/fetch-http-handler" "^5.3.16" + "@smithy/hash-node" "^4.2.13" + "@smithy/invalid-dependency" "^4.2.13" + "@smithy/middleware-content-length" "^4.2.13" + "@smithy/middleware-endpoint" "^4.4.29" + "@smithy/middleware-retry" "^4.5.0" + "@smithy/middleware-serde" "^4.2.17" + "@smithy/middleware-stack" "^4.2.13" + "@smithy/node-config-provider" "^4.3.13" + "@smithy/node-http-handler" "^4.5.2" + "@smithy/protocol-http" "^5.3.13" + "@smithy/smithy-client" "^4.12.9" + "@smithy/types" "^4.14.0" + "@smithy/url-parser" "^4.2.13" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.45" + "@smithy/util-defaults-mode-node" "^4.2.49" + "@smithy/util-endpoints" "^3.3.4" + "@smithy/util-middleware" "^4.2.13" + "@smithy/util-retry" "^4.3.0" + "@smithy/util-utf8" "^4.2.2" + "@smithy/util-waiter" "^4.2.15" + tslib "^2.6.2" + "@aws-sdk/client-lambda@^3.1021.0", "@aws-sdk/client-lambda@^3.943.0": version "3.1021.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-lambda/-/client-lambda-3.1021.0.tgz#6f757ba466b686fb04128e931522fa5a33cf00d8" @@ -568,6 +614,25 @@ "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" +"@aws-sdk/core@^3.973.27": + version "3.973.27" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.973.27.tgz#cc2872a8d54357f5bc6d9475400291c653ab5d08" + integrity sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A== + dependencies: + "@aws-sdk/types" "^3.973.7" + "@aws-sdk/xml-builder" "^3.972.17" + "@smithy/core" "^3.23.14" + "@smithy/node-config-provider" "^4.3.13" + "@smithy/property-provider" "^4.2.13" + "@smithy/protocol-http" "^5.3.13" + "@smithy/signature-v4" "^5.3.13" + "@smithy/smithy-client" "^4.12.9" + "@smithy/types" "^4.14.0" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-middleware" "^4.2.13" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + "@aws-sdk/credential-provider-env@^3.972.24": version "3.972.24" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.24.tgz#bc33a34f15704d02552aa8b3994d17008b991f86" @@ -579,6 +644,17 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-env@^3.972.25": + version "3.972.25" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.25.tgz#6a55730ec56597545119e2013101c5872c7b1602" + integrity sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A== + dependencies: + "@aws-sdk/core" "^3.973.27" + "@aws-sdk/types" "^3.973.7" + "@smithy/property-provider" "^4.2.13" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-http@^3.972.26": version "3.972.26" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.26.tgz#6524c3681dbb62d3c4de82262631ab94b800f00e" @@ -595,6 +671,22 @@ "@smithy/util-stream" "^4.5.21" tslib "^2.6.2" +"@aws-sdk/credential-provider-http@^3.972.27": + version "3.972.27" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.27.tgz#371cca39c19b52012ec2bf025299a233d26445b2" + integrity sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ== + dependencies: + "@aws-sdk/core" "^3.973.27" + "@aws-sdk/types" "^3.973.7" + "@smithy/fetch-http-handler" "^5.3.16" + "@smithy/node-http-handler" "^4.5.2" + "@smithy/property-provider" "^4.2.13" + "@smithy/protocol-http" "^5.3.13" + "@smithy/smithy-client" "^4.12.9" + "@smithy/types" "^4.14.0" + "@smithy/util-stream" "^4.5.22" + tslib "^2.6.2" + "@aws-sdk/credential-provider-ini@^3.972.28": version "3.972.28" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.28.tgz#6bc0d684c245914dca7a1a4dd3c2d84212833320" @@ -615,6 +707,26 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-ini@^3.972.29": + version "3.972.29" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.29.tgz#0129911b1ca5e561b4e25d494447457ee7540eaa" + integrity sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw== + dependencies: + "@aws-sdk/core" "^3.973.27" + "@aws-sdk/credential-provider-env" "^3.972.25" + "@aws-sdk/credential-provider-http" "^3.972.27" + "@aws-sdk/credential-provider-login" "^3.972.29" + "@aws-sdk/credential-provider-process" "^3.972.25" + "@aws-sdk/credential-provider-sso" "^3.972.29" + "@aws-sdk/credential-provider-web-identity" "^3.972.29" + "@aws-sdk/nested-clients" "^3.996.19" + "@aws-sdk/types" "^3.973.7" + "@smithy/credential-provider-imds" "^4.2.13" + "@smithy/property-provider" "^4.2.13" + "@smithy/shared-ini-file-loader" "^4.4.8" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-login@^3.972.28": version "3.972.28" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.28.tgz#b2d47d4d43690d2d824edc94ce955d86dd3877f1" @@ -629,6 +741,20 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-login@^3.972.29": + version "3.972.29" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.29.tgz#a861534cc0bdec0ce506c6c7310fdd57a4caacc8" + integrity sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ== + dependencies: + "@aws-sdk/core" "^3.973.27" + "@aws-sdk/nested-clients" "^3.996.19" + "@aws-sdk/types" "^3.973.7" + "@smithy/property-provider" "^4.2.13" + "@smithy/protocol-http" "^5.3.13" + "@smithy/shared-ini-file-loader" "^4.4.8" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-node@^3.972.29": version "3.972.29" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.29.tgz#4bcc991fcbf245f75494a119b3446a678a51e019" @@ -647,6 +773,24 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-node@^3.972.30": + version "3.972.30" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.30.tgz#cbf0da21b1fe14108829ed17eaa153fb5fe55c85" + integrity sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.25" + "@aws-sdk/credential-provider-http" "^3.972.27" + "@aws-sdk/credential-provider-ini" "^3.972.29" + "@aws-sdk/credential-provider-process" "^3.972.25" + "@aws-sdk/credential-provider-sso" "^3.972.29" + "@aws-sdk/credential-provider-web-identity" "^3.972.29" + "@aws-sdk/types" "^3.973.7" + "@smithy/credential-provider-imds" "^4.2.13" + "@smithy/property-provider" "^4.2.13" + "@smithy/shared-ini-file-loader" "^4.4.8" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-process@^3.972.24": version "3.972.24" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.24.tgz#940c76a2db0aece23879dcf75ac5b6ee8f8fa135" @@ -659,6 +803,18 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-process@^3.972.25": + version "3.972.25" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.25.tgz#631bd69f28600a6ef134a4cb6e0395371814d3f4" + integrity sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ== + dependencies: + "@aws-sdk/core" "^3.973.27" + "@aws-sdk/types" "^3.973.7" + "@smithy/property-provider" "^4.2.13" + "@smithy/shared-ini-file-loader" "^4.4.8" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-sso@^3.972.28": version "3.972.28" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.28.tgz#bf150bfb7e708d58f35bb2b5786b902df19fd92d" @@ -673,6 +829,20 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-sso@^3.972.29": + version "3.972.29" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.29.tgz#7410169f97f686eaab33daed7e18789a46de1116" + integrity sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig== + dependencies: + "@aws-sdk/core" "^3.973.27" + "@aws-sdk/nested-clients" "^3.996.19" + "@aws-sdk/token-providers" "3.1026.0" + "@aws-sdk/types" "^3.973.7" + "@smithy/property-provider" "^4.2.13" + "@smithy/shared-ini-file-loader" "^4.4.8" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-web-identity@^3.972.28": version "3.972.28" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.28.tgz#27fc2a0fe0d2ff1460171d2a6912898c2235a7df" @@ -686,6 +856,19 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-web-identity@^3.972.29": + version "3.972.29" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.29.tgz#ed3c750076cb9131fd940535ea7e94b846a885dd" + integrity sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew== + dependencies: + "@aws-sdk/core" "^3.973.27" + "@aws-sdk/nested-clients" "^3.996.19" + "@aws-sdk/types" "^3.973.7" + "@smithy/property-provider" "^4.2.13" + "@smithy/shared-ini-file-loader" "^4.4.8" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/dynamodb-codec@^3.972.27": version "3.972.27" resolved "https://registry.yarnpkg.com/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.972.27.tgz#3d29a2f00bbc145260419878a5f3640af81d36b3" @@ -760,6 +943,16 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/middleware-host-header@^3.972.9": + version "3.972.9" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.9.tgz#0a7e66857bcb0ebce1aff1cd0e9eb2fe46069260" + integrity sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ== + dependencies: + "@aws-sdk/types" "^3.973.7" + "@smithy/protocol-http" "^5.3.13" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/middleware-logger@^3.972.8": version "3.972.8" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz#7fee4223afcb6f7828dbdf4ea745ce15027cf384" @@ -769,6 +962,26 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/middleware-logger@^3.972.9": + version "3.972.9" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.972.9.tgz#a47610fe11f953718d405ec3b36d807c9f3c8b22" + integrity sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog== + dependencies: + "@aws-sdk/types" "^3.973.7" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + +"@aws-sdk/middleware-recursion-detection@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.10.tgz#9300b3fa7843f5c353b6be7a3c64a2cf486c3a22" + integrity sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ== + dependencies: + "@aws-sdk/types" "^3.973.7" + "@aws/lambda-invoke-store" "^0.2.2" + "@smithy/protocol-http" "^5.3.13" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/middleware-recursion-detection@^3.972.9": version "3.972.9" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.9.tgz#53a2cc0cf827863163b2351209212f642015c2e2" @@ -794,6 +1007,20 @@ "@smithy/util-retry" "^4.2.13" tslib "^2.6.2" +"@aws-sdk/middleware-user-agent@^3.972.29": + version "3.972.29" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.29.tgz#60931e54bf78cfd41bb39e620d86e30bececbf43" + integrity sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ== + dependencies: + "@aws-sdk/core" "^3.973.27" + "@aws-sdk/types" "^3.973.7" + "@aws-sdk/util-endpoints" "^3.996.6" + "@smithy/core" "^3.23.14" + "@smithy/protocol-http" "^5.3.13" + "@smithy/types" "^4.14.0" + "@smithy/util-retry" "^4.3.0" + tslib "^2.6.2" + "@aws-sdk/middleware-websocket@^3.972.14": version "3.972.14" resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.14.tgz#52ea3b4fddb4320bd23891a4ce103f193b94cadf" @@ -856,6 +1083,50 @@ "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" +"@aws-sdk/nested-clients@^3.996.19": + version "3.996.19" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.996.19.tgz#3e43e3154038e33a59917ec5d015d1f438b6af22" + integrity sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.973.27" + "@aws-sdk/middleware-host-header" "^3.972.9" + "@aws-sdk/middleware-logger" "^3.972.9" + "@aws-sdk/middleware-recursion-detection" "^3.972.10" + "@aws-sdk/middleware-user-agent" "^3.972.29" + "@aws-sdk/region-config-resolver" "^3.972.11" + "@aws-sdk/types" "^3.973.7" + "@aws-sdk/util-endpoints" "^3.996.6" + "@aws-sdk/util-user-agent-browser" "^3.972.9" + "@aws-sdk/util-user-agent-node" "^3.973.15" + "@smithy/config-resolver" "^4.4.14" + "@smithy/core" "^3.23.14" + "@smithy/fetch-http-handler" "^5.3.16" + "@smithy/hash-node" "^4.2.13" + "@smithy/invalid-dependency" "^4.2.13" + "@smithy/middleware-content-length" "^4.2.13" + "@smithy/middleware-endpoint" "^4.4.29" + "@smithy/middleware-retry" "^4.5.0" + "@smithy/middleware-serde" "^4.2.17" + "@smithy/middleware-stack" "^4.2.13" + "@smithy/node-config-provider" "^4.3.13" + "@smithy/node-http-handler" "^4.5.2" + "@smithy/protocol-http" "^5.3.13" + "@smithy/smithy-client" "^4.12.9" + "@smithy/types" "^4.14.0" + "@smithy/url-parser" "^4.2.13" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.45" + "@smithy/util-defaults-mode-node" "^4.2.49" + "@smithy/util-endpoints" "^3.3.4" + "@smithy/util-middleware" "^4.2.13" + "@smithy/util-retry" "^4.3.0" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + "@aws-sdk/region-config-resolver@^3.972.10": version "3.972.10" resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.10.tgz#cbabd969a2d4fedb652273403e64d98b79d0144c" @@ -867,6 +1138,17 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/region-config-resolver@^3.972.11": + version "3.972.11" + resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.11.tgz#b9e48d6b900b2a525adecd62ce67597ebf330835" + integrity sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg== + dependencies: + "@aws-sdk/types" "^3.973.7" + "@smithy/config-resolver" "^4.4.14" + "@smithy/node-config-provider" "^4.3.13" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/token-providers@3.1021.0": version "3.1021.0" resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1021.0.tgz#90905a8def49f90e54a73849e25ad4bcc4dbea2a" @@ -880,6 +1162,19 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/token-providers@3.1026.0": + version "3.1026.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1026.0.tgz#af571864ad4ff3ab2a81ce38cc6d2fa58019df70" + integrity sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA== + dependencies: + "@aws-sdk/core" "^3.973.27" + "@aws-sdk/nested-clients" "^3.996.19" + "@aws-sdk/types" "^3.973.7" + "@smithy/property-provider" "^4.2.13" + "@smithy/shared-ini-file-loader" "^4.4.8" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.6": version "3.973.6" resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.6.tgz#1964a7c01b5cb18befa445998ad1d02f86c5432d" @@ -888,6 +1183,14 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@aws-sdk/types@^3.973.7": + version "3.973.7" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.7.tgz#0dc48b436638d9f19ca52f686912edda2d5d6dee" + integrity sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg== + dependencies: + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@aws-sdk/util-dynamodb@^3.996.2": version "3.996.2" resolved "https://registry.yarnpkg.com/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.2.tgz#9521dfe84c031809f8cf2e32f03c58fd8a4bb84f" @@ -906,6 +1209,17 @@ "@smithy/util-endpoints" "^3.3.3" tslib "^2.6.2" +"@aws-sdk/util-endpoints@^3.996.6": + version "3.996.6" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.996.6.tgz#90934298b655d036d0b181b9fc3239629ba25166" + integrity sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg== + dependencies: + "@aws-sdk/types" "^3.973.7" + "@smithy/types" "^4.14.0" + "@smithy/url-parser" "^4.2.13" + "@smithy/util-endpoints" "^3.3.4" + tslib "^2.6.2" + "@aws-sdk/util-format-url@^3.972.8": version "3.972.8" resolved "https://registry.yarnpkg.com/@aws-sdk/util-format-url/-/util-format-url-3.972.8.tgz#803273f72617edb16b4087bcff2e52d740a26250" @@ -933,6 +1247,16 @@ bowser "^2.11.0" tslib "^2.6.2" +"@aws-sdk/util-user-agent-browser@^3.972.9": + version "3.972.9" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.9.tgz#3fe2f2bf5949d6ccc21c1bcdd75fd79db6cd4d7f" + integrity sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw== + dependencies: + "@aws-sdk/types" "^3.973.7" + "@smithy/types" "^4.14.0" + bowser "^2.11.0" + tslib "^2.6.2" + "@aws-sdk/util-user-agent-node@^3.973.14": version "3.973.14" resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.14.tgz#955e50e8222c9861fdf8f273ba8ff8e28ba04a5c" @@ -945,6 +1269,18 @@ "@smithy/util-config-provider" "^4.2.2" tslib "^2.6.2" +"@aws-sdk/util-user-agent-node@^3.973.15": + version "3.973.15" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.15.tgz#ac4e1a42c89c205d30aa90992171848f8524d490" + integrity sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w== + dependencies: + "@aws-sdk/middleware-user-agent" "^3.972.29" + "@aws-sdk/types" "^3.973.7" + "@smithy/node-config-provider" "^4.3.13" + "@smithy/types" "^4.14.0" + "@smithy/util-config-provider" "^4.2.2" + tslib "^2.6.2" + "@aws-sdk/xml-builder@^3.972.16": version "3.972.16" resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.16.tgz#ea22fe022cf12d12b07f6faf75c4fa214dea00bc" @@ -954,6 +1290,15 @@ fast-xml-parser "5.5.8" tslib "^2.6.2" +"@aws-sdk/xml-builder@^3.972.17": + version "3.972.17" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.17.tgz#748480460eaf075acaf16804b2c32158cbfe984d" + integrity sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg== + dependencies: + "@smithy/types" "^4.14.0" + fast-xml-parser "5.5.8" + tslib "^2.6.2" + "@aws/durable-execution-sdk-js@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@aws/durable-execution-sdk-js/-/durable-execution-sdk-js-1.1.0.tgz#c32a4a358cc5940414accc13cd9825766299898d" @@ -2580,6 +2925,18 @@ "@smithy/util-middleware" "^4.2.12" tslib "^2.6.2" +"@smithy/config-resolver@^4.4.14": + version "4.4.14" + resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.4.14.tgz#6803498f1be96d88da3e6d88a244e4ec99fe3174" + integrity sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ== + dependencies: + "@smithy/node-config-provider" "^4.3.13" + "@smithy/types" "^4.14.0" + "@smithy/util-config-provider" "^4.2.2" + "@smithy/util-endpoints" "^3.3.4" + "@smithy/util-middleware" "^4.2.13" + tslib "^2.6.2" + "@smithy/core@^3.23.13": version "3.23.13" resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.23.13.tgz#343e0d78b907f463b560d9e50d8ae16456281830" @@ -2596,6 +2953,22 @@ "@smithy/uuid" "^1.1.2" tslib "^2.6.2" +"@smithy/core@^3.23.14": + version "3.23.14" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.23.14.tgz#29c3b6cf771ee8898018a1cc34c0fe3f418468e5" + integrity sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg== + dependencies: + "@smithy/protocol-http" "^5.3.13" + "@smithy/types" "^4.14.0" + "@smithy/url-parser" "^4.2.13" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-middleware" "^4.2.13" + "@smithy/util-stream" "^4.5.22" + "@smithy/util-utf8" "^4.2.2" + "@smithy/uuid" "^1.1.2" + tslib "^2.6.2" + "@smithy/credential-provider-imds@^4.2.12": version "4.2.12" resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz#fa2e52116cac7eaf5625e0bfd399a4927b598f66" @@ -2607,6 +2980,17 @@ "@smithy/url-parser" "^4.2.12" tslib "^2.6.2" +"@smithy/credential-provider-imds@^4.2.13": + version "4.2.13" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.13.tgz#c0533f362dec6644f403c7789d8e81233f78c63f" + integrity sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ== + dependencies: + "@smithy/node-config-provider" "^4.3.13" + "@smithy/property-provider" "^4.2.13" + "@smithy/types" "^4.14.0" + "@smithy/url-parser" "^4.2.13" + tslib "^2.6.2" + "@smithy/eventstream-codec@^4.2.12": version "4.2.12" resolved "https://registry.yarnpkg.com/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz#8cd62d08709344fb8b35fd17870fdf1435de61a3" @@ -2663,6 +3047,17 @@ "@smithy/util-base64" "^4.3.2" tslib "^2.6.2" +"@smithy/fetch-http-handler@^5.3.16": + version "5.3.16" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.16.tgz#2cd94de19ac2bcdb51682259cf6dcacbb1b382a9" + integrity sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ== + dependencies: + "@smithy/protocol-http" "^5.3.13" + "@smithy/querystring-builder" "^4.2.13" + "@smithy/types" "^4.14.0" + "@smithy/util-base64" "^4.3.2" + tslib "^2.6.2" + "@smithy/hash-node@^4.2.12": version "4.2.12" resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.2.12.tgz#0ee7f6a1d2958c313ee24b07159dcb9547792441" @@ -2673,6 +3068,16 @@ "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" +"@smithy/hash-node@^4.2.13": + version "4.2.13" + resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.2.13.tgz#5ec1b80c27f5446136ce98bf6ab0b0594ca34511" + integrity sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA== + dependencies: + "@smithy/types" "^4.14.0" + "@smithy/util-buffer-from" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + "@smithy/invalid-dependency@^4.2.12": version "4.2.12" resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz#1a28c13fb33684b91848d4d6ec5104a1c1413e7f" @@ -2681,6 +3086,14 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/invalid-dependency@^4.2.13": + version "4.2.13" + resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-4.2.13.tgz#0f23859d529ba669f24860baacb41835f604a8ae" + integrity sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg== + dependencies: + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/is-array-buffer@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz#f84f0d9f9a36601a9ca9381688bd1b726fd39111" @@ -2704,6 +3117,15 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/middleware-content-length@^4.2.13": + version "4.2.13" + resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-4.2.13.tgz#0bbc3706fe1321ba99be29703ff98abde996d49d" + integrity sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig== + dependencies: + "@smithy/protocol-http" "^5.3.13" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/middleware-endpoint@^4.4.28": version "4.4.28" resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.28.tgz#201b568f3669bd816f60a6043d914c134d80f46c" @@ -2718,6 +3140,20 @@ "@smithy/util-middleware" "^4.2.12" tslib "^2.6.2" +"@smithy/middleware-endpoint@^4.4.29": + version "4.4.29" + resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.29.tgz#86fa2f206469e48bff1b30b2c35e433b5f453119" + integrity sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw== + dependencies: + "@smithy/core" "^3.23.14" + "@smithy/middleware-serde" "^4.2.17" + "@smithy/node-config-provider" "^4.3.13" + "@smithy/shared-ini-file-loader" "^4.4.8" + "@smithy/types" "^4.14.0" + "@smithy/url-parser" "^4.2.13" + "@smithy/util-middleware" "^4.2.13" + tslib "^2.6.2" + "@smithy/middleware-retry@^4.4.46": version "4.4.46" resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.4.46.tgz#dbbf0af08c1bd03fe2afa09a6cfb7a9056387ce6" @@ -2733,6 +3169,22 @@ "@smithy/uuid" "^1.1.2" tslib "^2.6.2" +"@smithy/middleware-retry@^4.5.0": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.5.0.tgz#d39bec675ba3133f399c21261212d690f1e10d61" + integrity sha512-/NzISn4grj/BRFVua/xnQwF+7fakYZgimpw2dfmlPgcqecBMKxpB9g5mLYRrmBD5OrPoODokw4Vi1hrSR4zRyw== + dependencies: + "@smithy/core" "^3.23.14" + "@smithy/node-config-provider" "^4.3.13" + "@smithy/protocol-http" "^5.3.13" + "@smithy/service-error-classification" "^4.2.13" + "@smithy/smithy-client" "^4.12.9" + "@smithy/types" "^4.14.0" + "@smithy/util-middleware" "^4.2.13" + "@smithy/util-retry" "^4.3.0" + "@smithy/uuid" "^1.1.2" + tslib "^2.6.2" + "@smithy/middleware-serde@^4.2.16": version "4.2.16" resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-4.2.16.tgz#7f259e1e4e43332ad29b53cf3b4d9f14fde690ce" @@ -2743,6 +3195,16 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/middleware-serde@^4.2.17": + version "4.2.17" + resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-4.2.17.tgz#45b1eaa99c3b536042eb56365096e6681f2a347b" + integrity sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ== + dependencies: + "@smithy/core" "^3.23.14" + "@smithy/protocol-http" "^5.3.13" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/middleware-stack@^4.2.12": version "4.2.12" resolved "https://registry.yarnpkg.com/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz#96b43b2fab0d4a6723f813f76b72418b0fdb6ba0" @@ -2751,6 +3213,14 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/middleware-stack@^4.2.13": + version "4.2.13" + resolved "https://registry.yarnpkg.com/@smithy/middleware-stack/-/middleware-stack-4.2.13.tgz#88007ea7eb40ab3ff632701c21149e0e8a57b55f" + integrity sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw== + dependencies: + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/node-config-provider@^4.3.12": version "4.3.12" resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz#bb722da6e2a130ae585754fa7bc8d909f9f5d702" @@ -2761,6 +3231,16 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/node-config-provider@^4.3.13": + version "4.3.13" + resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.3.13.tgz#a65c696a38a0c2e7012652b1c1138799882b12bc" + integrity sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw== + dependencies: + "@smithy/property-provider" "^4.2.13" + "@smithy/shared-ini-file-loader" "^4.4.8" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/node-http-handler@^4.5.1": version "4.5.1" resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.5.1.tgz#9f05b4478ccfc6db82af37579a36fa48ee8f6067" @@ -2771,6 +3251,16 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/node-http-handler@^4.5.2": + version "4.5.2" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.5.2.tgz#21d70f4c9cf1ce59921567bab59ae1177b6c60b1" + integrity sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA== + dependencies: + "@smithy/protocol-http" "^5.3.13" + "@smithy/querystring-builder" "^4.2.13" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/property-provider@^4.2.12": version "4.2.12" resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.2.12.tgz#e9f8e5ce125413973b16e39c87cf4acd41324e21" @@ -2779,6 +3269,14 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/property-provider@^4.2.13": + version "4.2.13" + resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.2.13.tgz#4859f887414f2c251517125258870a70509f8bbd" + integrity sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ== + dependencies: + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/protocol-http@^5.3.12": version "5.3.12" resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.12.tgz#c913053e7dfbac6cdd7f374f0b4f5aa7c518d0e1" @@ -2787,6 +3285,14 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/protocol-http@^5.3.13": + version "5.3.13" + resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.13.tgz#1e8fcacd61282cafc2c783ab002cb0debe763588" + integrity sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg== + dependencies: + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/querystring-builder@^4.2.12": version "4.2.12" resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz#20a0266b151a4b58409f901e1463257a72835c16" @@ -2796,6 +3302,15 @@ "@smithy/util-uri-escape" "^4.2.2" tslib "^2.6.2" +"@smithy/querystring-builder@^4.2.13": + version "4.2.13" + resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-4.2.13.tgz#1f3c009493a06d83f998da70f5920246dfcd88dd" + integrity sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ== + dependencies: + "@smithy/types" "^4.14.0" + "@smithy/util-uri-escape" "^4.2.2" + tslib "^2.6.2" + "@smithy/querystring-parser@^4.2.12": version "4.2.12" resolved "https://registry.yarnpkg.com/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz#918cb609b2d606ab81f2727bfde0265d2ebb2758" @@ -2804,6 +3319,14 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/querystring-parser@^4.2.13": + version "4.2.13" + resolved "https://registry.yarnpkg.com/@smithy/querystring-parser/-/querystring-parser-4.2.13.tgz#c2ab4446a50d0de232bbffdab534b3e0023bf879" + integrity sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA== + dependencies: + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/service-error-classification@^4.2.12": version "4.2.12" resolved "https://registry.yarnpkg.com/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz#795e9484207acf63817a9e9cf67e90b42e720840" @@ -2811,6 +3334,13 @@ dependencies: "@smithy/types" "^4.13.1" +"@smithy/service-error-classification@^4.2.13": + version "4.2.13" + resolved "https://registry.yarnpkg.com/@smithy/service-error-classification/-/service-error-classification-4.2.13.tgz#22aa256bbad30d98e13a4896eee165ee184cd33b" + integrity sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw== + dependencies: + "@smithy/types" "^4.14.0" + "@smithy/shared-ini-file-loader@^4.4.7": version "4.4.7" resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz#18cc5a21f871509fafbe535a7bf44bde5a500727" @@ -2819,6 +3349,14 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/shared-ini-file-loader@^4.4.8": + version "4.4.8" + resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.8.tgz#c45099e8aea8f48af97d05be91ab6ae93d105ae7" + integrity sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw== + dependencies: + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/signature-v4@^5.3.12": version "5.3.12" resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.12.tgz#b61ce40a94bdd91dfdd8f5f2136631c8eb67f253" @@ -2833,6 +3371,20 @@ "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" +"@smithy/signature-v4@^5.3.13": + version "5.3.13" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.13.tgz#0c3760a5837673ddbb66c433637d5e16742b991f" + integrity sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg== + dependencies: + "@smithy/is-array-buffer" "^4.2.2" + "@smithy/protocol-http" "^5.3.13" + "@smithy/types" "^4.14.0" + "@smithy/util-hex-encoding" "^4.2.2" + "@smithy/util-middleware" "^4.2.13" + "@smithy/util-uri-escape" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + "@smithy/smithy-client@^4.12.8": version "4.12.8" resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.12.8.tgz#b2982fe8b72e44621c139045d991555c07df0e1a" @@ -2846,6 +3398,19 @@ "@smithy/util-stream" "^4.5.21" tslib "^2.6.2" +"@smithy/smithy-client@^4.12.9": + version "4.12.9" + resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.12.9.tgz#2eb54ee07050a8bcd3792f8b8c4e03fac4bfb422" + integrity sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ== + dependencies: + "@smithy/core" "^3.23.14" + "@smithy/middleware-endpoint" "^4.4.29" + "@smithy/middleware-stack" "^4.2.13" + "@smithy/protocol-http" "^5.3.13" + "@smithy/types" "^4.14.0" + "@smithy/util-stream" "^4.5.22" + tslib "^2.6.2" + "@smithy/types@^4.13.1": version "4.13.1" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.13.1.tgz#8aaf15bb0f42b4e7c93c87018a3678a06d74691d" @@ -2853,6 +3418,13 @@ dependencies: tslib "^2.6.2" +"@smithy/types@^4.14.0": + version "4.14.0" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.14.0.tgz#72fb6fd315f2eff7d4878142db2d1db4ef94f9bc" + integrity sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ== + dependencies: + tslib "^2.6.2" + "@smithy/url-parser@^4.2.12": version "4.2.12" resolved "https://registry.yarnpkg.com/@smithy/url-parser/-/url-parser-4.2.12.tgz#e940557bf0b8e9a25538a421970f64bd827f456f" @@ -2862,6 +3434,15 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/url-parser@^4.2.13": + version "4.2.13" + resolved "https://registry.yarnpkg.com/@smithy/url-parser/-/url-parser-4.2.13.tgz#cc582733d1181e1a135b05bb600f12c9889be7f4" + integrity sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw== + dependencies: + "@smithy/querystring-parser" "^4.2.13" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/util-base64@^4.3.2": version "4.3.2" resolved "https://registry.yarnpkg.com/@smithy/util-base64/-/util-base64-4.3.2.tgz#be02bcb29a87be744356467ea25ffa413e695cea" @@ -2918,6 +3499,16 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/util-defaults-mode-browser@^4.3.45": + version "4.3.45" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.45.tgz#42cb7fb97857a6b67d54e38adaf1476fdc7d1339" + integrity sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw== + dependencies: + "@smithy/property-provider" "^4.2.13" + "@smithy/smithy-client" "^4.12.9" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/util-defaults-mode-node@^4.2.48": version "4.2.48" resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.48.tgz#8ee63e2ea706bd111104e8f3796d858cc186625f" @@ -2931,6 +3522,19 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/util-defaults-mode-node@^4.2.49": + version "4.2.49" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.49.tgz#fa443a16daedef503c0d41bbed22526c3e228cee" + integrity sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ== + dependencies: + "@smithy/config-resolver" "^4.4.14" + "@smithy/credential-provider-imds" "^4.2.13" + "@smithy/node-config-provider" "^4.3.13" + "@smithy/property-provider" "^4.2.13" + "@smithy/smithy-client" "^4.12.9" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/util-endpoints@^3.3.3": version "3.3.3" resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz#0119f15bcac30b3b9af1d3cc0a8477e7199d0185" @@ -2940,6 +3544,15 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/util-endpoints@^3.3.4": + version "3.3.4" + resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.3.4.tgz#e372596c9aebd7939a0452f6b8ec417cfac18f7c" + integrity sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog== + dependencies: + "@smithy/node-config-provider" "^4.3.13" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/util-hex-encoding@^4.2.2": version "4.2.2" resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz#4abf3335dd1eb884041d8589ca7628d81a6fd1d3" @@ -2955,6 +3568,14 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/util-middleware@^4.2.13": + version "4.2.13" + resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.2.13.tgz#fda5518f95cc3f4a3086d9ee46cc42797baaedf8" + integrity sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow== + dependencies: + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/util-retry@^4.2.13": version "4.2.13" resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.2.13.tgz#ad816d6ddf197095d188e9ef56664fbd392a39c9" @@ -2964,6 +3585,15 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/util-retry@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.3.0.tgz#efff6f9859ddfeb7747b269cf236f47c4bc2a54d" + integrity sha512-tSOPQNT/4KfbvqeMovWC3g23KSYy8czHd3tlN+tOYVNIDLSfxIsrPJihYi5TpNcoV789KWtgChUVedh2y6dDPg== + dependencies: + "@smithy/service-error-classification" "^4.2.13" + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/util-stream@^4.5.21": version "4.5.21" resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-4.5.21.tgz#a9ea13d0299d030c72ab4b4e394db111cd581629" @@ -2978,6 +3608,20 @@ "@smithy/util-utf8" "^4.2.2" tslib "^2.6.2" +"@smithy/util-stream@^4.5.22": + version "4.5.22" + resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-4.5.22.tgz#16e449bbd174243b9e202f0f75d33a1d700c2020" + integrity sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew== + dependencies: + "@smithy/fetch-http-handler" "^5.3.16" + "@smithy/node-http-handler" "^4.5.2" + "@smithy/types" "^4.14.0" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-buffer-from" "^4.2.2" + "@smithy/util-hex-encoding" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + "@smithy/util-uri-escape@^4.2.2": version "4.2.2" resolved "https://registry.yarnpkg.com/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz#48e40206e7fe9daefc8d44bb43a1ab17e76abf4a" @@ -3009,6 +3653,14 @@ "@smithy/types" "^4.13.1" tslib "^2.6.2" +"@smithy/util-waiter@^4.2.15": + version "4.2.15" + resolved "https://registry.yarnpkg.com/@smithy/util-waiter/-/util-waiter-4.2.15.tgz#0338ad7e5b47380836cfedd21a6b5bda4e43a88f" + integrity sha512-oUt9o7n8hBv3BL56sLSneL0XeigZSuem0Hr78JaoK33D9oKieyCvVP8eTSe3j7g2mm/S1DvzxKieG7JEWNJUNg== + dependencies: + "@smithy/types" "^4.14.0" + tslib "^2.6.2" + "@smithy/uuid@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@smithy/uuid/-/uuid-1.1.2.tgz#b6e97c7158615e4a3c775e809c00d8c269b5a12e"